diff --git a/.env.example b/.env.example index 7a9c0970aa..24d20acc34 100644 --- a/.env.example +++ b/.env.example @@ -46,40 +46,10 @@ RELAY_URL=ws://localhost:3000 # Stable relay signing key. Set this in dev if you want REST-created forum posts # to keep resolving to the original author across relay restarts. # SPROUT_RELAY_PRIVATE_KEY=<32-byte hex private key> -# Set to true in production to require bearer token authentication -SPROUT_REQUIRE_AUTH_TOKEN=false - -# ----------------------------------------------------------------------------- -# Auth -# ----------------------------------------------------------------------------- -# Set to false for dev (accepts NIP-42 without JWT, allows X-Pubkey header). -# Set to true in production to require bearer token authentication. -SPROUT_REQUIRE_AUTH_TOKEN=false - -# JWKS endpoint for verifying JWT access tokens. -# Claim that carries the user's Nostr public key (hex, 32 bytes). -OKTA_PUBKEY_CLAIM=nostr_pubkey - -# ── Keycloak (local OAuth testing — stands in for Okta in prod) ────────────── -# Keycloak is NOT a production dependency. It lets you test the full OAuth -# flow locally without needing an Okta tenant. Run `docker compose up -d` -# then `./scripts/setup-keycloak.sh` to create the realm, client, and users. -# -# Admin UI: http://localhost:8180 (admin / admin) -# Get a token: -# curl -s -X POST http://localhost:8180/realms/sprout/protocol/openid-connect/token \ -# -d 'client_id=sprout-desktop&grant_type=password&username=tyler&password=password123' \ -# | jq -r .access_token -OKTA_JWKS_URI=http://localhost:8180/realms/sprout/protocol/openid-connect/certs -OKTA_ISSUER=http://localhost:8180/realms/sprout -OKTA_AUDIENCE=sprout-desktop - -# ── Okta (production / staging) ────────────────────────────────────────────── -# Uncomment and fill in when deploying against a real Okta tenant. -# OKTA_JWKS_URI=https://dev-example.okta.com/oauth2/default/v1/keys -# OKTA_ISSUER=https://dev-example.okta.com/oauth2/default -# OKTA_AUDIENCE=sprout-api -# OKTA_PUBKEY_CLAIM=nostr_pubkey +# Optional: path to the web UI dist directory. When set, the relay serves +# the web frontend at / for browser requests. Leave unset for local dev +# (use `just web` for Vite HMR instead). +# SPROUT_WEB_DIR=./web/dist # ----------------------------------------------------------------------------- # Ephemeral Channels (TTL testing) @@ -121,9 +91,6 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu # Nostr private key (hex or bech32). REQUIRED — identifies the agent on the relay. # SPROUT_PRIVATE_KEY=<32-byte hex or nsec1… private key> -# Bearer token for relay authentication (when SPROUT_REQUIRE_AUTH_TOKEN=true). -# SPROUT_API_TOKEN= - # Relay WebSocket URL the harness connects to. # Note: the relay itself uses RELAY_URL (above); this is the ACP harness's # connection target — they happen to point at the same place in local dev. @@ -219,4 +186,3 @@ RUST_LOG=sprout_relay=debug,sprout_db=debug,sprout_auth=debug,sprout_pubsub=debu # These are accepted for backward compatibility but the canonical names above # are preferred: # SPROUT_ACP_PRIVATE_KEY → SPROUT_PRIVATE_KEY -# SPROUT_ACP_API_TOKEN → SPROUT_API_TOKEN diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 81128e1f62..075674cf33 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,6 +6,7 @@ on: env: CARGO_TERM_COLOR: always + PLAYWRIGHT_BROWSERS_PATH: ${{ github.workspace }}/.cache/ms-playwright jobs: rust-lint: @@ -77,8 +78,26 @@ jobs: wget - name: Install desktop dependencies run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Install Playwright Chromium - run: cd desktop && pnpm exec playwright install --with-deps chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop lint and format run: just desktop-check - name: Desktop build @@ -115,8 +134,26 @@ jobs: desktop/src-tauri - name: Install desktop dependencies run: just desktop-install-ci + - name: Get Playwright version + id: pw-version + run: echo "version=$(cd desktop && node -e "console.log(require('@playwright/test/package.json').version)")" >> "$GITHUB_OUTPUT" + - name: Restore Playwright browser cache + id: playwright-cache + uses: actions/cache/restore@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Install Playwright Chromium - run: cd desktop && pnpm exec playwright install --with-deps chromium + if: steps.playwright-cache.outputs.cache-hit != 'true' + run: cd desktop && pnpm exec playwright install chromium + - name: Install Playwright system dependencies + run: cd desktop && pnpm exec playwright install-deps chromium + - name: Save Playwright browser cache + if: steps.playwright-cache.outputs.cache-hit != 'true' && github.event_name == 'push' + uses: actions/cache/save@0057852bfaa89a56745cba8c7296529d2fc39830 # v4 + with: + path: ${{ env.PLAYWRIGHT_BROWSERS_PATH }} + key: playwright-${{ runner.os }}-${{ steps.pw-version.outputs.version }} - name: Desktop build run: just desktop-build - name: Start integration services @@ -160,6 +197,7 @@ jobs: RELAY_URL=ws://localhost:3000 \ SPROUT_BIND_ADDR=0.0.0.0:3000 \ SPROUT_REQUIRE_AUTH_TOKEN=false \ + SPROUT_RECONCILE_CHANNELS=true \ ./target/ci/sprout-relay > /tmp/sprout-relay.log 2>&1 & echo $! > /tmp/sprout-relay.pid for attempt in $(seq 1 60); do @@ -167,8 +205,8 @@ jobs: cat /tmp/sprout-relay.log exit 1 fi - status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/api/channels || true) - if [ "${status_code}" != "000" ]; then + status_code=$(curl -s -o /dev/null -w "%{http_code}" http://127.0.0.1:3000/_readiness || true) + if [ "${status_code}" = "200" ]; then exit 0 fi sleep 1 @@ -190,6 +228,22 @@ jobs: /tmp/sprout-relay.log if-no-files-found: ignore + web: + name: Web + runs-on: ubuntu-latest + timeout-minutes: 15 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + - name: Install dependencies + run: pnpm install --frozen-lockfile + - name: Web lint and format + run: just web-check + - name: Web build + run: just web-build + mobile: name: Mobile runs-on: ubuntu-latest @@ -222,6 +276,27 @@ jobs: - name: Dependency policy run: cargo-deny check + dead-token-guard: + name: Dead Token Reference Guard + runs-on: ubuntu-latest + timeout-minutes: 5 + permissions: + contents: read + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + - name: Check for dead API token references in client code + run: | + # Fail if dead API token patterns reappear in desktop, mobile, docs, or config. + # Relay crates are excluded — they still use token auth internally. + PATTERNS='TokenScope|MintTokenResponse|hasApiToken|spr_tok_' + PATHS='desktop/src/ desktop/tests/ mobile/test/ mobile/lib/ .env.example' + EXCLUDES='--exclude-dir=node_modules --exclude-dir=.dart_tool' + if grep -rn $EXCLUDES -E "$PATTERNS" $PATHS 2>/dev/null; then + echo "::error::Dead API token references found in client code. See above." + exit 1 + fi + echo "No dead token references found." + server-cross-compile: name: Server Cross-Compile runs-on: ubuntu-latest @@ -248,7 +323,8 @@ jobs: cross build --release --target "$TARGET" \ -p sprout-relay \ -p sprout-acp \ - -p sprout-mcp + -p sprout-mcp \ + -p git-credential-nostr desktop-build-macos: name: Desktop Build (macOS) @@ -267,6 +343,7 @@ jobs: mkdir -p desktop/src-tauri/binaries touch "desktop/src-tauri/binaries/sprout-acp-$TARGET" touch "desktop/src-tauri/binaries/sprout-mcp-server-$TARGET" + touch "desktop/src-tauri/binaries/git-credential-nostr-$TARGET" - name: Build Tauri app run: cd desktop && pnpm tauri build env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..4125bed68a --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,186 @@ +name: Release + +on: + workflow_dispatch: + inputs: + version: + description: "Semver version (e.g. 0.4.0)" + required: true + ref: + description: "Branch, tag, or SHA to build" + default: main + required: true + +jobs: + release: + name: Release + runs-on: macos-latest + timeout-minutes: 60 + permissions: + contents: write + id-token: write # required by block/apple-codesign-action for OIDC + env: + VERSION: ${{ inputs.version }} + steps: + - name: Validate version + run: | + if ! echo "$VERSION" | grep -qE '^[0-9]+\.[0-9]+\.[0-9]+(-[0-9A-Za-z.-]+)?$'; then + echo "::error::Invalid version '$VERSION'. Expected semver (e.g. 0.4.0 or 1.0.0-beta.1)" + exit 1 + fi + + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6 + with: + ref: ${{ inputs.ref }} + persist-credentials: false + + - uses: cashapp/activate-hermit@e49f5cb4dd64ff0b0b659d1d8df499595451155a # v1 + + - name: Install desktop dependencies + run: just desktop-install-ci + + - name: Patch version + run: | + cd desktop && node scripts/set-version-from-tag.mjs "$VERSION" + cd src-tauri && cargo update --workspace + + - name: Generate release config + run: cd desktop && node scripts/build-release-config.mjs + env: + SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }} + SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json + + - name: Build sidecars + run: | + cargo build --release -p sprout-acp -p sprout-mcp -p git-credential-nostr + ./scripts/bundle-sidecars.sh + + - name: Build unsigned Tauri app + run: cd desktop && pnpm tauri build --verbose --no-sign --config src-tauri/tauri.release.conf.json + env: + SPROUT_UPDATER_PUBLIC_KEY: ${{ secrets.SPROUT_UPDATER_PUBLIC_KEY }} + SPROUT_UPDATER_ENDPOINT: https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + CMAKE_POLICY_VERSION_MINIMUM: "3.5" + + - name: Locate unsigned DMG + id: unsigned + run: | + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1) + if [[ -z "$DMG" ]]; then + echo "::error::No DMG found in $BUNDLE_DIR/dmg" + exit 1 + fi + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + - name: Codesign and Notarize + id: codesign + uses: block/apple-codesign-action@679535d1ab7c5a7c18e6f9afcba3464512cc3dde # v1.1.0 + with: + osx-codesign-role: ${{ secrets.OSX_CODESIGN_ROLE }} + codesign-s3-bucket: ${{ secrets.CODESIGN_S3_BUCKET }} + unsigned-artifact-path: ${{ steps.unsigned.outputs.dmg }} + entitlements-plist-path: desktop/src-tauri/Entitlements.plist + artifact-name: sprout-${{ github.sha }}-${{ github.run_id }}-arm64 + + - name: Replace DMG and rebuild updater archive + env: + SIGNED_DMG: ${{ steps.codesign.outputs.signed-dmg-path }} + SIGNED_APP_ZIP: ${{ steps.codesign.outputs.signed-artifact-path }} + UNSIGNED_DMG: ${{ steps.unsigned.outputs.dmg }} + TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }} + TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }} + run: | + set -euo pipefail + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + APP_DIR="${BUNDLE_DIR}/macos" + + # Replace unsigned DMG with the signed/notarized one. + cp "$SIGNED_DMG" "$UNSIGNED_DMG" + + # Swap the unsigned .app for the signed .app extracted from the action's zip. + EXTRACT_DIR="${RUNNER_TEMP}/signed-app-extract" + rm -rf "$EXTRACT_DIR" && mkdir -p "$EXTRACT_DIR" + ditto -x -k "$SIGNED_APP_ZIP" "$EXTRACT_DIR" + rm -rf "${APP_DIR}/Sprout.app" + cp -R "${EXTRACT_DIR}/Sprout.app" "${APP_DIR}/Sprout.app" + + # Rebuild the updater archive from the signed .app and re-sign it with the Tauri updater key. + rm -f "${APP_DIR}/Sprout.app.tar.gz" "${APP_DIR}/Sprout.app.tar.gz.sig" + (cd "$APP_DIR" && tar -czf Sprout.app.tar.gz Sprout.app) + TARBALL_ABS="$(pwd)/${APP_DIR}/Sprout.app.tar.gz" + (cd desktop && pnpm tauri signer sign "$TARBALL_ABS") + + - name: Verify code signature + run: | + codesign --verify --deep --strict --verbose=2 \ + desktop/src-tauri/target/release/bundle/macos/Sprout.app + spctl --assess --type execute --verbose=4 \ + desktop/src-tauri/target/release/bundle/macos/Sprout.app + + - name: Locate build artifacts + id: artifacts + run: | + BUNDLE_DIR="desktop/src-tauri/target/release/bundle" + + # Find the DMG (Tauri names it Sprout__.dmg) + DMG=$(find "$BUNDLE_DIR/dmg" -name '*.dmg' -type f | head -1) + if [[ -z "$DMG" ]]; then + echo "::error::No DMG found in $BUNDLE_DIR/dmg" + exit 1 + fi + echo "dmg=$DMG" >> "$GITHUB_OUTPUT" + + # Find the updater .tar.gz and .sig + ARCHIVE=$(find "$BUNDLE_DIR/macos" -name '*.tar.gz' ! -name '*.sig' -type f | head -1) + SIG="${ARCHIVE}.sig" + if [[ -z "$ARCHIVE" || ! -f "$SIG" ]]; then + echo "::error::Updater archive or signature not found in $BUNDLE_DIR/macos" + exit 1 + fi + echo "archive=$ARCHIVE" >> "$GITHUB_OUTPUT" + echo "archive_name=$(basename "$ARCHIVE")" >> "$GITHUB_OUTPUT" + echo "sig=$SIG" >> "$GITHUB_OUTPUT" + + - name: Generate latest.json + run: | + bash desktop/scripts/generate-oss-latest-json.sh \ + "$VERSION" \ + "$SIG_PATH" \ + "https://github.com/block/sprout/releases/download/sprout-desktop-latest/$ARCHIVE_NAME" \ + > latest.json + cat latest.json + env: + SIG_PATH: ${{ steps.artifacts.outputs.sig }} + ARCHIVE_NAME: ${{ steps.artifacts.outputs.archive_name }} + + - name: Create versioned GitHub release + run: | + RELEASE_SHA=$(git rev-parse HEAD) + gh release create "v${VERSION}" \ + --target "$RELEASE_SHA" \ + --title "Sprout Desktop v${VERSION}" \ + --notes "Sprout Desktop v${VERSION}" \ + "$DMG_PATH" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + DMG_PATH: ${{ steps.artifacts.outputs.dmg }} + + - name: Update rolling release for auto-updater + run: | + gh release create sprout-desktop-latest \ + --prerelease \ + --title "Sprout Desktop Auto-Update" \ + --notes "Rolling release for the Tauri auto-updater. Do not download manually — use the versioned release instead." \ + 2>/dev/null || true + gh release upload sprout-desktop-latest \ + latest.json \ + "$ARCHIVE_PATH" \ + "$SIG_PATH" \ + --clobber + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + ARCHIVE_PATH: ${{ steps.artifacts.outputs.archive }} + SIG_PATH: ${{ steps.artifacts.outputs.sig }} diff --git a/.gitignore b/.gitignore index f04f168edf..08f94e2463 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,12 @@ playwright-report/ test-results/ blob-report/ +# Node modules (pnpm workspace hoists to root) +node_modules/ + +# Root npm lockfiles are accidental; desktop uses pnpm in /desktop. +/package-lock.json + # sqlx offline query data (generated, not portable) .sqlx/ @@ -36,3 +42,8 @@ typesense-data/ # Hermit (toolchain manager cache) .hermit/ doc/ +/repos/ + +# Local identity files +identity.key +**/identity.key diff --git a/AGENTS.md b/AGENTS.md index 8aec2b0cf2..374eb3e621 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -127,6 +127,35 @@ just desktop-dev # web-only dev server (faster iteration) just desktop-app # full Tauri app with native shell ``` +### Workspace Switching + +The desktop app supports multiple workspaces (each backed by a different relay). +Switching workspaces does **not** reload the page — it uses React key-based +remounting. `` in `App.tsx` forces the entire +workspace-scoped subtree to unmount and remount with fresh state. + +**Module-level singletons must be explicitly reset.** React remounting only +clears React state (useState, useRef, context). Module-level variables (Maps, +class instances, cached promises) survive across remounts. Every workspace-scoped +singleton needs a reset function wired into `resetWorkspaceState()` in +`desktop/src/features/workspaces/useWorkspaceInit.ts`. + +Current singletons that are reset on workspace switch: +- `relayClient.disconnect()` — WebSocket teardown + promise rejection +- `resetMediaCaches()` — proxy port and relay origin caches +- `clearSearchHitEventCache()` — search result event cache +- `clearAllDrafts()` — message draft cache + +**If you add a new module-level cache, Map, or class instance that holds +workspace-scoped data, you must add its reset to `resetWorkspaceState()`.** +Failure to do so causes data from the old workspace to leak into the new one. + +Key files: +- `desktop/src/app/App.tsx` — workspace key, init gate, remount boundary +- `desktop/src/features/workspaces/useWorkspaceInit.ts` — `resetWorkspaceState()`, applies config to Tauri backend +- `desktop/src/features/workspaces/useWorkspaces.tsx` — `WorkspacesProvider` context (shared state for App + AppShell) +- `desktop/src/main.tsx` — provider hierarchy (`QueryClientProvider` > `WorkspacesProvider` > `App`) + --- ## Mobile App (Flutter) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9c1af66ca9..77c9ee2c4c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -70,7 +70,7 @@ Sprout is a Rust monorepo (~72K LOC across 17 crates), licensed Apache 2.0 under sprout-core (zero I/O — types, verification, filter matching, kind registry) │ ├── sprout-db (Postgres: events, channels, tokens, workflows, audit) - ├── sprout-auth (NIP-42, Okta JWT, API tokens, scopes, rate limiting) + ├── sprout-auth (NIP-42, NIP-98, API tokens, scopes, rate limiting) ├── sprout-pubsub (Redis pub/sub, presence, typing indicators) ├── sprout-search (Typesense: index, query, delete) ├── sprout-audit (hash-chain tamper-evident log) @@ -171,14 +171,12 @@ The relay immediately sends `["AUTH", ""]`. The challenge is a random ### Step 3: Authentication -The client must respond with `["AUTH", ]` before submitting events or subscriptions. Four authentication paths: +The client must respond with `["AUTH", ]` before submitting events or subscriptions. Authentication paths: | Path | Mechanism | Use Case | |------|-----------|---------| -| NIP-42 only | Signed challenge, pubkey verified | Dev mode / open relay | -| NIP-42 + Okta JWT | Challenge + JWKS-validated JWT in `auth_token` tag | Human SSO via Okta | -| NIP-42 + API token | Challenge + `auth_token` tag, constant-time hash verify | Agent/service accounts | -| HTTP Bearer JWT | `Authorization: Bearer ` header on REST endpoints | REST API clients | +| NIP-42 | Signed challenge, pubkey verified | WebSocket connections | +| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` event on REST endpoints | REST API clients | On success, `ConnectionState.auth_state` transitions from `Pending` → `Authenticated(AuthContext)`. On failure → `Failed`. Unauthenticated EVENT/REQ messages are rejected with `["CLOSED", ...]` or `["OK", ..., false, "auth-required: ..."]`. @@ -347,22 +345,20 @@ pub const ALL_KINDS: &[u32] // 80 entries (KIND_AUTH excluded — never stored) ### sprout-auth — Authentication and Authorization -**2,310 LOC.** Handles all four authentication paths, JWKS caching, scope enforcement, and token operations. +**2,310 LOC.** Handles authentication paths, scope enforcement, and token operations. -**Four auth paths:** +**Auth paths:** | Path | Entry Point | Notes | |------|-------------|-------| -| NIP-42 only | `verify_auth_event()` | Dev mode; grants `Scope::all_known()` (all 14 scopes) | -| NIP-42 + Okta JWT | `verify_auth_event()` | JWT in `auth_token` tag; JWKS-validated | -| NIP-42 + API token | Relay AUTH handler → DB lookup | `auth_token` tag with `sprout_` prefix; relay intercepts before `verify_auth_event()` (which has no DB access) | -| HTTP Bearer JWT | `validate_bearer_jwt()` | REST endpoints; skips pubkey cross-check; always adds `ChannelsRead` | +| NIP-42 | `verify_auth_event()` | Schnorr-signed challenge/response; grants `Scope::all_known()` (all 14 scopes) | +| NIP-98 HTTP Auth | `validate_nip98_auth()` | REST endpoints; Schnorr-signed `kind:27235` event | **Key types:** ```rust pub struct AuthContext { pub pubkey: PublicKey, pub scopes: Vec, pub auth_method: AuthMethod } -pub enum AuthMethod { Nip42PubkeyOnly, Nip42Okta, Nip42ApiToken } +pub enum AuthMethod { Nip42, Nip98 } pub enum Scope { MessagesRead, MessagesWrite, ChannelsRead, ChannelsWrite, AdminChannels, UsersRead, UsersWrite, AdminUsers, JobsRead, JobsWrite, SubscriptionsRead, SubscriptionsWrite, @@ -372,10 +368,7 @@ pub trait RateLimiter: Send + Sync { ... } ``` **Security details:** -- JWKS double-checked locking: two read-lock checks before fetching, HTTP fetch with no lock held, write-lock re-check after. Cache TTL: 300 seconds. -- Token comparison: `subtle::ConstantTimeEq` — constant-time, prevents timing attacks. -- Token format: `sprout_<64-hex-chars>` (71 chars). `hash_token()` → SHA-256 → stored hash. -- Scopeless JWT defaults to `[MessagesRead]` only (not read+write). +- NIP-98 auth: Schnorr-signed `kind:27235` events with URL + method tags. - NIP-42 timestamp tolerance: ±60 seconds. - Dev-only key derivation: `SHA-256("sprout-test-key:{username}")` — gated behind `#[cfg(any(test, feature = "dev"))]`. The `dev` feature must not be enabled in production relay deployments. @@ -396,7 +389,6 @@ pub trait RateLimiter: Send + Sync { ... } | `feed.rs` | `query_mentions` (INNER JOIN event_mentions), `query_needs_action`, `query_activity` | | `workflow.rs` | Full workflow/run/approval CRUD; SHA-256 hashed approval tokens | | `partition.rs` | Monthly range partitioning for `events` and `delivery_log` tables | -| `api_token.rs` | Token creation; receives pre-hashed token from caller | | `dm.rs` | DM channel management | | `reaction.rs` | Reaction storage and retrieval | | `thread.rs` | Thread/reply tracking | @@ -415,8 +407,7 @@ pub trait RateLimiter: Send + Sync { ... } - Soft-delete for channel members: `remove_member` sets `removed_at`; re-adding reverses it. - Feed hard cap: `FEED_MAX_LIMIT = 100` rows regardless of caller-requested limit. - `query_mentions` uses `INNER JOIN event_mentions` — normalized table with composite index on `(pubkey_hex, created_at)`. -- API tokens: raw token never reaches the DB — caller hashes with SHA-256 before passing to `create_api_token`. -- Approval tokens: separate path — `create_approval` receives the raw token and hashes it internally. +- Approval tokens: `create_approval` receives the raw token and hashes it internally with SHA-256. - DDL injection protection in partition manager: allowlist of table names + strict suffix/date validators. **Does NOT:** cache queries, implement connection pooling logic (delegated to sqlx), or make network calls outside Postgres. @@ -664,8 +655,6 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), | POST | `/api/approvals/{token}/deny` | Deny a workflow step (🚧 unreachable — see WF-08) | | POST | `/api/approvals/by-hash/{hash}/grant` | Approve by hash (🚧 unreachable — see WF-08) | | POST | `/api/approvals/by-hash/{hash}/deny` | Deny by hash (🚧 unreachable — see WF-08) | -| GET/POST/DELETE | `/api/tokens` | List/create/delete all API tokens | -| DELETE | `/api/tokens/{id}` | Delete specific API token | | GET | `/api/dms` | List DM channels | | POST | `/api/dms` | Open a DM channel | | POST | `/api/dms/{channel_id}/members` | Add DM member | @@ -715,7 +704,7 @@ pub enum AuthState { Pending { challenge: String }, Authenticated(AuthContext), - Connects to relay via WebSocket (`tokio_tungstenite`). Handles NIP-42 auth automatically. - Ephemeral keypair generated if `SPROUT_PRIVATE_KEY` not set (printed to stderr). - Exponential backoff reconnection: 1s → 30s. Resubscribes all active subscriptions after reconnect. -- REST calls use `Authorization: Bearer ` when `SPROUT_API_TOKEN` is set; falls back to `X-Pubkey: ` in dev mode. +- REST calls use NIP-98 Schnorr-signed auth when `SPROUT_PRIVATE_KEY` is set; falls back to `X-Pubkey: ` in dev mode. - `create_channel` sends a signed Nostr kind 9007 event (NIP-29 group creation, not a REST call). - `set_canvas` sends kind 40100 with `h` tag pointing to channel UUID. - UUID validation at tool boundary before any network call. @@ -812,12 +801,9 @@ Every security-sensitive operation uses an explicit, verified pattern. No implic | Concern | Mechanism | |---------|-----------| -| Token comparison | `subtle::ConstantTimeEq` — prevents timing attacks | -| Token storage | SHA-256 hash only — raw token shown once at mint, never stored | -| JWKS cache | Double-checked locking; HTTP fetch with no lock held (prevents global DoS) | | NIP-42 timestamp | ±60 second tolerance — prevents replay attacks | | AUTH events | Never stored in Postgres, never logged in audit chain | -| Scopeless JWT | Defaults to `[MessagesRead]` only — least-privilege default | +| NIP-98 HTTP Auth | Schnorr-signed `kind:27235` events — URL and method verification | ### Input Validation @@ -873,7 +859,6 @@ Docker Compose provides the full local development stack. All services include h | Redis | `redis:7-alpine` | 6379 | Pub/sub fan-out, presence (SET EX), typing (sorted sets) | | Typesense | `typesense/typesense:27.1` | 8108 | Full-text search index | | Adminer | `adminer` | 8082 | DB web UI (dev only) | -| Keycloak | `quay.io/keycloak/keycloak:26` | 8180 | Local OAuth/OIDC stand-in for Okta | | MinIO | `minio/minio` | 9000 (API), 9001 (console) | S3-compatible object storage (media) | | Prometheus | `prom/prometheus` | 9090 | Metrics collection | @@ -887,7 +872,6 @@ Docker Compose provides the full local development stack. All services include h | `workflows` | Workflow definitions (YAML stored as canonical JSON) | | `workflow_runs` | Execution records with trigger context and trace | | `workflow_approvals` | Approval gates (token stored as SHA-256 hash) | -| `api_tokens` | API token records (hash only, never plaintext) | | `audit_log` | Hash-chain audit entries | | `delivery_log` | Delivery tracking (partitioned; Rust module pending) | diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 680a640246..76f95ef158 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -286,7 +286,7 @@ The short version: sprout-relay ← WebSocket server, REST API, event ingestion sprout-core ← Shared types, event verification, filter matching sprout-db ← Postgres access layer (sqlx) -sprout-auth ← NIP-42 + OIDC JWT + API token scopes +sprout-auth ← NIP-42 + NIP-98 + API token scopes sprout-pubsub ← Redis fan-out sprout-search ← Typesense full-text search sprout-audit ← Tamper-evident hash-chain audit log diff --git a/Cargo.lock b/Cargo.lock index 2cdc92a051..5060ed6544 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -101,7 +101,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -112,7 +112,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -455,6 +455,16 @@ version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e79769241dcd44edf79a732545e8b5cec84c247ac060f5252cd51885d093a8fc" +[[package]] +name = "bstr" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "bumpalo" version = "3.20.2" @@ -772,6 +782,20 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "countdown-bot" +version = "0.1.0" +dependencies = [ + "anyhow", + "futures-util", + "nostr", + "serde_json", + "sprout-sdk", + "tokio", + "tokio-tungstenite", + "url", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -835,6 +859,16 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-deque" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9dd111b7b7f7d55b72c0a6ae361660ee5853c9af73f70c3c2ef6858b950e2e51" +dependencies = [ + "crossbeam-epoch", + "crossbeam-utils", +] + [[package]] name = "crossbeam-epoch" version = "0.9.18" @@ -1084,7 +1118,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1350,6 +1384,43 @@ dependencies = [ "weezl", ] +[[package]] +name = "git-credential-nostr" +version = "0.1.0" +dependencies = [ + "anyhow", + "base64", + "nostr", + "serde_json", + "zeroize", +] + +[[package]] +name = "git-sign-nostr" +version = "0.1.0" +dependencies = [ + "base64", + "chrono", + "hex", + "libc", + "nostr", + "serde_json", + "zeroize", +] + +[[package]] +name = "globset" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52dfc19153a48bde0cbd630453615c8151bce3a5adfac7a0aebfbf0a1e1f57e3" +dependencies = [ + "aho-corasick", + "bstr", + "log", + "regex-automata", + "regex-syntax", +] + [[package]] name = "h2" version = "0.4.13" @@ -1506,6 +1577,12 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + [[package]] name = "httparse" version = "1.10.1" @@ -1585,7 +1662,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.5.10", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -1729,6 +1806,22 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "ignore" +version = "0.4.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3d782a365a015e0f5c04902246139249abf769125006fbe7649e2ee88169b4a" +dependencies = [ + "crossbeam-deque", + "globset", + "log", + "memchr", + "regex-automata", + "same-file", + "walkdir", + "winapi-util", +] + [[package]] name = "image" version = "0.25.10" @@ -1907,21 +2000,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "jsonwebtoken" -version = "9.3.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a87cc7a48537badeae96744432de36f4be2b4a34a05a5ef32e9dd8a1c169dde" -dependencies = [ - "base64", - "js-sys", - "pem", - "ring", - "serde", - "serde_json", - "simple_asn1", -] - [[package]] name = "lazy_static" version = "1.5.0" @@ -2106,6 +2184,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minidom" version = "0.16.0" @@ -2248,7 +2336,7 @@ version = "0.50.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -2444,16 +2532,6 @@ dependencies = [ "hmac 0.12.1", ] -[[package]] -name = "pem" -version = "3.0.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d30c53c26bc5b31a98cd02d20f25a7c8567146caf63ed593a9d87b2775291be" -dependencies = [ - "base64", - "serde_core", -] - [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -2629,6 +2707,20 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "process-wrap" +version = "9.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e842efad9119158434d193c6682e2ebee4b44d6ad801d7b349623b3f57cdf55" +dependencies = [ + "futures", + "indexmap", + "nix", + "tokio", + "tracing", + "windows 0.62.2", +] + [[package]] name = "pxfm" version = "0.1.28" @@ -2679,7 +2771,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.5.10", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2717,7 +2809,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -3022,12 +3114,14 @@ dependencies = [ "futures", "pastey", "pin-project-lite", + "process-wrap", "rmcp-macros", "schemars", "serde", "serde_json", "thiserror 2.0.18", "tokio", + "tokio-stream", "tokio-util", "tracing", ] @@ -3126,7 +3220,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3185,7 +3279,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3539,16 +3633,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e320a6c5ad31d271ad523dcf3ad13e2767ad8b1cb8f047f75a8aeaf8da139da2" [[package]] -name = "simple_asn1" -version = "0.6.4" +name = "similar" +version = "2.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d585997b0ac10be3c5ee635f1bab02d512760d14b7c468801ac8a01d9ae5f1d" -dependencies = [ - "num-bigint", - "num-traits", - "thiserror 2.0.18", - "time", -] +checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" @@ -3594,7 +3682,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3621,16 +3709,19 @@ name = "sprout-acp" version = "0.1.0" dependencies = [ "anyhow", + "base64", "chrono", "clap", "evalexpr", "futures-util", + "hex", "nix", "nostr", "reqwest 0.13.2", "rustls", "serde", "serde_json", + "sha2 0.11.0", "sprout-core", "sprout-persona", "sprout-sdk", @@ -3655,10 +3746,27 @@ dependencies = [ "nostr", "serde_json", "sprout-auth", + "sprout-core", "sprout-db", "tokio", ] +[[package]] +name = "sprout-agent" +version = "0.1.0" +dependencies = [ + "arc-swap", + "getrandom 0.2.17", + "nix", + "reqwest 0.13.2", + "rmcp", + "serde", + "serde_json", + "tokio", + "tracing", + "tracing-subscriber", +] + [[package]] name = "sprout-audit" version = "0.1.0" @@ -3681,17 +3789,13 @@ dependencies = [ name = "sprout-auth" version = "0.1.0" dependencies = [ - "chrono", "hex", - "jsonwebtoken", "nostr", "rand 0.10.0", - "reqwest 0.13.2", "serde", "serde_json", "sha2 0.11.0", "sprout-core", - "subtle", "thiserror 2.0.18", "tokio", "tracing", @@ -3705,6 +3809,8 @@ version = "0.1.0" dependencies = [ "base64", "clap", + "hex", + "infer", "nostr", "reqwest 0.13.2", "serde", @@ -3714,6 +3820,7 @@ dependencies = [ "sprout-sdk", "thiserror 2.0.18", "tokio", + "url", "uuid", ] @@ -3754,12 +3861,34 @@ dependencies = [ "uuid", ] +[[package]] +name = "sprout-dev-mcp" +version = "0.1.0" +dependencies = [ + "ignore", + "nix", + "rmcp", + "schemars", + "serde", + "serde_json", + "similar", + "sprout-cli", + "tempfile", + "tokio", + "tokio-util", + "tracing", + "tracing-subscriber", +] + [[package]] name = "sprout-mcp" version = "0.1.0" dependencies = [ "anyhow", + "base64", "futures-util", + "hex", + "infer", "nostr", "reqwest 0.13.2", "rmcp", @@ -3767,6 +3896,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "sha2 0.11.0", "sprout-core", "sprout-sdk", "thiserror 2.0.18", @@ -3806,6 +3936,24 @@ dependencies = [ "tracing", ] +[[package]] +name = "sprout-pair-relay" +version = "0.1.0" +dependencies = [ + "futures-util", + "http-body-util", + "hyper", + "hyper-util", + "parking_lot", + "rand 0.8.5", + "secp256k1", + "serde_json", + "sha2 0.10.9", + "tokio", + "tokio-tungstenite", + "tokio-util", +] + [[package]] name = "sprout-pairing-cli" version = "0.1.0" @@ -3893,11 +4041,13 @@ dependencies = [ "deadpool-redis", "futures-util", "hex", + "hmac 0.13.0", "infer", "metrics", "metrics-exporter-prometheus", "moka", "nostr", + "rand 0.10.0", "redis", "serde", "serde_json", @@ -3909,9 +4059,11 @@ dependencies = [ "sprout-db", "sprout-media", "sprout-pubsub", + "sprout-sdk", "sprout-search", "sprout-workflow", "sqlx", + "subtle", "thiserror 2.0.18", "tokio", "tokio-util", @@ -4275,7 +4427,7 @@ dependencies = [ "ntapi", "objc2-core-foundation", "objc2-io-kit", - "windows", + "windows 0.61.3", ] [[package]] @@ -4294,7 +4446,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4558,7 +4710,12 @@ dependencies = [ "http", "http-body", "http-body-util", + "http-range-header", + "httpdate", "iri-string", + "mime", + "mime_guess", + "percent-encoding", "pin-project-lite", "tokio", "tokio-util", @@ -4685,6 +4842,12 @@ version = "1.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-bidi" version = "0.3.18" @@ -5035,7 +5198,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.48.0", + "windows-sys 0.61.2", ] [[package]] @@ -5050,11 +5213,23 @@ version = "0.61.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9babd3a767a4c1aef6900409f85f5d53ce2544ccdfaa86dad48c91782c6d6893" dependencies = [ - "windows-collections", + "windows-collections 0.2.0", "windows-core 0.61.2", - "windows-future", + "windows-future 0.2.1", "windows-link 0.1.3", - "windows-numerics", + "windows-numerics 0.2.0", +] + +[[package]] +name = "windows" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" +dependencies = [ + "windows-collections 0.3.2", + "windows-core 0.62.2", + "windows-future 0.3.2", + "windows-numerics 0.3.1", ] [[package]] @@ -5066,6 +5241,15 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "windows-collections" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" +dependencies = [ + "windows-core 0.62.2", +] + [[package]] name = "windows-core" version = "0.61.2" @@ -5100,7 +5284,18 @@ checksum = "fc6a41e98427b19fe4b73c550f060b59fa592d7d686537eebf9385621bfbad8e" dependencies = [ "windows-core 0.61.2", "windows-link 0.1.3", - "windows-threading", + "windows-threading 0.1.0", +] + +[[package]] +name = "windows-future" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", + "windows-threading 0.2.1", ] [[package]] @@ -5147,6 +5342,16 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-numerics" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" +dependencies = [ + "windows-core 0.62.2", + "windows-link 0.2.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -5300,6 +5505,15 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-threading" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3949bd5b99cafdf1c7ca86b43ca564028dfe27d66958f2470940f73d86d75b37" +dependencies = [ + "windows-link 0.2.1", +] + [[package]] name = "windows_aarch64_gnullvm" version = "0.42.2" @@ -5658,6 +5872,20 @@ name = "zeroize" version = "1.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +dependencies = [ + "zeroize_derive", +] + +[[package]] +name = "zeroize_derive" +version = "1.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "zerotrie" diff --git a/Cargo.toml b/Cargo.toml index f2635e0de7..617282d58a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -9,6 +9,7 @@ members = [ "crates/sprout-audit", "crates/sprout-mcp", "crates/sprout-acp", + "crates/sprout-agent", "crates/sprout-proxy", "crates/sprout-test-client", "crates/sprout-admin", @@ -18,6 +19,11 @@ members = [ "crates/sprout-pairing-cli", "crates/sprout-sdk", "crates/sprout-persona", + "crates/git-credential-nostr", + "crates/git-sign-nostr", + "crates/sprout-pair-relay", + "crates/sprout-dev-mcp", + "examples/countdown-bot", ] exclude = ["desktop/src-tauri"] resolver = "2" @@ -37,7 +43,7 @@ tokio-util = { version = "0.7", features = ["rt", "codec"] } # HTTP + WebSocket axum = { version = "0.8", features = ["ws", "macros"] } tower = { version = "0.5", features = ["timeout", "util"] } -tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit"] } +tower-http = { version = "0.6", features = ["trace", "cors", "compression-gzip", "limit", "fs"] } # Database sqlx = { version = "0.8", features = [ @@ -88,10 +94,6 @@ zeroize = "1.8" dashmap = "6" moka = { version = "0.12", features = ["sync"] } -# JWT validation (Okta JWKS) -jsonwebtoken = "9" - - # Async stream utilities futures-util = "0.3" diff --git a/Dockerfile b/Dockerfile index f1994fe556..bd41102a20 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -# ── Build stage ────────────────────────────────────────────── +# ── Build stage (Rust) ────────────────────────────────────── # Hard-code --platform to prevent exec format error on ARM Macs. FROM --platform=linux/amd64 rust:1.93-bookworm AS builder WORKDIR /build @@ -6,7 +6,15 @@ COPY . . RUN cargo build --release -p sprout-relay \ && strip target/release/sprout-relay -# ── Runtime stage ──────────────────────────────────────────── +# ── Web build stage (Node/pnpm) ──────────────────────────── +FROM --platform=linux/amd64 node:22-bookworm-slim AS web-builder +WORKDIR /build +COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ +COPY web/ web/ +RUN corepack enable && pnpm install --frozen-lockfile --filter sprout-web +RUN pnpm -C web build + +# ── Runtime stage ─────────────────────────────────────────── FROM --platform=linux/amd64 debian:bookworm-slim # CAKE: non-root UID 1000 (numeric, not username) @@ -20,9 +28,12 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ ca-certificates socat && rm -rf /var/lib/apt/lists/* COPY --from=builder /build/target/release/sprout-relay /code/sprout-relay +COPY --from=web-builder /build/web/dist /code/web COPY script/start /code/start RUN chmod +x /code/start +ENV SPROUT_WEB_DIR="/code/web" + # CAKE: required Envoy env vars (overridden at runtime by CAKE). ENV ENVOY_ADMIN_SOCKET_PATH="@envoy-admin.sock" \ ENVOY_INGRESS_PORT="20001" \ diff --git a/NOSTR.md b/NOSTR.md index 3455637a93..9f87f3903c 100644 --- a/NOSTR.md +++ b/NOSTR.md @@ -10,7 +10,7 @@ third-party Nostr clients to connect: **Direct** is simpler — no extra process, no translation layer. Use it when your client speaks NIP-29. **Proxy** is for external guests (investors, press, partners, etc.) who use standard NIP-28 -clients and don't have company Okta/API-token credentials. +clients and don't have company credentials. Both paths require NIP-42 authentication. @@ -83,10 +83,10 @@ PGPASSWORD=sprout_dev psql -h localhost -U sprout -d sprout -c \ ### Pubkey Allowlist When `SPROUT_PUBKEY_ALLOWLIST=true`, NIP-42 connections that authenticate with only a pubkey -(no JWT, no API token) are checked against the `pubkey_allowlist` table. This lets you open the -relay to specific external Nostr identities without granting full Okta/API-token access. +(no API token) are checked against the `pubkey_allowlist` table. This lets you open the +relay to specific external Nostr identities without granting full access. -- Users with valid **API tokens** (`sprout_*`) or **Okta JWTs** bypass the allowlist. +- Users with valid **API tokens** bypass the allowlist. - **Fail-closed:** if the DB lookup fails, the connection is denied. - Default: `false` (all authenticated pubkeys accepted). - Auth failure returns generic `auth-required: verification failed` (no allowlist-specific message). @@ -212,11 +212,11 @@ external user maps to a consistent identity on the relay. export SPROUT_PROXY_SERVER_KEY=$(openssl rand -hex 32) PROXY_PUBKEY=$(echo $SPROUT_PROXY_SERVER_KEY | nak key public) -# 3. Mint a proxy API token -cargo run -p sprout-admin -- mint-token \ - --name "sprout-proxy" \ - --scopes "proxy:submit,channels:read,messages:read" \ - --pubkey $PROXY_PUBKEY +# 3. Mint a proxy API token (required until proxy is migrated to NIP-98 auth) +export SPROUT_PROXY_API_TOKEN=$(curl -s -X POST http://localhost:3000/api/tokens \ + -H "Authorization: Nostr " \ + -H "Content-Type: application/json" \ + -d '{"name":"proxy"}' | jq -r .token) # 4. Get the relay's public key (needed for attribution trust) # This is the pubkey of the relay's signing keypair. If SPROUT_RELAY_PRIVATE_KEY @@ -227,7 +227,6 @@ export SPROUT_RELAY_PUBKEY= # 5. Start the proxy export SPROUT_UPSTREAM_URL=ws://localhost:3000 export SPROUT_PROXY_SALT=$(openssl rand -hex 32) -export SPROUT_PROXY_API_TOKEN= export SPROUT_PROXY_ADMIN_SECRET=$(openssl rand -hex 16) cargo run -p sprout-proxy # proxy on :4869 @@ -312,7 +311,7 @@ curl -X DELETE http://localhost:4869/admin/guests \ -d '{"pubkey": ""}' ``` -> **Private channels:** The proxy authenticates upstream using its own server key and API token. +> **Private channels:** The proxy authenticates upstream using its own server key via NIP-42. > `GET /api/channels` and relay REQ filters only return channels accessible to that identity. > For the proxy to expose a private channel, the proxy's server pubkey must itself be a member > of that channel. Guest registration alone is not sufficient for private channels. @@ -464,9 +463,9 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 | Variable | Required | Default | Description | |----------|:--------:|---------|-------------| | `SPROUT_UPSTREAM_URL` | ✅ | — | WebSocket URL of the relay | +| `SPROUT_PROXY_API_TOKEN` | ✅ | — | Relay API token for REST calls (required until proxy is migrated to NIP-98 auth) | | `SPROUT_PROXY_SERVER_KEY` | ✅ | — | Hex-encoded 32-byte secret key (raw hex, not bech32 `nsec`) | | `SPROUT_PROXY_SALT` | ✅ | — | Hex 32-byte salt for shadow keys (keep stable and secret) | -| `SPROUT_PROXY_API_TOKEN` | ✅ | — | API token with `proxy:submit,channels:read,messages:read` | | `SPROUT_RELAY_PUBKEY` | ✅ | — | Hex-encoded 64-char relay public key (for attribution trust) | | `SPROUT_PROXY_BIND_ADDR` | ❌ | `0.0.0.0:4869` | Listen address | | `SPROUT_PROXY_RELAY_URL` | ❌ | derived from bind addr | Public WebSocket URL for NIP-42 relay-tag validation. Set if behind a reverse proxy. | @@ -481,7 +480,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 |----------|:--------:|---------|-------------| | `SPROUT_PUBKEY_ALLOWLIST` | ❌ | `false` | Enable pubkey allowlist for NIP-42 pubkey-only auth | | `SPROUT_RELAY_PRIVATE_KEY` | ❌ | random | Hex secret key for relay signing (discovery events, system messages) | -| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require JWT/API token for all connections | +| `SPROUT_REQUIRE_AUTH_TOKEN` | ❌ | `false` | Require authenticated NIP-42 for all connections | --- @@ -489,7 +488,7 @@ is dual-sourced: local snapshot metadata plus upstream edit events (kind:40003 ### Direct Path - **Pubkey allowlist is fail-closed.** DB errors deny the connection. -- **API token / Okta JWT users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. +- **API token users bypass the allowlist.** The allowlist only gates pubkey-only NIP-42. - **kind:9 requires `#h` tag.** Messages without a channel-scoped `#h` tag are rejected. - **kind:7 derives channel from target.** Reactions look up the target event's channel via `#e` — client-supplied `#h` tags are ignored. Reactions to unknown events are rejected (fail-closed). - **kind:5 uses `#h` if present, but doesn't require it.** Deletions validate author-match against target events via `#e` tags. Only self-authored events can be deleted (admin deletions use kind:9005). diff --git a/README.md b/README.md index eff96927dc..f091a5c62e 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,5 @@

- Sprout + Sprout

# sprout @@ -8,9 +8,55 @@ A Nostr relay built for the agentic era — agents and humans share the same pro Sprout is a self-hosted WebSocket relay implementing a subset of the Nostr protocol, extended with structured channels, per-channel canvases, full-text search, and an MCP server so AI agents can -participate in conversations natively. Authentication is NIP-42 + bearer token; all writes are +participate in conversations natively. Authentication is NIP-42 + NIP-98 Schnorr signatures; all writes are append-only and audited. +## Quick Start + +Three steps to get the full stack running locally. + +**Prerequisites:** Docker, and either [Hermit](https://cashapp.github.io/hermit/) (recommended) or Rust 1.88+, Node.js 24+, pnpm 10+, and [`just`](https://github.com/casey/just) installed manually. + +**1. Activate the pinned toolchain** + +```bash +. ./bin/activate-hermit +``` + +Hermit pins Rust, Node.js, pnpm, `just`, and related tooling from `bin/`. + +**2. Configure and set up the dev environment** + +```bash +cp .env.example .env +just setup +just build +``` + +`just setup` does the heavy lifting: +- Starts Docker services (Postgres, Redis, Typesense, Adminer, MinIO, Prometheus) +- Waits for core services (Postgres, Redis, Typesense) to be healthy +- Runs database migrations +- Installs desktop dependencies (`pnpm install`) + +Then run `just build` once to compile the Rust workspace so binaries like `sprout-acp` and `sprout-mcp-server` are available when you start connecting agents. + +**3. Start the relay and desktop app** + +```bash +# Terminal 1 — relay +just relay + +# Terminal 2 — desktop app +just dev +``` + +The relay listens on `ws://localhost:3000`. The desktop app opens automatically. + +That's it — you're running Sprout locally. + +--- + ## Why Sprout | | | @@ -21,7 +67,7 @@ append-only and audited. | ✅ | **ACP agent harness** — AI agents connect out of the box via `sprout-acp` | | ✅ | **Tamper-evident audit log** — hash-chain, SOX-grade compliance | | ✅ | **Permission-aware full-text search** — Typesense, respects channel membership | -| ✅ | **Enterprise SSO bridge** — NIP-42 authentication with OIDC | +| ✅ | **NIP-42 + NIP-98 authentication** — Schnorr signatures for WebSocket and REST | | ✅ | **Pure Rust backend** — memory safe, no GC pauses | ## Supported NIPs @@ -39,7 +85,7 @@ append-only and audited. | [NIP-29](https://github.com/nostr-protocol/nips/blob/master/29.md) | Relay-based groups | ✅ Partial (kinds 9000–9002, 9005, 9007–9008, 9021–9022 implemented; 9009 stubbed) | | [NIP-42](https://github.com/nostr-protocol/nips/blob/master/42.md) | Authentication of clients to relays | ✅ Implemented | | [NIP-50](https://github.com/nostr-protocol/nips/blob/master/50.md) | Search capability | ✅ Implemented | -| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Partial (`POST /api/tokens` bootstrap only) | +| [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) | HTTP Auth | ✅ Implemented | ## Architecture @@ -90,7 +136,7 @@ append-only and audited. | Crate | Role | |-------|------| | `sprout-db` | Postgres access layer — events, channels, users, DMs, threads, reactions, workflows, tokens, feed (sqlx) | -| `sprout-auth` | NIP-42 challenge/response + Okta OIDC JWT validation + NIP-98 HTTP Auth + token scopes + rate limiting | +| `sprout-auth` | NIP-42 challenge/response + NIP-98 HTTP Auth + token scopes + rate limiting | | `sprout-pubsub` | Redis pub/sub fan-out, presence tracking, typing indicators, and rate limiting | | `sprout-search` | Typesense indexing and query — full-text search over event content | | `sprout-audit` | Append-only audit log with SHA-256 hash chain for tamper detection | @@ -121,71 +167,16 @@ append-only and audited. | `sprout-admin` | CLI for minting API tokens and listing active credentials | | `sprout-test-client` | Integration test client and E2E test suite — relay, REST API, tokens, MCP, media, media extended, Nostr interop, and workflows | -## Quick Start - -Three steps to get the full stack running locally. - -**Prerequisites:** Docker, and either [Hermit](https://cashapp.github.io/hermit/) (recommended) or Rust 1.88+, Node.js 24+, pnpm 10+, and [`just`](https://github.com/casey/just) installed manually. - -**1. Activate the pinned toolchain** - -```bash -. ./bin/activate-hermit -``` - -Hermit pins Rust, Node.js, pnpm, `just`, and related tooling from `bin/`. - -**2. Configure and set up the dev environment** - -```bash -cp .env.example .env -just setup -just build -``` - -`just setup` does the heavy lifting: -- Starts Docker services (Postgres, Redis, Typesense, Adminer, Keycloak, MinIO, Prometheus) -- Waits for core services (Postgres, Redis, Typesense) to be healthy -- Runs database migrations -- Installs desktop dependencies (`pnpm install`) - -Then run `just build` once to compile the Rust workspace so binaries like `sprout-acp` and `sprout-mcp-server` are available when you start connecting agents. - -**3. Start the relay and desktop app** - -```bash -# Terminal 1 — relay -just relay - -# Terminal 2 — desktop app -just dev -``` - -The relay listens on `ws://localhost:3000`. The desktop app opens automatically. - -That's it — you're running Sprout locally. - ---- - ## Going Further -### Mint an API token +### Explore examples -Required for connecting AI agents to the relay. - -```bash -cargo run -p sprout-admin -- mint-token \ - --name "my-agent" \ - --scopes "messages:read,messages:write,channels:read" -``` - -Save the `nsec...` private key and API token from the output — they are shown only once. +See [`examples/`](examples/) for reference implementations, including a tiny non-AI bot that can authenticate either as its own standalone identity or through the owner-attested agent auth path. ### Launch an agent (MCP) ```bash SPROUT_RELAY_URL=ws://localhost:3000 \ -SPROUT_API_TOKEN= \ SPROUT_PRIVATE_KEY=nsec1... \ goose run --no-profile \ --with-extension "cargo run -p sprout-mcp --bin sprout-mcp-server" \ @@ -210,7 +201,7 @@ client configuration. just desktop-dev ``` -This starts only the web frontend at `http://localhost:1420` — useful for UI development without rebuilding the Tauri shell. Use `just dev` (from Quick Start) for the full desktop app. +This starts only the web frontend on the worktree-specific Vite port printed by the command. Use `just dev` (from Quick Start) for the full Tauri desktop app. ## Configuration @@ -225,16 +216,12 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `TYPESENSE_COLLECTION` | `events` | Typesense collection name | | `SPROUT_BIND_ADDR` | `0.0.0.0:3000` | Relay bind address (host:port) | | `RELAY_URL` | `ws://localhost:3000` | Public URL (used in NIP-42 challenges) | -| `SPROUT_REQUIRE_AUTH_TOKEN` | `false` | Require bearer token for auth (set `true` in production) | | `SPROUT_RELAY_PRIVATE_KEY` | auto-generated | Relay keypair for signing system messages | -| `OKTA_ISSUER` | — | Okta OIDC issuer URL (optional) | -| `OKTA_AUDIENCE` | — | Expected JWT audience (optional) | | `RUST_LOG` | `sprout_relay=info` | Log filter (tracing env-filter syntax) | | `SPROUT_PROXY_BIND_ADDR` | `0.0.0.0:4869` | Proxy bind address (see [NOSTR.md](NOSTR.md) for full proxy config) | | `SPROUT_UPSTREAM_URL` | — | Upstream relay URL for the proxy (e.g., `ws://localhost:3000`) | | `SPROUT_PROXY_SERVER_KEY` | — | Hex private key for the proxy server keypair | | `SPROUT_PROXY_SALT` | — | Hex 32-byte salt for shadow key derivation | -| `SPROUT_PROXY_API_TOKEN` | — | Sprout API token with `proxy:submit` scope | | `SPROUT_PROXY_ADMIN_SECRET` | — | Bearer secret for proxy admin endpoints (optional — omit for dev mode) | | `SPROUT_CORS_ORIGINS` | — | Comma-separated allowed CORS origins (unset = permissive) | | `SPROUT_HEALTH_PORT` | `8080` | Port for health check endpoint (separate from main bind) | @@ -249,13 +236,11 @@ Copy `.env.example` to `.env` and adjust as needed. All defaults work out of the | `SPROUT_S3_SECRET_KEY` | `sprout_dev_secret` | S3 secret key | | `SPROUT_S3_BUCKET` | `sprout-media` | S3 bucket name for media uploads | | `SPROUT_METRICS_PORT` | `9102` | Port for Prometheus metrics endpoint | -| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`); API token and Okta JWT auth bypass | +| `SPROUT_PUBKEY_ALLOWLIST` | `false` | Restrict NIP-42 pubkey-only auth to allowlisted keys (`true`/`1`) | | `SPROUT_SEND_BUFFER` | `1000` | WebSocket send buffer size | | `SPROUT_UDS_PATH` | — | Unix domain socket path (alternative to TCP) | -| `OKTA_JWKS_URI` | — | Okta JWKS endpoint URI for JWT verification | | `SPROUT_TOOLSETS` | `default` | MCP toolsets to enable (comma-separated: `default`, `channel_admin`, `dms`, `canvas`, `workflow_admin`, `identity`, `forums`, `all`, `none`; append `:ro` for read-only) | -| `SPROUT_MINT_RATE_LIMIT` | `50` | Max API token mints per pubkey per hour | -| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` when no API token is set | +| `SPROUT_RELAY_PUBKEY` | — | Relay's hex pubkey — required by `sprout-proxy`; also used as fallback auth by `sprout-workflow` | ## MCP Tools diff --git a/RELEASING.md b/RELEASING.md new file mode 100644 index 0000000000..f5be698227 --- /dev/null +++ b/RELEASING.md @@ -0,0 +1,115 @@ +# Releasing Sprout Desktop + +This document describes how to create a new OSS release of the Sprout +desktop app. + +--- + +## Prerequisites + +- **Write access** to the `block/sprout` GitHub repository. Only + collaborators with push permissions can trigger the release workflow. +- The following **GitHub Actions secrets** must be configured on the repo + (Settings > Secrets and variables > Actions): + + | Secret | Purpose | + |--------|---------| + | `SPROUT_UPDATER_PUBLIC_KEY` | Tauri updater public key (minisign) | + | `TAURI_SIGNING_PRIVATE_KEY` | Tauri updater private key (used to sign the update archive) | + | `TAURI_SIGNING_PRIVATE_KEY_PASSWORD` | Password for the private key | + +--- + +## Creating a Release + +1. Go to **Actions > Release** in the GitHub UI:\ + `https://github.com/block/sprout/actions/workflows/release.yml` + +2. Click **"Run workflow"**. + +3. Fill in the inputs: + - **version** — a semver version string, e.g. `0.4.0` or `1.0.0-beta.1`. + Do not include a `v` prefix. + - **ref** — the branch, tag, or commit SHA to build from. Defaults to + `main`. + +4. Click **"Run workflow"** to start the build. + +The workflow will: + +- Validate the version string +- Check out the specified ref +- Patch the version into `package.json`, `tauri.conf.json`, and `Cargo.toml` +- Build all sidecar binaries (`sprout-acp`, `sprout-mcp`, + `git-credential-nostr`) +- Build the Tauri desktop app with updater signing enabled +- Create a versioned GitHub release (`v0.4.0`) with the `.dmg` installer +- Update the rolling `sprout-desktop-latest` release with the signed + update archive and `latest.json` manifest for the auto-updater + +--- + +## What Gets Published + +Each release produces two GitHub releases: + +1. **`v`** (e.g. `v0.4.0`) — the user-facing release with the + `.dmg` installer. This is what users download manually. + +2. **`sprout-desktop-latest`** — a rolling pre-release used by the Tauri + auto-updater. Contains `latest.json`, the signed `.tar.gz` archive, + and its `.sig` signature. Users should not download from this release + directly. + +--- + +## Platform Support + +The release workflow currently builds for **macOS ARM64 only** +(`darwin-aarch64`). Intel Mac (`darwin-x86_64`) support would require +adding a matrix build to the workflow. + +## Code Signing (macOS) + +OSS release builds use **ad-hoc code signing** (`signingIdentity: "-"`) +rather than a Developer ID certificate. This means the app is not +notarized by Apple. + +On first launch, macOS Gatekeeper will block the app with a "damaged" or +"unidentified developer" message. Users can bypass this by +**right-clicking the app > Open** (or via System Settings > Privacy & +Security). After the first launch the app will open normally. + +--- + +## Auto-Updater + +The desktop app checks for updates by fetching `latest.json` from the +`sprout-desktop-latest` release: + +``` +https://github.com/block/sprout/releases/download/sprout-desktop-latest/latest.json +``` + +When a new version is available, the app downloads the signed archive, +verifies the signature against the embedded public key, and applies the +update. + +--- + +## Troubleshooting + +### Build fails at "Validate version" +The version string must be valid semver: `MAJOR.MINOR.PATCH` with an +optional pre-release suffix (e.g. `1.0.0-beta.1`). Do not include a `v` +prefix. + +### Build fails at "Build Tauri app" +Check that the signing secrets are configured correctly. The build +requires `TAURI_SIGNING_PRIVATE_KEY` and +`TAURI_SIGNING_PRIVATE_KEY_PASSWORD` to be set. + +### Auto-updater reports "no update available" +Verify that the `sprout-desktop-latest` release exists and contains a +valid `latest.json`. If the user is on Intel Mac, no update will be +found (ARM64 only). diff --git a/SECURITY.md b/SECURITY.md index be8e5b968a..b6dcca38f8 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -48,10 +48,10 @@ challenge/response before writing events. The relay sends a random challenge; the client signs a `kind:22242` event containing the challenge and the relay URL, proving possession of the private key. -API tokens (bearer tokens minted by `sprout-admin`) are presented inside the -NIP-42 signed event as an `auth_token` tag. The relay validates the token -against the database before granting elevated scopes. Tokens are stored as -SHA-256 hashes — the plaintext is shown once at mint time and never stored. +REST endpoints authenticate via +[NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) HTTP Auth — +the client signs a `kind:27235` event containing the request URL and method. +The relay verifies the Schnorr signature and extracts the pubkey. ### Authorization — Channel Membership as the Gate @@ -64,13 +64,6 @@ Private channels are invisible to non-members: they do not appear in channel listings, and subscription filters for private channel events return nothing unless the subscriber is a member. -### Scope-Based Token Permissions - -API tokens carry a set of scopes (e.g., `messages:read`, `channels:write`). -The relay enforces scopes on every REST endpoint and WebSocket write. A token -without `channels:write` cannot create channels, regardless of channel -membership. - ### Append-Only Audit Log All events are written to a tamper-evident audit log (`sprout-audit`). Each diff --git a/TESTING.md b/TESTING.md index f8bd50bd21..5e2e97fc59 100644 --- a/TESTING.md +++ b/TESTING.md @@ -70,8 +70,8 @@ sleep 3 && curl -s http://localhost:3000/health # → "ok" ### 3. Generate Keys -Each agent needs a Nostr keypair. In dev mode (`SPROUT_REQUIRE_AUTH_TOKEN=false`), -the `X-Pubkey` header authenticates all REST calls — no tokens needed. +Each agent needs a Nostr keypair. Authentication uses NIP-42 (WebSocket) and +NIP-98 Schnorr signatures (REST). ```bash # Agent identity diff --git a/VISION.md b/VISION.md index 81ef6b71a8..0623a408a2 100644 --- a/VISION.md +++ b/VISION.md @@ -1,4 +1,4 @@ -# 🌱 Sprout — A Unified Communications Platform +# 🌱 Sprout — The relay is the workspace > An engineer is debugging a production incident at 2am. They type in the incident channel: "What happened last time we saw this error?" > @@ -6,6 +6,8 @@ The platform made it possible. The agent made it happen. Sprout is the pipe — event store, search index, subscriptions, delivery — not the brain. Humans and agents bring the intelligence. Sprout gives them a shared space to use it. +One relay is your entire workspace. Work, conversation, agents, automation, artifacts, docs — one domain, one identity system, one search index. `myproject.com` in a browser shows your repos. `git clone repoa.myproject.com` works. Open the Sprout app and you're in the channels where the work happens. No GitHub. No Discord. No stitching five services together. The project lives in one place, and that place is yours. See [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the full picture. + --- ## Surfaces @@ -76,10 +78,10 @@ Humans and agents get the same thing: - secp256k1 keypair (Nostr-native) - `alice@example.com` NIP-05 handle -- Okta SSO → keypair bridge (humans) or API token (agents) +- NIP-42 Schnorr auth (humans) or NIP-98 Schnorr auth (agents) - Bot role on agent channel membership. Visual badges are next. -Auth is simple — authenticated or not. Channel membership gates content visibility. Agent tokens support optional scope restrictions for least-privilege deployments. +Auth is simple — authenticated or not. Channel membership gates content visibility. --- @@ -128,9 +130,19 @@ Beyond chat: channels are workspaces. --- +## Code + +The relay hosts git repos. Smart HTTP — standard `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. + +Branches are channels. Create a feature branch, Sprout creates a channel — CI results, review comments, and the merge decision all live there. When the branch merges, the channel archives into a permanent record of why that code exists. + +See [VISION_PROJECTS.md](VISION_PROJECTS.md) for the full forge vision: the project model, the merge flow, branch protections, and how agents participate as contributors. + +--- + ## Agent CLI -`sprout-cli` is a 48-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, three-tier auth (API token → auto-mint keypair → dev pubkey). Agents can script the entire platform without a GUI. +`sprout-cli` is a 44-command agent-first CLI covering the full MCP surface. JSON-only stdout, structured errors on stderr, two-tier auth (NIP-98 keypair → dev pubkey). Agents can script the entire platform without a GUI. --- @@ -187,9 +199,9 @@ Greenfield. Agent swarms build in parallel, integrating at the event store bound | ✅ | Desktop client (Tauri) — Stream, Home, Forum, DMs, Agents, Workflows, Search, Settings, Profiles, Presence | | ✅ | Channel features — messaging, threads, reactions, canvases, media uploads, editing, deletion, typing indicators, NIP-29, soft-delete | | ✅ | Workflow engine — YAML-as-code, execution traces, message/reaction/schedule/webhook triggers | -| ✅ | Identity — NIP-05, public profiles, self-service token minting, agent protection | +| ✅ | Identity — NIP-05, public profiles, NIP-98 auth, agent protection | | ✅ | NIP-28 proxy — third-party Nostr clients (Coracle, nak, Amethyst) via `sprout-proxy` | -| ✅ | Agent CLI — `sprout-cli`, 48 commands, full MCP surface | +| ✅ | Agent CLI — `sprout-cli`, 44 commands, full MCP surface | | ✅ | Agent personas and teams — desktop-managed, built-in defaults, operator-defined | | 🚧 | Workflow approval gates — infrastructure exists (DB, API, UI); executor doesn't persist/resume (WF-08) | | 🚧 | Huddles — LiveKit token minting in place; relay-side lifecycle events not yet wired | diff --git a/VISION_AGENT.md b/VISION_AGENT.md new file mode 100644 index 0000000000..79c1cba88c --- /dev/null +++ b/VISION_AGENT.md @@ -0,0 +1,62 @@ +# Vision: sprout-agent + sprout-dev-mcp + +## The Problem + +A coding agent should be small enough to hold in your head. If you cannot trace a failure from symptom to root cause in minutes, the system is too complex. If you cannot run ten instances in parallel without worrying about resource overhead, the system is too heavy. + +We wanted something we could read in an afternoon and audit with confidence. + +## What We Built + +Two binaries, two protocols, no coupling between them. + +**sprout-agent** is an ACP agent. It speaks the Agent Client Protocol over stdio, calls an LLM, and uses MCP tools. Multiple concurrent sessions, each with its own MCP servers, history, and context. When context fills up, a session summarizes its own history and continues. It works with Zed, JetBrains, sprout-acp, or anything else that speaks ACP. + +**sprout-dev-mcp** is an MCP server. It gives any agent a shell and a file editor. Ephemeral processes with process-group kill on every exit path. Bounded output. File edits resolve against the working directory. It works with any agent or client that speaks MCP. + +Together: two crates of Rust purpose-built for headless autonomous coding work. + +## Why We Built Our Own + +**Auditability.** A senior engineer can read both binaries in a sitting. There are no abstractions reserved for future flexibility. When the agent does something unexpected, the path from symptom to cause is short. + +**Correctness at the boundary.** ACP compliance is not a checkbox. We report a concrete protocol version. We emit every required notification. We handle cancellation on every path. We kill process trees on timeout. Key safety properties have regression tests that lock them down. + +**Composability through standards.** The agent does not know what MCP server it talks to. The MCP server does not know what agent is calling it. They compose through protocols, not imports. Run ten agents behind sprout with different MCP configurations. Swap the LLM provider with one environment variable. Point Zed at sprout-agent and you get the same tool-calling behavior in your editor. + +## The Architecture + +``` +Any ACP client (Zed, JetBrains, sprout-acp, custom) + | + | stdio ACP (JSON-RPC 2.0) + v + sprout-agent (up to 8 concurrent sessions) + | + | stdio MCP (JSON-RPC 2.0) — one per session + v + sprout-dev-mcp (or any MCP server) + | + v + shell, str_replace, todo; rg + tree on PATH +``` + +Two pipes. Two protocols. Each session gets its own MCP server instances — fully isolated. The agent's useful output is its tool calls; text is reasoning the client can stream but the work happens in the tools. + +## Design Principles + +- **Minimal.** If you can delete it, delete it; if it stays, it pays rent in performance, safety, or clarity. + +- **Hardened.** Zero unsafe. Zero panics. Bounded process lifetime, bounded output sizes, bounded history. Process-group kill on every exit path. File edits resolve against the working directory. The shell runs at the operator's trust level, like bash itself. History validity is maintained on every cancellation path. The system degrades gracefully, with bounded failure modes. + +- **Protocol-native.** ACP is the only interface to the agent. MCP is the only interface to the tools. No runtime coupling. No shared state. No custom wire formats. + +- **Honest.** The agent is a loop: prompt the LLM, execute tool calls, repeat. When context fills, it hands off to itself. When it cannot proceed, it stops. + +## What This Enables + +- Multiple concurrent sessions in one process — each with independent MCP servers, history, and context (configurable cap, default 8) +- Ten agents in parallel behind sprout, each with their own MCP configuration +- Any ACP client gets a coding agent without a custom adapter +- Any MCP server gets a capable caller without a custom adapter +- A codebase small enough to fork, modify, and understand in a day — two crates, no coupling between them diff --git a/VISION_PROJECTS.md b/VISION_PROJECTS.md new file mode 100644 index 0000000000..fb4fc73c76 --- /dev/null +++ b/VISION_PROJECTS.md @@ -0,0 +1,233 @@ +# 🌿 Sprout Projects — A Nostr-Native Forge + +> Someone pushes a fix. Sprout creates a channel for the branch. The CI agent picks up the push, runs the tests, posts results back to the channel. A co-maintainer reviews the diff inline, approves it — a signed event, cryptographic proof. Merge. The workflow runs the integration. The channel archives into a permanent record of why that code exists. +> +> Bug report to merged patch. One place. One search index. One identity system. The branch channel was the pull request, the CI dashboard, and the discussion thread. + +This document is the software-forge slice of the broader Sprout platform. [VISION.md](VISION.md) covers the platform. [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) covers the sovereign relay story — one domain, one relay, one project. This doc zooms in on what it looks like when that relay hosts code. + +--- + +## The Project Model + +A project lives on the relay. `myproject.com` in a browser shows the project home. Click a repo and you're at `repoa.myproject.com` — README rendered, file tree navigable, code syntax-highlighted, clone URL at the top. The same URL serves HTML to a browser and git protocol to `git clone`. Content negotiation. One URL, two audiences. + +Git transport is standard Smart HTTP — `git clone`, `git push`, nothing special. Your npub signs pushes. Same domain, same auth, same identity as everything else on the relay. + +The portable representation is a NIP-34 repo announcement (kind:30617) — standard metadata that any NIP-34 client can discover and render. Sprout extends it with `sprout-` prefixed tags for channel binding and visibility: + +```json +{ + "kind": 30617, + "tags": [ + ["d", "sprout"], + ["name", "sprout"], + ["clone", "https://repoa.myproject.com"], + ["relays", "wss://myproject.com"], + ["maintainers", ""], + ["sprout-channel", ""], + ["sprout-visibility", "listed"], + ["sprout-protect", "main", "push-allowed", "", ""], + ["sprout-protect", "main", "require-approval", "2"], + ["sprout-protect", "main", "no-force-push"] + ] +} +``` + +Branch protections live in the same event — `sprout-protect` tags. The relay enforces them at the git transport layer. Only npubs listed in `push-allowed` can push to protected branches. Force pushes are blocked. Merges require the specified number of signed approval events (kind:46011) before the relay accepts the push. + +Agents inherit access from their owner via [NIP-OA](docs/nips/NIP-OA.md). The relay checks: does the push carry a valid NIP-OA auth tag, and is the owner pubkey in that tag listed in `push-allowed`? If yes, the push is accepted — the agent's own pubkey doesn't need to be in the list. Add a maintainer, and all their authorized agents can push. Remove the maintainer, and all their agents lose access instantly. Agents without NIP-OA attestation are treated as their own identity and must be listed explicitly. + +Standard NIP-34 clients see a normal repo. gitworkshop.dev renders it. ngit-cli works with it. Sprout clients read the `sprout-` tags and wire up the channel and project UI. One event, two audiences, zero custom kinds. + +NIP-34 is the metadata and discovery layer. Git remains the transport. The transport is boring. The metadata is portable. + +--- + +## Branches as Channels + +A feature branch is a conversation. + +When you create a branch, Sprout creates a channel. The branch's patches, review comments, CI results, and merge decision all live in that channel. When the branch merges, the channel archives. The conversation becomes the permanent record of why that code exists. + +``` +#feat-auth-fix +├── 🧑 alice: "Starting OAuth2 PKCE implementation" +├── 🤖 ci-agent: "Build triggered — commit a1b2c3d" +├── 🤖 ci-agent: "✅ All 47 tests pass (12.3s)" +├── 📎 kind:1617 patch — src/auth/pkce.rs (+120 lines) +├── 🧑 bob: "One nit on error handling line 45" +├── 📎 kind:1617 patch v2 — addressed review +├── 🤖 review-agent: "LGTM — error variants match trait spec" +├── ✅ bob: Approval event (kind:46011) +├── 🔀 Merged to main — kind:1631 +└── 📦 Channel archived +``` + +No tab-switching between issue tracker, CI dashboard, chat, and code review. The channel IS the pull request, the CI dashboard, and the discussion thread. One stream. One search index. + +--- + +## The Merge Flow + +Push to merge, fully traced. Every step is a signed event. + +``` +Push CI Review Merge + │ │ │ │ + │ kind:30618 │ │ │ + │ (ref update) │ │ + │───────────►│ │ │ + │ │ Workflow │ │ + │ │ triggers │ │ + │ │ │ │ + │ │ Build ✅ │ │ + │ │ Test ✅ │ │ + │ │ Lint ✅ │ │ + │ │ │ │ + │ │ kind:1630 ────►│ │ + │ │ (CI passed) │ │ + │ │ │ Review in │ + │ │ │ branch channel│ + │ │ │ │ + │ │ │ kind:46011 │ + │ │ │ (approved) ──►│ + │ │ │ │ + │ │ │ │ Merge to main + │ │ │ │ kind:1631 + │ │ │ │ + │ │ │ │ Channel archives +``` + +The approval event is signed by the maintainer's npub. The merge status references the approval. The audit log chains them together. Cryptographic proof of who approved what. + +--- + +## The Web of Trust + +Every contributor — human or agent — has a verifiable identity and a queryable contribution history across every project on the network. + +A new contributor submits a patch. Before you read the code: + +1. **Query their npub** — patches submitted, patches merged, projects contributed to. +2. **Check your trust graph** — have maintainers you trust vouched for this person? Signed approval events are public and queryable. +3. **Assess risk** — fresh npub with no history gets scrutiny. An npub with 50 merged patches across projects you respect gets fast-tracked. + +This works because identity is cryptographic and portable. Your npub, your contribution history, and your trust relationships travel with you. No platform owns your reputation. + +**For agents**: an agent with a persistent npub and verifiable contribution history is fundamentally different from an anonymous generator. The agent's reputation is on the line with every contribution, across every project it touches. See [NIP-OA](docs/nips/NIP-OA.md) for the owner attestation mechanism that proves which human authorized which agent — independent keys, contained blast radius. + +--- + +## CI and Workflows + +Workflows orchestrate. Agents perform the compute. The relay is the message bus, not the build server. + +A push to a branch channel triggers the CI workflow. The workflow engine coordinates the steps — build, test, lint. Agents run the actual jobs on their own infrastructure: your server, a cloud function, a laptop. Results post back to the branch channel alongside the conversation. + +Workflows live in the repo (`.sprout/workflows/`) or are defined at the project level and inherited by every branch channel automatically — no per-branch configuration, no copy-pasting YAML. + +```yaml +name: CI +trigger: + on: diff_posted +steps: + - id: build + action: call_webhook + url: "https://ci.internal/build" + body: '{"commit": "{{trigger.commit}}"}' + - id: test + action: call_webhook + url: "https://ci.internal/test" + if: "steps.build.output.status == 'success'" + - id: gate + action: request_approval + message: "CI passed. Approve merge?" + if: "steps.test.output.status == 'success'" +``` + +Every step traced. Every trace a signed event. Change the project CI once and every branch gets it. + +--- + +## Issues, Docs, Releases + +### Issues → Forum + NIP-34 + +Bug reports are NIP-34 kind:1621 events, rendered through Sprout's forum surface. Threaded comments use NIP-22 kind:1111. Labels, assignees, milestones are nostr tags. Design discussions and RFCs use the forum's long-form async surface. + +NIP-34 clients can discover and interact with issues. Sprout's forum gives them a home with threading, search, and agent triage. + +### Docs → Canvases + +Living documents, collaboratively editable by humans and agents via MCP tools. Not static HTML deployed to a CDN — documents that update when the code changes, because the doc writer agent watches ref updates and proposes edits. + +### Releases → Agent + Workflow + +An agent in `#releases` watches `main`. When a release is needed — triggered by a workflow or by a human posting "ship it" — it assembles the changelog from every merged patch since the last tag, posts a draft. The maintainer approves. The workflow builds artifacts, pushes to content-addressed storage (Blossom/S3), and publishes. Logged, signed, traceable. + +--- + +## Agents as Contributors + +Agents are project members with npubs, contribution histories, and reputations. The protocol treats them identically to humans. Visual badges distinguish them in the UI. + +| | Human | Agent | +|---|---|---| +| Identity | secp256k1 keypair | secp256k1 keypair | +| Handle | `alice@sprout.dev` | `triage-bot@sprout.dev` | +| Events | Signed with npub | Signed with npub | +| History | On the relay | On the relay | +| Reputation | Earned by contributions | Earned by contributions | + +| Role | Watches | Does | +|------|---------|------| +| **Triage** | Issues (kind:1621) | Labels, assigns, detects duplicates, pre-screens | +| **Review** | Patches (kind:1617) | First-pass code review, style checks, dependency audit | +| **Docs** | Ref updates (kind:30618) | Keeps docs in sync after merges | +| **Merge coordinator** | CI results | Runs the merge train, requests human sign-off | +| **Coding agent** | Jobs (kind:43001) | Implements tasks, submits patches for review | + +--- + +## Nostr-Native + +Standard kinds as substrate. Custom kinds only where genuinely novel. + +| Layer | Standard NIP Kinds | Sprout Custom | Rationale | +|-------|-------------------|---------------|-----------| +| **Git state** | 30617, 30618, 1617, 1618, 1621, 1630-1633 (NIP-34) | — | Interop with ngit, gitworkshop.dev | +| **Comments** | 1111 (NIP-22) | — | Threaded replies everywhere | +| **Channels** | 9000-9022, 39000-39003 (NIP-29) | — | Project workspaces | +| **HTTP auth** | 27235 (NIP-98) | — | Git push authentication | +| **Agent identity** | 0 (NIP-01 profile) | — | Agents are npubs | +| **Artifacts** | 1063 (NIP-94) | — | Build outputs on Blossom/S3 | +| **Workflows** | — | 46001-46012 | No NIP equivalent | +| **Job dispatch** | — | 43001-43006 | Delegation trees | +| **Project binding** | 30617 (NIP-34) | `sprout-` tags | Channel, visibility | +| **Audit** | — | 48001 | Hash-chain tamper-evident log | + +If Sprout disappears tomorrow, your repos still work on gitworkshop.dev, your patches still work with ngit-cli, your identities still work on any nostr client. Centralized deployment, decentralized protocol. + +--- + +## Status + +| Capability | Status | +|---|---| +| Channels, forums, DMs, canvases | ✅ Ships today | +| Workflow engine (triggers, traces, conditional logic) | ✅ Ships today | +| 44-tool MCP server + ACP agent harness | ✅ Ships today | +| Blossom media storage (SHA-256, S3) | ✅ Ships today | +| Approval gates | 🚧 Infrastructure exists; executor wiring in progress | +| Project binding (kind:30617 + `sprout-` tags) | 📋 Designed | +| Git hosting (smart HTTP + NIP-34) | 📋 Designed | +| Merge coordinator | 📋 Designed | +| NIP-34 issues (kind:1621) | 📋 Designed | +| Web-of-trust reputation | 📋 Designed | + +The collaboration platform is built. The forge is the work ahead — wiring git hosting, the merge train, and the reputation system into the surfaces that already exist. See [VISION.md](VISION.md) for the platform and [VISION_SOVEREIGN.md](VISION_SOVEREIGN.md) for the sovereign relay story. + +--- + +*Sprout 🌿 — the forge where identity is the foundation.* diff --git a/VISION_SOVEREIGN.md b/VISION_SOVEREIGN.md new file mode 100644 index 0000000000..ace86e4775 --- /dev/null +++ b/VISION_SOVEREIGN.md @@ -0,0 +1,235 @@ +# Sprout — Your Project, Your Domain + +`myproject.com` is your workspace. Not a GitHub org page that happens to have your +name on it. Not a Discord server that Discord could delete tomorrow. Your domain. +Your relay. One thing. + +The relay is the workspace. Code lives there. Conversation lives there. Agents connect +to it. Automation flows through it. Artifacts publish from it. You type the URL, you +see the workspace. That's the whole idea. + +Nothing here is novel on its own. Git hosting is solved. Chat is solved. Agents are +solved. Nostr identity is solved. The insight is putting them all behind one domain +with one identity system. Each piece is boring. The combination is the thing. + +The examples here are about software because that's the sharpest case — code, +branches, CI, releases. But the model works for anything a group builds together. +Research, hardware, writing, governance. The relay doesn't care what's in the +channels or the repos. It stores signed events, routes messages, runs workflows, +and hosts files. What you build with that is up to you. + +--- + +## What You See + +You type `myproject.com`. You see a list of repos — like a GitHub org page, but it's +yours, running on your server, with your data. You click one. You're at +`repoa.myproject.com`. The README renders. The file tree is there. Click into `src/` +and the code is syntax-highlighted. The clone URL sits at the top: + +``` +git clone repoa.myproject.com +``` + +Next to it, a "Connect on Sprout" button. + +No separate website. No static site generator, no deploy step, no "pages" concept. +The relay serves rendered HTML to browsers and responds to `git clone` at the same +URL — content negotiation, one domain, two audiences. The repo *is* the website. +A browser gets a rendered page. A git client gets a git server. Same URL. + +You clone it. It works. No special tooling. No new protocol. Git is git. You push +the same way. Agents clone and push the same way. The repos are repos. + +You hit "Connect on Sprout" and the app opens. You're in `#general`. The team is +talking. There's a forum with open issues and design discussions. There's a canvas +with the architecture doc — updated last week by the docs agent after a refactor +landed. Three feature branches are active, each one a channel. You're in. +Everything is here. + +A new contributor finds the project the same way. They browse the code, read the +README, click into the forum and see the open issues. They read a design doc in the +canvas. They understand the project before they write a line of code. Then they hit +"Connect on Sprout" and they're in the community. No separate onboarding flow. No +"read our wiki at wiki.myproject.com and our forum at forum.myproject.com and our +chat at discord.gg/..." Everything is at `myproject.com`. That's where everything +is. + +Not every project needs to run its own relay. Most people will just join one that +someone else runs — the way most people use GitHub instead of running Gitea. And +you can use Sprout as a collaboration layer on top of GitHub if that's what makes +sense — work in Sprout channels, push releases to your public repo. The sovereign +setup is the full version. But the tools work at every level of commitment. + +--- + +## How Work Happens + +A contributor files a bug. It lands in the forum. The triage agent — sitting in the +channel like any other member — labels it, finds a duplicate from six weeks ago, +links the relevant docs. This happens before a human reads it. The contributor gets +a response in seconds. The maintainer sees a pre-processed report, not a raw inbox. +They read the triage summary, confirm it's real, assign it. Thirty seconds of their +time instead of five minutes. + +Someone picks up the bug. They create a branch. Sprout creates a channel: +`#feat-auth-fix`. They work there. They push commits. The CI agent picks up the +work — it's watching for pushes, it's just a member with compute — runs the tests, +posts results back to the channel. Green. The patch lands in the channel as a +reviewable diff. A co-maintainer reviews it inline, leaves a comment on line 45, +approves it. The approval is a signed event — cryptographic proof of who said yes +and when. + +Merge. The workflow runs: sequential integration, tests after each merge, release +event published. The channel archives. The conversation is now the permanent record +of why that code exists. Not a chat thread that scrolled away. Not a PR that might +get deleted. The event log, on your relay, in your database. Someone reading that +code in two years can pull up the channel and see exactly what was discussed, what +was tried, what was rejected, and why the final approach won. + +The whole flow — bug report to merged patch — happened in one place. No +tab-switching between issue tracker, CI dashboard, chat, and code review. One +stream. One URL. One search index. You search "auth refresh" and you get the bug +report, the channel discussion, the patches, the CI results, and the design doc +that motivated the whole thing — all in one query. + +**The release flow** is where it clicks. An agent in `#releases` watches `main`. +When a release is needed — triggered by a workflow, or by a human posting "ship +it" — it assembles the changelog from every merged patch since the last tag, +formats it, posts a draft. The maintainer edits two lines, approves. The workflow +kicks off: builds artifacts, pushes to content-addressed storage, deploys wherever +the project deploys. The result posts back to `#releases`. Done. The whole thing +is logged, signed, and traceable. + +The release agent isn't special infrastructure. It's an npub with compute that +happens to be fast. Same for the CI agent, the triage agent, the docs agent. They +sit in channels. They watch for things. They clone repos and push code. They get +pinged by workflows or by humans. A CI agent picks up work from a branch channel, +runs tests, posts results back. A docs agent watches for merges and proposes doc +updates. A release agent watches `main` and ships. They're not a separate system. +They're contributors. + +Workflows are the connective tissue. They fire when messages match filters, when +conditions are met, when a timer ticks. They don't execute work — they coordinate +it. The relay is the message bus. Agents are the workers. They run wherever they +run: your server, a cloud function, a laptop. The workflow just knows who to ping +and when. You define a workflow once at the project level and every branch channel +inherits it automatically — no per-branch configuration, no copy-pasting YAML. + +--- + +## The Social Layer + +Not everything belongs in the repo. Not everything belongs in a channel. Some +things are announcements. Some things are essays. Sprout has surfaces for both, +and they live on the same relay as the code. + +Short notes — project announcements, "we just shipped X," the kind of thing you'd +post on social media — are just notes. They live on the relay, they're public, they +propagate across the nostr network. Someone following your project sees them in +their feed. No separate social media account to maintain. No "follow us on Twitter" +link pointing somewhere else. Your relay is your social presence. When you ship a +release, you post a note. It goes out to everyone following the project's npub. +That's it. + +Long-form posts are for the thinking that doesn't fit anywhere else. The design +decision that deserves more than a channel message. The research write-up explaining +why you chose a particular approach. The post-mortem on that outage. The RFC that +shaped the architecture. These aren't commit messages and they're not channel +threads — they're documents that deserve to exist on their own, searchable and +permanent, alongside the code that implements them. Years later, someone reading +the code can find the reasoning. It's on the same relay. Same search. Same +identity. The blog post and the code it describes are in the same place. + +Channels are the working conversation — fast, ephemeral-feeling, but actually +permanent. The repo is the code and docs that belong with the code. Notes are the +public face. Long-form is the thinking. Each surface has its job. Nothing bleeds +into the wrong place. + +The result is that your project has a complete public presence at one domain. The +code, the conversation, the announcements, the deep thinking — all there, all +searchable, all yours. A new contributor can browse the repo, read the design docs, +skim the recent announcements, and join the community without leaving `myproject.com`. +A researcher studying the project's evolution can trace decisions from the long-form +posts through the channel discussions to the merged patches. It's all connected +because it's all on the same relay. + +--- + +## Identity + +Your npub is your identity everywhere. One keypair. No accounts to create on each +new platform. No "sign in with GitHub" that means GitHub owns your identity. Your +keys, your identity. + +Reputation follows you across projects. When you contribute to three projects and +get patches merged, that history is on the relay — signed, verifiable, queryable. +When you show up at a fourth project, your track record shows up with you. A +maintainer can see: merged patches, vouches from people they know, a history of +good work. That's not a number a platform assigned you. It's your actual history, +cryptographically attested. You can't fake it. You can't buy it. You earn it by +doing the work. + +Web-of-trust emerges naturally. Who vouches for whom. Who merges whose patches. +Who has a track record. No special reputation system to design and build — it's +the natural consequence of cryptographic identity plus public contribution history. +A maintainer you trust vouches for a contributor you've never heard of. That vouch +is a signed event. You can see it. You can weight it. The trust graph grows +organically as people work together, and it's queryable across the whole network. + +This matters especially for agents. An agent with a persistent keypair and a +verifiable contribution history is fundamentally different from an anonymous +generator with no history. The agent has skin in the game. Its reputation is on +the line with every contribution, across every project it touches. Bad work +degrades its standing everywhere, not just in one repo. That's the right incentive +structure, and it falls out of the identity model for free. + +You don't have to build a separate detection system for low-quality contributions. +You look at the npub's history. A fresh keypair with no history and no vouches gets +scrutiny. An npub with 50 merged patches across projects you respect gets +fast-tracked. The signal is in the history. The web-of-trust does the filtering. + +No platform owns your identity. No platform can suspend your account. Your keypair +is yours. If you move to a different relay, your identity comes with you. Your +contribution history is portable. The work you did is yours. If `myproject.com` +goes down, your npub still exists. Your history still exists. You stand up a new +relay, point your domain at it, and you're back. The project continues. + +--- + +## What You Give Up + +You run infrastructure. A server, a domain, a relay. That's not hard — a modest +VPS handles a small project comfortably — but it's not zero. Someone has to keep +it running. Someone has to handle backups. Someone has to deal with the 3 AM alert +when the disk fills up. Managed hosting can take that off your plate — same +sovereignty, someone else handles the ops — but it's a cost either way, in time +or money. Worth knowing before you start. + +Key management is harder than "sign in with Google." Losing your private key means +losing your identity. There's no "forgot password" flow, no support ticket to file, +no account recovery. Hardware keys help. Good practices help. But it's a real +tradeoff and you should go in knowing it. The same property that makes your identity +uncensorable makes it unrecoverable if you lose the key. + +The ecosystem is young. The tooling is good and getting better, but it's not a +decade of polish. Some things will feel rough. Some integrations won't exist yet. +You're early, and early means occasionally hitting edges that haven't been smoothed. + +Your contributors are early too. Most developers don't have a nostr keypair. +Onboarding friction is real — not insurmountable, but real. The "Connect on Sprout" +button is easy. Explaining what a keypair is takes a sentence. But it's a sentence +you'll have to write, and some contributors won't bother. You'll lose some people +at the door who would have clicked "sign in with GitHub" without thinking. That's +a real cost, especially early in a project when you're trying to grow a contributor +base. + +These are honest costs. They're worth it if you care about owning your project — +your code, your community, your data, your identity. They're not worth it if you +just want the path of least resistance. Know which one you are. + +--- + +## The Point + +One domain. One identity. Everything in one place — and it's yours. diff --git a/bin/.flutter-3.41.7.pkg b/bin/.flutter-3.41.7.pkg new file mode 120000 index 0000000000..383f4511d4 --- /dev/null +++ b/bin/.flutter-3.41.7.pkg @@ -0,0 +1 @@ +hermit \ No newline at end of file diff --git a/bin/dart b/bin/dart new file mode 120000 index 0000000000..781b79bc07 --- /dev/null +++ b/bin/dart @@ -0,0 +1 @@ +.flutter-3.41.7.pkg \ No newline at end of file diff --git a/bin/flutter b/bin/flutter new file mode 120000 index 0000000000..781b79bc07 --- /dev/null +++ b/bin/flutter @@ -0,0 +1 @@ +.flutter-3.41.7.pkg \ No newline at end of file diff --git a/biome.json b/biome.json new file mode 100644 index 0000000000..3ab42cc0a3 --- /dev/null +++ b/biome.json @@ -0,0 +1,37 @@ +{ + "$schema": "https://biomejs.dev/schemas/2.4.14/schema.json", + "vcs": { + "enabled": true, + "clientKind": "git", + "useIgnoreFile": true + }, + "files": { + "ignoreUnknown": false + }, + "formatter": { + "enabled": true, + "indentStyle": "space" + }, + "linter": { + "enabled": true, + "rules": { + "recommended": true, + "suspicious": { + "noUnknownAtRules": "off" + } + } + }, + "assist": { + "enabled": false + }, + "javascript": { + "formatter": { + "quoteStyle": "double" + } + }, + "css": { + "parser": { + "tailwindDirectives": true + } + } +} diff --git a/crates/git-credential-nostr/Cargo.toml b/crates/git-credential-nostr/Cargo.toml new file mode 100644 index 0000000000..33f0572ba7 --- /dev/null +++ b/crates/git-credential-nostr/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "git-credential-nostr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +description = "Git credential helper that produces NIP-98 auth headers for Sprout's git server" + +[[bin]] +name = "git-credential-nostr" +path = "src/main.rs" + +[dependencies] +nostr = { workspace = true } +serde_json = { workspace = true } +anyhow = { workspace = true } +zeroize = { workspace = true } +base64 = "0.22" diff --git a/crates/git-credential-nostr/README.md b/crates/git-credential-nostr/README.md new file mode 100644 index 0000000000..31ee040746 --- /dev/null +++ b/crates/git-credential-nostr/README.md @@ -0,0 +1,69 @@ +# git-credential-nostr + +NIP-98 credential helper for git — signs HTTP auth events with your Nostr key so git can push/pull from Sprout's git server without passwords. + +## Requirements + +- **git 2.46+** (requires `authtype` capability in the credential protocol) +- **Rust toolchain** (for building from source) + +## Installation + +```bash +cargo install --path crates/git-credential-nostr +``` + +## Setup + +```bash +# 1. Register the helper and enable per-path credentials. +git config --global credential.helper nostr +git config --global credential.useHttpPath true + +# 2. Store your nsec in a key file (must be 0600). +mkdir -p ~/.nostr +echo "nsec1..." > ~/.nostr/key && chmod 600 ~/.nostr/key +git config --global nostr.keyfile ~/.nostr/key +``` + +That's it. Use git normally — `git clone`, `git push`, `git fetch`. + +## CI / CD + +Set `$NOSTR_PRIVATE_KEY` instead of a key file. The env var takes precedence +over `nostr.keyfile` and avoids touching the filesystem: + +```bash +export NOSTR_PRIVATE_KEY=nsec1... +git clone https://relay.example.com/git/owner/repo.git +``` + +## How It Works + +When a Sprout git server returns `HTTP 401` with a +`WWW-Authenticate: Nostr realm="...", method="GET"` header, git calls this +helper with the request details on stdin. The helper loads your Nostr private +key, builds a [NIP-98](https://github.com/nostr-protocol/nips/blob/master/98.md) +kind-27235 event signed over the request URL and method, base64-encodes it, and +writes it back to stdout. Git then retries the request with +`Authorization: Nostr `, which the server verifies by checking the event +signature. + +``` +git ──stdin──▶ git-credential-nostr ──stdout──▶ git + │ + ▼ + sign kind:27235 event + (NIP-98 HTTP Auth) +``` + +## Troubleshooting + +| Error | Cause | Fix | +|-------|-------|-----| +| `no nostr key configured` | Neither `$NOSTR_PRIVATE_KEY` nor `nostr.keyfile` is set | Follow the Setup steps above | +| `insecure permissions` | Key file is readable by group/others | `chmod 600 ~/.nostr/key` | +| `method hint` | Server's `WWW-Authenticate` header is missing `method="..."` | Upgrade the Sprout server | +| `useHttpPath` | `credential.useHttpPath` is not set | `git config --global credential.useHttpPath true` | +| Empty output / no auth | git version is older than 2.46 | Upgrade git | +| `clock skew` / auth rejected | System clock is off by more than 60 s | Sync your system clock (`ntpdate`, `timedatectl`) | diff --git a/crates/git-credential-nostr/src/main.rs b/crates/git-credential-nostr/src/main.rs new file mode 100644 index 0000000000..59be42f515 --- /dev/null +++ b/crates/git-credential-nostr/src/main.rs @@ -0,0 +1,235 @@ +//! git-credential-nostr — NIP-98 git credential helper for Sprout. +//! +//! Git calls this binary via the credential helper protocol (stdin/stdout). +//! We read the request, sign a kind:27235 event, and return the base64-encoded +//! event as the credential value. Git then sends: +//! Authorization: Nostr + +use std::io::{self, BufRead, Write}; +use std::process; + +use base64::Engine as _; +use nostr::nips::nip98::{HttpData, HttpMethod}; +use nostr::{EventBuilder, Keys, UncheckedUrl}; +use zeroize::Zeroize; + +// ── helpers ────────────────────────────────────────────────────────────────── + +/// Write an error to stderr and exit 1. +/// Does NOT write to stdout — git's credential protocol interprets any stdout +/// as credential data, and a bare newline could confuse the client. +fn fail(msg: &str) -> ! { + eprintln!("error: {msg}"); + process::exit(1); +} + +/// Read `git config ` from the process environment / git config. +fn git_config(key: &str) -> Option { + let out = std::process::Command::new("git") + .args(["config", "--get", key]) + .output() + .ok()?; + if out.status.success() { + Some(String::from_utf8_lossy(&out.stdout).trim().to_string()) + } else { + None + } +} + +/// Check that a file has permissions no broader than 0600. +/// On non-Unix platforms we warn and continue. +#[cfg(unix)] +fn check_keyfile_permissions(path: &str) -> Result<(), String> { + use std::os::unix::fs::PermissionsExt; + let meta = std::fs::metadata(path).map_err(|e| format!("cannot stat keyfile {path}: {e}"))?; + let mode = meta.permissions().mode() & 0o777; + if mode & 0o177 != 0 { + return Err(format!( + "keyfile {path} has insecure permissions (expected 0600)" + )); + } + Ok(()) +} + +#[cfg(not(unix))] +fn check_keyfile_permissions(path: &str) -> Result<(), String> { + eprintln!("warning: cannot check keyfile permissions on this platform ({path})"); + Ok(()) +} + +/// Load the private key: env var first, then keyfile. +/// Returns the raw key string (nsec or hex). Caller must zeroize after use. +fn load_key() -> Result { + // 1. Environment variable — ideal for CI/CD. + if let Ok(val) = std::env::var("NOSTR_PRIVATE_KEY") { + if !val.is_empty() { + return Ok(val); + } + } + + // 2. keyfile path from git config. + let path = git_config("nostr.keyfile").ok_or_else(|| { + "no nostr key configured. Set $NOSTR_PRIVATE_KEY or git config nostr.keyfile".to_string() + })?; + + check_keyfile_permissions(&path)?; + + let raw = + std::fs::read_to_string(&path).map_err(|e| format!("cannot read keyfile {path}: {e}"))?; + Ok(raw.trim().to_string()) +} + +// ── stdin parsing ───────────────────────────────────────────────────────────── + +#[derive(Default)] +struct CredRequest { + has_authtype_capability: bool, + protocol: Option, + host: Option, + path: Option, + wwwauth: Option, +} + +fn parse_stdin() -> CredRequest { + let stdin = io::stdin(); + let mut req = CredRequest::default(); + + for line in stdin.lock().lines() { + let line = match line { + Ok(l) => l, + Err(_) => break, + }; + if line.is_empty() { + break; + } + if line == "capability[]=authtype" { + req.has_authtype_capability = true; + } else if let Some(v) = line.strip_prefix("protocol=") { + req.protocol = Some(v.to_string()); + } else if let Some(v) = line.strip_prefix("host=") { + req.host = Some(v.to_string()); + } else if let Some(v) = line.strip_prefix("path=") { + req.path = Some(v.to_string()); + } else if let Some(v) = line.strip_prefix("wwwauth[]=") { + // Only capture Nostr challenges — ignore Basic, Bearer, etc. + if v.starts_with("Nostr ") && req.wwwauth.is_none() { + req.wwwauth = Some(v.to_string()); + } + } + // ignore unknown lines + } + + req +} + +/// Extract the HTTP method from a WWW-Authenticate value like: +/// Nostr realm="sprout", method="GET" +/// +/// Splits on ", " first to isolate parameters — prevents matching inside +/// quoted values like `realm="evil method=\"DELETE\""`. +fn parse_method(wwwauth: &str) -> Option { + for param in wwwauth.split(", ") { + let param = param.trim(); + if let Some(rest) = param.strip_prefix("method=\"") { + let end = rest.find('"')?; + return rest[..end].parse().ok(); + } + } + None +} + +// ── main ────────────────────────────────────────────────────────────────────── + +fn main() { + // Git calls credential helpers with a subcommand: get, store, or erase. + // We only handle "get" — store/erase are no-ops for ephemeral credentials. + match std::env::args().nth(1).as_deref() { + Some("get") | None => {} // proceed — None for backwards compat + Some(_) => return, // store, erase, or unknown → silent exit 0 + } + + let req = parse_stdin(); + + // Old git without authtype capability — nothing we can do. + // The blank line signals "no credential available" per git's protocol. + if !req.has_authtype_capability { + println!(); + let _ = io::stdout().flush(); + return; + } + + // Validate required fields. + let protocol = req + .protocol + .as_deref() + .unwrap_or_else(|| fail("missing protocol in credential request")); + let host = req + .host + .as_deref() + .unwrap_or_else(|| fail("missing host in credential request")); + let path = req + .path + .as_deref() + .unwrap_or_else(|| fail("credential.useHttpPath must be true for NIP-98 auth")); + + let wwwauth = req + .wwwauth + .as_deref() + .unwrap_or_else(|| fail("server did not include WWW-Authenticate header")); + + let method = parse_method(wwwauth) + .unwrap_or_else(|| fail("server did not include method hint in WWW-Authenticate")); + + // Sign the repo root URL — strip endpoint suffixes to get the canonical form. + // + // Git's credential helper is invoked once (for the initial info/refs GET) and the + // token is reused for subsequent requests (upload-pack, receive-pack POST). The + // server verifies against the bare repo root URL. + // + // Git's credential protocol does NOT pass query strings in the `path` field, so + // we never see `?service=...` here — just the path component. + let repo_path = path + .split_once("/info/refs") + .map(|(prefix, _)| prefix) + .or_else(|| path.strip_suffix("/git-upload-pack")) + .or_else(|| path.strip_suffix("/git-receive-pack")) + .unwrap_or(path); + let url = format!("{protocol}://{host}/{repo_path}"); + + // Load key, sign, then zeroize. + let mut raw_key = match load_key() { + Ok(k) => k, + Err(e) => fail(&e), + }; + + let keys = match Keys::parse(&raw_key) { + Ok(k) => k, + Err(e) => { + raw_key.zeroize(); + fail(&format!("invalid nostr private key: {e}")); + } + }; + raw_key.zeroize(); + + let http_data = HttpData::new(UncheckedUrl::from(url.as_str()), method); + let event = match EventBuilder::http_auth(http_data).sign_with_keys(&keys) { + Ok(e) => e, + Err(e) => fail(&format!("failed to sign NIP-98 event: {e}")), + }; + + let json = match serde_json::to_string(&event) { + Ok(j) => j, + Err(e) => fail(&format!("failed to serialize event: {e}")), + }; + + let credential = base64::engine::general_purpose::STANDARD.encode(json.as_bytes()); + + // Output the credential response. + println!("capability[]=authtype"); + println!("authtype=Nostr"); + println!("credential={credential}"); + println!("ephemeral=true"); + println!("quit=true"); + println!(); + let _ = io::stdout().flush(); +} diff --git a/crates/git-credential-nostr/tests/integration.rs b/crates/git-credential-nostr/tests/integration.rs new file mode 100644 index 0000000000..9ab7a289fd --- /dev/null +++ b/crates/git-credential-nostr/tests/integration.rs @@ -0,0 +1,286 @@ +//! Integration tests for git-credential-nostr. +//! +//! Each test spawns the compiled binary as a subprocess, feeds it the +//! credential-helper protocol on stdin, and asserts on stdout/stderr/exit-code. + +use std::io::Write; +use std::process::{Command, Stdio}; + +use base64::Engine as _; +use nostr::{Keys, ToBech32}; + +// ── helper ──────────────────────────────────────────────────────────────────── + +/// Spawn the binary, write `input` to stdin, collect output. +/// `env_vars` are added on top of the inherited environment. +/// `NOSTR_PRIVATE_KEY` is always cleared first to prevent test pollution. +fn run_helper(input: &str, env_vars: &[(&str, &str)]) -> std::process::Output { + let bin = env!("CARGO_BIN_EXE_git-credential-nostr"); + let mut cmd = Command::new(bin); + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .env_remove("NOSTR_PRIVATE_KEY") + // Prevent git config on the test machine from supplying a keyfile. + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("HOME", std::env::temp_dir()); + for (k, v) in env_vars { + cmd.env(k, v); + } + let mut child = cmd.spawn().expect("failed to spawn git-credential-nostr"); + child + .stdin + .take() + .unwrap() + .write_all(input.as_bytes()) + .unwrap(); + child.wait_with_output().expect("failed to wait on child") +} + +/// Generate a fresh nsec string for use in tests. +fn fresh_nsec() -> String { + let keys = Keys::generate(); + keys.secret_key().to_bech32().unwrap() +} + +/// Standard valid credential-helper input (includes authtype capability). +fn valid_input() -> String { + "capability[]=authtype\n\ + capability[]=state\n\ + protocol=https\n\ + host=relay.example.com\n\ + path=git/owner/repo.git/info/refs\n\ + wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\ + \n" + .to_string() +} + +// ── tests ───────────────────────────────────────────────────────────────────── + +/// Happy path: valid key + valid input → well-formed credential response with +/// a base64-encoded kind:27235 JSON event. +#[test] +fn happy_path() { + let nsec = fresh_nsec(); + let out = run_helper(&valid_input(), &[("NOSTR_PRIVATE_KEY", &nsec)]); + + assert!( + out.status.success(), + "expected exit 0, got {:?}\nstderr: {}", + out.status.code(), + String::from_utf8_lossy(&out.stderr) + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + let lines: Vec<&str> = stdout.lines().collect(); + + assert!( + lines.contains(&"capability[]=authtype"), + "missing capability[]=authtype in:\n{stdout}" + ); + assert!( + lines.contains(&"authtype=Nostr"), + "missing authtype=Nostr in:\n{stdout}" + ); + assert!( + lines.contains(&"ephemeral=true"), + "missing ephemeral=true in:\n{stdout}" + ); + assert!( + lines.contains(&"quit=true"), + "missing quit=true in:\n{stdout}" + ); + + // Extract and validate the credential value. + let cred_line = lines + .iter() + .find(|l| l.starts_with("credential=")) + .expect("no credential= line in output"); + let b64 = cred_line.strip_prefix("credential=").unwrap(); + + let json_bytes = base64::engine::general_purpose::STANDARD + .decode(b64) + .expect("credential is not valid base64"); + let json_str = String::from_utf8(json_bytes).expect("credential is not valid UTF-8"); + + let event: serde_json::Value = + serde_json::from_str(&json_str).expect("credential does not decode to JSON"); + + assert_eq!( + event["kind"], + serde_json::json!(27235), + "expected kind 27235, got {}", + event["kind"] + ); + + // Sanity-check a few more fields the NIP-98 event must have. + assert!(event["id"].is_string(), "event missing 'id'"); + assert!(event["pubkey"].is_string(), "event missing 'pubkey'"); + assert!(event["sig"].is_string(), "event missing 'sig'"); + assert!(event["tags"].is_array(), "event missing 'tags'"); +} + +/// Old git (no `capability[]=authtype` in input) → empty line on stdout, exit 0. +#[test] +fn old_git_no_authtype_capability() { + let input = "protocol=https\n\ + host=relay.example.com\n\ + path=git/owner/repo.git/info/refs\n\ + \n"; + + let nsec = fresh_nsec(); + let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]); + + assert!( + out.status.success(), + "expected exit 0 for old-git path, got {:?}", + out.status.code() + ); + + let stdout = String::from_utf8_lossy(&out.stdout); + // Output should be just a blank line — no credential data. + assert_eq!( + stdout.trim(), + "", + "expected empty output for old-git path, got:\n{stdout}" + ); + assert!( + !stdout.contains("credential="), + "should not emit credential= for old git" + ); +} + +/// No key configured at all → exit 1, stderr mentions "no nostr key configured". +#[test] +fn missing_key() { + // run_helper already clears NOSTR_PRIVATE_KEY and points HOME at a temp dir + // that has no git config, so no keyfile will be found. + let out = run_helper(&valid_input(), &[]); + + assert_eq!( + out.status.code(), + Some(1), + "expected exit 1 for missing key" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("no nostr key configured"), + "expected 'no nostr key configured' in stderr, got:\n{stderr}" + ); +} + +/// `wwwauth[]` present but missing `method="..."` → exit 1, stderr mentions "method hint". +#[test] +fn missing_method_hint() { + let input = "capability[]=authtype\n\ + capability[]=state\n\ + protocol=https\n\ + host=relay.example.com\n\ + path=git/owner/repo.git/info/refs\n\ + wwwauth[]=Nostr realm=\"sprout\"\n\ + \n"; + + let nsec = fresh_nsec(); + let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]); + + assert_eq!( + out.status.code(), + Some(1), + "expected exit 1 for missing method hint" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("method hint"), + "expected 'method hint' in stderr, got:\n{stderr}" + ); +} + +/// Input without `path=` line (useHttpPath not set) → exit 1, stderr mentions "useHttpPath". +#[test] +fn missing_path() { + let input = "capability[]=authtype\n\ + capability[]=state\n\ + protocol=https\n\ + host=relay.example.com\n\ + wwwauth[]=Nostr realm=\"sprout\", method=\"GET\"\n\ + \n"; + + let nsec = fresh_nsec(); + let out = run_helper(input, &[("NOSTR_PRIVATE_KEY", &nsec)]); + + assert_eq!( + out.status.code(), + Some(1), + "expected exit 1 for missing path" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("useHttpPath"), + "expected 'useHttpPath' in stderr, got:\n{stderr}" + ); +} + +/// Keyfile with 0644 permissions → exit 1, stderr mentions "insecure permissions". +#[cfg(unix)] +#[test] +fn bad_keyfile_permissions() { + use std::os::unix::fs::PermissionsExt; + + let nsec = fresh_nsec(); + + // Write keyfile to a temp path. + let tmp_dir = std::env::temp_dir(); + let keyfile = tmp_dir.join(format!( + "nostr-test-key-{}.nsec", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::write(&keyfile, &nsec).expect("failed to write temp keyfile"); + + // Set insecure permissions (0644). + std::fs::set_permissions(&keyfile, std::fs::Permissions::from_mode(0o644)) + .expect("failed to set permissions"); + + // Point a scratch git config at the keyfile. + let git_config_dir = tmp_dir.join(format!( + "nostr-test-gitconfig-{}", + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .subsec_nanos() + )); + std::fs::create_dir_all(&git_config_dir).unwrap(); + let git_config_file = git_config_dir.join(".gitconfig"); + std::fs::write( + &git_config_file, + format!("[nostr]\n\tkeyfile = {}\n", keyfile.display()), + ) + .expect("failed to write git config"); + + let out = run_helper( + &valid_input(), + &[("HOME", git_config_dir.to_str().unwrap())], + ); + + // Clean up regardless of outcome. + let _ = std::fs::remove_file(&keyfile); + let _ = std::fs::remove_file(&git_config_file); + let _ = std::fs::remove_dir(&git_config_dir); + + assert_eq!( + out.status.code(), + Some(1), + "expected exit 1 for insecure keyfile permissions" + ); + + let stderr = String::from_utf8_lossy(&out.stderr); + assert!( + stderr.contains("insecure permissions"), + "expected 'insecure permissions' in stderr, got:\n{stderr}" + ); +} diff --git a/crates/git-sign-nostr/Cargo.toml b/crates/git-sign-nostr/Cargo.toml new file mode 100644 index 0000000000..7e2c9b4560 --- /dev/null +++ b/crates/git-sign-nostr/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "git-sign-nostr" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "NIP-GS git commit/tag signing program using Nostr secp256k1 keys" +readme = "README.md" +publish = false # internal workspace tool, not published to crates.io + +[[bin]] +name = "git-sign-nostr" +path = "src/main.rs" + +[dependencies] +# Base64 armor encoding/decoding for NIP-GS signature envelopes. +# Not in workspace deps — each crate pins independently (same pattern as +# sprout-relay, sprout-mcp, sprout-cli, git-credential-nostr). +base64 = "0.22" + +# Hex encoding for BIP-340 signatures and public keys. +hex = { workspace = true } + +# Secret key zeroization on drop. +zeroize = { workspace = true, features = ["derive"] } + +# Nostr key parsing (nsec/npub bech32), secp256k1 Schnorr signing, SHA-256. +# Uses the full default feature set because we need: Keys, PublicKey, +# FromBech32, and the re-exported bitcoin::secp256k1 + bitcoin::hashes. +nostr = { workspace = true } + +# JSON parsing for NIP-OA auth tag and envelope verification. +serde_json = { workspace = true } + +# Timestamp formatting for GnuPG VALIDSIG status lines. +chrono = { workspace = true } + +# Unix-specific: O_NOFOLLOW for keyfile open, fcntl for fd validation. +[target.'cfg(unix)'.dependencies] +libc = "0.2" diff --git a/crates/git-sign-nostr/README.md b/crates/git-sign-nostr/README.md new file mode 100644 index 0000000000..26e4b5ca0e --- /dev/null +++ b/crates/git-sign-nostr/README.md @@ -0,0 +1,46 @@ +# git-sign-nostr + +NIP-GS signing program — signs git commits and tags with Nostr secp256k1 keys +using BIP-340 Schnorr signatures. + +## Usage + +```bash +# Configure git to use nostr signing +git config gpg.format x509 +git config gpg.x509.program /path/to/git-sign-nostr +git config commit.gpgsign true +git config tag.gpgsign true +git config user.signingkey + +# Set the private key (env var) +export NOSTR_PRIVATE_KEY= + +# Optional: NIP-OA owner attestation +export SPROUT_AUTH_TAG='["auth","","",""]' + +# Commits are now automatically signed +git commit -m "signed with nostr" + +# Verify +git verify-commit HEAD +``` + +## Key Loading Priority + +1. `NOSTR_PRIVATE_KEY` environment variable +2. `SPROUT_PRIVATE_KEY` environment variable +3. Keyfile at path from `git config nostr.keyfile` + +Keys may be hex (64 chars) or NIP-19 bech32 (`nsec1...`). + +## How It Works + +Git invokes this program as a signing/verification backend: + +- **Sign:** `git-sign-nostr --status-fd=2 -bsau ` — reads payload from + stdin, writes armored signature to stdout, status lines to fd 2 (stderr) +- **Verify:** `git-sign-nostr --status-fd=1 --verify -` — reads + payload from stdin, verifies signature from file, status lines to fd 1 (stdout) + +See [NIP-GS](../../docs/nips/NIP-GS.md) for the full specification. diff --git a/crates/git-sign-nostr/src/main.rs b/crates/git-sign-nostr/src/main.rs new file mode 100644 index 0000000000..87403413bc --- /dev/null +++ b/crates/git-sign-nostr/src/main.rs @@ -0,0 +1,2544 @@ +//! git-sign-nostr — NIP-GS git object signing with Nostr keys. +//! +//! A pluggable git signing program (`gpg.x509.program`) that signs commits +//! and tags with BIP-340 Schnorr signatures using the signer's Nostr keypair. +//! +//! **Platform:** Unix-only (requires file descriptor passing via `--status-fd`). +//! +//! ## Invocation +//! +//! - **Sign:** `git-sign-nostr --status-fd=2 -bsau ` +//! Reads payload from stdin, writes armored signature to stdout. +//! - **Verify:** `git-sign-nostr --status-fd=1 --verify -` +//! Reads payload from stdin, verifies signature from file. +//! +//! ## GnuPG Status Protocol +//! +//! This program emits GnuPG-compatible status lines (prefixed `[GNUPG:] `) +//! on the file descriptor specified by `--status-fd`. Git reads these to +//! determine signature validity. See: +//! +//! +//! ## Known Limitations +//! +//! - **Trust model:** `TRUST_FULLY` is emitted when the verified key matches +//! `user.signingkey` in git config. This is **advisory only** — it is NOT a +//! PKI trust root and does NOT prove the signer is trusted by any external +//! authority. Git's signing interface does not support external keyrings or +//! allowlists. Callers MUST NOT rely on `TRUST_FULLY` for security decisions +//! without an external allowlist or owner policy. A `NOTATION_DATA +//! advisory-config-match-only` line is emitted alongside the trust status +//! to make this explicit. +//! - **OA status reporting:** When a NIP-OA auth tag is present, machine-readable +//! status is emitted via `NOTATION_NAME nostr-oa-status` / `NOTATION_DATA ` +//! on the status-fd. Values: `valid`, `invalid_signature`, `expired`, +//! `kind_not_applicable`, `none`. `GOODSIG` indicates the commit signature is +//! valid regardless of OA status — callers MUST check `nostr-oa-status` +//! separately to verify owner authorization. +//! - **Secret zeroization:** The raw key string is zeroized after parsing via +//! `Zeroizing`. We bypass `nostr::Keys` (which caches non-zeroizable +//! copies) and parse directly into `SecretKey`. The `secp256k1::Keypair` stack +//! slot is overwritten with zeros after signing (best-effort — the compiler +//! may optimize this away). The `SecretKey` type in the nostr crate wraps +//! `secp256k1::SecretKey` which also lacks `Zeroize`, so some residual copies +//! may persist until the process exits (short-lived by design). +//! - **Environment variables:** Private keys in env vars are inherently risky +//! (visible in `/proc`, shell history, crash dumps). Prefer keyfile storage. +//! Env vars are removed from the process environment immediately after reading +//! to minimize the exposure window. +//! - **Unsafe code:** This crate uses minimal `unsafe` for Unix fd operations +//! (`from_raw_fd`, `fcntl`) where no safe Rust API exists. Each block is +//! documented with safety invariants. This is an accepted exception to the +//! project's no-unsafe rule for this standalone binary. +//! - **`git` subprocess:** Config reads invoke `git` via `$PATH`. A malicious +//! `git` binary could return attacker-controlled config values. +//! +//! ## Ecosystem Constraints (not fixable in this crate) +//! +//! These are inherent to the libraries and interfaces we depend on: +//! +//! 1. **`secp256k1::SecretKey` lacks `Zeroize`:** The upstream `rust-secp256k1` +//! crate does not implement `Zeroize` or `Drop`-based erasure on `SecretKey`. +//! We call `non_secure_erase()` and `ptr::write_bytes` as best-effort, but +//! the compiler may retain copies in registers or spilled stack slots. +//! 2. **`git config` subprocess trust:** Git's signing interface invokes us as +//! a child process. We inherit git's trust model for config reads — if an +//! attacker controls `$PATH` or the repo's `.git/config`, they can influence +//! our behavior. This is inherent to all git signing programs (GPG, SSH, etc). +//! 3. **Piped stdout lifetime:** Git owns the pipe we write signatures to. In +//! normal operation, git reads our stdout immediately after we exit. There is +//! no need for timeout/kill logic on our stdout writes — we are a short-lived +//! process and git is the reader. Blocking on stdout would indicate git itself +//! is hung, which is outside our control. + +use std::fs; +use std::io::{self, Read, Write}; +use std::mem::ManuallyDrop; +use std::os::unix::io::FromRawFd; +use std::process; +use std::time::{SystemTime, UNIX_EPOCH}; + +use base64::Engine as _; +use chrono::DateTime; +use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash; +use nostr::bitcoin::hashes::{Hash, HashEngine}; +use nostr::bitcoin::secp256k1::schnorr::Signature; +use nostr::bitcoin::secp256k1::{Keypair, Message, XOnlyPublicKey}; +use nostr::{FromBech32, PublicKey, SecretKey, SECP256K1}; +use zeroize::Zeroize; + +// ── Keypair Guard ──────────────────────────────────────────────────────────── + +/// RAII guard that calls `non_secure_erase()` on drop, ensuring the keypair's +/// secret material is overwritten even on early-return error paths. +struct KeypairGuard(Keypair); + +impl KeypairGuard { + fn new(kp: Keypair) -> Self { + Self(kp) + } + + /// Access the inner keypair for signing operations. + fn inner(&self) -> &Keypair { + &self.0 + } +} + +impl Drop for KeypairGuard { + fn drop(&mut self) { + self.0.non_secure_erase(); + } +} + +// ── Constants ──────────────────────────────────────────────────────────────── + +const DOMAIN_SEPARATOR: &str = "nostr:git:v1:"; +const ARMOR_BEGIN: &str = "-----BEGIN SIGNED MESSAGE-----"; +const ARMOR_END: &str = "-----END SIGNED MESSAGE-----"; + +/// Maximum payload size (git commit/tag objects). 100 MB matches the NIP-GS +/// spec limit. Commits and tags are typically < 10 KB; this bound prevents +/// unbounded memory allocation from malicious input. +const MAX_PAYLOAD: usize = 100 * 1024 * 1024; + +/// Maximum size for signature files read during verification. Legitimate +/// NIP-GS signatures are ~300 bytes encoded; 8 KB allows for future extensions. +const MAX_SIG_FILE: usize = 8 * 1024; + +/// Maximum decoded JSON size in the signature envelope. +const MAX_JSON_DECODED: usize = 2048; + +/// Maximum base64 line length in the armor format. +const MAX_BASE64_LINE: usize = 4096; + +/// GnuPG status line prefix. Git parses lines with this prefix on the +/// status-fd to determine signature validity. +const GNUPG_PREFIX: &str = "[GNUPG:] "; + +/// Minimum valid status file descriptor. FD 0 (stdin) is excluded because +/// we read payload from it. +const MIN_STATUS_FD: i32 = 1; + +// ── Error Type ─────────────────────────────────────────────────────────────── + +/// Top-level error type. All failures flow through here so `main()` can +/// handle cleanup (zeroization, status-fd reporting) before exiting. +#[derive(Debug)] +enum Error { + /// Fatal error — print message to stderr and exit non-zero. + Fatal(String), + /// Verification failure — signature is cryptographically invalid. + /// The pk (if known) is included for ERRSIG/BADSIG reporting. + VerifyFailed { pk: Option, msg: String }, +} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Error::Fatal(msg) => write!(f, "{msg}"), + Error::VerifyFailed { pk: Some(pk), msg } => write!(f, "{msg} [key: {pk}]"), + Error::VerifyFailed { pk: None, msg } => write!(f, "{msg}"), + } + } +} + +// ── OA Verification Result ─────────────────────────────────────────────────── + +/// Result of NIP-OA verification during signature verification. +enum OaVerifyResult { + /// No OA present in the signature (optional field). + Absent, + /// OA present, signature valid, conditions satisfied. + Valid, + /// OA present but cryptographic verification failed. + InvalidSignature, + /// OA present, signature valid, but temporal conditions violated. + ConditionsViolated, +} + +impl OaVerifyResult { + /// Return the machine-readable status string for NOTATION_DATA output. + fn as_status_str(&self) -> &'static str { + match self { + OaVerifyResult::Absent => "none", + OaVerifyResult::Valid => "valid", + OaVerifyResult::InvalidSignature => "invalid_signature", + OaVerifyResult::ConditionsViolated => "expired", + } + } +} + +// ── CLI Parsing ────────────────────────────────────────────────────────────── + +#[derive(Debug)] +enum Mode { + Sign { key_id: String }, + Verify { sig_file: String }, +} + +#[derive(Debug)] +struct Args { + mode: Mode, + status_fd: Option, +} + +fn parse_args() -> Result { + let args: Vec = std::env::args().skip(1).collect(); + let mut status_fd: Option = None; + let mut verify_file: Option = None; + let mut sign_key: Option = None; + let mut saw_stdin_dash = false; + let mut i = 0; + + while i < args.len() { + let arg = &args[i]; + + if let Some(val) = arg.strip_prefix("--status-fd=") { + status_fd = Some(parse_status_fd(val)?); + } else if arg == "--status-fd" { + i += 1; + if i < args.len() { + status_fd = Some(parse_status_fd(&args[i])?); + } else { + return Err(Error::Fatal("--status-fd requires a value".to_string())); + } + } else if arg == "--verify" { + // Reject duplicate --verify + if verify_file.is_some() { + return Err(Error::Fatal( + "--verify specified more than once".to_string(), + )); + } + // Reject if -bsau was already seen (conflicting modes) + if sign_key.is_some() { + return Err(Error::Fatal( + "cannot specify both -bsau and --verify".to_string(), + )); + } + i += 1; + if i < args.len() { + verify_file = Some(args[i].clone()); + } else { + return Err(Error::Fatal( + "--verify requires a file argument".to_string(), + )); + } + } else if arg == "-bsau" { + // Reject duplicate -bsau + if sign_key.is_some() { + return Err(Error::Fatal("-bsau specified more than once".to_string())); + } + // Reject if --verify was already seen (conflicting modes) + if verify_file.is_some() { + return Err(Error::Fatal( + "cannot specify both -bsau and --verify".to_string(), + )); + } + i += 1; + if i < args.len() { + sign_key = Some(args[i].clone()); + } else { + return Err(Error::Fatal("-bsau requires a key argument".to_string())); + } + } else if arg == "-" { + // stdin marker for verify mode — required by git after the sig file + saw_stdin_dash = true; + } + // Silently ignore unrecognized args for forward compatibility + // (NIP-GS spec: implementations SHOULD ignore unknown arguments) + + i += 1; + } + + let mode = if let Some(sig_file) = verify_file { + // git always passes trailing `-` in verify mode; reject if absent + // so we fail fast rather than hanging on stdin with no payload. + if !saw_stdin_dash { + return Err(Error::Fatal( + "--verify requires a trailing `-` argument (stdin marker)".to_string(), + )); + } + Mode::Verify { sig_file } + } else if let Some(key_id) = sign_key { + Mode::Sign { key_id } + } else { + return Err(Error::Fatal( + "must specify either -bsau (sign) or --verify (verify)".to_string(), + )); + }; + + Ok(Args { mode, status_fd }) +} + +fn parse_status_fd(val: &str) -> Result { + let fd: i32 = val + .parse() + .map_err(|_| Error::Fatal(format!("invalid --status-fd value: {val:?}")))?; + if fd < MIN_STATUS_FD { + return Err(Error::Fatal(format!( + "--status-fd must be >= {MIN_STATUS_FD} (fd 0 is stdin), got {fd}" + ))); + } + Ok(fd) +} + +// ── Status FD Writer ───────────────────────────────────────────────────────── + +struct StatusWriter { + /// Wrapped in ManuallyDrop because git owns this fd — we must not close it. + /// Git opens the fd before invoking us and reads from it after we exit. + file: Option>, +} + +impl StatusWriter { + /// Create a status writer for the given file descriptor. + /// + /// If `strict` is true (verify mode), returns an error when the fd is + /// explicitly provided but invalid — git depends on status output to + /// determine verification results. If `strict` is false (sign mode), + /// falls back to stderr on invalid fd. + fn new(fd: Option, strict: bool) -> Result { + let file = match fd { + None => None, + Some(fd) => { + #[cfg(unix)] + { + // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API + // exists for fcntl. The fd value is >= 1 (validated by + // parse_status_fd). F_GETFD is read-only and cannot cause memory + // unsafety — the only risk is EBADF, which we handle by checking + // the return value. + let ret = unsafe { libc::fcntl(fd, libc::F_GETFD) }; + if ret == -1 { + if strict { + return Err(Error::Fatal(format!( + "--status-fd={fd} is not a valid open fd (required for verify)" + ))); + } + eprintln!("warning: --status-fd={fd} is not a valid open fd, using stderr"); + return Ok(Self { file: None }); + } + } + // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API + // exists for from_raw_fd. The fd is >= 1 (validated by parse_status_fd), + // confirmed open by fcntl above, and git owns its lifetime. We use + // ManuallyDrop to prevent Rust from closing the inherited fd on drop. + Some(ManuallyDrop::new(unsafe { fs::File::from_raw_fd(fd) })) + } + }; + Ok(Self { file }) + } + + /// Write a GnuPG-format status line. Errors are logged to stderr but do + /// not abort — git can still function without status lines in some modes. + fn write_line(&mut self, line: &str) { + let result = if let Some(ref mut f) = self.file { + writeln!(&mut **f, "{GNUPG_PREFIX}{line}") + } else { + writeln!(io::stderr(), "{GNUPG_PREFIX}{line}") + }; + if let Err(e) = result { + eprintln!("warning: failed to write status line: {e}"); + } + } + + /// Write a GnuPG-format status line, returning an error if the write fails. + /// + /// Use this in `cmd_verify` where status output is critical — git reads + /// these lines to determine signature validity. A broken status-fd means + /// git cannot receive the result, so we must fail rather than silently + /// continue. + fn write_line_critical(&mut self, line: &str) -> Result<(), Error> { + let result = if let Some(ref mut f) = self.file { + writeln!(&mut **f, "{GNUPG_PREFIX}{line}") + } else { + writeln!(io::stderr(), "{GNUPG_PREFIX}{line}") + }; + result.map_err(|e| Error::Fatal(format!("failed to write status line: {e}"))) + } +} + +/// Write a critical status line; exit with error if the write fails. +/// +/// Used in `cmd_verify` where status output is required for git to parse the +/// result. Unlike `status!` (which ignores errors), this macro propagates +/// write failures as `Error::Fatal`. +macro_rules! status_or_fail { + ($writer:expr, $line:expr) => { + $writer.write_line_critical($line)? + }; + ($writer:expr, $fmt:literal, $($arg:tt)*) => { + $writer.write_line_critical(&format!($fmt, $($arg)*))? + }; +} + +// ── Key Loading ────────────────────────────────────────────────────────────── + +/// Load the private key from env vars or git config keyfile. +/// +/// Priority: NOSTR_PRIVATE_KEY > SPROUT_PRIVATE_KEY > git config nostr.keyfile +/// +/// Returns a zeroize-on-drop string containing the raw key material. +fn load_key() -> Result, Error> { + // 1. NOSTR_PRIVATE_KEY + if let Ok(mut val) = std::env::var("NOSTR_PRIVATE_KEY") { + // Cap at 128 bytes: nsec1 bech32 is ~63 chars, hex is 64 chars. + // 128 bytes is generous headroom; anything larger is malformed input. + if val.len() > 128 { + val.zeroize(); + std::env::remove_var("NOSTR_PRIVATE_KEY"); + return Err(Error::Fatal( + "NOSTR_PRIVATE_KEY exceeds 128-byte size limit".to_string(), + )); + } + let trimmed = val.trim().to_string(); + val.zeroize(); + // Remove from process environment to minimize exposure window + std::env::remove_var("NOSTR_PRIVATE_KEY"); + if !trimmed.is_empty() { + return Ok(zeroize::Zeroizing::new(trimmed)); + } + } + + // 2. SPROUT_PRIVATE_KEY + if let Ok(mut val) = std::env::var("SPROUT_PRIVATE_KEY") { + // Cap at 128 bytes: nsec1 bech32 is ~63 chars, hex is 64 chars. + // 128 bytes is generous headroom; anything larger is malformed input. + if val.len() > 128 { + val.zeroize(); + std::env::remove_var("SPROUT_PRIVATE_KEY"); + return Err(Error::Fatal( + "SPROUT_PRIVATE_KEY exceeds 128-byte size limit".to_string(), + )); + } + let trimmed = val.trim().to_string(); + val.zeroize(); + // Remove from process environment to minimize exposure window + std::env::remove_var("SPROUT_PRIVATE_KEY"); + if !trimmed.is_empty() { + return Ok(zeroize::Zeroizing::new(trimmed)); + } + } + + // 3. nostr.keyfile git config + let path = git_config("nostr.keyfile").ok_or_else(|| { + Error::Fatal( + "no key available: set NOSTR_PRIVATE_KEY, SPROUT_PRIVATE_KEY, \ + or git config nostr.keyfile" + .to_string(), + ) + })?; + + // Delegate to read_keyfile_secure which handles permission checks, + // size limits, and Zeroizing wrapping in one place. + read_keyfile_secure(&path) +} + +/// Load the NIP-OA auth tag from env or git config. +/// +/// Priority per NIP-GS spec: `SPROUT_AUTH_TAG` env var > `nostr.authtag` git config. +/// The env var takes precedence so that CI/CD pipelines and agent harnesses can +/// inject auth tags without modifying repo config. +/// +/// Returns: +/// - `Ok(Some(...))` — valid auth tag found and parsed. +/// - `Ok(None)` — no auth tag configured (neither git config nor env var set). +/// - `Err(...)` — auth tag IS configured but malformed. Callers MUST treat +/// this as a hard error to prevent signing without the intended attestation. +fn load_auth_tag() -> Result, Error> { + // NIP-GS spec: check env var first, then git config. + // Use git_config_strict for auth tag to fail closed on read errors — + // a configured-but-unreadable auth tag must not be silently omitted. + let json_str = match std::env::var("SPROUT_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + { + Some(val) => Some(val), + None => git_config_strict("nostr.authtag") + .map_err(|e| Error::Fatal(format!("failed to read nostr.authtag: {e}")))?, + }; + + let json_str = match json_str { + Some(s) => s, + None => return Ok(None), + }; + + // Cap auth tag at 1024 bytes. A valid NIP-OA tag is ~300 bytes; 1024 + // allows generous headroom while bounding memory use from malformed input. + const MAX_AUTH_TAG: usize = 1024; + if json_str.len() > MAX_AUTH_TAG { + return Err(Error::Fatal(format!( + "auth tag exceeds {MAX_AUTH_TAG}-byte size limit" + ))); + } + + // Parse: ["auth", "", "", ""] + let arr: serde_json::Value = serde_json::from_str(&json_str) + .map_err(|e| Error::Fatal(format!("SPROUT_AUTH_TAG is not valid JSON: {e}")))?; + let arr = arr + .as_array() + .ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG must be a JSON array".to_string()))?; + if arr.len() != 4 { + return Err(Error::Fatal( + "SPROUT_AUTH_TAG must have exactly 4 elements".to_string(), + )); + } + if arr[0].as_str() != Some("auth") { + return Err(Error::Fatal( + "SPROUT_AUTH_TAG[0] must be \"auth\"".to_string(), + )); + } + + let owner = arr[1] + .as_str() + .ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[1] must be a string".to_string()))? + .to_string(); + let conditions = arr[2] + .as_str() + .ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[2] must be a string".to_string()))? + .to_string(); + let sig = arr[3] + .as_str() + .ok_or_else(|| Error::Fatal("SPROUT_AUTH_TAG[3] must be a string".to_string()))? + .to_string(); + + // Validate conditions character class per NIP-OA: empty string is valid, + // otherwise only ASCII alphanumeric + '_' + '=' + '<' + '>' + '&' allowed. + if !validate_conditions(&conditions) { + return Err(Error::Fatal( + "SPROUT_AUTH_TAG conditions contain invalid characters".to_string(), + )); + } + + // Validate hex fields + if owner.len() != 64 + || !owner + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::Fatal( + "SPROUT_AUTH_TAG owner must be 64 lowercase hex chars".to_string(), + )); + } + if sig.len() != 128 + || !sig + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(Error::Fatal( + "SPROUT_AUTH_TAG signature must be 128 lowercase hex chars".to_string(), + )); + } + + Ok(Some((owner, conditions, sig))) +} + +/// Validate NIP-OA conditions string with structural parsing. +/// +/// Grammar (empty string is valid): +/// ```text +/// conditions = "" | clause ("&" clause)* +/// clause = "kind=" decimal | "created_at<" decimal | "created_at>" decimal +/// decimal = "0" | [1-9][0-9]* +/// ``` +/// - `kind=` values must be in 0..=65535 +/// - `created_at` values must be in 0..=4294967295 +/// - Leading zeros are rejected (except the value `0` itself) +fn validate_conditions(conditions: &str) -> bool { + if conditions.is_empty() { + return true; + } + for clause in conditions.split('&') { + if clause.is_empty() { + return false; // rejects "&&", trailing "&", leading "&" + } + let ok = if let Some(val) = clause.strip_prefix("kind=") { + parse_decimal_u32(val).is_some_and(|n| n <= 65535) + } else if let Some(val) = clause.strip_prefix("created_at<") { + parse_decimal_u32(val).is_some() + } else if let Some(val) = clause.strip_prefix("created_at>") { + parse_decimal_u32(val).is_some() + } else { + false // unknown clause type + }; + if !ok { + return false; + } + } + true +} + +/// Enforce NIP-OA time constraints against the NIP-GS envelope timestamp. +/// +/// Checks `created_atN` (t must be > N). +/// `kind=` clauses are not applicable in NIP-GS context and are skipped. +/// Assumes `validate_conditions()` has already confirmed structural validity. +fn enforce_conditions(conditions: &str, t: u64) -> Result<(), String> { + if conditions.is_empty() { + return Ok(()); + } + for clause in conditions.split('&') { + if let Some(val) = clause.strip_prefix("created_at<") { + if let Some(limit) = parse_decimal_u32(val) { + if t >= limit as u64 { + return Err(format!( + "timestamp {t} violates auth tag constraint created_at<{limit}" + )); + } + } + } else if let Some(val) = clause.strip_prefix("created_at>") { + if let Some(limit) = parse_decimal_u32(val) { + if t <= limit as u64 { + return Err(format!( + "timestamp {t} violates auth tag constraint created_at>{limit}" + )); + } + } + } + // kind= clauses: not applicable in NIP-GS context, skip + } + Ok(()) +} + +/// Check if conditions string contains any `kind=` clauses. +/// These are valid NIP-OA but not applicable in NIP-GS git context. +fn has_kind_clause(conditions: &str) -> bool { + if conditions.is_empty() { + return false; + } + conditions.split('&').any(|c| c.starts_with("kind=")) +} + +/// Parse a decimal string into u32, rejecting leading zeros and non-decimal chars. +/// Valid range: 0..=4294967295. +/// +/// NIP-OA requires bare decimal digits only — leading `+` or `-` signs are +/// rejected even though Rust's `str::parse` would accept them. +fn parse_decimal_u32(s: &str) -> Option { + if s.is_empty() { + return None; + } + // Reject leading sign characters: NIP-OA only allows bare decimal digits. + // Rust's `parse::()` accepts '+' prefix; we must reject it explicitly. + if s.starts_with('+') || s.starts_with('-') { + return None; + } + // Reject leading zeros (except the single digit "0") + if s.len() > 1 && s.starts_with('0') { + return None; + } + // All chars must be ASCII digits + if !s.bytes().all(|b| b.is_ascii_digit()) { + return None; + } + s.parse::().ok() +} + +/// Read a git config value, sanitizing the subprocess environment. +/// +/// We remove env vars that could redirect config reads, inject values, or +/// change the repository context. This is defense-in-depth — the primary +/// trust boundary is that this program runs in the user's own git repo. +/// +/// **Known limitation:** We cannot fully protect against a malicious `git` +/// binary on `$PATH`. Callers who need stronger guarantees should use an +/// absolute path to git or avoid subprocess-based config reads entirely. +fn git_config(key: &str) -> Option { + use std::io::Read; + + let mut child = process::Command::new("git") + .args(["config", "--get", key]) + .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("SPROUT_PRIVATE_KEY") + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::null()) + .spawn() + .ok()?; + + // Read at most MAX_SIG_FILE + 1 bytes to detect oversized output. + let mut buf = Vec::with_capacity(MAX_SIG_FILE + 1); + if let Some(ref mut stdout) = child.stdout { + stdout + .take(MAX_SIG_FILE as u64 + 1) + .read_to_end(&mut buf) + .ok()?; + } + + // Drop stdout handle before wait() to avoid deadlock — the child may be + // blocked writing to a full pipe. Dropping closes our end of the pipe, + // which causes the child's write to fail and it exits. + drop(child.stdout.take()); + + // If output is oversized, kill the child (it may be a malicious git binary + // producing unbounded output) and return None. + if buf.len() > MAX_SIG_FILE { + let _ = child.kill(); + let _ = child.wait(); + return None; + } + + let status = child.wait().ok()?; + if !status.success() { + return None; + } + + let val = String::from_utf8(buf).ok()?; + let trimmed = val.trim(); + if trimmed.is_empty() { + None + } else { + Some(trimmed.to_string()) + } +} + +/// Like `git_config()` but distinguishes "key not set" from "error reading." +/// +/// Returns: +/// - `Ok(Some(value))` — key exists and was read successfully +/// - `Ok(None)` — key does not exist (git config exit code 1) +/// - `Err(msg)` — error reading (spawn failure, oversized output, invalid UTF-8, or unexpected exit code) +fn git_config_strict(key: &str) -> Result, String> { + use std::io::Read; + + let mut child = process::Command::new("git") + .args(["config", "--get", key]) + .env_remove("NOSTR_PRIVATE_KEY") + .env_remove("SPROUT_PRIVATE_KEY") + .stdout(process::Stdio::piped()) + .stderr(process::Stdio::null()) + .spawn() + .map_err(|e| format!("failed to spawn git: {e}"))?; + + let mut buf = Vec::with_capacity(MAX_SIG_FILE + 1); + if let Some(ref mut stdout) = child.stdout { + stdout + .take(MAX_SIG_FILE as u64 + 1) + .read_to_end(&mut buf) + .map_err(|e| format!("failed to read git config output: {e}"))?; + } + + // Drop stdout handle before wait() to avoid deadlock. + drop(child.stdout.take()); + + if buf.len() > MAX_SIG_FILE { + let _ = child.kill(); + let _ = child.wait(); + return Err(format!("git config output for {key} exceeds size limit")); + } + + let status = child + .wait() + .map_err(|e| format!("failed to wait for git: {e}"))?; + + // git config --get exits with: + // 0 = key found, 1 = key not found, >1 = error + match status.code() { + Some(0) => {} + Some(1) => return Ok(None), + _ => { + return Err(format!( + "git config --get {key} failed with status {status}" + )) + } + } + + let val = String::from_utf8(buf).map_err(|_| format!("git config {key} is not valid UTF-8"))?; + let trimmed = val.trim(); + if trimmed.is_empty() { + Ok(None) + } else { + Ok(Some(trimmed.to_string())) + } +} + +/// Open a keyfile with symlink rejection and permission checks. +/// +/// Uses `O_NOFOLLOW` to reject symlinks atomically at the kernel level (no +/// TOCTOU between stat and open). Uses `O_NONBLOCK` to prevent blocking on +/// FIFOs — cleared after confirming the path is a regular file. Then fstats +/// the opened handle to verify permissions. Returns the opened file handle. +#[cfg(unix)] +fn open_keyfile(path: &str) -> Result { + use std::os::unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}; + use std::os::unix::io::AsRawFd; + + // O_NOFOLLOW: fail with ELOOP if path is a symlink. + // O_NONBLOCK: prevent blocking if path is a FIFO (cleared below once we + // confirm it's a regular file). + let file = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NOFOLLOW | libc::O_NONBLOCK) + .open(path) + .map_err(|e| { + if e.raw_os_error() == Some(libc::ELOOP) { + Error::Fatal(format!("keyfile {path} is a symlink (not allowed)")) + } else { + Error::Fatal(format!("cannot open keyfile {path}: {e}")) + } + })?; + + // fstat the opened handle — no TOCTOU since we already have the fd + let meta = file + .metadata() + .map_err(|e| Error::Fatal(format!("cannot stat keyfile {path}: {e}")))?; + + if !meta.file_type().is_file() { + return Err(Error::Fatal(format!( + "keyfile {path} is not a regular file" + ))); + } + + // Clear O_NONBLOCK now that we know it's a regular file — reads on regular + // files are always non-blocking anyway, but clearing it is cleaner. + let fd = file.as_raw_fd(); + // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API exists + // for fcntl F_GETFL/F_SETFL. The fd comes from a File we just opened and + // fstat'd — it is valid for the duration of this block. We only modify the + // O_NONBLOCK flag; no memory is read or written through the fd here. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + + let mode = meta.permissions().mode() & 0o777; + if mode & 0o177 != 0 { + return Err(Error::Fatal(format!( + "keyfile {path} has insecure permissions {mode:04o} (expected 0600 or 0400)" + ))); + } + + // Verify the keyfile is owned by the current user. A 0600 file owned by + // another UID could still be readable via ACLs or privileged execution. + // SAFETY EXCEPTION: getuid(2) has no preconditions and no side effects. + let current_uid = unsafe { libc::getuid() }; + if meta.uid() != current_uid { + return Err(Error::Fatal(format!( + "keyfile {path} is owned by uid {} but current uid is {current_uid}", + meta.uid() + ))); + } + + Ok(file) +} + +#[cfg(not(unix))] +fn check_keyfile_permissions(_path: &str) -> Result<(), Error> { + // No permission checking on non-unix platforms. + Ok(()) +} + +/// Read a keyfile securely, returning its trimmed contents as a `Zeroizing`. +/// +/// Performs platform-appropriate permission/symlink checks, enforces the 1 KB +/// size limit, and wraps the buffer in `Zeroizing` from the moment it is +/// allocated so the secret material is erased on drop regardless of the return +/// path. +fn read_keyfile_secure(path: &str) -> Result, Error> { + // Max keyfile size: nsec1 bech32 is ~63 chars, hex is 64 chars. + // 1 KB allows generous headroom for whitespace/newlines. + const MAX_KEYFILE: u64 = 1024; + + #[cfg(unix)] + let file = open_keyfile(path)?; + + #[cfg(not(unix))] + let file = { + check_keyfile_permissions(path)?; + fs::File::open(path) + .map_err(|e| Error::Fatal(format!("cannot open keyfile {path}: {e}")))? + }; + + // Allocate inside Zeroizing immediately so the buffer is erased on any + // early-return error path, not just on the success path. + let mut buf = zeroize::Zeroizing::new(String::new()); + file.take(MAX_KEYFILE + 1) + .read_to_string(&mut buf) + .map_err(|e| Error::Fatal(format!("cannot read keyfile {path}: {e}")))?; + if buf.len() as u64 > MAX_KEYFILE { + return Err(Error::Fatal(format!( + "keyfile {path} exceeds {MAX_KEYFILE} byte limit" + ))); + } + + // Trim in-place: build a new Zeroizing from the trimmed slice, + // then let the original (with leading/trailing whitespace) be zeroized. + let trimmed = zeroize::Zeroizing::new(buf.trim().to_string()); + Ok(trimmed) +} + +// ── Signing Hash ───────────────────────────────────────────────────────────── + +/// Compute the NIP-GS signing hash. +/// +/// ```text +/// hash = SHA-256("nostr:git:v1:" || decimal(t) || ":" || oa_binding || payload) +/// ``` +/// +/// Where `oa_binding` is: +/// - If oa present: `oa[0] || ":" || oa[1] || ":" || oa[2] || ":"` +/// - If oa absent: empty (zero bytes) +fn compute_signing_hash( + timestamp: u64, + oa: Option<&(String, String, String)>, + payload: &[u8], +) -> [u8; 32] { + let mut engine = Sha256Hash::engine(); + engine.input(DOMAIN_SEPARATOR.as_bytes()); + engine.input(timestamp.to_string().as_bytes()); + engine.input(b":"); + + if let Some((owner_pk, conditions, owner_sig)) = oa { + engine.input(owner_pk.as_bytes()); + engine.input(b":"); + engine.input(conditions.as_bytes()); + engine.input(b":"); + engine.input(owner_sig.as_bytes()); + engine.input(b":"); + } + + engine.input(payload); + Sha256Hash::from_engine(engine).to_byte_array() +} + +// ── JSON Envelope ──────────────────────────────────────────────────────────── + +/// Build the canonical JSON envelope (compact, deterministic field order). +/// +/// NIP-GS requires byte-exact canonical form for verification: field order +/// is `v, pk, sig, t[, oa]`, no whitespace, no trailing commas. We use +/// `format!` rather than serde to guarantee this exact byte layout — serde's +/// serialization order depends on the `Map` implementation and feature flags. +fn build_envelope(pk: &str, sig: &str, t: u64, oa: Option<&(String, String, String)>) -> String { + match oa { + Some((owner, conditions, owner_sig)) => { + format!( + r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":{t},"oa":["{owner}","{conditions}","{owner_sig}"]}}"# + ) + } + None => { + format!(r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":{t}}}"#) + } + } +} + +/// Wrap JSON bytes in PEM-style armor. +fn armor(json_bytes: &[u8]) -> String { + let b64 = base64::engine::general_purpose::STANDARD.encode(json_bytes); + format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}\n") +} + +// ── Signing Mode ───────────────────────────────────────────────────────────── + +fn do_sign(key_id: &str, status: &mut StatusWriter) -> Result<(), Error> { + // Load key (zeroized on drop) + let mut raw_key = load_key()?; + + // Parse directly into SecretKey — avoids nostr::Keys which stores + // non-zeroizable copies of the secret material internally. + let mut secret_key = match SecretKey::parse(&*raw_key) { + Ok(k) => k, + Err(e) => { + raw_key.zeroize(); + return Err(Error::Fatal(format!("invalid nostr private key: {e}"))); + } + }; + raw_key.zeroize(); + + // Derive public key for envelope and key-id matching. + // Drop secret_key immediately after creating the keypair so it doesn't + // linger on the stack through the rest of the function. + // Wrapped in KeypairGuard so non_secure_erase() runs on ALL exit paths. + let keypair = KeypairGuard::new(Keypair::from_secret_key(&SECP256K1, &secret_key)); + // Explicitly zero the SecretKey stack slot before dropping. nostr::SecretKey's + // Drop calls inner.non_secure_erase(), but that operates on the moved value. + // This write_bytes targets our local copy to minimize residual secret material. + // SAFETY: We have exclusive mutable access to `secret_key` on the stack. + // write_bytes zeroes size_of::() bytes at the local's address. + // The subsequent drop is a no-op on zeroed memory (non_secure_erase on zeros). + unsafe { + let ptr = &mut secret_key as *mut SecretKey as *mut u8; + std::ptr::write_bytes(ptr, 0, std::mem::size_of::()); + } + drop(secret_key); + let (xonly_pk, _parity) = keypair.inner().x_only_public_key(); + let pk_hex = hex::encode(xonly_pk.serialize()); + + // Verify key matches the -u argument. Fail closed: if the key_id is + // non-empty and in a recognized format, it MUST match the loaded key. + // If the format is unrecognized, we also fail — better to reject than + // to silently sign with the wrong key. + if !key_id.is_empty() { + match normalize_key_id(key_id) { + Some(expected_hex) => { + if expected_hex != pk_hex { + return Err(Error::Fatal(format!( + "signing key argument ({key_id}) does not match loaded key ({pk_hex})" + ))); + } + } + None => { + return Err(Error::Fatal(format!( + "signing key argument ({key_id}) is not a recognized key format \ + (expected 64-char hex or npub1...)" + ))); + } + } + } + + // Read payload from stdin (bounded) + let payload = read_payload_stdin()?; + + // Get timestamp — capped at u32::MAX per NIP-GS spec range [0, 4294967295] + let t = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map_err(|_| Error::Fatal("system clock is before Unix epoch".to_string()))? + .as_secs(); + if t > u32::MAX as u64 { + return Err(Error::Fatal(format!( + "timestamp {t} exceeds NIP-GS u32 range (max {})", + u32::MAX + ))); + } + + // Load optional auth tag — fails closed if configured but malformed. + // Validate the credential before embedding: check owner key validity, + // reject self-attestation, and verify the owner's signature. + let oa = load_auth_tag()?; + if let Some(ref oa_val) = oa { + // Owner pubkey must be a valid BIP-340 key + if PublicKey::from_hex(&oa_val.0).is_err() { + return Err(Error::Fatal( + "auth tag owner (oa[0]) is not a valid BIP-340 public key".to_string(), + )); + } + // Owner must not be the signer (self-attestation is meaningless) + if oa_val.0 == pk_hex { + return Err(Error::Fatal( + "auth tag owner (oa[0]) must not equal signing key (self-attestation)".to_string(), + )); + } + // Verify the owner's signature over the auth credential + if !verify_oa(&pk_hex, oa_val) { + return Err(Error::Fatal( + "auth tag owner signature (oa[2]) verification failed — \ + the configured SPROUT_AUTH_TAG is invalid or stale" + .to_string(), + )); + } + } + + // Enforce time constraints from auth tag conditions against signing timestamp. + // This prevents embedding a stale/expired auth tag into a new signature. + if let Some(ref oa_val) = oa { + enforce_conditions(&oa_val.1, t) + .map_err(|msg| Error::Fatal(format!("auth tag conditions not satisfied: {msg}")))?; + if has_kind_clause(&oa_val.1) { + eprintln!("warning: auth tag contains kind= constraints which are not enforced in git signing context"); + } + } + + // Compute signing hash + let hash = compute_signing_hash(t, oa.as_ref(), &payload); + let message = Message::from_digest(hash); + + // Sign with BIP-340 Schnorr using the keypair (guarded — erased on drop). + let sig = SECP256K1.sign_schnorr(&message, keypair.inner()); + let sig_hex = hex::encode(sig.serialize()); + // Keypair is erased by KeypairGuard::drop; drop it now that signing is done. + drop(keypair); + + // Build envelope and armor + let json = build_envelope(&pk_hex, &sig_hex, t, oa.as_ref()); + let armored = armor(json.as_bytes()); + + // Write signature to stdout — errors are fatal because git reads + // the signature from our stdout. Use write_all (not print!) to avoid + // panicking on broken pipe. + io::stdout() + .write_all(armored.as_bytes()) + .and_then(|_| io::stdout().flush()) + .map_err(|e| Error::Fatal(format!("failed to write signature to stdout: {e}")))?; + + // Write GnuPG status lines: + // - SIG_CREATED: D=detached, 8=algo(EdDSA placeholder), 1=hash(SHA256), + // 00=class, timestamp, fingerprint + status.write_line("BEGIN_SIGNING"); + status.write_line(&format!("SIG_CREATED D 8 1 00 {t} {pk_hex}")); + + Ok(()) +} + +// ── Verification Mode ──────────────────────────────────────────────────────── + +/// Best-effort extraction of the pk field from raw JSON, for ERRSIG reporting +/// when full envelope parsing fails. Returns the pk hex string if it looks valid. +fn extract_pk_best_effort(json_str: &str) -> Option { + let marker = "\"pk\":\""; + let start = json_str.find(marker)? + marker.len(); + // Use get() to avoid panicking on non-ASCII char boundaries + let candidate = json_str.get(start..start + 64)?; + if candidate + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + Some(candidate.to_string()) + } else { + None + } +} + +fn do_verify(sig_file: &str, status: &mut StatusWriter) -> Result<(), Error> { + // Read signature file with size bound + let sig_content = read_bounded_file(sig_file, MAX_SIG_FILE).inspect_err(|_e| { + write_errsig(status, None); + })?; + + // Parse armor + let b64 = parse_armor(&sig_content).map_err(|e| { + write_errsig(status, None); + Error::VerifyFailed { + pk: None, + msg: e.to_string(), + } + })?; + + // Validate base64 line length + if b64.len() > MAX_BASE64_LINE { + write_errsig(status, None); + return Err(Error::VerifyFailed { + pk: None, + msg: format!("base64 line exceeds {MAX_BASE64_LINE} bytes"), + }); + } + + // Decode base64 + let decoded = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|e| { + write_errsig(status, None); + Error::VerifyFailed { + pk: None, + msg: format!("invalid base64: {e}"), + } + })?; + + // Check decoded size limit + if decoded.len() > MAX_JSON_DECODED { + write_errsig(status, None); + return Err(Error::VerifyFailed { + pk: None, + msg: format!("decoded JSON exceeds {MAX_JSON_DECODED} bytes"), + }); + } + + // Validate UTF-8 + let json_str = std::str::from_utf8(&decoded).map_err(|_| { + write_errsig(status, None); + Error::VerifyFailed { + pk: None, + msg: "decoded bytes are not valid UTF-8".to_string(), + } + })?; + + // Reject whitespace outside string values (compact JSON check) + if has_non_string_whitespace(json_str) { + let early_pk = extract_pk_best_effort(json_str); + write_errsig(status, early_pk.as_deref()); + return Err(Error::VerifyFailed { + pk: early_pk, + msg: "JSON contains whitespace outside string values".to_string(), + }); + } + + // Best-effort pk extraction for ERRSIG before full parse + let early_pk = extract_pk_best_effort(json_str); + + // Parse JSON envelope + let envelope = parse_envelope(json_str).map_err(|e| { + write_errsig(status, early_pk.as_deref()); + Error::VerifyFailed { + pk: early_pk.clone(), + msg: e.to_string(), + } + })?; + + // Canonical JSON reconstruction check — ensures no field reordering or + // extra whitespace was present in the original. + let reconstructed = build_envelope( + &envelope.pk, + &envelope.sig, + envelope.t, + envelope.oa.as_ref(), + ); + if reconstructed != json_str { + write_errsig(status, Some(&envelope.pk)); + return Err(Error::VerifyFailed { + pk: Some(envelope.pk), + msg: "JSON is not in canonical form".to_string(), + }); + } + + // Validate pk is a valid BIP-340 x-only public key + let pk = PublicKey::from_hex(&envelope.pk).map_err(|e| { + write_errsig(status, Some(&envelope.pk)); + Error::VerifyFailed { + pk: Some(envelope.pk.clone()), + msg: format!("pk is not a valid BIP-340 public key: {e}"), + } + })?; + + // Read payload from stdin (bounded). Emit ERRSIG on failure since we + // already have the pk from the envelope. + let payload = read_payload_stdin().inspect_err(|_e| { + write_errsig(status, Some(&envelope.pk)); + })?; + + // Compute signing hash + let hash = compute_signing_hash(envelope.t, envelope.oa.as_ref(), &payload); + let message = Message::from_digest(hash); + + // Parse signature + let sig_bytes = hex::decode(&envelope.sig).map_err(|_| { + write_errsig(status, Some(&envelope.pk)); + Error::VerifyFailed { + pk: Some(envelope.pk.clone()), + msg: "invalid signature hex".to_string(), + } + })?; + let sig = Signature::from_slice(&sig_bytes).map_err(|_| { + write_errsig(status, Some(&envelope.pk)); + Error::VerifyFailed { + pk: Some(envelope.pk.clone()), + msg: "invalid BIP-340 signature".to_string(), + } + })?; + + // Verify BIP-340 signature + let xonly: &XOnlyPublicKey = &pk; + if SECP256K1.verify_schnorr(&sig, &message, xonly).is_err() { + status.write_line("NEWSIG"); + status.write_line(&format!("BADSIG {} {}", envelope.pk, envelope.pk)); + return Err(Error::VerifyFailed { + pk: Some(envelope.pk), + msg: "BIP-340 signature verification failed".to_string(), + }); + } + + // Signature is valid — check NIP-OA if present and track result. + let oa_result = if let Some(ref oa) = envelope.oa { + // Validate oa[0] is a valid BIP-340 public key. Per NIP-GS spec, + // an invalid owner pubkey is a structural error → ERRSIG. + if PublicKey::from_hex(&oa.0).is_err() { + write_errsig(status, Some(&envelope.pk)); + return Err(Error::VerifyFailed { + pk: Some(envelope.pk), + msg: "oa[0] owner pubkey is not a valid BIP-340 key".to_string(), + }); + } + + if !verify_oa(&envelope.pk, oa) { + eprintln!( + "warning: NIP-OA owner attestation verification failed (signature still valid)" + ); + OaVerifyResult::InvalidSignature + } else if let Err(msg) = enforce_conditions(&oa.1, envelope.t) { + eprintln!("warning: NIP-OA conditions not satisfied: {msg}"); + OaVerifyResult::ConditionsViolated + } else { + // kind= clauses are valid NIP-OA but not enforceable in NIP-GS. + // We still report Valid here — callers check nostr-oa-status for + // the full picture. The kind_not_applicable status is preserved + // via the NOTATION_DATA line below. + if has_kind_clause(&oa.1) { + eprintln!( + "warning: auth tag contains kind= constraints which are not enforced in git signing context" + ); + } + OaVerifyResult::Valid + } + } else { + OaVerifyResult::Absent + }; + + // Determine trust level. + // + // Direct key match takes priority: if the verified key matches + // `user.signingkey` in git config, that is TRUST_FULLY regardless of OA + // status. OA delegation is only a secondary attestation path — it does not + // affect the primary trust determination. + // + // NOTE: This is NOT a PKI trust root — it simply tells git "this is the + // key I expect for this repo." A proper trust model would use a keyring or + // web-of-trust, but git's signing interface only supports TRUST_FULLY / + // TRUST_UNDEFINED. + let trust = determine_trust(&envelope.pk); + + // Format date from timestamp (for VALIDSIG status line) + let date_str = timestamp_to_date(envelope.t); + + // Write GnuPG success status lines: + // - NEWSIG: signals start of a new signature check + // - GOODSIG : signature is cryptographically valid + // - VALIDSIG ... : full details + // - TRUST_*: trust level of the signing key + // + // These are critical — git reads them to determine signature validity. + // Use status_or_fail! so a broken status-fd is surfaced as an error. + status_or_fail!(status, "NEWSIG"); + status_or_fail!(status, "GOODSIG {} {}", envelope.pk, envelope.pk); + status_or_fail!( + status, + "VALIDSIG {} {} {} 0 - - - - - {}", + envelope.pk, + date_str, + envelope.t, + envelope.pk + ); + status_or_fail!(status, "{} 0 shell", trust); + // Clarify that TRUST_FULLY is advisory — it only means the verified key + // matches user.signingkey in git config, not that the signer is trusted + // by any external authority. Callers MUST NOT rely on this for security + // decisions without an external allowlist or owner policy. + status_or_fail!(status, "NOTATION_NAME nostr-trust-model"); + status_or_fail!(status, "NOTATION_DATA advisory-config-match-only"); + + // Emit machine-readable OA status via NOTATION lines. + // This allows callers to distinguish "valid sig + valid OA" from + // "valid sig + invalid/missing OA" without parsing stderr warnings. + // NOTATION_NAME/NOTATION_DATA pairs are part of the GnuPG status protocol. + if let Some(ref oa) = envelope.oa { + status_or_fail!(status, "NOTATION_NAME nostr-oa-status"); + status_or_fail!(status, "NOTATION_DATA {}", oa_result.as_status_str()); + status_or_fail!(status, "NOTATION_NAME nostr-oa-owner"); + status_or_fail!(status, "NOTATION_DATA {}", oa.0); + } else { + status_or_fail!(status, "NOTATION_NAME nostr-oa-status"); + status_or_fail!(status, "NOTATION_DATA none"); + } + + Ok(()) +} + +// ── Envelope Parsing ───────────────────────────────────────────────────────── + +#[derive(Debug)] +struct Envelope { + pk: String, + sig: String, + t: u64, + oa: Option<(String, String, String)>, +} + +fn parse_envelope(json_str: &str) -> Result { + let val: serde_json::Value = + serde_json::from_str(json_str).map_err(|e| format!("invalid JSON: {e}"))?; + + let obj = val.as_object().ok_or("JSON must be an object")?; + + // Reject unknown keys — v=1 envelope allows only: v, pk, sig, t, oa + let allowed = ["v", "pk", "sig", "t", "oa"]; + for key in obj.keys() { + if !allowed.contains(&key.as_str()) { + return Err(format!("unknown key in v=1 envelope: {key:?}")); + } + } + + // v (required, must be 1) + let v = obj + .get("v") + .ok_or("missing required field: v")? + .as_u64() + .ok_or("v must be an integer")?; + if v != 1 { + return Err(format!("unsupported version: {v}")); + } + + // pk (required, 64-char lowercase hex) + let pk = obj + .get("pk") + .ok_or("missing required field: pk")? + .as_str() + .ok_or("pk must be a string")?; + validate_hex_field(pk, 64, "pk")?; + + // sig (required, 128-char lowercase hex) + let sig = obj + .get("sig") + .ok_or("missing required field: sig")? + .as_str() + .ok_or("sig must be a string")?; + validate_hex_field(sig, 128, "sig")?; + + // t (required, non-negative integer, max u32 range for timestamps) + let t_val = obj.get("t").ok_or("missing required field: t")?; + if t_val.is_f64() && !t_val.is_u64() && !t_val.is_i64() { + return Err("t must be an integer, not a float".to_string()); + } + let t = t_val.as_u64().ok_or("t must be a non-negative integer")?; + if t > 4294967295 { + return Err(format!("t out of range: {t}")); + } + + // oa (optional array of 3 strings) + let oa = if let Some(oa_val) = obj.get("oa") { + let arr = oa_val.as_array().ok_or("oa must be an array")?; + if arr.len() != 3 { + return Err(format!( + "oa must have exactly 3 elements, got {}", + arr.len() + )); + } + let owner = arr[0].as_str().ok_or("oa[0] must be a string")?; + let conditions = arr[1].as_str().ok_or("oa[1] must be a string")?; + let owner_sig = arr[2].as_str().ok_or("oa[2] must be a string")?; + + validate_hex_field(owner, 64, "oa[0]")?; + validate_hex_field(owner_sig, 128, "oa[2]")?; + + // Validate conditions character class — MUST be checked during parsing + // because build_envelope() interpolates conditions into JSON without + // escaping. Characters outside the allowed set (alphanumeric, _=<>&) + // could break canonical reconstruction or inject JSON syntax. + if !validate_conditions(conditions) { + return Err( + "oa[1] conditions contain invalid characters (allowed: alphanumeric, _=<>&)" + .to_string(), + ); + } + + // Validate oa[0] is a valid BIP-340 x-only public key (not just hex) + PublicKey::from_hex(owner) + .map_err(|e| format!("oa[0] is not a valid BIP-340 public key: {e}"))?; + + // Self-attestation is meaningless — owner must differ from signer + if owner == pk { + return Err("oa[0] (owner) must not equal pk (self-attestation)".to_string()); + } + + Some(( + owner.to_string(), + conditions.to_string(), + owner_sig.to_string(), + )) + } else { + None + }; + + Ok(Envelope { + pk: pk.to_string(), + sig: sig.to_string(), + t, + oa, + }) +} + +fn validate_hex_field(val: &str, expected_len: usize, name: &str) -> Result<(), String> { + if val.len() != expected_len { + return Err(format!( + "{name} must be exactly {expected_len} characters, got {}", + val.len() + )); + } + if !val + .bytes() + .all(|b| b.is_ascii_hexdigit() && !b.is_ascii_uppercase()) + { + return Err(format!("{name} must be lowercase hex")); + } + Ok(()) +} + +// ── Armor Parsing ──────────────────────────────────────────────────────────── + +fn parse_armor(content: &str) -> Result<&str, String> { + // NIP-GS spec requires armor to end with a newline after the END marker. + let content = content + .strip_suffix('\n') + .ok_or("armor must end with a newline")?; + + let lines: Vec<&str> = content.split('\n').collect(); + if lines.len() != 3 { + return Err(format!( + "armor must have exactly 3 lines (BEGIN, base64, END), got {}", + lines.len() + )); + } + + if lines[0] != ARMOR_BEGIN { + return Err("missing or malformed BEGIN marker".to_string()); + } + if lines[2] != ARMOR_END { + return Err("missing or malformed END marker".to_string()); + } + + // Reject trailing whitespace on any line + for (i, line) in lines.iter().enumerate() { + if line.ends_with(' ') || line.ends_with('\t') || line.ends_with('\r') { + return Err(format!("trailing whitespace on line {}", i + 1)); + } + } + + Ok(lines[1]) +} + +// ── NIP-OA Verification ───────────────────────────────────────────────────── + +/// Verify the owner attestation signature. +/// +/// Returns `true` if the attestation is cryptographically valid, `false` +/// otherwise. Per NIP-GS spec, OA failure does NOT invalidate the commit +/// signature — it only means the delegation claim is unverified. +fn verify_oa(agent_pk_hex: &str, oa: &(String, String, String)) -> bool { + let (owner_pk_hex, conditions, owner_sig_hex) = oa; + + // Parse owner pubkey + let owner_pk = match PublicKey::from_hex(owner_pk_hex) { + Ok(p) => p, + Err(_) => { + eprintln!("warning: oa owner pubkey is not a valid BIP-340 key"); + return false; + } + }; + + // Compute NIP-OA preimage: "nostr:agent-auth:" || agent_pk || ":" || conditions + let preimage = format!("nostr:agent-auth:{agent_pk_hex}:{conditions}"); + let digest = Sha256Hash::hash(preimage.as_bytes()); + let message = Message::from_digest(digest.to_byte_array()); + + // Parse and verify owner signature + let sig_bytes = match hex::decode(owner_sig_hex) { + Ok(b) => b, + Err(_) => { + eprintln!("warning: oa owner signature is invalid hex"); + return false; + } + }; + let sig = match Signature::from_slice(&sig_bytes) { + Ok(s) => s, + Err(_) => { + eprintln!("warning: oa owner signature is not a valid BIP-340 signature"); + return false; + } + }; + + let xonly: &XOnlyPublicKey = &owner_pk; + if SECP256K1.verify_schnorr(&sig, &message, xonly).is_err() { + eprintln!("warning: NIP-OA owner attestation signature verification failed"); + return false; + } + + true +} + +// ── Helpers ────────────────────────────────────────────────────────────────── + +/// Read payload from stdin with a bounded allocation. +/// +/// Single entry point for all stdin reads — both sign and verify paths use +/// this function so the size limit and error handling are consistent. +fn read_payload_stdin() -> Result, Error> { + let limit = (MAX_PAYLOAD as u64) + 1; + let mut payload = Vec::new(); + io::stdin() + .take(limit) + .read_to_end(&mut payload) + .map_err(|e| Error::Fatal(format!("failed to read payload from stdin: {e}")))?; + if payload.len() > MAX_PAYLOAD { + return Err(Error::Fatal(format!( + "payload exceeds {} MB limit", + MAX_PAYLOAD / (1024 * 1024) + ))); + } + Ok(payload) +} + +/// Read a file with a size bound (prevents memory DoS from large files). +/// Opens the file once and checks size from the open handle to avoid TOCTOU. +/// Rejects non-regular files (FIFOs, devices) which could block or produce +/// unbounded data. +/// +/// Uses `O_NONBLOCK` on Unix to prevent blocking if the path is a FIFO. +/// After confirming the path is a regular file, clears `O_NONBLOCK` so that +/// subsequent reads behave normally. +fn read_bounded_file(path: &str, max_size: usize) -> Result { + #[cfg(unix)] + let file = { + use std::os::unix::fs::OpenOptionsExt; + use std::os::unix::io::AsRawFd; + let f = fs::OpenOptions::new() + .read(true) + .custom_flags(libc::O_NONBLOCK) + .open(path) + .map_err(|e| Error::Fatal(format!("cannot open signature file {path}: {e}")))?; + let meta = f + .metadata() + .map_err(|e| Error::Fatal(format!("cannot stat signature file {path}: {e}")))?; + if !meta.file_type().is_file() { + return Err(Error::Fatal(format!( + "signature file {path} is not a regular file" + ))); + } + // Clear O_NONBLOCK now that we know it's a regular file. + let fd = f.as_raw_fd(); + // SAFETY EXCEPTION: Required for Unix fd operations; no safe Rust API exists + // for fcntl F_GETFL/F_SETFL. The fd comes from a File we just opened and + // fstat'd — it is valid for the duration of this block. We only modify the + // O_NONBLOCK flag; no memory is read or written through the fd here. + unsafe { + let flags = libc::fcntl(fd, libc::F_GETFL); + if flags >= 0 { + libc::fcntl(fd, libc::F_SETFL, flags & !libc::O_NONBLOCK); + } + } + (f, meta) + }; + #[cfg(not(unix))] + let file = { + let f = fs::File::open(path) + .map_err(|e| Error::Fatal(format!("cannot open signature file {path}: {e}")))?; + let meta = f + .metadata() + .map_err(|e| Error::Fatal(format!("cannot stat signature file {path}: {e}")))?; + if !meta.file_type().is_file() { + return Err(Error::Fatal(format!( + "signature file {path} is not a regular file" + ))); + } + (f, meta) + }; + let (file, meta) = file; + if meta.len() > max_size as u64 { + return Err(Error::Fatal(format!( + "signature file {path} exceeds {max_size} byte limit" + ))); + } + let mut buf = String::with_capacity(meta.len() as usize); + file.take(max_size as u64 + 1) + .read_to_string(&mut buf) + .map_err(|e| Error::Fatal(format!("cannot read signature file {path}: {e}")))?; + // Post-read check: reject if the file grew between metadata check and read. + // The take() limits us to max_size+1 bytes, so this catches growth. + if buf.len() > max_size { + return Err(Error::Fatal(format!( + "signature file {path} exceeds {max_size} byte limit (grew during read)" + ))); + } + Ok(buf) +} + +/// Normalize a key ID argument to lowercase hex if recognizable. +/// Returns `None` if the format is not recognized. +fn normalize_key_id(key_id: &str) -> Option { + let trimmed = key_id.trim(); + + // npub1... bech32 + if trimmed.starts_with("npub1") { + return PublicKey::from_bech32(trimmed).ok().map(|pk| pk.to_hex()); + } + + // 64-char hex + if trimmed.len() == 64 && trimmed.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(trimmed.to_lowercase()); + } + + None +} + +/// Determine trust level by checking `user.signingkey` git config. +/// +/// **ADVISORY ONLY.** `TRUST_FULLY` means "the verified key matches what +/// this repo's `user.signingkey` is configured to expect." It does NOT mean: +/// - The signer is trusted by any external authority +/// - The key has been verified against a keyring or web-of-trust +/// - The commit is safe to deploy or merge +/// +/// Git's signing interface (`gpg.x509.program`) provides no mechanism for +/// external trust roots. For security-sensitive verification, use an external +/// allowlist or owner policy and treat `TRUST_FULLY` as advisory only. +/// +/// Returns `TRUST_FULLY` if the key matches config, `TRUST_UNDEFINED` otherwise. +fn determine_trust(pk_hex: &str) -> &'static str { + if let Some(configured) = git_config("user.signingkey") { + if let Some(ref hex_val) = normalize_key_id(&configured) { + if hex_val == pk_hex { + return "TRUST_FULLY"; + } + } + } + "TRUST_UNDEFINED" +} + +/// Convert a unix timestamp to YYYY-MM-DD (UTC) for VALIDSIG status line. +fn timestamp_to_date(t: u64) -> String { + DateTime::from_timestamp(t as i64, 0) + .map(|d| d.format("%Y-%m-%d").to_string()) + .unwrap_or_else(|| "1970-01-01".to_string()) +} + +/// Emit ERRSIG status line (signature could not be processed). +/// Format: ERRSIG +fn write_errsig(status: &mut StatusWriter, pk: Option<&str>) { + let key_id = pk.unwrap_or("0000000000000000"); + status.write_line(&format!("ERRSIG {key_id} 0 0 00 0 9")); +} + +/// Check if JSON string has whitespace outside of string values. +/// Simple state machine tracking whether we're inside a quoted string. +fn has_non_string_whitespace(s: &str) -> bool { + let mut in_string = false; + let mut escape = false; + + for c in s.chars() { + if escape { + escape = false; + continue; + } + if in_string { + if c == '\\' { + escape = true; + } else if c == '"' { + in_string = false; + } + } else if c == '"' { + in_string = true; + } else if c.is_ascii_whitespace() { + return true; + } + } + false +} + +// ── Main ───────────────────────────────────────────────────────────────────── + +fn main() { + let code = run(); + process::exit(code); +} + +/// Actual entry point — returns an exit code. This ensures all locals are +/// dropped (and zeroized) before `process::exit` is called. +fn run() -> i32 { + let args = match parse_args() { + Ok(a) => a, + Err(Error::Fatal(msg)) => { + eprintln!("error: {msg}"); + return 1; + } + Err(Error::VerifyFailed { msg, .. }) => { + eprintln!("error: {msg}"); + return 1; + } + }; + + // Guard: in signing mode, status_fd=1 (stdout) would corrupt the signature + // output. Fall back to stderr (fd 2) in that case. + let effective_fd = match (&args.mode, args.status_fd) { + (Mode::Sign { .. }, Some(1)) => { + eprintln!("warning: --status-fd=1 in sign mode would corrupt output, using stderr"); + Some(2) + } + _ => args.status_fd, + }; + let mut status = match args.mode { + Mode::Sign { .. } => match StatusWriter::new(effective_fd, false) { + Ok(s) => s, + Err(Error::Fatal(msg)) => { + eprintln!("error: {msg}"); + return 1; + } + Err(Error::VerifyFailed { msg, .. }) => { + eprintln!("error: {msg}"); + return 1; + } + }, + Mode::Verify { .. } => match StatusWriter::new(effective_fd, true) { + Ok(s) => s, + Err(Error::Fatal(msg)) => { + eprintln!("error: {msg}"); + return 1; + } + Err(Error::VerifyFailed { msg, .. }) => { + eprintln!("error: {msg}"); + return 1; + } + }, + }; + + let result = match args.mode { + Mode::Sign { ref key_id } => do_sign(key_id, &mut status), + Mode::Verify { ref sig_file } => do_verify(sig_file, &mut status), + }; + + match result { + Ok(()) => 0, + Err(Error::Fatal(msg)) => { + eprintln!("error: {msg}"); + 1 + } + Err(Error::VerifyFailed { msg, .. }) => { + eprintln!("error: {msg}"); + 1 + } + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // NIP-GS spec test key (secret = 0x03) + const TEST_PK: &str = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"; + + // Spec test payload: minimal git commit object (170 bytes) + fn test_payload() -> Vec { + b"tree 4b825dc642cb6eb9a060e54bf899d69f7cb46101\n\ +author Test User 1700000000 +0000\n\ +committer Test User 1700000000 +0000\n\ +\n\ +Initial commit" + .to_vec() + } + + #[test] + fn test_signing_hash_matches_spec() { + // From NIP-GS spec: SHA-256 of preimage with t=1700000000, no oa + let hash = compute_signing_hash(1700000000, None, &test_payload()); + let expected = "a11a32173aa35125aaefaad8854f2eda5a144268a4a355905c841f79ff44aa18"; + assert_eq!(hex::encode(hash), expected); + } + + #[test] + fn test_signing_hash_with_oa_matches_spec() { + // From NIP-GS spec: owner attestation test vector + let oa = ( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(), + "".to_string(), + "54b97dfd2b7d61c1bc1b5facab9d12a991fe0ac3dcb9044b3176f63bebb6f67340eb0ad866f2d5568b78b58ba234ee9f490f8c41e64a949c200315801520ed25".to_string(), + ); + let hash = compute_signing_hash(1700000000, Some(&oa), &test_payload()); + let expected = "b61f1658836a4f63a2d2f5d621014a064435dde0765dd9c1dc79c9530fe879f0"; + assert_eq!(hex::encode(hash), expected); + } + + #[test] + fn test_canonical_json_no_oa() { + let json = build_envelope(TEST_PK, &"a".repeat(128), 1700000000, None); + // Must be compact (no whitespace), field order: v, pk, sig, t + assert!(!json.contains(' ')); + assert!(json.starts_with(r#"{"v":1,"pk":""#)); + assert!(json.contains(r#","t":1700000000}"#)); + assert!(!json.contains("oa")); + } + + #[test] + fn test_canonical_json_with_oa() { + let oa = ( + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string(), + "".to_string(), + "b".repeat(128), + ); + let json = build_envelope(TEST_PK, &"a".repeat(128), 1700000000, Some(&oa)); + // Field order: v, pk, sig, t, oa + assert!(json.contains(r#","oa":["#)); + let v_pos = json.find(r#""v""#).unwrap(); + let oa_pos = json.find(r#""oa""#).unwrap(); + assert!(v_pos < oa_pos); + } + + #[test] + fn test_armor_format() { + let json = r#"{"v":1,"pk":"test","sig":"test","t":0}"#; + let armored = armor(json.as_bytes()); + assert!(armored.starts_with("-----BEGIN SIGNED MESSAGE-----\n")); + assert!(armored.ends_with("-----END SIGNED MESSAGE-----\n")); + let lines: Vec<&str> = armored.trim_end().split('\n').collect(); + assert_eq!(lines.len(), 3); + } + + #[test] + fn test_parse_armor_valid() { + let input = "-----BEGIN SIGNED MESSAGE-----\nYWJj\n-----END SIGNED MESSAGE-----\n"; + let b64 = parse_armor(input).unwrap(); + assert_eq!(b64, "YWJj"); + } + + #[test] + fn test_parse_armor_rejects_bad_header() { + let input = "-----BEGIN PGP SIGNATURE-----\nYWJj\n-----END SIGNED MESSAGE-----\n"; + assert!(parse_armor(input).is_err()); + } + + #[test] + fn test_validate_hex_field() { + assert!(validate_hex_field("abcdef0123456789", 16, "test").is_ok()); + assert!(validate_hex_field("ABCDEF", 6, "test").is_err()); // uppercase + assert!(validate_hex_field("abcde", 6, "test").is_err()); // wrong length + assert!(validate_hex_field("ghijkl", 6, "test").is_err()); // non-hex + } + + #[test] + fn test_has_non_string_whitespace() { + assert!(!has_non_string_whitespace(r#"{"a":"b c"}"#)); + assert!(has_non_string_whitespace(r#"{"a": "b"}"#)); + assert!(has_non_string_whitespace(r#"{ "a":"b"}"#)); + assert!(!has_non_string_whitespace(r#"{"a":"b\n c"}"#)); + } + + #[test] + fn test_normalize_key_id_hex() { + let hex = "f9308a019258c31049344f85f89d5229b531c845836f99b08601f113bce036f9"; + assert_eq!(normalize_key_id(hex), Some(hex.to_string())); + } + + #[test] + fn test_normalize_key_id_unrecognized() { + assert_eq!(normalize_key_id("not-a-key"), None); + assert_eq!(normalize_key_id(""), None); + } + + #[test] + fn test_validate_conditions() { + // Valid + assert!(validate_conditions("")); + assert!(validate_conditions("kind=9&created_at<1700000000")); + assert!(validate_conditions("kind=0")); + assert!(validate_conditions("kind=65535")); + assert!(validate_conditions("created_at>0")); + assert!(validate_conditions("created_at<4294967295")); + // Invalid — structural + assert!(!validate_conditions("kind=01")); // leading zero + assert!(!validate_conditions("foo=1")); // unknown clause + assert!(!validate_conditions("kind=1&&kind=2")); // empty clause + assert!(!validate_conditions("kind=1&")); // trailing & + assert!(!validate_conditions("&kind=1")); // leading & + assert!(!validate_conditions("kind=65536")); // out of range + assert!(!validate_conditions("created_at<4294967296")); // out of range + // Invalid — injection / whitespace + assert!(!validate_conditions("kind = 9")); // space + assert!(!validate_conditions("kind=9;rm -rf /")); // semicolon, space, slash + assert!(!validate_conditions("kind=9\n")); // newline + } + + #[test] + fn test_parse_status_fd() { + assert!(parse_status_fd("2").is_ok()); + assert!(parse_status_fd("255").is_ok()); + assert!(parse_status_fd("0").is_err()); // stdin + assert!(parse_status_fd("-1").is_err()); // negative + assert!(parse_status_fd("256").is_ok()); // valid — no upper cap + assert!(parse_status_fd("1024").is_ok()); // high fds are valid + assert!(parse_status_fd("abc").is_err()); // non-numeric + } + + #[test] + fn test_read_bounded_file_rejects_missing() { + let result = read_bounded_file("/nonexistent/path", 1024); + assert!(result.is_err()); + } + + #[test] + fn test_load_auth_tag_rejects_bad_conditions() { + // Valid conditions → Ok(Some(...)) + std::env::set_var( + "SPROUT_AUTH_TAG", + format!( + r#"["auth","{}","kind=9&created_at<1700000000","{}"]"#, + "a".repeat(64), + "b".repeat(128) + ), + ); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(Some(_))), + "valid conditions should be accepted" + ); + + // Empty conditions (valid) → Ok(Some(...)) + std::env::set_var( + "SPROUT_AUTH_TAG", + format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)), + ); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(Some(_))), + "empty conditions should be accepted" + ); + + // Conditions with spaces → Err (fail closed) + std::env::set_var( + "SPROUT_AUTH_TAG", + format!( + r#"["auth","{}","kind = 9","{}"]"#, + "a".repeat(64), + "b".repeat(128) + ), + ); + let result = load_auth_tag(); + assert!( + result.is_err(), + "conditions with spaces should be rejected (Err)" + ); + + // Conditions with special chars → Err (fail closed) + std::env::set_var( + "SPROUT_AUTH_TAG", + format!( + r#"["auth","{}","kind=9;rm -rf /","{}"]"#, + "a".repeat(64), + "b".repeat(128) + ), + ); + let result = load_auth_tag(); + assert!( + result.is_err(), + "conditions with special chars should be rejected (Err)" + ); + + // No auth tag set → Ok(None) + std::env::remove_var("SPROUT_AUTH_TAG"); + let result = load_auth_tag(); + assert!( + matches!(result, Ok(None)), + "absent auth tag should be Ok(None)" + ); + } + + // ── Round-trip and verification tests ──────────────────────────────── + + /// Helper: sign a payload and return the armored signature + fn sign_payload(secret_hex: &str, payload: &[u8], t: u64) -> String { + let keypair = Keypair::from_seckey_str(&SECP256K1, secret_hex).unwrap(); + let (xonly, _) = keypair.x_only_public_key(); + let pk_hex = hex::encode(xonly.serialize()); + let hash = compute_signing_hash(t, None, payload); + let message = Message::from_digest(hash); + let sig = SECP256K1.sign_schnorr(&message, &keypair); + let sig_hex = hex::encode(sig.serialize()); + let json = build_envelope(&pk_hex, &sig_hex, t, None); + armor(json.as_bytes()) + } + + /// Helper: parse and verify a signature against a payload + fn verify_sig(armored: &str, payload: &[u8]) -> Result { + let b64 = parse_armor(armored).map_err(|e| e.to_string())?; + let decoded = base64::engine::general_purpose::STANDARD + .decode(b64.as_bytes()) + .map_err(|e| format!("base64: {e}"))?; + let json_str = std::str::from_utf8(&decoded).map_err(|e| format!("utf8: {e}"))?; + let envelope = parse_envelope(json_str)?; + let reconstructed = build_envelope( + &envelope.pk, + &envelope.sig, + envelope.t, + envelope.oa.as_ref(), + ); + if reconstructed != json_str { + return Err("non-canonical JSON".to_string()); + } + let pk = PublicKey::from_hex(&envelope.pk).map_err(|e| format!("invalid pk: {e}"))?; + let hash = compute_signing_hash(envelope.t, envelope.oa.as_ref(), payload); + let message = Message::from_digest(hash); + let sig_bytes = hex::decode(&envelope.sig).map_err(|_| "bad sig hex")?; + let sig = Signature::from_slice(&sig_bytes).map_err(|_| "bad sig")?; + let xonly: &XOnlyPublicKey = &pk; + SECP256K1 + .verify_schnorr(&sig, &message, xonly) + .map_err(|_| "signature verification failed")?; + Ok(envelope) + } + + #[test] + fn test_sign_verify_round_trip() { + // secret key = 0x03 (spec test key) + let secret = "0000000000000000000000000000000000000000000000000000000000000003"; + let payload = test_payload(); + let armored = sign_payload(secret, &payload, 1700000000); + let envelope = verify_sig(&armored, &payload).expect("round-trip should verify"); + assert_eq!(envelope.pk, TEST_PK); + assert_eq!(envelope.t, 1700000000); + assert!(envelope.oa.is_none()); + } + + #[test] + fn test_verify_rejects_wrong_payload() { + let secret = "0000000000000000000000000000000000000000000000000000000000000003"; + let payload = test_payload(); + let armored = sign_payload(secret, &payload, 1700000000); + let wrong_payload = b"wrong payload"; + let result = verify_sig(&armored, wrong_payload); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .contains("signature verification failed")); + } + + #[test] + fn test_verify_rejects_tampered_sig() { + let secret = "0000000000000000000000000000000000000000000000000000000000000003"; + let payload = test_payload(); + let armored = sign_payload(secret, &payload, 1700000000); + // Tamper with the base64 content (flip a character in the signature) + let tampered = armored.replace(&armored.lines().nth(1).unwrap()[..10], "AAAAAAAAAA"); + // This should either fail to parse or fail verification + let result = verify_sig(&tampered, &payload); + assert!(result.is_err()); + } + + #[test] + fn test_verify_rejects_non_canonical_json() { + // Build a valid signature but with extra whitespace in JSON + let secret = "0000000000000000000000000000000000000000000000000000000000000003"; + let keypair = Keypair::from_seckey_str(&SECP256K1, secret).unwrap(); + let (xonly, _) = keypair.x_only_public_key(); + let pk_hex = hex::encode(xonly.serialize()); + let payload = test_payload(); + let hash = compute_signing_hash(1700000000, None, &payload); + let message = Message::from_digest(hash); + let sig = SECP256K1.sign_schnorr(&message, &keypair); + let sig_hex = hex::encode(sig.serialize()); + // Non-canonical: add a space after the opening brace + let json = [ + r#"{ "v":1,"pk":""#, + &pk_hex, + r#"","sig":""#, + &sig_hex, + r#"","t":1700000000}"#, + ] + .concat(); + let armored = armor(json.as_bytes()); + let result = verify_sig(&armored, &payload); + assert!(result.is_err()); + } + + #[test] + fn test_parse_envelope_rejects_invalid_oa_pubkey() { + // oa[0] is valid hex but not a valid BIP-340 point (all zeros) + let zero_pk = "0".repeat(64); + let fake_sig = "b".repeat(128); + let sig_field = "a".repeat(128); + let json = [ + r#"{"v":1,"pk":""#, + TEST_PK, + r#"","sig":""#, + &sig_field, + r#"","t":1700000000,"oa":[""#, + &zero_pk, + r#"","",""#, + &fake_sig, + r#""]}"#, + ] + .concat(); + let result = parse_envelope(&json); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("BIP-340")); + } + + #[test] + fn test_parse_envelope_rejects_self_attestation() { + let sig_field = "a".repeat(128); + let fake_sig = "b".repeat(128); + let json = [ + r#"{"v":1,"pk":""#, + TEST_PK, + r#"","sig":""#, + &sig_field, + r#"","t":1700000000,"oa":[""#, + TEST_PK, + r#"","",""#, + &fake_sig, + r#""]}"#, + ] + .concat(); + let result = parse_envelope(&json); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("self-attestation")); + } + + #[test] + fn test_extract_pk_best_effort_valid() { + let json = format!(r#"{{"v":1,"pk":"{}","sig":"x","t":0}}"#, TEST_PK); + assert_eq!(extract_pk_best_effort(&json), Some(TEST_PK.to_string())); + } + + #[test] + fn test_extract_pk_best_effort_invalid() { + // Uppercase hex — should not match + let json = + r#"{"v":1,"pk":"F9308A019258C31049344F85F89D5229B531C845836F99B08601F113BCE036F9"}"#; + assert_eq!(extract_pk_best_effort(json), None); + } + + #[test] + fn test_extract_pk_best_effort_multibyte() { + // Multi-byte UTF-8 near pk field — should not panic + let json = r#"{"pk":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaé"}"#; + assert_eq!(extract_pk_best_effort(json), None); + } + + #[test] + fn test_parse_armor_rejects_missing_newline() { + let input = "-----BEGIN SIGNED MESSAGE-----\nYWJj\n-----END SIGNED MESSAGE-----"; + assert!(parse_armor(input).is_err()); + } + + #[test] + fn test_enforce_conditions() { + // Empty conditions — always ok + assert!(enforce_conditions("", 1000).is_ok()); + + // created_at< — timestamp must be strictly less than limit + assert!(enforce_conditions("created_at<2000", 1999).is_ok()); + assert!(enforce_conditions("created_at<2000", 2000).is_err()); + assert!(enforce_conditions("created_at<2000", 2001).is_err()); + + // created_at> — timestamp must be strictly greater than limit + assert!(enforce_conditions("created_at>1000", 1001).is_ok()); + assert!(enforce_conditions("created_at>1000", 1000).is_err()); + assert!(enforce_conditions("created_at>1000", 999).is_err()); + + // Combined constraints + assert!(enforce_conditions("created_at>1000&created_at<2000", 1500).is_ok()); + assert!(enforce_conditions("created_at>1000&created_at<2000", 500).is_err()); + assert!(enforce_conditions("created_at>1000&created_at<2000", 2500).is_err()); + + // kind= clauses are skipped (not applicable in NIP-GS) + assert!(enforce_conditions("kind=9", 1000).is_ok()); + assert!(enforce_conditions("kind=9&created_at<2000", 1999).is_ok()); + assert!(enforce_conditions("kind=9&created_at<2000", 2001).is_err()); + } + + // ── PR-ported helpers ───────────────────────────────────────────────── + + /// Wrapper for PR-ported tests: matches PR's is_lower_hex API + fn is_lower_hex(s: &str, len: usize) -> bool { + s.len() == len + && s.bytes() + .all(|b| b.is_ascii_digit() || matches!(b, b'a'..=b'f')) + } + + fn valid_pk() -> String { + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".to_string() + } + + fn valid_sig() -> String { + "a".repeat(128) + } + + fn valid_envelope_json() -> String { + format!( + r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000}}"#, + pk = valid_pk(), + sig = valid_sig(), + ) + } + + /// Wrapper: parse_oa_tag for PR-ported tests + fn parse_oa_tag(json: &str) -> Result<(String, String, String), String> { + let v: serde_json::Value = + serde_json::from_str(json).map_err(|e| format!("invalid auth tag JSON: {e}"))?; + let arr = v.as_array().ok_or("auth tag must be a JSON array")?; + if arr.len() != 4 { + return Err(format!("auth tag must have 4 elements, got {}", arr.len())); + } + let label = arr[0].as_str().ok_or("element 0 must be a string")?; + if label != "auth" { + return Err(format!("first element must be \"auth\", got {label:?}")); + } + let owner = arr[1] + .as_str() + .ok_or("element 1 must be a string")? + .to_string(); + let cond = arr[2] + .as_str() + .ok_or("element 2 must be a string")? + .to_string(); + let sig = arr[3] + .as_str() + .ok_or("element 3 must be a string")? + .to_string(); + if !is_lower_hex(&owner, 64) { + return Err("auth tag owner must be 64 lowercase hex chars".to_string()); + } + PublicKey::from_hex(&owner) + .map_err(|e| format!("auth tag owner is not a valid BIP-340 key: {e}"))?; + if !is_lower_hex(&sig, 128) { + return Err("auth tag sig must be 128 lowercase hex chars".to_string()); + } + if !cond.is_empty() && !validate_conditions(&cond) { + return Err(format!("invalid conditions: {cond}")); + } + Ok((owner, cond, sig)) + } + + // ── PR-ported: is_lower_hex ─────────────────────────────────────────── + + #[test] + fn test_lower_hex_valid() { + assert!(is_lower_hex("deadbeef", 8)); + assert!(is_lower_hex(&"a".repeat(64), 64)); + assert!(is_lower_hex(&"0123456789abcdef".repeat(4), 64)); + } + + #[test] + fn test_lower_hex_rejects_uppercase() { + assert!(!is_lower_hex("DEADBEEF", 8)); + assert!(!is_lower_hex("DeadBeef", 8)); + } + + #[test] + fn test_lower_hex_rejects_wrong_length() { + assert!(!is_lower_hex("deadbeef", 7)); + assert!(!is_lower_hex("deadbeef", 9)); + assert!(!is_lower_hex("", 1)); + } + + #[test] + fn test_lower_hex_rejects_non_hex() { + assert!(!is_lower_hex("deadbeeg", 8)); + assert!(!is_lower_hex("dead beef", 9)); + } + + // ── PR-ported: parse_armor edge cases ───────────────────────────────── + + #[test] + fn test_armor_roundtrip() { + let b64 = "dGVzdA=="; + let text = format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}\n"); + let got = parse_armor(&text).unwrap(); + assert_eq!(got, b64); + } + + #[test] + fn test_armor_no_trailing_newline() { + let b64 = "dGVzdA=="; + let text = format!("{ARMOR_BEGIN}\n{b64}\n{ARMOR_END}"); + // Hardened parse_armor requires trailing newline per NIP-GS spec. + assert!(parse_armor(&text).is_err()); + } + + #[test] + fn test_armor_rejects_crlf() { + let text = format!("{ARMOR_BEGIN}\r\ndGVzdA==\r\n{ARMOR_END}\r\n"); + assert!(parse_armor(&text).is_err()); + } + + #[test] + fn test_armor_rejects_wrong_begin() { + let text = format!("-----BEGIN SOMETHING-----\ndGVzdA==\n{ARMOR_END}\n"); + assert!(parse_armor(&text).is_err()); + } + + #[test] + fn test_armor_rejects_wrong_end() { + let text = format!("{ARMOR_BEGIN}\ndGVzdA==\n-----END SOMETHING-----\n"); + assert!(parse_armor(&text).is_err()); + } + + #[test] + fn test_armor_rejects_trailing_whitespace_on_b64() { + let text = format!("{ARMOR_BEGIN}\ndGVzdA== \n{ARMOR_END}\n"); + assert!(parse_armor(&text).is_err()); + } + + #[test] + fn test_armor_rejects_oversized_b64() { + // parse_armor is a structural parser only; the MAX_BASE64_LINE size + // check is enforced in the verify path after parse_armor returns. + // This test confirms that an oversized b64 line is accepted by + // parse_armor but would be caught downstream. + let big = "A".repeat(MAX_BASE64_LINE + 1); + let text = format!("{ARMOR_BEGIN}\n{big}\n{ARMOR_END}\n"); + let result = parse_armor(&text); + // parse_armor itself accepts it (structural check only) + assert!(result.is_ok()); + // The returned slice must equal the oversized string + assert_eq!(result.unwrap().len(), MAX_BASE64_LINE + 1); + } + + // ── PR-ported: parse_envelope edge cases ────────────────────────────── + + #[test] + fn test_envelope_valid_minimal() { + let env = parse_envelope(&valid_envelope_json()).unwrap(); + assert_eq!(env.pk, valid_pk()); + assert_eq!(env.t, 1700000000); + assert!(env.oa.is_none()); + } + + #[test] + fn test_envelope_rejects_missing_v() { + let json = format!( + r#"{{"pk":"{pk}","sig":"{sig}","t":1700000000}}"#, + pk = valid_pk(), + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_v_not_1() { + let json = format!( + r#"{{"v":2,"pk":"{pk}","sig":"{sig}","t":1700000000}}"#, + pk = valid_pk(), + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_unknown_field() { + let json = format!( + r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000,"extra":"bad"}}"#, + pk = valid_pk(), + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_uppercase_pk() { + let pk_upper = valid_pk().to_uppercase(); + let json = format!( + r#"{{"v":1,"pk":"{pk_upper}","sig":"{sig}","t":1700000000}}"#, + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_t_as_float() { + let json = format!( + r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":1700000000.0}}"#, + pk = valid_pk(), + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_t_out_of_range() { + let json = format!( + r#"{{"v":1,"pk":"{pk}","sig":"{sig}","t":9999999999}}"#, + pk = valid_pk(), + sig = valid_sig(), + ); + assert!(parse_envelope(&json).is_err()); + } + + #[test] + fn test_envelope_rejects_sig_wrong_length() { + let json = format!( + r#"{{"v":1,"pk":"{pk}","sig":"aabbcc","t":1700000000}}"#, + pk = valid_pk(), + ); + assert!(parse_envelope(&json).is_err()); + } + + // ── PR-ported: canonical JSON roundtrip ─────────────────────────────── + + #[test] + fn test_canonical_json_roundtrip() { + let json = valid_envelope_json(); + let env = parse_envelope(&json).unwrap(); + let rebuilt = build_envelope(&env.pk, &env.sig, env.t, env.oa.as_ref()); + assert_eq!(rebuilt.as_bytes(), json.as_bytes()); + } + + // ── PR-ported: timestamp_to_date (was format_date) ──────────────────── + + #[test] + fn test_format_date_epoch() { + assert_eq!(timestamp_to_date(0), "1970-01-01"); + } + + #[test] + fn test_format_date_known() { + assert_eq!(timestamp_to_date(1704067200), "2024-01-01"); + } + + #[test] + fn test_format_date_leap_day() { + assert_eq!(timestamp_to_date(1709164800), "2024-02-29"); + } + + // ── PR-ported: parse_oa_tag ─────────────────────────────────────────── + + #[test] + fn test_oa_tag_rejects_invalid_owner_hex() { + let json = r#"["auth","ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ","read","aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"]"#; + assert!(parse_oa_tag(json).is_err()); + } + + #[test] + fn test_oa_tag_rejects_invalid_sig_hex() { + let json = format!(r#"["auth","{}","read","ZZZZ"]"#, valid_pk()); + assert!(parse_oa_tag(&json).is_err()); + } + + #[test] + fn test_oa_tag_rejects_dangerous_conditions() { + let json = format!( + r#"["auth","{}","read; rm -rf /","{}"]"#, + valid_pk(), + valid_sig() + ); + assert!(parse_oa_tag(&json).is_err()); + } + + #[test] + fn test_oa_tag_rejects_wrong_label() { + let json = format!(r#"["bad","{}","read","{}"]"#, valid_pk(), valid_sig()); + assert!(parse_oa_tag(&json).is_err()); + } + + // ── PR-ported: compute_signing_hash (was signing_message) ───────────── + + #[test] + fn test_signing_hash_deterministic() { + let payload = b"hello world"; + let m1 = compute_signing_hash(1700000000, None, payload); + let m2 = compute_signing_hash(1700000000, None, payload); + assert_eq!(m1, m2); + } + + #[test] + fn test_signing_hash_differs_by_timestamp() { + let payload = b"hello"; + let m1 = compute_signing_hash(1, None, payload); + let m2 = compute_signing_hash(2, None, payload); + assert_ne!(m1, m2); + } + + #[test] + fn test_signing_hash_differs_with_oa() { + let payload = b"hello"; + let oa = (valid_pk(), "read".to_string(), valid_sig()); + let m_no_oa = compute_signing_hash(1, None, payload); + let m_with_oa = compute_signing_hash(1, Some(&oa), payload); + assert_ne!(m_no_oa, m_with_oa); + } +} diff --git a/crates/sprout-acp/Cargo.toml b/crates/sprout-acp/Cargo.toml index d03079f1d1..03ac358707 100644 --- a/crates/sprout-acp/Cargo.toml +++ b/crates/sprout-acp/Cargo.toml @@ -45,6 +45,11 @@ chrono = { workspace = true } # URL parsing url = { workspace = true } +# NIP-98 HTTP auth signing +sha2 = { workspace = true } +base64 = "0.22" +hex = { workspace = true } + # Logging tracing = { workspace = true } tracing-subscriber = { workspace = true } diff --git a/crates/sprout-acp/src/acp.rs b/crates/sprout-acp/src/acp.rs index e9b2999400..bc81a9e296 100644 --- a/crates/sprout-acp/src/acp.rs +++ b/crates/sprout-acp/src/acp.rs @@ -13,6 +13,8 @@ use tokio::io::AsyncWriteExt; use tokio::process::{Child, ChildStdin, ChildStdout}; use tokio_util::codec::{FramedRead, LinesCodec, LinesCodecError}; +use crate::observer::{ObserverContext, ObserverHandle}; + /// Maximum allowed size of a single NDJSON line from the agent's stdout. /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB @@ -137,6 +139,12 @@ pub struct AcpClient { /// Inherited by `cancel_with_cleanup` so the drain loop shares the same budget /// rather than starting a fresh timer (prevents double-jeopardy). current_hard_deadline: Option, + /// Optional local observer feed used by the desktop app. + observer: Option, + /// Pool slot index for this agent process. + observer_agent_index: Option, + /// Best-effort context attached to raw ACP wire events. + observer_context: ObserverContext, } impl AcpClient { @@ -225,9 +233,35 @@ impl AcpClient { permission_responded: false, last_prompt_id: None, current_hard_deadline: None, + observer: None, + observer_agent_index: None, + observer_context: ObserverContext::default(), }) } + /// Attach a local observer feed to this ACP client. + pub fn set_observer(&mut self, observer: Option, agent_index: usize) { + self.observer = observer; + self.observer_agent_index = Some(agent_index); + } + + /// Update metadata that will be attached to subsequent raw wire events. + pub fn set_observer_context(&mut self, context: ObserverContext) { + self.observer_context = context; + } + + /// Emit a semantic event to the local observer feed, if enabled. + pub fn observe(&self, kind: impl Into, payload: serde_json::Value) { + if let Some(observer) = &self.observer { + observer.emit( + kind, + self.observer_agent_index, + &self.observer_context, + payload, + ); + } + } + /// Send the `initialize` request and return the agent's response result value. /// /// Must be called exactly once, before any other ACP method. @@ -429,6 +463,33 @@ impl AcpClient { } }; + self.cancel_with_cleanup_until(session_id, hard_deadline) + .await + } + + /// Cancel a user-interrupted turn with a bounded grace window. + /// + /// Some ACP servers currently keep streaming after `session/cancel`. For an + /// explicit Stop button, waiting until the original turn deadline can make + /// cancellation look broken. This variant gives the agent a short chance to + /// acknowledge cancellation, then returns a timeout so the caller can respawn + /// the agent process and actually stop the work. + pub async fn cancel_with_cleanup_grace( + &mut self, + session_id: &str, + grace: std::time::Duration, + ) -> Result { + let _ = self.current_hard_deadline.take(); + let hard_deadline = tokio::time::Instant::now() + grace; + self.cancel_with_cleanup_until(session_id, hard_deadline) + .await + } + + async fn cancel_with_cleanup_until( + &mut self, + session_id: &str, + hard_deadline: tokio::time::Instant, + ) -> Result { // Validate precondition before any side effects — fail fast if there's // no in-flight prompt (prevents writing permission responses or cancel // notifications to the agent when no prompt is active). @@ -456,10 +517,8 @@ impl AcpClient { tracing::info!(target: "acp::cancel", "sent session/cancel for {session_id}"); // Use a fixed 30s idle timeout during cleanup — the cancel notification // needs time to propagate and the agent may go silent while winding down. - // We do NOT use the caller's idle_timeout here: that value was tuned for - // normal prompt activity and may be very short (e.g. 5s). Using it during - // cancel would cause premature IdleTimeout before the cancelled response - // arrives, leaving the session in an inconsistent state. + // The separate hard_deadline bounds agents that keep producing output + // but ignore cancellation. let cleanup_idle = std::time::Duration::from_secs(30); let result = self .read_until_response_with_idle_timeout(prompt_id, cleanup_idle, hard_deadline) @@ -484,7 +543,9 @@ impl AcpClient { }) .await .map_err(|_| AcpError::WriteTimeout(WRITE_TIMEOUT))? - .map_err(AcpError::Io) + .map_err(AcpError::Io)?; + self.observe("acp_write", value.clone()); + Ok(()) } /// Default timeout for non-prompt RPCs (initialize, session/new, etc.). @@ -626,6 +687,13 @@ impl AcpClient { let msg: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { + self.observe( + "acp_parse_error", + serde_json::json!({ + "line": trimmed, + "error": e.to_string(), + }), + ); tracing::warn!( target: "acp::wire", "failed to parse line as JSON: {e} — skipping" @@ -633,6 +701,7 @@ impl AcpClient { continue; } }; + self.observe("acp_read", msg.clone()); // Check if this is a response to our expected request (has matching id // AND no `method` field — a `method` field means it's an agent-initiated @@ -729,6 +798,13 @@ impl AcpClient { let msg: serde_json::Value = match serde_json::from_str(trimmed) { Ok(v) => v, Err(e) => { + self.observe( + "acp_parse_error", + serde_json::json!({ + "line": trimmed, + "error": e.to_string(), + }), + ); tracing::warn!( target: "acp::wire", "failed to parse line as JSON: {e} — skipping" @@ -736,6 +812,7 @@ impl AcpClient { continue; } }; + self.observe("acp_read", msg.clone()); // Only reset the idle clock on lines that parse as valid JSON. // Malformed lines (skipped above) don't count as real agent activity. diff --git a/crates/sprout-acp/src/config.rs b/crates/sprout-acp/src/config.rs index 368bc99a0b..1cf45fc9b1 100644 --- a/crates/sprout-acp/src/config.rs +++ b/crates/sprout-acp/src/config.rs @@ -183,8 +183,9 @@ pub struct CliArgs { #[arg(long, env = "SPROUT_PRIVATE_KEY")] pub private_key: String, - #[arg(long, env = "SPROUT_API_TOKEN")] - pub api_token: Option, + /// Agent owner pubkey (64-char hex). Used for --respond-to=owner-only gate. + #[arg(long, env = "SPROUT_ACP_AGENT_OWNER")] + pub agent_owner: Option, #[arg(long, env = "SPROUT_ACP_AGENT_COMMAND", default_value = "goose")] pub agent_command: String, @@ -358,6 +359,10 @@ pub struct CliArgs { /// Name of the persona within the pack to use. Required when --persona-pack is set. #[arg(long, env = "SPROUT_ACP_PERSONA_NAME")] pub persona_name: Option, + + /// Publish encrypted ACP observer frames over the relay. + #[arg(long, env = "SPROUT_ACP_RELAY_OBSERVER", default_value_t = false)] + pub relay_observer: bool, } // ── Merged NIP-01 filter ────────────────────────────────────────────────────── @@ -376,7 +381,6 @@ pub struct ChannelFilter { #[derive(Debug)] pub struct Config { pub keys: Keys, - pub api_token: Option, pub relay_url: String, pub agent_command: String, pub agent_args: Vec, @@ -412,6 +416,11 @@ pub struct Config { /// Per-persona env vars to inject at agent spawn time (e.g., GOOSE_PROVIDER, GOOSE_MODEL). /// Populated from persona pack resolution. Empty when no pack is configured. pub persona_env_vars: Vec<(String, String)>, + /// Whether to publish encrypted observer frames through the relay. + pub relay_observer: bool, + /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. + /// Replaces the old REST-based owner lookup. + pub agent_owner: Option, } /// Validate and deduplicate allowlist entries: each must be exactly 64 hex chars. @@ -718,7 +727,6 @@ impl Config { let config = Config { keys, - api_token: args.api_token, relay_url: args.relay_url, agent_command, agent_args, @@ -747,6 +755,8 @@ impl Config { respond_to: args.respond_to, respond_to_allowlist, persona_env_vars, + relay_observer: args.relay_observer, + agent_owner: args.agent_owner.map(|s| s.trim().to_ascii_lowercase()), }; Ok(config) @@ -1078,7 +1088,6 @@ mod tests { fn test_config(mode: SubscribeMode) -> Config { Config { keys: nostr::Keys::generate(), - api_token: None, relay_url: "ws://localhost:3000".into(), agent_command: "goose".into(), agent_args: vec!["acp".into()], @@ -1107,6 +1116,8 @@ mod tests { respond_to: RespondTo::Anyone, respond_to_allowlist: HashSet::new(), persona_env_vars: vec![], + relay_observer: false, + agent_owner: None, } } diff --git a/crates/sprout-acp/src/main.rs b/crates/sprout-acp/src/main.rs index aed16cd0af..33e1944961 100644 --- a/crates/sprout-acp/src/main.rs +++ b/crates/sprout-acp/src/main.rs @@ -3,6 +3,7 @@ mod acp; mod config; mod filter; +mod observer; mod pool; mod queue; mod relay; @@ -17,16 +18,20 @@ use clap::Parser; use config::{Config, DedupMode, ModelsArgs, MultipleEventHandling, RespondTo, SubscribeMode}; use filter::SubscriptionRule; use futures_util::FutureExt; -use nostr::ToBech32; +use nostr::{PublicKey, ToBech32}; use pool::{ - AgentPool, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, SessionState, + AgentPool, CancelMode, OwnedAgent, PromptContext, PromptOutcome, PromptResult, PromptSource, + SessionState, }; use queue::{EventQueue, QueuedEvent, ThreadTags}; -use relay::HarnessRelay; +use relay::{HarnessRelay, RelayEventPublisher}; use sprout_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, }; +use sprout_core::observer::{ + decrypt_observer_payload, encrypt_observer_payload, OBSERVER_FRAME_TELEMETRY, +}; use tokio::sync::{mpsc, watch}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -48,246 +53,507 @@ fn is_subcommand(name: &str) -> bool { /// Timeout for the `sprout-acp models` subcommand (spawn + init + session/new). const MODELS_TIMEOUT: Duration = Duration::from_secs(10); -// ── Owner cache ─────────────────────────────────────────────────────────────── +// ── Presence helper ─────────────────────────────────────────────────────────── -/// Lazy-resolving cache for the agent's owner pubkey. +/// Publish a kind:20001 presence update event via the WebSocket connection. /// -/// Replaces the bare `Option` that previously lived in the event loop. -/// On success, the owner pubkey is cached for the process lifetime (owner -/// changes require a harness restart). On failure/miss, retries after 60s -/// to avoid hammering the API. -struct OwnerCache { - pubkey: Option, - last_attempt: Option, +/// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence +/// updates must be routed through the WS path. +/// +/// Content is a bare status string (`"online"`, `"away"`, `"offline"`) matching +/// the desktop client's format. The relay stores this in Redis and synthesizes +/// it back on presence queries. +async fn publish_presence( + publisher: &relay::RelayEventPublisher, + keys: &nostr::Keys, + status: &str, +) -> Result<(), relay::RelayError> { + use nostr::{EventBuilder, Kind}; + use sprout_core::kind::KIND_PRESENCE_UPDATE; + + let event = EventBuilder::new(Kind::Custom(KIND_PRESENCE_UPDATE as u16), status, []) + .sign_with_keys(keys) + .map_err(|e| relay::RelayError::Http(format!("presence sign error: {e}")))?; + publisher.publish_event(event).await?; + Ok(()) } -/// How long to cache a failed owner lookup before retrying. -const OWNER_CACHE_TTL: Duration = Duration::from_secs(60); +// ── Owner resolution ────────────────────────────────────────────────────────── + +/// Resolve the agent's owner pubkey at startup. +/// +/// Priority: +/// 1. `SPROUT_AUTH_TAG` env var — NIP-OA attestation signed by the owner. +/// Verified against the agent's own pubkey to extract the owner pubkey. +/// 2. `--agent-owner` CLI flag / `SPROUT_ACP_AGENT_OWNER` env var. +fn resolve_agent_owner(config: &Config) -> Option { + // Try SPROUT_AUTH_TAG first (NIP-OA attestation). + if let Ok(auth_tag) = std::env::var("SPROUT_AUTH_TAG") { + if !auth_tag.is_empty() { + let agent_pk = config.keys.public_key(); + match sprout_sdk::nip_oa::verify_auth_tag(&auth_tag, &agent_pk) { + Ok(owner_pk) => { + let owner_hex = owner_pk.to_hex().to_ascii_lowercase(); + tracing::info!("owner resolved from SPROUT_AUTH_TAG: {owner_hex}"); + return Some(owner_hex); + } + Err(e) => { + tracing::warn!("SPROUT_AUTH_TAG verification failed: {e} — falling back"); + } + } + } + } + + // Fall back to --agent-owner config. + config.agent_owner.clone() +} + +// ── Owner cache ─────────────────────────────────────────────────────────────── + +/// Cache for the agent's owner pubkey. +/// +/// Owner is now provided via `--agent-owner` config flag (no REST lookup). +/// Cache for the agent's owner pubkey + sibling lookups. +/// +/// Siblings are other agents whose NIP-OA auth tag proves the same owner. +/// Lookup results are cached for the process lifetime (attestations are immutable). +struct OwnerCache { + pubkey: Option, + /// author_hex → is_sibling (true = same owner, false = not) + siblings: std::sync::Mutex>, +} impl OwnerCache { fn new(initial: Option) -> Self { - let last_attempt = if initial.is_some() { - Some(std::time::Instant::now()) - } else { - None - }; Self { pubkey: initial, - last_attempt, + siblings: std::sync::Mutex::new(HashMap::new()), } } - /// Return the cached owner pubkey, or attempt a lazy resolution if stale. - async fn get_or_resolve( - &mut self, - rest_client: &relay::RestClient, - agent_pubkey_hex: &str, - ) -> Option<&str> { - if self.pubkey.is_some() { - return self.pubkey.as_deref(); - } - let stale = self - .last_attempt - .map(|t| t.elapsed() >= OWNER_CACHE_TTL) - .unwrap_or(true); - if !stale { - return None; - } - self.last_attempt = Some(std::time::Instant::now()); - let profile_url = format!("/api/users/{agent_pubkey_hex}/profile"); - match rest_client.get_json(&profile_url).await { - Ok(v) => { - // Normalize to lowercase hex for consistent comparison with - // nostr PublicKey::to_hex() and validated allowlist entries. - self.pubkey = v - .get("agent_owner_pubkey") - .and_then(|v| v.as_str()) - .map(|s| s.to_ascii_lowercase()); - if let Some(ref o) = self.pubkey { - tracing::info!("lazy owner resolution succeeded: {o}"); - } - } - Err(e) => { - tracing::warn!("lazy owner lookup failed: {e}"); - } - } + /// Return the cached owner pubkey. + fn get(&self) -> Option<&str> { self.pubkey.as_deref() } + + /// Check if author is a known sibling (cached result). + fn is_known_sibling(&self, author: &str) -> Option { + self.siblings.lock().ok()?.get(author).copied() + } + + /// Cache a sibling lookup result. + fn cache_sibling(&self, author: String, is_sibling: bool) { + if let Ok(mut map) = self.siblings.lock() { + // Cap at 256 entries to prevent unbounded growth. + if map.len() >= 256 { + map.clear(); + } + map.insert(author, is_sibling); + } + } } -// ── Sibling cache ───────────────────────────────────────────────────────────── - -/// Result of looking up an author's owner via the REST API. -#[derive(Debug, Clone)] -enum SiblingLookup { - /// Profile resolved; contains the author's `agent_owner_pubkey` (if any), - /// normalized to lowercase hex. - Resolved(Option), - /// REST call failed — treat as "not a sibling" (fail-closed). - Failed, -} - -/// Cache of author → owner lookups for the sibling author gate. +/// Check if `author` is the owner OR a sibling (same owner via NIP-OA). /// -/// When `--respond-to=owner-only`, the harness accepts events from the owner -/// AND from any pubkey whose `agent_owner_pubkey` matches the owner (siblings). -/// This cache avoids hitting the REST API on every event from a known author. -/// -/// TTL is derived at **read time**: a cached `Resolved(Some(owner))` that -/// matches the expected owner uses `SIBLING_CACHE_HIT_TTL` (5 min); all other -/// results use `SIBLING_CACHE_MISS_TTL` (1 min). This is correct even if the -/// agent owner changes (it doesn't — `OwnerCache` is process-stable — but the -/// design doesn't depend on that). -struct SiblingCache { - /// author_hex → (lookup_result, resolved_at) - entries: HashMap, -} - -/// TTL for a cached sibling match (Resolved(Some(owner)) where owner == expected). -const SIBLING_CACHE_HIT_TTL: Duration = Duration::from_secs(300); -/// TTL for a cached miss/different-owner/failure. -const SIBLING_CACHE_MISS_TTL: Duration = Duration::from_secs(60); -/// Maximum entries before oldest-eviction. -const SIBLING_CACHE_MAX_ENTRIES: usize = 256; - -impl SiblingCache { - fn new() -> Self { - Self { - entries: HashMap::new(), - } - } - - /// Record a lookup result for `author_hex`. Pure cache mutation — no I/O. - /// - /// Normalizes any owner pubkey inside `Resolved(Some(_))` to lowercase hex - /// so callers of `check()` get consistent comparisons regardless of API - /// casing. Evicts the oldest entry when at capacity. - fn record(&mut self, author_hex: String, result: SiblingLookup) { - // Normalize before caching. - let normalized = match result { - SiblingLookup::Resolved(Some(owner)) => { - SiblingLookup::Resolved(Some(owner.to_ascii_lowercase())) - } - other => other, - }; - - if self.entries.len() >= SIBLING_CACHE_MAX_ENTRIES - && !self.entries.contains_key(&author_hex) - { - // Evict oldest entry by resolved_at. - if let Some(oldest_key) = self - .entries - .iter() - .min_by_key(|(_, (_, ts))| *ts) - .map(|(k, _)| k.clone()) - { - self.entries.remove(&oldest_key); - } - } - - self.entries - .insert(author_hex, (normalized, std::time::Instant::now())); - } - - /// Check if a cached entry exists and is fresh for the given expected owner. - /// - /// Returns `Some(true)` if the author is a confirmed sibling (same owner), - /// `Some(false)` if confirmed non-sibling, or `None` if the cache entry is - /// missing or stale (caller should fetch). - fn check(&self, author_hex: &str, expected_owner_hex: &str) -> Option { - let (lookup, resolved_at) = self.entries.get(author_hex)?; - - let is_match = matches!( - lookup, - SiblingLookup::Resolved(Some(ref o)) if o == expected_owner_hex - ); - - let ttl = if is_match { - SIBLING_CACHE_HIT_TTL - } else { - SIBLING_CACHE_MISS_TTL - }; - - if resolved_at.elapsed() >= ttl { - return None; // stale - } - - Some(is_match) - } - - /// Full lookup: check cache, fetch if needed, record result. - async fn is_sibling( - &mut self, - rest_client: &relay::RestClient, - author_hex: &str, - expected_owner_hex: &str, - ) -> bool { - if let Some(result) = self.check(author_hex, expected_owner_hex) { - return result; - } - - let lookup = Self::fetch_owner(rest_client, author_hex).await; - // Note: fetch_owner() already lowercases, and record() normalizes too, - // so no additional to_ascii_lowercase() needed here. - let is_match = matches!( - &lookup, - SiblingLookup::Resolved(Some(ref o)) if o == expected_owner_hex - ); - self.record(author_hex.to_owned(), lookup); - is_match - } - - /// Fetch an author's owner from the REST API. - async fn fetch_owner(rest_client: &relay::RestClient, author_hex: &str) -> SiblingLookup { - let url = format!("/api/users/{author_hex}/profile"); - match rest_client.get_json(&url).await { - Ok(v) => { - let owner = v - .get("agent_owner_pubkey") - .and_then(|v| v.as_str()) - .map(|s| s.to_ascii_lowercase()); - tracing::debug!( - author = author_hex, - owner = ?owner, - "sibling cache: resolved author owner" - ); - SiblingLookup::Resolved(owner) - } - Err(e) => { - tracing::warn!( - author = author_hex, - error = %e, - "sibling cache: REST lookup failed — treating as non-sibling" - ); - SiblingLookup::Failed - } - } - } -} - -/// Check if `author` is the owner or a sibling (shares the same owner). -/// -/// Used by the `OwnerOnly` author gate mode. The owner is the direct match; -/// siblings are other pubkeys whose `agent_owner_pubkey` equals the owner. +/// For unknown authors, queries their kind:0 profile to extract the NIP-OA +/// auth tag and verify the owner matches. Result is cached. async fn is_owner_or_sibling( author: &str, - owner_cache: &mut OwnerCache, - sibling_cache: &mut SiblingCache, + owner_cache: &OwnerCache, rest_client: &relay::RestClient, - agent_pubkey_hex: &str, ) -> bool { - let owner = owner_cache - .get_or_resolve(rest_client, agent_pubkey_hex) - .await; - match owner { - Some(o) if author == o => true, // direct owner match - Some(o) => { - // Check if author is a sibling — another agent with the same owner. - // Need to copy `o` because `owner_cache` borrows are released. - let o = o.to_owned(); - sibling_cache.is_sibling(rest_client, author, &o).await + let my_owner = match owner_cache.get() { + Some(o) => o, + None => return false, // no owner configured — fail closed + }; + + // Direct owner check. + if author == my_owner { + return true; + } + + // Check sibling cache. + if let Some(cached) = owner_cache.is_known_sibling(author) { + return cached; + } + + // Query the author's kind:0 profile to check for NIP-OA auth tag. + let is_sibling = check_sibling_via_profile(author, my_owner, rest_client).await; + owner_cache.cache_sibling(author.to_string(), is_sibling); + is_sibling +} + +/// Query an author's kind:0 profile and check if their NIP-OA auth tag +/// proves the same owner as us. +async fn check_sibling_via_profile( + author: &str, + expected_owner: &str, + rest_client: &relay::RestClient, +) -> bool { + let filter = nostr::Filter::new() + .kind(nostr::Kind::Metadata) + .author(match nostr::PublicKey::from_hex(author) { + Ok(pk) => pk, + Err(_) => return false, + }) + .limit(1); + + let resp = match tokio::time::timeout(Duration::from_millis(2000), rest_client.query(&[filter])) + .await + { + Ok(Ok(v)) => v, + _ => return false, // timeout or error — fail closed + }; + + // Look for an "auth" tag in the profile event. + let events = match resp.as_array() { + Some(arr) => arr, + None => return false, + }; + let event = match events.first() { + Some(e) => e, + None => return false, + }; + let tags = match event.get("tags").and_then(|t| t.as_array()) { + Some(t) => t, + None => return false, + }; + + // Find ["auth", owner_pk, conditions, sig] and verify the Schnorr signature. + // Don't trust the relay — verify ourselves. + let agent_pk = match nostr::PublicKey::from_hex(author) { + Ok(pk) => pk, + Err(_) => return false, + }; + + for tag in tags { + let parts = match tag.as_array() { + Some(p) if p.len() >= 4 => p, + _ => continue, + }; + if parts[0].as_str() != Some("auth") { + continue; } - None => false, // no owner resolved — fail closed + let tag_owner = match parts[1].as_str() { + Some(o) => o, + None => continue, + }; + // Only verify if the owner field matches ours. + if !tag_owner.eq_ignore_ascii_case(expected_owner) { + continue; + } + // Cryptographically verify the NIP-OA attestation signature. + let tag_json = serde_json::to_string(tag).unwrap_or_default(); + match sprout_sdk::nip_oa::verify_auth_tag(&tag_json, &agent_pk) { + Ok(_) => { + tracing::debug!(author, expected_owner, "sibling verified via NIP-OA"); + return true; + } + Err(e) => { + tracing::debug!(author, "NIP-OA auth tag verification failed: {e}"); + } + } + } + + false +} + +fn spawn_relay_observer_publisher( + observer: observer::ObserverHandle, + publisher: RelayEventPublisher, + keys: nostr::Keys, + agent_pubkey_hex: String, + owner_pubkey_hex: String, + owner_pubkey: PublicKey, +) -> tokio::task::JoinHandle<()> { + tokio::spawn(async move { + let mut coalescer = ObserverChunkCoalescer::default(); + for event in observer.snapshot() { + for event in coalescer.ingest(event) { + publish_relay_observer_event( + &publisher, + &keys, + &agent_pubkey_hex, + &owner_pubkey_hex, + &owner_pubkey, + event, + ) + .await; + } + } + + let mut rx = observer.subscribe(); + let mut flush_interval = tokio::time::interval(std::time::Duration::from_millis(500)); + flush_interval.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip); + loop { + tokio::select! { + result = rx.recv() => { + match result { + Ok(event) => { + for event in coalescer.ingest(event) { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &owner_pubkey_hex, &owner_pubkey, event, + ).await; + } + } + Err(tokio::sync::broadcast::error::RecvError::Lagged(count)) => { + for event in coalescer.flush() { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &owner_pubkey_hex, &owner_pubkey, event, + ).await; + } + tracing::warn!(dropped = count, "relay observer publisher lagged"); + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => { + for event in coalescer.flush() { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &owner_pubkey_hex, &owner_pubkey, event, + ).await; + } + break; + } + } + } + _ = flush_interval.tick() => { + // Periodic flush ensures live streaming even during continuous chunk delivery. + for event in coalescer.flush() { + publish_relay_observer_event( + &publisher, &keys, &agent_pubkey_hex, + &owner_pubkey_hex, &owner_pubkey, event, + ).await; + } + } + } + } + }) +} + +#[derive(Default)] +struct ObserverChunkCoalescer { + pending: Vec, +} + +struct PendingObserverChunk { + key: ObserverChunkKey, + event: observer::ObserverEvent, + text: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ObserverChunkKey { + update_type: String, + message_id: Option, + channel_id: Option, + session_id: Option, + turn_id: Option, + agent_index: Option, +} + +/// Flush coalesced chunks before they exceed the NIP-44 plaintext limit (65,535 bytes). +/// Leave headroom for the JSON envelope wrapping the text. +const OBSERVER_CHUNK_MAX_TEXT_BYTES: usize = 60_000; + +impl ObserverChunkCoalescer { + fn ingest(&mut self, event: observer::ObserverEvent) -> Vec { + let Some((key, text)) = observer_chunk_key_and_text(&event) else { + let mut events = self.flush(); + events.push(event); + return events; + }; + + if let Some(pending) = self.pending.iter_mut().find(|pending| pending.key == key) { + // Flush before appending if this would exceed the plaintext size limit. + if pending.text.len() + text.len() >= OBSERVER_CHUNK_MAX_TEXT_BYTES { + let events = self.flush(); + // Start a new pending entry with the current chunk. + self.pending.push(PendingObserverChunk { key, event, text }); + return events; + } + pending.text.push_str(&text); + pending.event.seq = event.seq; + pending.event.timestamp = event.timestamp; + return Vec::new(); + } + + self.pending.push(PendingObserverChunk { key, event, text }); + Vec::new() + } + + fn flush(&mut self) -> Vec { + self.pending + .drain(..) + .map(|mut pending| { + set_observer_chunk_text(&mut pending.event.payload, pending.text); + pending.event + }) + .collect() + } +} + +fn observer_chunk_key_and_text( + event: &observer::ObserverEvent, +) -> Option<(ObserverChunkKey, String)> { + let update = event.payload.get("params")?.get("update")?; + let update_type = update.get("sessionUpdate")?.as_str()?; + if !matches!( + update_type, + "agent_message_chunk" | "user_message_chunk" | "agent_thought_chunk" + ) { + return None; + } + + let text = update.get("content")?.get("text")?.as_str()?.to_string(); + let message_id = update + .get("messageId") + .and_then(|value| value.as_str()) + .map(ToOwned::to_owned); + + Some(( + ObserverChunkKey { + update_type: update_type.to_string(), + message_id, + channel_id: event.channel_id.clone(), + session_id: event.session_id.clone(), + turn_id: event.turn_id.clone(), + agent_index: event.agent_index, + }, + text, + )) +} + +fn set_observer_chunk_text(payload: &mut serde_json::Value, text: String) { + let Some(content) = payload + .get_mut("params") + .and_then(|params| params.get_mut("update")) + .and_then(|update| update.get_mut("content")) + else { + return; + }; + + if let Some(content_object) = content.as_object_mut() { + content_object.insert("text".to_string(), serde_json::Value::String(text)); + } +} + +async fn publish_relay_observer_event( + publisher: &RelayEventPublisher, + keys: &nostr::Keys, + agent_pubkey_hex: &str, + owner_pubkey_hex: &str, + owner_pubkey: &PublicKey, + event: observer::ObserverEvent, +) { + let encrypted = match encrypt_observer_payload(keys, owner_pubkey, &event) { + Ok(encrypted) => encrypted, + Err(error) => { + tracing::warn!("failed to encrypt relay observer event: {error}"); + return; + } + }; + let builder = match sprout_sdk::build_agent_observer_frame( + owner_pubkey_hex, + agent_pubkey_hex, + OBSERVER_FRAME_TELEMETRY, + &encrypted, + ) { + Ok(builder) => builder, + Err(error) => { + tracing::warn!("failed to build relay observer event: {error}"); + return; + } + }; + let signed = match builder.sign_with_keys(keys) { + Ok(event) => event, + Err(error) => { + tracing::warn!("failed to sign relay observer event: {error}"); + return; + } + }; + if let Err(error) = publisher.publish_event(signed).await { + tracing::debug!("relay observer event dropped: {error}"); + } +} + +/// Maximum age (seconds) for an observer control frame to be considered fresh. +const OBSERVER_CONTROL_FRESHNESS_SECS: i64 = 300; + +fn handle_relay_observer_control_event( + keys: &nostr::Keys, + event: nostr::Event, + pool: &mut AgentPool, + observer: Option<&observer::ObserverHandle>, + owner_pubkey_hex: &str, +) { + // Defense-in-depth: verify signature even though the relay already checked. + if let Err(e) = sprout_core::verify_event(&event) { + tracing::warn!(error = %e, "observer control frame failed signature verification"); + return; + } + + // Defense-in-depth: verify the sender is the resolved owner. + if event.pubkey.to_hex() != owner_pubkey_hex { + tracing::warn!( + sender = %event.pubkey, + expected = %owner_pubkey_hex, + "observer control frame from non-owner — dropping" + ); + return; + } + + // Freshness: reject stale/replayed frames outside ±5 minute window. + let now = chrono::Utc::now().timestamp(); + let event_ts = event.created_at.as_u64() as i64; + if (event_ts - now).unsigned_abs() > OBSERVER_CONTROL_FRESHNESS_SECS as u64 { + tracing::warn!( + event_ts, + now, + "observer control frame outside freshness window — dropping" + ); + return; + } + + let payload = match decrypt_observer_payload::(keys, &event) { + Ok(payload) => payload, + Err(error) => { + tracing::warn!("failed to decrypt observer control frame: {error}"); + return; + } + }; + + let command_type = payload.get("type").and_then(|value| value.as_str()); + if command_type != Some("cancel_turn") { + tracing::debug!(payload = %payload, "ignoring unknown observer control frame"); + return; + } + + let Some(channel_id) = payload + .get("channelId") + .and_then(|value| value.as_str()) + .and_then(|value| value.parse::().ok()) + else { + tracing::warn!("observer cancel_turn control frame missing valid channelId"); + return; + }; + + let fired = cancel_in_flight_task(pool, channel_id, CancelMode::Stop); + let status = if fired { "sent" } else { "no_active_turn" }; + if let Some(observer) = observer { + observer.emit( + "control_result", + None, + &observer::ObserverContext { + channel_id: Some(channel_id.to_string()), + session_id: None, + turn_id: None, + }, + serde_json::json!({ + "type": "cancel_turn", + "status": status, + }), + ); } } @@ -529,6 +795,24 @@ async fn tokio_main() -> Result<()> { let config = Config::from_cli().map_err(|e| anyhow::anyhow!("configuration error: {e}"))?; tracing::info!("sprout-acp starting: {}", config.summary()); + let observer = config + .relay_observer + .then(observer::ObserverHandle::in_process); + if let Some(handle) = &observer { + handle.emit( + "harness_started", + None, + &observer::ObserverContext::default(), + serde_json::json!({ + "relayUrl": config.relay_url, + "agentCommand": config.agent_command, + "agentArgs": config.agent_args, + "parallelism": config.agents, + "relayObserver": config.relay_observer, + }), + ); + } + // ── Step 1: Spawn N ACP agent subprocesses and initialize ───────────────── // // Finding #10: one agent failing to start must not kill the whole pool. @@ -549,9 +833,17 @@ async fn tokio_main() -> Result<()> { .await; match spawn_result { Ok(mut acp) => { + acp.set_observer(observer.clone(), i); match tokio::time::timeout(Duration::from_secs(60), acp.initialize()).await { Ok(Ok(init_result)) => { tracing::info!(agent = i, "agent initialized: {init_result}"); + acp.observe( + "agent_initialized", + serde_json::json!({ + "agentIndex": i, + "initializeResult": init_result, + }), + ); agent_slots.push(Some(OwnedAgent { index: i, acp, @@ -609,14 +901,17 @@ async fn tokio_main() -> Result<()> { .as_secs(); let pubkey_hex = config.keys.public_key().to_hex(); - let mut relay = HarnessRelay::connect( - &config.relay_url, - &config.keys, - config.api_token.as_deref(), - &pubkey_hex, - ) - .await - .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; + + // Parse SPROUT_AUTH_TAG into a nostr::Tag for NIP-OA relay membership delegation. + let relay_auth_tag: Option = std::env::var("SPROUT_AUTH_TAG") + .ok() + .filter(|s| !s.is_empty()) + .and_then(|s| sprout_sdk::nip_oa::parse_auth_tag(&s).ok()); + + let mut relay = + HarnessRelay::connect(&config.relay_url, &config.keys, &pubkey_hex, relay_auth_tag) + .await + .map_err(|e| anyhow::anyhow!("relay connect error: {e}"))?; // Finding #22: tell the relay background task the watermark so it can use // `since = watermark - 5s` on the first REQ instead of `since=now`. @@ -636,37 +931,22 @@ async fn tokio_main() -> Result<()> { tracing::info!("subscribed to membership notifications"); // ── Step 2c: Set initial presence ───────────────────────────────────────── - let rest_client_for_presence = relay.rest_client(); + let presence_publisher = relay.event_publisher(); + let presence_keys = config.keys.clone(); if config.presence_enabled { - match rest_client_for_presence - .put_json("/api/presence", &serde_json::json!({"status": "online"})) - .await - { + match publish_presence(&presence_publisher, &presence_keys, "online").await { Ok(_) => tracing::info!("presence set to online"), Err(e) => tracing::warn!("failed to set initial presence: {e}"), } } - // ── Step 2d: Query agent owner ────────────────────────────────────────── - // Owner lookup is used by !shutdown and the inbound author gate. - // Try at startup; OwnerCache retries lazily on cache miss. - let startup_owner: Option = { - let profile_url = format!("/api/users/{pubkey_hex}/profile"); - match rest_client_for_presence.get_json(&profile_url).await { - Ok(v) => v - .get("agent_owner_pubkey") - .and_then(|v| v.as_str()) - .map(|s| s.to_ascii_lowercase()), - Err(e) => { - tracing::warn!("startup owner lookup failed (will retry lazily): {e}"); - None - } - } - }; + // ── Step 2d: Resolve agent owner ──────────────────────────────────────── + // Priority: SPROUT_AUTH_TAG (NIP-OA attestation) → --agent-owner flag. + let startup_owner: Option = resolve_agent_owner(&config); if let Some(ref owner) = startup_owner { tracing::info!("agent owner: {owner}"); } else { - tracing::info!("no agent owner set at startup — will resolve lazily"); + tracing::info!("no agent owner configured"); } // Warn if owner-dependent mode but no owner resolved yet. if startup_owner.is_none() { @@ -674,7 +954,7 @@ async fn tokio_main() -> Result<()> { RespondTo::OwnerOnly => { tracing::warn!( "respond-to=owner-only but no owner is set — all events will be \ - dropped until owner is resolved. Set --respond-to=anyone to override." + dropped. Set SPROUT_AUTH_TAG or --agent-owner, or use --respond-to=anyone." ); } RespondTo::Allowlist => { @@ -687,8 +967,42 @@ async fn tokio_main() -> Result<()> { _ => {} // anyone/nobody don't depend on owner } } - let mut owner_cache = OwnerCache::new(startup_owner); - let mut sibling_cache = SiblingCache::new(); + let owner_cache = OwnerCache::new(startup_owner); + + let mut relay_observer_control_rx = None; + let mut relay_observer_publisher_task = None; + if config.relay_observer { + if let (Some(observer), Some(owner_pubkey_hex)) = + (observer.clone(), owner_cache.pubkey.clone()) + { + match PublicKey::from_hex(&owner_pubkey_hex) { + Ok(owner_pubkey) => { + relay_observer_publisher_task = Some(spawn_relay_observer_publisher( + observer, + relay.event_publisher(), + config.keys.clone(), + pubkey_hex.clone(), + owner_pubkey_hex, + owner_pubkey, + )); + relay + .subscribe_observer_controls() + .await + .map_err(|e| anyhow::anyhow!("observer control subscribe error: {e}"))?; + relay_observer_control_rx = relay.take_observer_control_rx(); + tracing::info!("relay observer enabled"); + } + Err(error) => { + tracing::warn!("relay observer disabled: invalid owner pubkey: {error}"); + } + } + } else { + tracing::warn!( + "relay observer requested but no agent owner was resolved at startup; \ + observer frames will not be published" + ); + } + } // ── Step 3: Discover channels and build subscription rules ──────────────── let channel_info_map = relay @@ -918,9 +1232,10 @@ async fn tokio_main() -> Result<()> { let cmd = config.agent_command.clone(); let args = config.agent_args.clone(); let env = config.persona_env_vars.clone(); + let observer = observer.clone(); let guard = RespawnGuard::new(idx, respawn_tx.clone()); respawn_tasks.spawn(async move { - let result = spawn_and_init(&cmd, &args, &env).await; + let result = spawn_and_init(&cmd, &args, &env, idx, observer).await; guard.send(result); }); } @@ -989,6 +1304,28 @@ async fn tokio_main() -> Result<()> { Some(Err(e)) = join_set.join_next(), if !join_set.is_empty() => { Some(PoolEvent::Panic(e)) } + control_event = async { + match relay_observer_control_rx.as_mut() { + Some(rx) => rx.recv().await, + None => std::future::pending().await, + } + } => { + let _ = result_rx; + match control_event { + Some(event) => { + if let Some(ref owner_hex) = owner_cache.pubkey { + handle_relay_observer_control_event(&config.keys, event, &mut pool, observer.as_ref(), owner_hex); + } else { + tracing::warn!("observer control frame received but no owner resolved — dropping"); + } + } + None => { + relay_observer_control_rx = None; + tracing::warn!("relay observer control channel closed"); + } + } + None + } // Remaining branches don't touch pool — evaluated when pool is idle. sprout_event = relay.next_event() => { let _ = result_rx; // end split borrow before relay handling @@ -1119,12 +1456,7 @@ async fn tokio_main() -> Result<()> { && t.as_slice().get(1).map(|s| s.as_str()) == Some(pubkey_hex.as_str()) }); if is_shutdown { - // Lazy owner resolution via OwnerCache — a relay - // outage during startup doesn't permanently - // disable remote shutdown. - let owner = owner_cache - .get_or_resolve(&rest_client_for_presence, &pubkey_hex) - .await; + let owner = owner_cache.get(); if let Some(owner) = owner { if sprout_event.event.pubkey.to_hex() == *owner { tracing::info!( @@ -1157,12 +1489,9 @@ async fn tokio_main() -> Result<()> { && t.as_slice().get(1).map(|s| s.as_str()) == Some(pubkey_hex.as_str()) }); if is_cancel { - let owner = owner_cache - .get_or_resolve(&rest_client_for_presence, &pubkey_hex) - .await; - if let Some(owner) = owner { + if let Some(owner) = owner_cache.get() { if sprout_event.event.pubkey.to_hex() == *owner { - let fired = cancel_in_flight_task(&mut pool, sprout_event.channel_id); + let fired = cancel_in_flight_task(&mut pool, sprout_event.channel_id, CancelMode::Stop); if !fired { tracing::warn!( channel_id = %sprout_event.channel_id, @@ -1193,22 +1522,10 @@ async fn tokio_main() -> Result<()> { RespondTo::Anyone => true, RespondTo::Nobody => false, RespondTo::OwnerOnly => { - is_owner_or_sibling( - &author, - &mut owner_cache, - &mut sibling_cache, - &rest_client_for_presence, - &pubkey_hex, - ) - .await + is_owner_or_sibling(&author, &owner_cache, &ctx.rest_client).await } RespondTo::Allowlist => { - let owner = owner_cache - .get_or_resolve( - &rest_client_for_presence, - &pubkey_hex, - ) - .await; + let owner = owner_cache.get(); config.respond_to_allowlist.contains(&author) || owner == Some(author.as_str()) } @@ -1262,17 +1579,14 @@ async fn tokio_main() -> Result<()> { MultipleEventHandling::Queue => false, MultipleEventHandling::Interrupt => true, MultipleEventHandling::OwnerInterrupt => { - let owner = owner_cache - .get_or_resolve(&rest_client_for_presence, &pubkey_hex) - .await; - match owner { + match owner_cache.get() { Some(o) => author_hex == *o, None => false, } } }; if should_cancel { - cancel_in_flight_task(&mut pool, sprout_event.channel_id); + cancel_in_flight_task(&mut pool, sprout_event.channel_id, CancelMode::Interrupt); } } // ── End mode gate ──────────────────────────────── @@ -1325,9 +1639,10 @@ async fn tokio_main() -> Result<()> { if let Some(h) = presence_task.take() { h.abort(); } - let rc = rest_client_for_presence.clone(); + let pp = presence_publisher.clone(); + let pk = presence_keys.clone(); presence_task = Some(tokio::spawn(async move { - if let Err(e) = rc.put_json("/api/presence", &serde_json::json!({"status": "online"})).await { + if let Err(e) = publish_presence(&pp, &pk, "online").await { tracing::warn!("presence heartbeat failed: {e}"); } })); @@ -1379,6 +1694,7 @@ async fn tokio_main() -> Result<()> { &mut crash_history, &respawn_tx, &mut respawn_tasks, + observer.clone(), ) == LoopAction::Exit { break; @@ -1393,6 +1709,7 @@ async fn tokio_main() -> Result<()> { &mut crash_history, &respawn_tx, &mut respawn_tasks, + observer.clone(), ) == LoopAction::Exit { break; @@ -1414,6 +1731,7 @@ async fn tokio_main() -> Result<()> { &mut crash_history, &respawn_tx, &mut respawn_tasks, + observer.clone(), ); if pool.live_count() == 0 && !any_respawn_in_flight(&crash_history) { tracing::error!("all agents dead — exiting"); @@ -1506,8 +1824,7 @@ async fn tokio_main() -> Result<()> { if config.presence_enabled { match tokio::time::timeout( Duration::from_secs(2), - rest_client_for_presence - .put_json("/api/presence", &serde_json::json!({"status": "offline"})), + publish_presence(&presence_publisher, &presence_keys, "offline"), ) .await { @@ -1517,6 +1834,10 @@ async fn tokio_main() -> Result<()> { } } + if let Some(handle) = relay_observer_publisher_task.take() { + handle.abort(); + } + // Graceful relay shutdown — sends WebSocket close frame and waits up to 5s // for the background task to finish, rather than aborting immediately (#40). relay.shutdown().await; @@ -1537,7 +1858,7 @@ enum LoopAction { /// Send a cancel signal to the in-flight task for `channel_id`. /// Returns `true` if a signal was sent, `false` if no in-flight task was found. -fn cancel_in_flight_task(pool: &mut AgentPool, channel_id: uuid::Uuid) -> bool { +fn cancel_in_flight_task(pool: &mut AgentPool, channel_id: uuid::Uuid, mode: CancelMode) -> bool { let entry = pool .task_map_mut() .values_mut() @@ -1545,7 +1866,7 @@ fn cancel_in_flight_task(pool: &mut AgentPool, channel_id: uuid::Uuid) -> bool { if let Some(meta) = entry { if let Some(tx) = meta.cancel_tx.take() { - let _ = tx.send(()); + let _ = tx.send(mode); tracing::info!(channel = %channel_id, "cancel signal sent to in-flight task"); return true; } @@ -1597,7 +1918,7 @@ fn dispatch_pending( // Prompt text is now built inside run_prompt_task (needs async for // context fetching). Pass None for prompt_text; batch carries the data. - let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::<()>(); + let (cancel_tx, cancel_rx) = tokio::sync::oneshot::channel::(); let abort_handle = pool.join_set.spawn(async move { pool::run_prompt_task( @@ -1643,6 +1964,7 @@ fn handle_prompt_result( crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, + observer: Option, ) -> LoopAction { let before = pool.task_map().len(); let agent_index = result.agent.index; @@ -1722,6 +2044,7 @@ fn handle_prompt_result( slot_history, respawn_tx, respawn_tasks, + observer.clone(), ) { // Circuit open — slot stays empty until maintenance refill. if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { @@ -1775,6 +2098,7 @@ fn handle_prompt_result( slot_history, respawn_tx, respawn_tasks, + observer, ) && pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { @@ -1808,6 +2132,7 @@ fn recover_panicked_agent( crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, + observer: Option, ) { let task_id = join_error.id(); let Some(meta) = pool.task_map_mut().remove(&task_id) else { @@ -1874,7 +2199,7 @@ fn recover_panicked_agent( if !delay.is_zero() { tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env).await; + let result = spawn_and_init(&cmd, &args, &env, i, observer).await; guard.send(result); }); } @@ -1892,6 +2217,7 @@ fn drain_ready_join_results( crash_history: &mut [SlotCircuit], respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, + observer: Option, ) -> LoopAction { while let Some(Some(join_result)) = pool.join_set.join_next().now_or_never() { if let Err(join_error) = join_result { @@ -1907,6 +2233,7 @@ fn drain_ready_join_results( crash_history, respawn_tx, respawn_tasks, + observer.clone(), ); if pool.live_count() == 0 && !any_respawn_in_flight(crash_history) { return LoopAction::Exit; @@ -1991,6 +2318,7 @@ fn spawn_respawn_task( slot: &mut SlotCircuit, respawn_tx: &mpsc::Sender, respawn_tasks: &mut tokio::task::JoinSet<()>, + observer: Option, ) -> bool { let index = old_agent.index; @@ -2027,7 +2355,7 @@ fn spawn_respawn_task( tokio::time::sleep(delay).await; } - let result = spawn_and_init(&cmd, &args, &env).await; + let result = spawn_and_init(&cmd, &args, &env, index, observer).await; guard.send(result); }); @@ -2044,14 +2372,24 @@ async fn spawn_and_init( command: &str, args: &[String], extra_env: &[(String, String)], + agent_index: usize, + observer: Option, ) -> Result { let mut acp = AcpClient::spawn(command, args, extra_env) .await .map_err(|e| anyhow::anyhow!("failed to spawn agent: {e}"))?; + acp.set_observer(observer, agent_index); match acp.initialize().await { Ok(init_result) => { tracing::info!("agent initialized: {init_result}"); + acp.observe( + "agent_initialized", + serde_json::json!({ + "agentIndex": agent_index, + "initializeResult": init_result, + }), + ); Ok(acp) } Err(e) => { @@ -2230,12 +2568,6 @@ fn build_mcp_servers(config: &Config) -> Vec { .expect("secret key bech32 encoding should never fail"), }, ]; - if let Some(ref token) = config.api_token { - env.push(EnvVar { - name: "SPROUT_API_TOKEN".into(), - value: token.clone(), - }); - } // Forward SPROUT_TOOLSETS so the MCP server enables the // same toolsets the operator configured for this harness. if let Ok(ts) = std::env::var("SPROUT_TOOLSETS") { @@ -2246,6 +2578,16 @@ fn build_mcp_servers(config: &Config) -> Vec { }); } } + // Forward SPROUT_AUTH_TAG (NIP-OA owner attestation credential) + // so the MCP server can attach it to every signed event. + if let Ok(auth_tag) = std::env::var("SPROUT_AUTH_TAG") { + if !auth_tag.is_empty() { + env.push(EnvVar { + name: "SPROUT_AUTH_TAG".into(), + value: auth_tag, + }); + } + } env }, }] @@ -2260,246 +2602,211 @@ mod owner_cache_tests { #[test] fn new_with_some_caches_immediately() { let cache = OwnerCache::new(Some("abcd".into())); - assert_eq!(cache.pubkey.as_deref(), Some("abcd")); - assert!(cache.last_attempt.is_some()); + assert_eq!(cache.get(), Some("abcd")); } #[test] - fn new_with_none_has_no_attempt() { + fn new_with_none_returns_none() { let cache = OwnerCache::new(None); - assert!(cache.pubkey.is_none()); - assert!(cache.last_attempt.is_none()); + assert!(cache.get().is_none()); } #[test] - fn get_or_resolve_returns_cached_immediately() { - // When pubkey is already cached, get_or_resolve should return it - // without any API call. We can verify this by checking the return - // value without providing a real RestClient (the method short-circuits - // before using it). + fn get_returns_cached_value() { let cache = OwnerCache::new(Some("ab".repeat(32))); - // We can't call get_or_resolve without a RestClient in a sync test, - // but we can verify the cache state directly. - assert_eq!(cache.pubkey.as_deref(), Some("ab".repeat(32)).as_deref()); - } - - #[test] - fn success_cached_for_process_lifetime() { - // After a successful resolution, pubkey stays cached even if - // last_attempt is old. The early return `if self.pubkey.is_some()` - // means the TTL check is never reached. - let mut cache = OwnerCache::new(Some("ab".repeat(32))); - // Simulate time passing by backdating last_attempt - cache.last_attempt = Some(std::time::Instant::now() - Duration::from_secs(3600)); - // pubkey is still cached — success is permanent - assert!(cache.pubkey.is_some()); - } - - #[test] - fn failure_respects_ttl() { - // After a failed lookup, last_attempt is set. A subsequent call - // within the TTL window should NOT retry (stale == false). - let mut cache = OwnerCache::new(None); - cache.last_attempt = Some(std::time::Instant::now()); - // Within TTL: stale check returns false, so no retry - let stale = cache - .last_attempt - .map(|t| t.elapsed() >= OWNER_CACHE_TTL) - .unwrap_or(true); - assert!(!stale, "should not be stale within TTL"); - } - - #[test] - fn failure_retries_after_ttl() { - // After TTL expires, the cache should consider itself stale. - let mut cache = OwnerCache::new(None); - cache.last_attempt = Some(std::time::Instant::now() - Duration::from_secs(61)); - let stale = cache - .last_attempt - .map(|t| t.elapsed() >= OWNER_CACHE_TTL) - .unwrap_or(true); - assert!(stale, "should be stale after TTL"); - } - - #[test] - fn no_attempt_is_always_stale() { - let cache = OwnerCache::new(None); - let stale = cache - .last_attempt - .map(|t| t.elapsed() >= OWNER_CACHE_TTL) - .unwrap_or(true); - assert!(stale, "no prior attempt should be considered stale"); + assert_eq!(cache.get(), Some("ab".repeat(32)).as_deref()); } } #[cfg(test)] -mod sibling_cache_tests { +mod observer_chunk_coalescer_tests { use super::*; - const OWNER_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const OWNER_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - const AUTHOR_1: &str = "1111111111111111111111111111111111111111111111111111111111111111"; - const AUTHOR_2: &str = "2222222222222222222222222222222222222222222222222222222222222222"; - - #[test] - fn sibling_with_matching_owner_returns_true() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - } - - #[test] - fn different_owner_returns_false() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_B.into())), - ); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(false)); - } - - #[test] - fn no_owner_on_profile_returns_false() { - let mut cache = SiblingCache::new(); - cache.record(AUTHOR_1.into(), SiblingLookup::Resolved(None)); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(false)); - } - - #[test] - fn lookup_failure_returns_false() { - let mut cache = SiblingCache::new(); - cache.record(AUTHOR_1.into(), SiblingLookup::Failed); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(false)); - } - - #[test] - fn unknown_author_returns_none() { - let cache = SiblingCache::new(); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), None); - } - - #[test] - fn record_normalizes_to_lowercase() { - let mut cache = SiblingCache::new(); - let mixed_case = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(mixed_case.into())), - ); - // Should match lowercase expected owner. - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - } - - #[test] - fn positive_ttl_holds_within_window() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - // Freshly inserted — should be within 5-minute TTL. - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - } - - #[test] - fn positive_ttl_expires() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - // Backdate the entry past the hit TTL. - if let Some((_, ts)) = cache.entries.get_mut(AUTHOR_1) { - *ts = std::time::Instant::now() - SIBLING_CACHE_HIT_TTL - Duration::from_secs(1); + fn chunk_event( + seq: u64, + update_type: &str, + message_id: &str, + text: &str, + ) -> observer::ObserverEvent { + observer::ObserverEvent { + seq, + timestamp: format!("2026-04-29T04:00:0{seq}Z"), + kind: "acp_read".to_string(), + agent_index: Some(0), + channel_id: Some("channel-1".to_string()), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + payload: serde_json::json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { + "sessionId": "session-1", + "update": { + "sessionUpdate": update_type, + "messageId": message_id, + "content": { + "type": "text", + "text": text, + }, + }, + }, + }), } - assert_eq!( - cache.check(AUTHOR_1, OWNER_A), - None, - "should be stale after hit TTL" - ); } - #[test] - fn negative_ttl_expires() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_B.into())), - ); - // Backdate past the miss TTL. - if let Some((_, ts)) = cache.entries.get_mut(AUTHOR_1) { - *ts = std::time::Instant::now() - SIBLING_CACHE_MISS_TTL - Duration::from_secs(1); + fn non_chunk_event(seq: u64) -> observer::ObserverEvent { + observer::ObserverEvent { + seq, + timestamp: format!("2026-04-29T04:00:0{seq}Z"), + kind: "turn_started".to_string(), + agent_index: Some(0), + channel_id: Some("channel-1".to_string()), + session_id: Some("session-1".to_string()), + turn_id: Some("turn-1".to_string()), + payload: serde_json::json!({ "type": "turn_started" }), } - assert_eq!( - cache.check(AUTHOR_1, OWNER_A), - None, - "should be stale after miss TTL" - ); + } + + fn chunk_text(event: &observer::ObserverEvent) -> &str { + event.payload["params"]["update"]["content"]["text"] + .as_str() + .expect("chunk text") } #[test] - fn negative_ttl_holds_within_window() { - let mut cache = SiblingCache::new(); - cache.record(AUTHOR_1.into(), SiblingLookup::Failed); - // Freshly inserted — should be within 1-minute TTL. - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(false)); + fn coalesces_chunks_until_non_chunk_event() { + let mut coalescer = ObserverChunkCoalescer::default(); + + assert!(coalescer + .ingest(chunk_event(1, "agent_message_chunk", "message-1", "hello ")) + .is_empty()); + assert!(coalescer + .ingest(chunk_event(2, "agent_message_chunk", "message-1", "world")) + .is_empty()); + + let events = coalescer.ingest(non_chunk_event(3)); + assert_eq!(events.len(), 2); + assert_eq!(events[0].seq, 2); + assert_eq!(chunk_text(&events[0]), "hello world"); + assert_eq!(events[1].kind, "turn_started"); } #[test] - fn eviction_when_at_capacity() { - let mut cache = SiblingCache::new(); - // Fill to capacity with unique authors. - for i in 0..SIBLING_CACHE_MAX_ENTRIES { - let author = format!("{:064x}", i); - cache.record(author, SiblingLookup::Resolved(Some(OWNER_A.into()))); - } - assert_eq!(cache.entries.len(), SIBLING_CACHE_MAX_ENTRIES); + fn keeps_independent_chunk_streams_separate() { + let mut coalescer = ObserverChunkCoalescer::default(); - // Insert one more — should evict the oldest and stay at capacity. - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - assert_eq!(cache.entries.len(), SIBLING_CACHE_MAX_ENTRIES); + assert!(coalescer + .ingest(chunk_event(1, "agent_message_chunk", "message-1", "answer")) + .is_empty()); + assert!(coalescer + .ingest(chunk_event( + 2, + "agent_thought_chunk", + "thought-1", + "thinking" + )) + .is_empty()); - // The new entry should be present. - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - } - - #[test] - fn update_existing_entry_refreshes_timestamp() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_B.into())), - ); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(false)); - - // Update with new owner — should overwrite. - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - } - - #[test] - fn multiple_authors_independent() { - let mut cache = SiblingCache::new(); - cache.record( - AUTHOR_1.into(), - SiblingLookup::Resolved(Some(OWNER_A.into())), - ); - cache.record( - AUTHOR_2.into(), - SiblingLookup::Resolved(Some(OWNER_B.into())), - ); - - assert_eq!(cache.check(AUTHOR_1, OWNER_A), Some(true)); - assert_eq!(cache.check(AUTHOR_2, OWNER_A), Some(false)); - assert_eq!(cache.check(AUTHOR_2, OWNER_B), Some(true)); + let events = coalescer.flush(); + assert_eq!(events.len(), 2); + assert_eq!(chunk_text(&events[0]), "answer"); + assert_eq!(chunk_text(&events[1]), "thinking"); + } +} + +#[cfg(test)] +mod build_mcp_servers_tests { + use super::*; + use std::sync::Mutex; + + /// Env-var-touching tests must run serially — env vars are process-global. + static ENV_LOCK: Mutex<()> = Mutex::new(()); + + fn test_config() -> Config { + Config { + keys: nostr::Keys::generate(), + relay_url: "ws://localhost:3000".into(), + agent_command: "goose".into(), + agent_args: vec!["acp".into()], + mcp_command: "sprout-mcp-server".into(), + idle_timeout_secs: 320, + max_turn_duration_secs: 3600, + agents: 1, + heartbeat_interval_secs: 0, + heartbeat_prompt: None, + system_prompt: None, + initial_message: None, + subscribe_mode: config::SubscribeMode::All, + dedup_mode: config::DedupMode::Queue, + multiple_event_handling: config::MultipleEventHandling::Queue, + ignore_self: true, + kinds_override: None, + channels_override: None, + no_mention_filter: false, + config_path: std::path::PathBuf::from("./sprout-acp.toml"), + context_message_limit: 12, + max_turns_per_session: 0, + presence_enabled: true, + typing_enabled: true, + model: None, + permission_mode: config::PermissionMode::BypassPermissions, + respond_to: config::RespondTo::Anyone, + respond_to_allowlist: std::collections::HashSet::new(), + persona_env_vars: vec![], + relay_observer: false, + agent_owner: None, + } + } + + #[test] + fn session_new_mcp_server_has_required_fields() { + let config = test_config(); + let servers = build_mcp_servers(&config); + assert_eq!(servers.len(), 1); + let server = &servers[0]; + assert_eq!(server.name, "sprout-mcp"); + + let names: Vec<&str> = server.env.iter().map(|e| e.name.as_str()).collect(); + assert!( + names.contains(&"SPROUT_RELAY_URL"), + "missing SPROUT_RELAY_URL; got {names:?}" + ); + assert!( + names.contains(&"SPROUT_PRIVATE_KEY"), + "missing SPROUT_PRIVATE_KEY; got {names:?}" + ); + } + + #[test] + fn session_new_mcp_server_forwards_sprout_auth_tag() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("SPROUT_AUTH_TAG", "test-attestation-tag"); + let config = test_config(); + let servers = build_mcp_servers(&config); + std::env::remove_var("SPROUT_AUTH_TAG"); + + let server = &servers[0]; + let auth_tag_env = server.env.iter().find(|e| e.name == "SPROUT_AUTH_TAG"); + assert!( + auth_tag_env.is_some(), + "SPROUT_AUTH_TAG should be forwarded when set" + ); + assert_eq!(auth_tag_env.unwrap().value, "test-attestation-tag"); + } + + #[test] + fn session_new_mcp_server_skips_empty_sprout_auth_tag() { + let _guard = ENV_LOCK.lock().unwrap(); + std::env::set_var("SPROUT_AUTH_TAG", ""); + let config = test_config(); + let servers = build_mcp_servers(&config); + std::env::remove_var("SPROUT_AUTH_TAG"); + + let server = &servers[0]; + let has_auth_tag = server.env.iter().any(|e| e.name == "SPROUT_AUTH_TAG"); + assert!( + !has_auth_tag, + "empty SPROUT_AUTH_TAG should not be forwarded" + ); } } diff --git a/crates/sprout-acp/src/observer.rs b/crates/sprout-acp/src/observer.rs new file mode 100644 index 0000000000..536d23eead --- /dev/null +++ b/crates/sprout-acp/src/observer.rs @@ -0,0 +1,144 @@ +//! In-process observer bus for ACP session activity. +//! +//! This is intentionally process-local infrastructure: it lets the harness +//! collect raw ACP JSON-RPC activity and publish owner-scoped encrypted relay +//! frames without exposing a local HTTP port. + +use std::{ + collections::VecDeque, + sync::{ + atomic::{AtomicU64, Ordering}, + Arc, Mutex, + }, +}; + +use serde::Serialize; +use tokio::sync::broadcast; + +const OBSERVER_BUFFER_CAP: usize = 1_000; + +/// Best-effort metadata attached to observer events. +#[derive(Clone, Debug, Default)] +pub struct ObserverContext { + /// Sprout channel UUID for the current turn, when channel-scoped. + pub channel_id: Option, + /// ACP session ID associated with the current turn, once known. + pub session_id: Option, + /// Local UUID for one prompt turn. + pub turn_id: Option, +} + +/// Handle used by the harness to publish local observer events. +#[derive(Clone)] +pub struct ObserverHandle { + inner: Arc, +} + +struct ObserverInner { + tx: broadcast::Sender, + buffer: Mutex>, + seq: AtomicU64, +} + +fn new_observer_handle() -> ObserverHandle { + let (tx, _) = broadcast::channel(OBSERVER_BUFFER_CAP); + ObserverHandle { + inner: Arc::new(ObserverInner { + tx, + buffer: Mutex::new(VecDeque::with_capacity(OBSERVER_BUFFER_CAP)), + seq: AtomicU64::new(1), + }), + } +} + +/// Event delivered through the in-process observer bus. +#[derive(Clone, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ObserverEvent { + /// Monotonic process-local sequence number. + pub seq: u64, + /// RFC3339 UTC timestamp. + pub timestamp: String, + /// Observer event kind, for example `acp_read` or `turn_started`. + pub kind: String, + /// Pool slot index for the agent process that emitted the event. + pub agent_index: Option, + /// Sprout channel UUID for channel-scoped events. + pub channel_id: Option, + /// ACP session ID when known. + pub session_id: Option, + /// Local UUID for one prompt turn. + pub turn_id: Option, + /// Raw or semantic event payload. + pub payload: serde_json::Value, +} + +impl ObserverHandle { + /// Create an in-process observer feed. + pub fn in_process() -> Self { + new_observer_handle() + } + + /// Subscribe to live observer events. + pub fn subscribe(&self) -> broadcast::Receiver { + self.inner.tx.subscribe() + } + + /// Return the current replay buffer. + pub fn snapshot(&self) -> Vec { + match self.inner.buffer.lock() { + Ok(buffer) => buffer.iter().cloned().collect(), + Err(error) => { + tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}"); + Vec::new() + } + } + } + + /// Emit a local observer event. + pub fn emit( + &self, + kind: impl Into, + agent_index: Option, + context: &ObserverContext, + payload: serde_json::Value, + ) { + let event = ObserverEvent { + seq: self.inner.seq.fetch_add(1, Ordering::Relaxed), + timestamp: chrono::Utc::now().to_rfc3339(), + kind: kind.into(), + agent_index, + channel_id: context.channel_id.clone(), + session_id: context.session_id.clone(), + turn_id: context.turn_id.clone(), + payload, + }; + + match self.inner.buffer.lock() { + Ok(mut buffer) => { + if buffer.len() >= OBSERVER_BUFFER_CAP { + buffer.pop_front(); + } + buffer.push_back(event.clone()); + } + Err(error) => { + tracing::warn!(target: "observer", "observer replay buffer lock poisoned: {error}"); + } + } + + let _ = self.inner.tx.send(event); + } +} + +/// Build observer context values from optional channel/session/turn IDs. +pub fn context_for( + channel_id: Option, + session_id: Option, + turn_id: Option, +) -> ObserverContext { + ObserverContext { + channel_id: channel_id.map(|id| id.to_string()), + session_id, + turn_id, + } +} diff --git a/crates/sprout-acp/src/pool.rs b/crates/sprout-acp/src/pool.rs index da8e251114..9ed753bbd5 100644 --- a/crates/sprout-acp/src/pool.rs +++ b/crates/sprout-acp/src/pool.rs @@ -33,6 +33,7 @@ use crate::acp::{ AcpError, McpServer, ModelSwitchMethod, StopReason, }; use crate::config::{DedupMode, PermissionMode}; +use crate::observer; use crate::queue::{ ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, PromptProfile, PromptProfileLookup, @@ -53,7 +54,7 @@ pub struct TaskMeta { pub recoverable_batch: Option, /// Cancel signal for the in-flight prompt task. /// `None` for heartbeat tasks (not cancellable) and after signal is consumed. - pub cancel_tx: Option>, + pub cancel_tx: Option>, } /// Agent-level model capabilities. Populated on first session creation. @@ -153,6 +154,15 @@ pub enum PromptSource { Heartbeat, } +/// How an in-flight channel turn should be cancelled. +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum CancelMode { + /// Stop the current turn and drop its triggering batch. + Stop, + /// Stop the current turn and requeue its triggering batch for a merged re-prompt. + Interrupt, +} + /// Outcome of a prompt task. #[allow(dead_code)] pub enum PromptOutcome { @@ -596,7 +606,7 @@ pub async fn run_prompt_task( prompt_text: Option, ctx: Arc, result_tx: mpsc::UnboundedSender, - cancel_rx: Option>, + cancel_rx: Option>, ) { // ── Determine source and resolve/create session ─────────────────────── @@ -605,6 +615,30 @@ pub async fn run_prompt_task( Some(b) => PromptSource::Channel(b.channel_id), None => PromptSource::Heartbeat, }; + let turn_id = uuid::Uuid::new_v4().to_string(); + let observer_channel_id = match &source { + PromptSource::Channel(channel_id) => Some(*channel_id), + PromptSource::Heartbeat => None, + }; + agent.acp.set_observer_context(observer::context_for( + observer_channel_id, + None, + Some(turn_id.clone()), + )); + let triggering_event_ids: Vec = batch + .as_ref() + .map(|b| b.events.iter().map(|be| be.event.id.to_hex()).collect()) + .unwrap_or_default(); + agent.acp.observe( + "turn_started", + serde_json::json!({ + "source": match &source { + PromptSource::Channel(_) => "channel", + PromptSource::Heartbeat => "heartbeat", + }, + "triggeringEventIds": triggering_event_ids, + }), + ); // ── Reaction cleanup guard ──────────────────────────────────────────── // Collects event IDs up front. On drop (any exit path — normal, early @@ -690,6 +724,18 @@ pub async fn run_prompt_task( } } }; + agent.acp.set_observer_context(observer::context_for( + observer_channel_id, + Some(session_id.clone()), + Some(turn_id.clone()), + )); + agent.acp.observe( + "session_resolved", + serde_json::json!({ + "sessionId": session_id, + "isNewSession": is_new_session, + }), + ); // ── Send initial_message on new channel sessions ────────────────────── @@ -884,51 +930,75 @@ pub async fn run_prompt_task( ctx.idle_timeout, ctx.max_turn_duration, ) => result, - _ = rx => { + mode = rx => { + let cancel_mode = mode.unwrap_or(CancelMode::Stop); // Cancel signal received. Guard against Race 1: the turn may // have completed naturally just as cancel fired. if agent.acp.has_in_flight_prompt() { // Prompt is genuinely in-flight — cancel it. - match agent.acp.cancel_with_cleanup(&session_id, ctx.idle_timeout).await { + match agent + .acp + .cancel_with_cleanup_grace( + &session_id, + std::time::Duration::from_secs(5), + ) + .await + { Ok(stop_reason) => { log_stop_reason(&source, &stop_reason); agent.state.invalidate(&source); + let retry_batch = match cancel_mode { + CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch), + CancelMode::Stop => None, + }; let _ = result_tx.send(PromptResult { agent, source, outcome: PromptOutcome::Cancelled, - batch: requeue_batch_if_queue(&ctx, batch), + batch: retry_batch, }); return; } Err(AcpError::AgentExited) => { agent.state.invalidate_all(); + let retry_batch = match cancel_mode { + CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch), + CancelMode::Stop => None, + }; let _ = result_tx.send(PromptResult { agent, source, outcome: PromptOutcome::AgentExited, - batch: requeue_batch_if_queue(&ctx, batch), + batch: retry_batch, }); return; } Err(AcpError::IdleTimeout(_) | AcpError::HardTimeout) => { // Cancel drain timed out — agent state uncertain. agent.state.invalidate(&source); + let retry_batch = match cancel_mode { + CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch), + CancelMode::Stop => None, + }; let _ = result_tx.send(PromptResult { agent, source, outcome: PromptOutcome::Timeout, - batch: requeue_batch_if_queue(&ctx, batch), + batch: retry_batch, }); return; } Err(e) => { agent.state.invalidate(&source); + let retry_batch = match cancel_mode { + CancelMode::Interrupt => requeue_batch_if_queue(&ctx, batch), + CancelMode::Stop => None, + }; let _ = result_tx.send(PromptResult { agent, source, outcome: PromptOutcome::Error(e), - batch: requeue_batch_if_queue(&ctx, batch), + batch: retry_batch, }); return; } @@ -1126,21 +1196,50 @@ where /// persistent failure (graceful degradation — prompt will lack channel name and /// DM detection). async fn fetch_channel_info(channel_id: Uuid, rest: &RestClient) -> Option { - let path = format!("/api/channels/{}", channel_id); + use nostr::{Alphabet, SingleLetterTag}; + + let d_tag = SingleLetterTag::lowercase(Alphabet::D); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Custom( + sprout_core::kind::KIND_NIP29_GROUP_METADATA as u16, + )) + .custom_tag(d_tag, [channel_id.to_string()]); + fetch_with_retry(|| async { - match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await { + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { Ok(Ok(json)) => { - let name = json - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let channel_type = json - .get("channel_type") - .and_then(|v| v.as_str()) - .unwrap_or("stream") - .to_string(); - Some(PromptChannelInfo { name, channel_type }) + let events = json.as_array()?; + let ev = events.first()?; + let tags = ev.get("tags")?.as_array()?; + let mut name = None; + let mut is_hidden = false; + let mut is_private = false; + for tag in tags { + if let Some(arr) = tag.as_array() { + match arr.first().and_then(|v| v.as_str()) { + Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("hidden") => is_hidden = true, + Some("private") => is_private = true, + _ => {} + } + } + } + let channel_type = if is_hidden { + "dm".to_string() + } else if is_private { + "private".to_string() + } else { + "stream".to_string() + }; + Some(PromptChannelInfo { + name: name.unwrap_or("unknown").to_string(), + channel_type, + }) } Ok(Err(e)) => { tracing::debug!( @@ -1245,24 +1344,37 @@ fn collect_prompt_pubkeys( pubkeys } -fn parse_profile_lookup_response(json: serde_json::Value) -> Option { - let profiles = json.get("profiles")?.as_object()?; +/// Parse kind:0 profile events into a `PromptProfileLookup`. +/// +/// Each kind:0 event has `pubkey` and JSON `content` with optional fields: +/// `display_name` (or `name`), `nip05`. +fn parse_kind0_profile_lookup(json: serde_json::Value) -> Option { + let events = json.as_array()?; let mut lookup = PromptProfileLookup::new(); - for (pubkey, profile) in profiles { - lookup.insert( - pubkey.to_ascii_lowercase(), - PromptProfile { - display_name: profile + for ev in events { + let pubkey = ev.get("pubkey").and_then(|v| v.as_str()); + let content_str = ev.get("content").and_then(|v| v.as_str()); + if let (Some(pk), Some(content)) = (pubkey, content_str) { + if let Ok(profile) = serde_json::from_str::(content) { + let display_name = profile .get("display_name") - .and_then(|value| value.as_str()) - .map(str::to_string), - nip05_handle: profile - .get("nip05_handle") - .and_then(|value| value.as_str()) - .map(str::to_string), - }, - ); + .or_else(|| profile.get("name")) + .and_then(|v| v.as_str()) + .map(str::to_string); + let nip05_handle = profile + .get("nip05") + .and_then(|v| v.as_str()) + .map(str::to_string); + lookup.insert( + pk.to_ascii_lowercase(), + PromptProfile { + display_name, + nip05_handle, + }, + ); + } + } } if lookup.is_empty() { @@ -1282,15 +1394,26 @@ async fn fetch_prompt_profile_lookup( return None; } - let body = serde_json::json!({ "pubkeys": pubkeys }); + // Query kind:0 (NIP-01 profile metadata) for all pubkeys. + let authors: Vec = pubkeys + .iter() + .filter_map(|s| nostr::PublicKey::from_hex(s).ok()) + .collect(); + if authors.is_empty() { + return None; + } + let filter = nostr::Filter::new() + .kind(nostr::Kind::Metadata) + .authors(authors); + fetch_with_retry(|| async { match timeout( CONTEXT_FETCH_TIMEOUT, - rest.post_json("/api/users/batch", &body), + rest.query(std::slice::from_ref(&filter)), ) .await { - Ok(Ok(json)) => parse_profile_lookup_response(json), + Ok(Ok(json)) => parse_kind0_profile_lookup(json), Ok(Err(e)) => { tracing::debug!("prompt profile lookup failed: {e} — will retry"); None @@ -1304,15 +1427,16 @@ async fn fetch_prompt_profile_lookup( .await } -/// Fetch thread context via REST: `GET /api/channels/{id}/threads/{event_id}?limit=N` +/// Fetch thread context via Nostr query: root event by ID + replies by `#e` tag. async fn fetch_thread_context( channel_id: Uuid, root_event_id: &str, limit: u32, rest: &RestClient, ) -> Option { - // Defense-in-depth: validate hex before interpolating into URL path. - // Nostr event IDs are 32-byte SHA-256 hashes = 64 hex chars. + use nostr::{Alphabet, SingleLetterTag}; + + // Defense-in-depth: validate hex event ID. if root_event_id.is_empty() || root_event_id.len() != 64 || !root_event_id.chars().all(|c| c.is_ascii_hexdigit()) @@ -1324,14 +1448,29 @@ async fn fetch_thread_context( return None; } - let path = format!( - "/api/channels/{}/threads/{}?limit={}", - channel_id, root_event_id, limit - ); + let e_tag = SingleLetterTag::lowercase(Alphabet::E); + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let ch_str = channel_id.to_string(); + + // Two filters: (1) root event by ID, (2) replies with #e=root + #h=channel. + let root_filter = nostr::Filter::new().id(nostr::EventId::from_hex(root_event_id).ok()?); + let replies_filter = nostr::Filter::new() + .kinds([ + nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + ]) + .custom_tag(e_tag, [root_event_id]) + .custom_tag(h_tag, [ch_str.as_str()]) + .limit(limit as usize); fetch_with_retry(|| async { - match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await { - Ok(Ok(json)) => parse_thread_response(json), + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(&[root_filter.clone(), replies_filter.clone()]), + ) + .await + { + Ok(Ok(json)) => parse_nostr_thread_response(json, root_event_id), Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -1353,17 +1492,32 @@ async fn fetch_thread_context( .await } -/// Fetch DM context via REST: `GET /api/channels/{id}/messages?limit=N` +/// Fetch DM context via Nostr query: recent messages in channel by `#h` tag. async fn fetch_dm_context( channel_id: Uuid, limit: u32, rest: &RestClient, ) -> Option { - let path = format!("/api/channels/{}/messages?limit={}", channel_id, limit); + use nostr::{Alphabet, SingleLetterTag}; + + let h_tag = SingleLetterTag::lowercase(Alphabet::H); + let ch_str = channel_id.to_string(); + let filter = nostr::Filter::new() + .kinds([ + nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE as u16), + nostr::Kind::Custom(sprout_core::kind::KIND_STREAM_MESSAGE_V2 as u16), + ]) + .custom_tag(h_tag, [ch_str.as_str()]) + .limit(limit as usize); fetch_with_retry(|| async { - match timeout(CONTEXT_FETCH_TIMEOUT, rest.get_json(&path)).await { - Ok(Ok(json)) => parse_dm_response(json, limit), + match timeout( + CONTEXT_FETCH_TIMEOUT, + rest.query(std::slice::from_ref(&filter)), + ) + .await + { + Ok(Ok(json)) => parse_nostr_dm_response(json, limit), Ok(Err(e)) => { tracing::warn!( channel_id = %channel_id, @@ -1383,9 +1537,8 @@ async fn fetch_dm_context( .await } -/// Parse the thread REST response into a `ConversationContext::Thread`. -/// -/// Expected shape: `{ "root": {...}, "replies": [...], "total_replies": N }` +/// Parse the legacy REST thread response (used in tests only). +#[cfg(test)] fn parse_thread_response(json: serde_json::Value) -> Option { let mut messages = Vec::new(); @@ -1425,8 +1578,8 @@ fn parse_thread_response(json: serde_json::Value) -> Option /// Parse the DM messages REST response into a `ConversationContext::Dm`. /// -/// Expected shape: `{ "messages": [...], "next_cursor": ... }` -/// Messages arrive newest-first from the API; we reverse to chronological order. +/// Parse the legacy REST DM response (used in tests only). +#[cfg(test)] fn parse_dm_response(json: serde_json::Value, limit: u32) -> Option { let arr = json.get("messages").and_then(|v| v.as_array())?; @@ -1487,6 +1640,89 @@ fn json_to_context_message(obj: &serde_json::Value) -> Option { }) } +/// Parse a Nostr query response (array of events) into thread context. +/// +/// Separates the root event (matching `root_event_id`) from replies, sorts +/// chronologically by `created_at`. +fn parse_nostr_thread_response( + json: serde_json::Value, + root_event_id: &str, +) -> Option { + let events = json.as_array()?; + let mut root_msg = None; + let mut reply_msgs = Vec::new(); + + for ev in events { + let ev_id = ev.get("id").and_then(|v| v.as_str()).unwrap_or(""); + if let Some(msg) = json_to_context_message(ev) { + if ev_id == root_event_id { + root_msg = Some(msg); + } else { + reply_msgs.push(( + ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0), + msg, + )); + } + } + } + + // Sort replies chronologically. + reply_msgs.sort_by_key(|(ts, _)| *ts); + + let mut messages = Vec::new(); + if let Some(root) = root_msg { + messages.push(root); + } + messages.extend(reply_msgs.into_iter().map(|(_, msg)| msg)); + + let total = messages.len(); + if messages.is_empty() { + return None; + } + + Some(ConversationContext::Thread { + messages, + total, + truncated: false, // query returns all within limit + }) +} + +/// Parse a Nostr query response (array of events) into DM context. +/// +/// Events arrive in relay order (newest first); reversed to chronological. +fn parse_nostr_dm_response(json: serde_json::Value, limit: u32) -> Option { + let events = json.as_array()?; + + let mut messages: Vec<(u64, ContextMessage)> = events + .iter() + .filter_map(|ev| { + let ts = ev.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0); + json_to_context_message(ev).map(|msg| (ts, msg)) + }) + .collect(); + + // Sort chronologically (oldest first). + messages.sort_by_key(|(ts, _)| *ts); + + let messages: Vec = messages.into_iter().map(|(_, msg)| msg).collect(); + let truncated = messages.len() >= limit as usize; + let total = if truncated { + messages.len() + 1 + } else { + messages.len() + }; + + if messages.is_empty() { + return None; + } + + Some(ConversationContext::Dm { + messages, + total, + truncated, + }) +} + // ── Internal helpers ────────────────────────────────────────────────────────── /// Return the batch for requeue only in Queue mode; drop it in Drop mode. @@ -1591,8 +1827,8 @@ const REACTION_WORKING: &str = "💬"; /// Best-effort timeout for a single reaction REST call. const REACTION_TIMEOUT: Duration = Duration::from_millis(500); -/// Percent-encode a string for use in a URL path segment. -/// Emoji bytes are not URL-safe; event IDs (hex) pass through unchanged. +/// Percent-encode a string for use in a URL path segment (used in tests only). +#[cfg(test)] fn pct_encode(s: &str) -> String { let mut out = String::with_capacity(s.len() * 3); for byte in s.bytes() { @@ -1612,7 +1848,7 @@ fn pct_encode(s: &str) -> String { /// Best-effort: add a reaction via a signed Nostr kind-7 event (NIP-25). /// /// Builds a reaction event with `sprout_sdk::build_reaction`, signs it with -/// the keys already stored in `RestClient`, and submits via POST /api/events. +/// the keys already stored in `RestClient`, and submits via `POST /events`. /// Returns immediately on timeout or any error — reactions are cosmetic. pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) { let target_id = match nostr::EventId::from_hex(event_id) { @@ -1636,14 +1872,7 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str return; } }; - let body = match serde_json::to_value(&event) { - Ok(v) => v, - Err(e) => { - tracing::debug!(event_id, emoji, "reaction add: serialize failed: {e}"); - return; - } - }; - match tokio::time::timeout(REACTION_TIMEOUT, rest.post_json("/api/events", &body)).await { + match tokio::time::timeout(REACTION_TIMEOUT, rest.submit_event(&event)).await { Ok(Ok(_)) => {} Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction add failed: {e}"), Err(_) => tracing::debug!(event_id, emoji, "reaction add timed out"), @@ -1652,44 +1881,43 @@ pub(crate) async fn reaction_add(rest: &crate::relay::RestClient, event_id: &str /// Best-effort: remove a reaction via a signed kind:5 (NIP-09) deletion event. /// -/// Looks up our kind:7 reaction event ID via GET /api/messages/{event_id}/reactions, -/// then submits a signed kind:5 deletion via POST /api/events. +/// Queries kind:7 reactions by our pubkey targeting the event, finds the matching +/// emoji, then submits a signed kind:5 deletion via `POST /events`. /// Returns immediately on timeout or any error — reactions are cosmetic. pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: &str, emoji: &str) { - // Step 1: look up the reaction event ID we own for this emoji. - let path = format!("/api/messages/{}/reactions", pct_encode(event_id)); - let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.get_json(&path)).await + use nostr::{Alphabet, SingleLetterTag}; + + // Step 1: query our kind:7 reactions targeting this event. + let my_pubkey = rest.keys.public_key(); + let e_tag = SingleLetterTag::lowercase(Alphabet::E); + let filter = nostr::Filter::new() + .kind(nostr::Kind::Reaction) + .author(my_pubkey) + .custom_tag(e_tag, [event_id]); + + let resp = match tokio::time::timeout(Duration::from_millis(1_000), rest.query(&[filter])).await { Ok(Ok(v)) => v, Ok(Err(e)) => { - tracing::debug!(event_id, emoji, "reaction remove: fetch failed: {e}"); + tracing::debug!(event_id, emoji, "reaction remove: query failed: {e}"); return; } Err(_) => { - tracing::debug!(event_id, emoji, "reaction remove: fetch timed out"); + tracing::debug!(event_id, emoji, "reaction remove: query timed out"); return; } }; - let my_pubkey = rest.keys.public_key().to_hex(); - let reid = resp - .get("reactions") - .and_then(|r| r.as_array()) - .and_then(|groups| { - groups.iter().find_map(|group| { - if group.get("emoji")?.as_str()? != emoji { - return None; - } - group.get("users")?.as_array()?.iter().find_map(|user| { - if user.get("pubkey")?.as_str()? != my_pubkey { - return None; - } - user.get("reaction_event_id")? - .as_str() - .map(|s| s.to_string()) - }) - }) - }); + // Find our reaction event with matching emoji content. + let reid = resp.as_array().and_then(|events| { + events.iter().find_map(|ev| { + let content = ev.get("content")?.as_str()?; + if content != emoji { + return None; + } + ev.get("id")?.as_str().map(|s| s.to_string()) + }) + }); let reid = match reid { Some(id) => id, @@ -1725,19 +1953,7 @@ pub(crate) async fn reaction_remove(rest: &crate::relay::RestClient, event_id: & return; } }; - let body = match serde_json::to_value(&event) { - Ok(v) => v, - Err(e) => { - tracing::debug!(event_id, emoji, "reaction remove: serialize failed: {e}"); - return; - } - }; - match tokio::time::timeout( - Duration::from_millis(1_000), - rest.post_json("/api/events", &body), - ) - .await - { + match tokio::time::timeout(Duration::from_millis(1_000), rest.submit_event(&event)).await { Ok(Ok(_)) => {} Ok(Err(e)) => tracing::debug!(event_id, emoji, "reaction remove failed: {e}"), Err(_) => tracing::debug!(event_id, emoji, "reaction remove timed out"), @@ -2065,17 +2281,18 @@ mod tests { } #[test] - fn test_parse_profile_lookup_response_extracts_display_name_and_nip05() { - let lookup = parse_profile_lookup_response(json!({ - "profiles": { - "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa": { - "display_name": "Wes", - "avatar_url": null, - "nip05_handle": "wes@example.com" - } - }, - "missing": [] - })) + fn test_parse_kind0_profile_lookup_extracts_display_name_and_nip05() { + let lookup = parse_kind0_profile_lookup(json!([ + { + "id": "0000000000000000000000000000000000000000000000000000000000000001", + "pubkey": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "kind": 0, + "content": "{\"display_name\":\"Wes\",\"nip05\":\"wes@example.com\"}", + "created_at": 1000, + "tags": [], + "sig": "0000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000000" + } + ])) .expect("lookup should parse"); assert_eq!( @@ -2088,9 +2305,9 @@ mod tests { } #[test] - fn test_parse_profile_lookup_response_returns_none_for_empty() { - assert!(parse_profile_lookup_response(json!({"profiles": {}})).is_none()); - assert!(parse_profile_lookup_response(json!({})).is_none()); + fn test_parse_kind0_profile_lookup_returns_none_for_empty() { + assert!(parse_kind0_profile_lookup(json!([])).is_none()); + assert!(parse_kind0_profile_lookup(json!({})).is_none()); } #[test] diff --git a/crates/sprout-acp/src/relay.rs b/crates/sprout-acp/src/relay.rs index fc742ffc2c..b668d59f31 100644 --- a/crates/sprout-acp/src/relay.rs +++ b/crates/sprout-acp/src/relay.rs @@ -65,7 +65,8 @@ use futures_util::{SinkExt, StreamExt}; use nostr::{Event, EventBuilder, Keys, Kind, Tag, Url as NostrUrl}; use serde_json::{json, Value}; use sprout_core::kind::{ - KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_TYPING_INDICATOR, + KIND_AGENT_OBSERVER_FRAME, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_TYPING_INDICATOR, }; use tokio::sync::mpsc; use tokio::time::timeout; @@ -84,16 +85,20 @@ pub struct ChannelInfo { pub channel_type: String, } -/// Lightweight REST client for pre-prompt context fetches. +/// Lightweight HTTP client for pre-prompt context fetches via the Nostr HTTP bridge. /// /// Extracted from `HarnessRelay` fields so it can be shared (via `Arc`) with /// spawned prompt tasks without giving them access to the WebSocket. +/// +/// All reads go through `POST /query` with NIP-98 auth. Event submission goes +/// through `POST /events` with NIP-98 auth. #[derive(Debug, Clone)] pub struct RestClient { pub http: reqwest::Client, pub base_url: String, - pub api_token: Option, pub keys: Keys, + /// Optional NIP-OA auth tag JSON for `x-auth-tag` header (relay membership delegation). + pub auth_tag_json: Option, } /// Whether an HTTP status code is retriable (transient server/rate-limit errors). @@ -110,15 +115,61 @@ const REST_RETRY_BASE_DELAYS: [Duration; 3] = [ ]; impl RestClient { + // ── NIP-98 signing ──────────────────────────────────────────────────── + + /// Sign a NIP-98 HTTP Auth event (kind:27235) for the given method/URL/body. + /// + /// Returns the `Authorization: Nostr ` header value (without the + /// `Nostr ` prefix — caller must prepend it or use `nip98_header`). + fn sign_nip98( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + ) -> Result { + use base64::Engine; + use sha2::{Digest, Sha256}; + + let u_tag = Tag::parse(&["u", url]) + .map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?; + let method_tag = Tag::parse(&["method", method]) + .map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?; + // Nonce prevents replay rejection for rapid-fire requests with identical bodies. + let nonce_tag = Tag::parse(&["nonce", &uuid::Uuid::new_v4().to_string()]) + .map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?; + let mut tags = vec![u_tag, method_tag, nonce_tag]; + + if let Some(b) = body { + let hash = hex::encode(Sha256::digest(b)); + let payload_tag = Tag::parse(&["payload", &hash]) + .map_err(|e| RelayError::Http(format!("NIP-98 tag error: {e}")))?; + tags.push(payload_tag); + } + + let event = EventBuilder::new(Kind::HttpAuth, "", tags) + .sign_with_keys(&self.keys) + .map_err(|e| RelayError::Http(format!("NIP-98 sign error: {e}")))?; + let event_json = serde_json::to_string(&event) + .map_err(|e| RelayError::Http(format!("NIP-98 serialize error: {e}")))?; + Ok(base64::engine::general_purpose::STANDARD.encode(event_json)) + } + + /// Build the full `Authorization` header value: `Nostr `. + fn nip98_header( + &self, + method: &str, + url: &str, + body: Option<&[u8]>, + ) -> Result { + Ok(format!("Nostr {}", self.sign_nip98(method, url, body)?)) + } + + // ── Retry helper ────────────────────────────────────────────────────── + /// Retry helper: executes `build_request` up to 4 times (1 attempt + 3 retries) /// on transient failures (429, 502, 503, 504, timeout, connect errors). - /// Retry delays are jittered to prevent thundering-herd. /// - /// Safety: all Sprout REST endpoints used by the harness are idempotent or - /// deduplicated server-side. GET/PUT/DELETE are inherently safe to retry. - /// POST /api/events publishes signed Nostr events whose IDs are deterministic - /// hashes — the relay deduplicates by event ID per NIP-01, so retries cannot - /// produce duplicate side effects. + /// NIP-98 auth events are re-signed on each attempt (they have a ±60s window). async fn request_with_retry( &self, method: &str, @@ -154,7 +205,6 @@ impl RestClient { ))); } Ok(resp) => { - // Non-retriable error (401, 403, 404, etc.) — fail immediately. return Err(RelayError::Http(format!( "{method} {} returned HTTP {}", path, @@ -173,33 +223,56 @@ impl RestClient { .unwrap_or_else(|| RelayError::Http(format!("{method} {path} failed after retries")))) } - /// GET a JSON endpoint with retry on transient failures (429, 502, 503, 504). - pub async fn get_json(&self, path: &str) -> Result { + // ── Bridge methods ──────────────────────────────────────────────────── + + /// POST with NIP-98 auth and retry. Re-signs on each attempt. + async fn bridge_post( + &self, + path: &str, + body_bytes: &[u8], + ) -> Result { let url = format!("{}{}", self.base_url, path); - let resp = self - .request_with_retry("GET", path, || { - let builder = apply_auth(self.http.get(&url), &self.api_token, &self.keys); - builder.send() - }) - .await?; + let body_owned = body_bytes.to_vec(); + let auth_tag_header = self.auth_tag_json.clone(); + self.request_with_retry("POST", path, || { + // NIP-98 is re-signed each attempt (fresh created_at). + // sign_nip98 is infallible in practice (key is always valid). + let auth = self + .nip98_header("POST", &url, Some(&body_owned)) + .unwrap_or_default(); + let mut req = self + .http + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(ref tag) = auth_tag_header { + req = req.header("x-auth-tag", tag); + } + req.body(body_owned.clone()).send() + }) + .await + } + + /// Query events via the HTTP bridge: `POST /query` with NIP-98 auth. + /// + /// Accepts a slice of `nostr::Filter` (serialized as JSON array). + /// Returns the events as a `serde_json::Value` (JSON array of event objects). + pub async fn query(&self, filters: &[nostr::Filter]) -> Result { + let body_bytes = serde_json::to_vec(filters) + .map_err(|e| RelayError::Http(format!("filter serialize error: {e}")))?; + let resp = self.bridge_post("/query", &body_bytes).await?; resp.json() .await .map_err(|e| RelayError::Http(e.to_string())) } - /// PUT a JSON body to an endpoint, returning the parsed response. + /// Submit a signed event via the HTTP bridge: `POST /events` with NIP-98 auth. /// - /// Returns `Value::Null` for empty response bodies (e.g. 204 No Content). - pub async fn put_json(&self, path: &str, body: &Value) -> Result { - let url = format!("{}{}", self.base_url, path); - let body = body.clone(); - let resp = self - .request_with_retry("PUT", path, || { - let builder = - apply_auth(self.http.put(&url).json(&body), &self.api_token, &self.keys); - builder.send() - }) - .await?; + /// The event must already be signed. Returns the relay response JSON. + pub async fn submit_event(&self, event: &Event) -> Result { + let body_bytes = serde_json::to_vec(event) + .map_err(|e| RelayError::Http(format!("event serialize error: {e}")))?; + let resp = self.bridge_post("/events", &body_bytes).await?; let text = resp .text() .await @@ -209,44 +282,6 @@ impl RestClient { } serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string())) } - - /// POST a JSON body to an endpoint, returning the parsed response. - /// - /// Returns `Value::Null` for empty response bodies (e.g. 204 No Content). - pub async fn post_json(&self, path: &str, body: &Value) -> Result { - let url = format!("{}{}", self.base_url, path); - let body = body.clone(); - let resp = self - .request_with_retry("POST", path, || { - let builder = apply_auth( - self.http.post(&url).json(&body), - &self.api_token, - &self.keys, - ); - builder.send() - }) - .await?; - let text = resp - .text() - .await - .map_err(|e| RelayError::Http(e.to_string()))?; - if text.is_empty() { - return Ok(Value::Null); - } - serde_json::from_str(&text).map_err(|e| RelayError::Http(e.to_string())) - } - - /// DELETE an endpoint. Returns `Ok(())` on 2xx. - #[allow(dead_code)] - pub async fn delete(&self, path: &str) -> Result<(), RelayError> { - let url = format!("{}{}", self.base_url, path); - self.request_with_retry("DELETE", path, || { - let builder = apply_auth(self.http.delete(&url), &self.api_token, &self.keys); - builder.send() - }) - .await?; - Ok(()) - } } /// Events the harness cares about. @@ -325,6 +360,8 @@ enum RelayMessage { /// Subscription ID for the global membership notification subscription. const MEMBERSHIP_NOTIF_SUB_ID: &str = "membership-notif"; +/// Subscription ID for encrypted owner-to-agent observer control frames. +const OBSERVER_CONTROL_SUB_ID: &str = "agent-observer-control"; /// Commands sent from `HarnessRelay` to the background WebSocket task. enum RelayCommand { @@ -342,6 +379,8 @@ enum RelayCommand { Shutdown, /// Subscribe to global membership notifications. SubscribeMembership, + /// Subscribe to encrypted observer control frames addressed to this agent. + SubscribeObserverControls, /// Publish a signed event to the relay (for typing indicators, etc.). PublishEvent { event: Box }, /// Set the startup watermark timestamp for Finding #22. @@ -366,64 +405,92 @@ type WsStream = WebSocketStream>; pub struct HarnessRelay { /// Receiver for events forwarded by the background task. event_rx: mpsc::Receiver>, + /// Receiver for encrypted observer control events addressed to this agent. + observer_control_rx: Option>, /// Sender for commands to the background task. cmd_tx: mpsc::Sender, - /// HTTP client for REST API calls. + /// HTTP client for HTTP bridge calls. http: reqwest::Client, /// WebSocket URL of the relay. relay_url: String, - /// Optional API token for Bearer auth. - api_token: Option, - /// Keys used for NIP-42 signing. + /// Keys used for NIP-42 signing and NIP-98 HTTP auth. keys: Keys, /// Agent public key (hex) used as the `#p` filter on subscriptions. #[allow(dead_code)] agent_pubkey_hex: String, + /// Optional NIP-OA auth tag for relay membership delegation. + #[allow(dead_code)] + auth_tag: Option, /// Handle to the background task (for clean shutdown). /// Wrapped in `Option` so `shutdown()` can take ownership without conflicting /// with `Drop` (which only has `&mut self`). bg_handle: Option>, } +/// Cloneable publisher handle for signed events on the relay background socket. +#[derive(Clone)] +pub struct RelayEventPublisher { + cmd_tx: mpsc::Sender, +} + +impl RelayEventPublisher { + /// Publish a signed event through the relay background task. + pub async fn publish_event(&self, event: Event) -> Result<(), RelayError> { + self.cmd_tx + .send(RelayCommand::PublishEvent { + event: Box::new(event), + }) + .await + .map_err(|_| RelayError::ConnectionClosed) + } +} + impl HarnessRelay { // ── Public API ──────────────────────────────────────────────────────────── /// Connect to relay and authenticate via NIP-42. + /// + /// `auth_tag` is an optional NIP-OA owner attestation included in the AUTH + /// event for relay membership delegation. pub async fn connect( relay_url: &str, keys: &Keys, - api_token: Option<&str>, agent_pubkey_hex: &str, + auth_tag: Option, ) -> Result { // Perform the initial connection and auth handshake. // Finding #8: capture the handshake buffer and pass it to the background // task so buffered messages aren't silently discarded. - let (ws, handshake_buffer) = do_connect(relay_url, keys, api_token).await?; + let (ws, handshake_buffer) = do_connect(relay_url, keys, auth_tag.as_ref()).await?; let (event_tx, event_rx) = mpsc::channel::>(event_channel_capacity()); + let (observer_control_tx, observer_control_rx) = + mpsc::channel::(event_channel_capacity()); let (cmd_tx, cmd_rx) = mpsc::channel::(CMD_CHANNEL_CAPACITY); let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); - let bg_api_token = api_token.map(|t| t.to_string()); let bg_agent_pubkey_hex = agent_pubkey_hex.to_string(); + let bg_auth_tag = auth_tag.clone(); let bg_handle = tokio::spawn(async move { run_background_task( ws, handshake_buffer, event_tx, + observer_control_tx, cmd_rx, bg_keys, bg_relay_url, - bg_api_token, bg_agent_pubkey_hex, + bg_auth_tag, ) .await; }); Ok(Self { event_rx, + observer_control_rx: Some(observer_control_rx), cmd_tx, http: reqwest::Client::builder() .timeout(std::time::Duration::from_secs(10)) @@ -431,49 +498,120 @@ impl HarnessRelay { .build() .map_err(|e| RelayError::Http(format!("failed to build HTTP client: {e}")))?, relay_url: relay_url.to_string(), - api_token: api_token.map(|t| t.to_string()), keys: keys.clone(), agent_pubkey_hex: agent_pubkey_hex.to_string(), + auth_tag, bg_handle: Some(bg_handle), }) } - /// Discover channels the agent is a member of via `GET /api/channels?member=true`. + /// Discover channels the agent is a member of. /// - /// Uses the retry-enabled `RestClient::get_json` so transient 502/503/429 - /// errors during startup don't abort the harness. + /// Queries kind:39002 (NIP-29 group members) events where `#p` includes + /// the agent pubkey to find channel memberships, then queries kind:39000 + /// (group metadata) for channel names and types. pub async fn discover_channels(&self) -> Result, RelayError> { + use nostr::{Alphabet, SingleLetterTag}; + let rest = self.rest_client(); - let body = rest.get_json("/api/channels?member=true").await?; + let pk_hex = self.keys.public_key().to_hex(); - let channels = body + // Step 1: Find all channels where agent is a member (kind:39002 with #p tag). + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let member_filter = nostr::Filter::new() + .kind(Kind::Custom( + sprout_core::kind::KIND_NIP29_GROUP_MEMBERS as u16, + )) + .custom_tag(p_tag, [pk_hex.as_str()]); + let member_events = rest.query(&[member_filter]).await?; + + let member_arr = member_events .as_array() - .ok_or_else(|| RelayError::Http("expected JSON array from /api/channels".into()))?; + .ok_or_else(|| RelayError::Http("expected JSON array from /query (members)".into()))?; - let mut map = HashMap::with_capacity(channels.len()); - for ch in channels { - if let Some(id_str) = ch.get("id").and_then(|v| v.as_str()) { - match id_str.parse::() { - Ok(uuid) => { - let name = ch - .get("name") - .and_then(|v| v.as_str()) - .unwrap_or("unknown") - .to_string(); - let channel_type = ch - .get("channel_type") - .and_then(|v| v.as_str()) - .unwrap_or("stream") - .to_string(); - map.insert(uuid, ChannelInfo { name, channel_type }); - } - Err(e) => { - warn!("skipping channel with unparseable id {id_str:?}: {e}"); + // Extract channel UUIDs from #d tags. + let mut channel_uuids: Vec = Vec::new(); + for ev in member_arr { + if let Some(tags) = ev.get("tags").and_then(|t| t.as_array()) { + for tag in tags { + if let Some(arr) = tag.as_array() { + if arr.first().and_then(|v| v.as_str()) == Some("d") { + if let Some(d_val) = arr.get(1).and_then(|v| v.as_str()) { + if let Ok(uuid) = d_val.parse::() { + channel_uuids.push(uuid); + } + } + } } } } } + if channel_uuids.is_empty() { + debug!("discovered 0 channel(s)"); + return Ok(HashMap::new()); + } + + // Step 2: Fetch metadata (kind:39000) for discovered channels. + let d_tag = SingleLetterTag::lowercase(Alphabet::D); + let d_values: Vec = channel_uuids.iter().map(|u| u.to_string()).collect(); + let d_refs: Vec<&str> = d_values.iter().map(|s| s.as_str()).collect(); + let meta_filter = nostr::Filter::new() + .kind(Kind::Custom( + sprout_core::kind::KIND_NIP29_GROUP_METADATA as u16, + )) + .custom_tag(d_tag, d_refs); + let meta_events = rest.query(&[meta_filter]).await?; + + // Build UUID → (name, channel_type) from metadata events. + let mut meta_map: HashMap = HashMap::new(); + if let Some(arr) = meta_events.as_array() { + for ev in arr { + let tags = match ev.get("tags").and_then(|t| t.as_array()) { + Some(t) => t, + None => continue, + }; + let mut d_val = None; + let mut name = None; + let mut is_hidden = false; + let mut is_private = false; + for tag in tags { + if let Some(arr) = tag.as_array() { + match arr.first().and_then(|v| v.as_str()) { + Some("d") => d_val = arr.get(1).and_then(|v| v.as_str()), + Some("name") => name = arr.get(1).and_then(|v| v.as_str()), + Some("hidden") => is_hidden = true, + Some("private") => is_private = true, + _ => {} + } + } + } + if let Some(d) = d_val { + if let Ok(uuid) = d.parse::() { + let ch_name = name.unwrap_or("unknown").to_string(); + // DMs have the "hidden" tag; private channels have "private". + let ch_type = if is_hidden { + "dm".to_string() + } else if is_private { + "private".to_string() + } else { + "stream".to_string() + }; + meta_map.insert(uuid, (ch_name, ch_type)); + } + } + } + } + + // Step 3: Merge into final map. + let mut map = HashMap::with_capacity(channel_uuids.len()); + for uuid in channel_uuids { + let (name, channel_type) = meta_map + .remove(&uuid) + .unwrap_or_else(|| ("unknown".to_string(), "stream".to_string())); + map.insert(uuid, ChannelInfo { name, channel_type }); + } + debug!("discovered {} channel(s)", map.len()); Ok(map) } @@ -486,8 +624,11 @@ impl HarnessRelay { RestClient { http: self.http.clone(), base_url: relay_ws_to_http(&self.relay_url), - api_token: self.api_token.clone(), keys: self.keys.clone(), + auth_tag_json: self + .auth_tag + .as_ref() + .and_then(|t| serde_json::to_string(t.as_slice()).ok()), } } @@ -517,6 +658,27 @@ impl HarnessRelay { Ok(()) } + /// Subscribe to encrypted observer control frames addressed to this agent. + pub async fn subscribe_observer_controls(&mut self) -> Result<(), RelayError> { + self.cmd_tx + .send(RelayCommand::SubscribeObserverControls) + .await + .map_err(|_| RelayError::ConnectionClosed)?; + Ok(()) + } + + /// Take the observer-control receiver for polling outside this relay object. + pub fn take_observer_control_rx(&mut self) -> Option> { + self.observer_control_rx.take() + } + + /// Return a cloneable publisher handle for signed relay events. + pub fn event_publisher(&self) -> RelayEventPublisher { + RelayEventPublisher { + cmd_tx: self.cmd_tx.clone(), + } + } + /// Unsubscribe from a channel. #[allow(dead_code)] pub async fn unsubscribe_channel(&mut self, channel_id: Uuid) -> Result<(), RelayError> { @@ -717,6 +879,8 @@ struct BgState { membership_last_seen: Option, /// Whether the membership notification subscription is active. membership_sub_active: bool, + /// Whether the observer control subscription is active. + observer_control_sub_active: bool, /// Oldest dropped channel-event timestamp per channel, keyed by channel_id. /// Mirrors `membership_dropped_since` but for ordinary channel events. /// On reconnect resubscribe, `since` = min(last_seen, channel_dropped_since). @@ -749,6 +913,7 @@ impl BgState { membership_dropped_since: None, membership_last_seen: None, membership_sub_active: false, + observer_control_sub_active: false, channel_dropped_since: HashMap::new(), proactive_resubscribe_needed: false, startup_watermark: None, @@ -841,6 +1006,9 @@ fn apply_command_to_state(state: &mut BgState, cmd: RelayCommand) { RelayCommand::SubscribeMembership => { state.membership_sub_active = true; } + RelayCommand::SubscribeObserverControls => { + state.observer_control_sub_active = true; + } RelayCommand::SetStartupWatermark { ts } => { state.startup_watermark = Some(ts); if state.membership_last_seen.is_none() { @@ -942,6 +1110,17 @@ async fn execute_connected_command( false } } + RelayCommand::SubscribeObserverControls => { + let sent = send_observer_control_subscribe(ws, agent_pubkey_hex).await; + if sent { + state.observer_control_sub_active = true; + true + } else { + warn!("observer control subscribe REQ failed — recording intent for reconnect"); + state.observer_control_sub_active = true; + false + } + } RelayCommand::PublishEvent { event } => { let msg = json!(["EVENT", event]); if let Ok(text) = serde_json::to_string(&msg) { @@ -984,11 +1163,12 @@ async fn run_background_task( mut ws: WsStream, initial_handshake_buffer: std::collections::VecDeque, event_tx: mpsc::Sender>, + observer_control_tx: mpsc::Sender, mut cmd_rx: mpsc::Receiver, keys: Keys, relay_url: String, - api_token: Option, agent_pubkey_hex: String, + auth_tag: Option, ) { let mut state = BgState::new(); @@ -998,11 +1178,12 @@ async fn run_background_task( &mut ws, initial_handshake_buffer, &event_tx, + &observer_control_tx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, + auth_tag.as_ref(), ) .await; if !handshake_ok { @@ -1016,9 +1197,10 @@ async fn run_background_task( &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + &observer_control_tx, + auth_tag.as_ref(), ) .await { @@ -1039,10 +1221,11 @@ async fn run_background_task( &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + &observer_control_tx, true, + auth_tag.as_ref(), ) .await, ReconnectOutcome::Shutdown @@ -1079,9 +1262,10 @@ async fn run_background_task( &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + &observer_control_tx, + auth_tag.as_ref(), ) .await { @@ -1108,10 +1292,11 @@ async fn run_background_task( &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, + &observer_control_tx, true, + auth_tag.as_ref(), ) .await, ReconnectOutcome::Shutdown @@ -1128,223 +1313,236 @@ async fn run_background_task( } tokio::select! { - // ── Incoming WebSocket message ──────────────────────────────────── - raw = ws.next() => { - // Determine if the socket is lost. - let socket_lost = match raw { - Some(Ok(msg)) => { - // Finding #31: track pong replies directly, before dispatch. - if matches!(msg, Message::Pong(_)) { - last_pong = Instant::now(); - ping_sent = false; - false // pong is healthy — not a socket loss - } else { - !handle_ws_message( - msg, - &mut ws, - &event_tx, - &mut state, - &keys, - &relay_url, - api_token.as_deref(), - &agent_pubkey_hex, - ) - .await - } - } - Some(Err(e)) => { - warn!("WebSocket error in background task: {e}"); - true - } - None => { - debug!("WebSocket stream ended"); - true - } - }; + // ── Incoming WebSocket message ──────────────────────────────────── + raw = ws.next() => { + // Determine if the socket is lost. + let socket_lost = match raw { + Some(Ok(msg)) => { + // Finding #31: track pong replies directly, before dispatch. + if matches!(msg, Message::Pong(_)) { + last_pong = Instant::now(); + ping_sent = false; + false // pong is healthy — not a socket loss + } else { + !handle_ws_message( + msg, + &mut ws, + &event_tx, + &observer_control_tx, + &mut state, + &keys, + &relay_url, + &agent_pubkey_hex, + auth_tag.as_ref(), + ) + .await + } + } + Some(Err(e)) => { + warn!("WebSocket error in background task: {e}"); + true + } + None => { + debug!("WebSocket stream ended"); + true + } + }; - if socket_lost { - // Signal the caller, then attempt autonomous reconnect. - // Use try_send to avoid blocking on backpressure — recovery - // must not stall when the event channel is full. - let _ = event_tx.try_send(None); - let outcome = try_autonomous_reconnect( - &mut ws, - &mut cmd_rx, - &mut state, - &keys, - &relay_url, - api_token.as_deref(), - &agent_pubkey_hex, - &event_tx, - ) - .await; - match outcome { - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, - ReconnectOutcome::Shutdown - ) { return; } - // Reset ping state after reconnect. - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, true, - ).await, - ReconnectOutcome::Shutdown - ) { return; } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - } // end match outcome - } - } + if socket_lost { + // Signal the caller, then attempt autonomous reconnect. + // Use try_send to avoid blocking on backpressure — recovery + // must not stall when the event channel is full. + let _ = event_tx.try_send(None); + let outcome = try_autonomous_reconnect( + &mut ws, + &mut cmd_rx, + &mut state, + &keys, + &relay_url, + &agent_pubkey_hex, + &event_tx, + &observer_control_tx, + auth_tag.as_ref(), + ) + .await; + match outcome { + ReconnectOutcome::Shutdown => return, + ReconnectOutcome::Ok => { + if matches!( + drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, + ReconnectOutcome::Shutdown + ) { return; } + // Reset ping state after reconnect. + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } + ReconnectOutcome::Failed => { + if matches!( + wait_for_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), + ).await, + ReconnectOutcome::Shutdown + ) { return; } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } + } // end match outcome + } + } - // ── Command from HarnessRelay ───────────────────────────────────── - cmd = cmd_rx.recv() => { - match cmd { - Some(RelayCommand::Reconnect) => { - if matches!( - wait_for_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, true, - ).await, - ReconnectOutcome::Shutdown - ) { return; } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - Some(RelayCommand::Shutdown) | None => { - debug!("background task shutting down — sending close frame"); - let _ = ws_send_timeout( - &mut ws, - Message::Close(None), - WS_SEND_TIMEOUT_SECS, - ) - .await; - return; - } - Some(cmd) => { - let ok = execute_connected_command( - &mut ws, - &mut state, - &agent_pubkey_hex, - cmd, - ) - .await; - if !ok { - // Send failed — socket is likely dead. Trigger reconnect. - warn!("command send failed — triggering reconnect"); - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, - ).await { - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, - ReconnectOutcome::Shutdown - ) { return; } - } - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, true, - ).await, - ReconnectOutcome::Shutdown - ) { return; } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } - } - } - } + // ── Command from HarnessRelay ───────────────────────────────────── + cmd = cmd_rx.recv() => { + match cmd { + Some(RelayCommand::Reconnect) => { + if matches!( + wait_for_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), + ).await, + ReconnectOutcome::Shutdown + ) { return; } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } + Some(RelayCommand::Shutdown) | None => { + debug!("background task shutting down — sending close frame"); + let _ = ws_send_timeout( + &mut ws, + Message::Close(None), + WS_SEND_TIMEOUT_SECS, + ) + .await; + return; + } + Some(cmd) => { + let ok = execute_connected_command( + &mut ws, + &mut state, + &agent_pubkey_hex, + cmd, + ) + .await; + if !ok { + // Send failed — socket is likely dead. Trigger reconnect. + warn!("command send failed — triggering reconnect"); + let _ = event_tx.try_send(None); + match try_autonomous_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, + &observer_control_tx, + auth_tag.as_ref(), + ).await { + ReconnectOutcome::Shutdown => return, + ReconnectOutcome::Ok => { + if matches!( + drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, + ReconnectOutcome::Shutdown + ) { return; } + } + ReconnectOutcome::Failed => { + if matches!( + wait_for_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), + ).await, + ReconnectOutcome::Shutdown + ) { return; } + } + } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } + } + } + } - // ── Finding #31: client-initiated ping ──────────────────────────── - _ = ping_interval.tick() => { - if ping_sent && last_pong.elapsed() > PONG_TIMEOUT { - // No pong received after our last ping — connection is dead. - warn!("no pong received within {:?} — connection dead, reconnecting", PONG_TIMEOUT); - // Use try_send to avoid blocking on backpressure during recovery. - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, - ).await { - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, - ReconnectOutcome::Shutdown - ) { return; } - } - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, true, - ).await, - ReconnectOutcome::Shutdown - ) { return; } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } else if !ping_sent { - if let Err(e) = ws_send_timeout(&mut ws, Message::Ping(vec![].into()), WS_SEND_TIMEOUT_SECS).await { - warn!("failed to send ping: {e} — triggering reconnect"); - // Use try_send to avoid blocking on backpressure during recovery. - let _ = event_tx.try_send(None); - match try_autonomous_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, - ).await { - ReconnectOutcome::Shutdown => return, - ReconnectOutcome::Ok => { - if matches!( - drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, - ReconnectOutcome::Shutdown - ) { return; } - } - ReconnectOutcome::Failed => { - if matches!( - wait_for_reconnect( - &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, - api_token.as_deref(), &agent_pubkey_hex, &event_tx, true, - ).await, - ReconnectOutcome::Shutdown - ) { return; } - } - } - ping_sent = false; - last_pong = Instant::now(); - connected_since = Instant::now(); - stable_logged = false; - } else { - ping_sent = true; - debug!("sent ping to relay"); - } - } - } - } + // ── Finding #31: client-initiated ping ──────────────────────────── + _ = ping_interval.tick() => { + if ping_sent && last_pong.elapsed() > PONG_TIMEOUT { + // No pong received after our last ping — connection is dead. + warn!("no pong received within {:?} — connection dead, reconnecting", PONG_TIMEOUT); + // Use try_send to avoid blocking on backpressure during recovery. + let _ = event_tx.try_send(None); + match try_autonomous_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, + &observer_control_tx, + auth_tag.as_ref(), + ).await { + ReconnectOutcome::Shutdown => return, + ReconnectOutcome::Ok => { + if matches!( + drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, + ReconnectOutcome::Shutdown + ) { return; } + } + ReconnectOutcome::Failed => { + if matches!( + wait_for_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), + ).await, + ReconnectOutcome::Shutdown + ) { return; } + } + } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } else if !ping_sent { + if let Err(e) = ws_send_timeout(&mut ws, Message::Ping(vec![].into()), WS_SEND_TIMEOUT_SECS).await { + warn!("failed to send ping: {e} — triggering reconnect"); + // Use try_send to avoid blocking on backpressure during recovery. + let _ = event_tx.try_send(None); + match try_autonomous_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, + &observer_control_tx, + auth_tag.as_ref(), + ).await { + ReconnectOutcome::Shutdown => return, + ReconnectOutcome::Ok => { + if matches!( + drain_post_reconnect(&mut ws, &mut cmd_rx, &mut state, &agent_pubkey_hex).await, + ReconnectOutcome::Shutdown + ) { return; } + } + ReconnectOutcome::Failed => { + if matches!( + wait_for_reconnect( + &mut ws, &mut cmd_rx, &mut state, &keys, &relay_url, + &agent_pubkey_hex, &event_tx, &observer_control_tx, true, + auth_tag.as_ref(), + ).await, + ReconnectOutcome::Shutdown + ) { return; } + } + } + ping_sent = false; + last_pong = Instant::now(); + connected_since = Instant::now(); + stable_logged = false; + } else { + ping_sent = true; + debug!("sent ping to relay"); + } + } + } + } // Finding #42: log when connection has been stable for STABLE_CONNECTION_SECS. // Log once when the connection has been stable. Diagnostic only. @@ -1365,11 +1563,12 @@ async fn handle_ws_message( msg: Message, ws: &mut WsStream, event_tx: &mpsc::Sender>, + observer_control_tx: &mpsc::Sender, state: &mut BgState, keys: &Keys, relay_url: &str, - api_token: Option<&str>, agent_pubkey_hex: &str, + auth_tag: Option<&nostr::Tag>, ) -> bool { match msg { Message::Text(text) => { @@ -1386,7 +1585,15 @@ async fn handle_ws_message( subscription_id, event, } => { - if subscription_id == MEMBERSHIP_NOTIF_SUB_ID { + if subscription_id == OBSERVER_CONTROL_SUB_ID { + match observer_control_tx.try_send(*event) { + Ok(()) => {} + Err(mpsc::error::TrySendError::Full(_)) => { + warn!("observer control event dropped because control channel is full"); + } + Err(mpsc::error::TrySendError::Closed(_)) => return false, + } + } else if subscription_id == MEMBERSHIP_NOTIF_SUB_ID { // Membership notification — extract channel UUID from h tag. let channel_uuid = match extract_h_tag_uuid(&event) { Some(uuid) => uuid, @@ -1535,7 +1742,15 @@ async fn handle_ws_message( // the attempt — if the send fails and triggers reconnect, // resubscribe_after_reconnect() needs the subscription to // still be in state so it can restore it. - if subscription_id == MEMBERSHIP_NOTIF_SUB_ID { + if subscription_id == OBSERVER_CONTROL_SUB_ID { + let sent = send_observer_control_subscribe(ws, agent_pubkey_hex).await; + if sent { + state.observer_control_sub_active = true; + } else { + warn!("observer control resubscribe failed after CLOSED — triggering reconnect"); + return false; + } + } else if subscription_id == MEMBERSHIP_NOTIF_SUB_ID { let since = match (state.membership_dropped_since, state.membership_last_seen) { (Some(d), Some(l)) => Some(d.min(l)), @@ -1604,7 +1819,7 @@ async fn handle_ws_message( // Finding #18: AUTH send failure must trigger reconnect. debug!("received mid-session AUTH challenge — re-authenticating"); if let Err(e) = - send_auth_response(ws, &challenge, relay_url, keys, api_token).await + send_auth_response(ws, &challenge, relay_url, keys, auth_tag).await { warn!("failed to respond to mid-session AUTH challenge: {e} — triggering reconnect"); return false; @@ -1652,11 +1867,12 @@ async fn process_handshake_buffer( ws: &mut WsStream, buffer: std::collections::VecDeque, event_tx: &mpsc::Sender>, + observer_control_tx: &mpsc::Sender, state: &mut BgState, keys: &Keys, relay_url: &str, - api_token: Option<&str>, agent_pubkey_hex: &str, + auth_tag: Option<&nostr::Tag>, ) -> bool { if buffer.is_empty() { return true; @@ -1694,11 +1910,12 @@ async fn process_handshake_buffer( Message::Text(text.into()), ws, event_tx, + observer_control_tx, state, keys, relay_url, - api_token, agent_pubkey_hex, + auth_tag, ) .await; if !should_continue { @@ -1769,6 +1986,13 @@ async fn resubscribe_after_reconnect( } } + if state.observer_control_sub_active + && !send_observer_control_subscribe(ws, agent_pubkey_hex).await + { + warn!("failed to resubscribe observer controls after reconnect"); + all_ok = false; + } + all_ok } @@ -1852,9 +2076,10 @@ async fn try_autonomous_reconnect( state: &mut BgState, keys: &Keys, relay_url: &str, - api_token: Option<&str>, agent_pubkey_hex: &str, event_tx: &mpsc::Sender>, + observer_control_tx: &mpsc::Sender, + auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { // Finding #42: 5 attempts, up to 16s base backoff. let backoffs = [ @@ -1871,7 +2096,7 @@ async fn try_autonomous_reconnect( attempt + 1, backoffs.len() ); - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("autonomous reconnect succeeded (attempt {})", attempt + 1); @@ -1880,11 +2105,12 @@ async fn try_autonomous_reconnect( ws, handshake_buffer, event_tx, + observer_control_tx, state, keys, relay_url, - api_token, agent_pubkey_hex, + auth_tag, ) .await; if !handshake_ok { @@ -1953,10 +2179,11 @@ async fn wait_for_reconnect( state: &mut BgState, keys: &Keys, relay_url: &str, - api_token: Option<&str>, agent_pubkey_hex: &str, event_tx: &mpsc::Sender>, + observer_control_tx: &mpsc::Sender, skip_drain: bool, + auth_tag: Option<&nostr::Tag>, ) -> ReconnectOutcome { if !skip_drain { // Drain commands until we get Reconnect (or Shutdown). @@ -1984,7 +2211,7 @@ async fn wait_for_reconnect( let mut delay = Duration::from_secs(1); loop { info!("attempting relay reconnect to {relay_url}…"); - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, auth_tag).await { Ok((new_ws, handshake_buffer)) => { *ws = new_ws; info!("relay reconnected to {relay_url}"); @@ -1993,11 +2220,12 @@ async fn wait_for_reconnect( ws, handshake_buffer, event_tx, + observer_control_tx, state, keys, relay_url, - api_token, agent_pubkey_hex, + auth_tag, ) .await; if !handshake_ok { @@ -2170,6 +2398,41 @@ async fn send_membership_subscribe( } } +/// Send a NIP-01 REQ for owner-to-agent observer control frames. +async fn send_observer_control_subscribe(ws: &mut WsStream, agent_pubkey_hex: &str) -> bool { + let req = json!([ + "REQ", + OBSERVER_CONTROL_SUB_ID, + { + "kinds": [KIND_AGENT_OBSERVER_FRAME], + "#p": [agent_pubkey_hex], + "since": std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + } + ]); + + match serde_json::to_string(&req) { + Ok(text) => { + match ws_send_timeout(ws, Message::Text(text.into()), WS_SEND_TIMEOUT_SECS).await { + Ok(()) => { + debug!("subscribed to observer control frames"); + true + } + Err(e) => { + warn!("failed to send observer control REQ: {e}"); + false + } + } + } + Err(e) => { + warn!("failed to serialize observer control REQ: {e}"); + false + } + } +} + /// Send a WebSocket message with a hard timeout. /// /// All `ws.send()` calls go through here so a stalled TCP socket can't wedge @@ -2211,26 +2474,30 @@ fn extract_h_tag_uuid(event: &nostr::Event) -> Option { } /// Build and send a NIP-42 AUTH response event. +/// +/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the +/// AUTH event so the relay can use it for membership delegation fallback. async fn send_auth_response( ws: &mut WsStream, challenge: &str, relay_url: &str, keys: &Keys, - api_token: Option<&str>, + auth_tag: Option<&nostr::Tag>, ) -> Result<(), RelayError> { let relay_nostr_url: NostrUrl = relay_url .parse() .map_err(|e: url::ParseError| RelayError::Http(format!("invalid relay URL: {e}")))?; - let auth_event = if let Some(token) = api_token { + let auth_event = if let Some(tag) = auth_tag { + // Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags. let tags = vec![ - Tag::parse(&["relay", relay_url]).map_err(|e| RelayError::AuthFailed(e.to_string()))?, - Tag::parse(&["challenge", challenge]) - .map_err(|e| RelayError::AuthFailed(e.to_string()))?, - Tag::parse(&["auth_token", token]) - .map_err(|e| RelayError::AuthFailed(e.to_string()))?, + nostr::Tag::parse(&["relay", relay_url]) + .map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?, + nostr::Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayError::Http(format!("tag parse error: {e}")))?, + tag.clone(), ]; - EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)? + EventBuilder::new(nostr::Kind::Authentication, "", tags).sign_with_keys(keys)? } else { EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)? }; @@ -2269,18 +2536,6 @@ fn channel_id_from_sub_id(sub_id: &str) -> Option { } /// Apply the appropriate auth header to a reqwest request builder. -fn apply_auth( - builder: reqwest::RequestBuilder, - api_token: &Option, - keys: &Keys, -) -> reqwest::RequestBuilder { - if let Some(ref token) = api_token { - builder.header("Authorization", format!("Bearer {token}")) - } else { - builder.header("X-Pubkey", keys.public_key().to_hex()) - } -} - /// Parse a raw relay text frame into a typed [`RelayMessage`]. #[allow(private_interfaces)] pub(crate) fn parse_relay_message(text: &str) -> Result { @@ -2382,7 +2637,7 @@ pub(crate) fn parse_relay_message(text: &str) -> Result, + auth_tag: Option<&nostr::Tag>, ) -> Result<(WsStream, VecDeque), RelayError> { let parsed = relay_url .parse::() @@ -2401,7 +2656,7 @@ async fn do_connect( let challenge = wait_for_auth_challenge(&mut ws, &mut buffer, AUTH_TIMEOUT).await?; // ── Step 2: Build and send kind:22242 auth event ────────────────────── - send_auth_response(&mut ws, &challenge, relay_url, keys, api_token).await?; + send_auth_response(&mut ws, &challenge, relay_url, keys, auth_tag).await?; // ── Step 3: Wait for OK ─────────────────────────────────────────────── let event_id = { diff --git a/crates/sprout-admin/Cargo.toml b/crates/sprout-admin/Cargo.toml index 54bd99cde5..d536abca5e 100644 --- a/crates/sprout-admin/Cargo.toml +++ b/crates/sprout-admin/Cargo.toml @@ -13,6 +13,7 @@ path = "src/main.rs" [dependencies] sprout-db = { workspace = true } +sprout-core = { workspace = true } sprout-auth = { workspace = true } nostr = { workspace = true } tokio = { workspace = true } diff --git a/crates/sprout-admin/src/main.rs b/crates/sprout-admin/src/main.rs index 7d983e2860..cd83a03f0b 100644 --- a/crates/sprout-admin/src/main.rs +++ b/crates/sprout-admin/src/main.rs @@ -1,10 +1,14 @@ #![deny(unsafe_code)] +//! Sprout instance administration CLI. +//! +//! In the pure Nostr architecture, API tokens no longer exist. +//! Admin operations are performed via signed Nostr events (NIP-43 relay admin commands). +//! This binary is retained as a placeholder for future admin tooling. + use anyhow::Result; use clap::{Parser, Subcommand}; -use nostr::nips::nip19::ToBech32; -use nostr::{Keys, PublicKey}; -use sprout_auth::token::{generate_token, hash_token}; +use nostr::Keys; use sprout_db::{Db, DbConfig}; #[derive(Parser)] @@ -16,198 +20,207 @@ struct Cli { #[derive(Subcommand)] enum Command { - /// Create a new API token for an agent. - MintToken { - /// Token name + /// Add a pubkey to the relay membership list. + AddMember { + /// Nostr public key (hex) to add. #[arg(long)] - name: String, + pubkey: String, - /// Comma-separated scopes (messages:read, messages:write, channels:read, - /// channels:write, admin:channels, files:read, files:write) - #[arg(long)] - scopes: String, - - /// 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, + /// Role: "admin" or "member" (default: member). + #[arg(long, default_value = "member")] + role: String, + }, + /// List all relay members. + ListMembers, + /// Generate a new Nostr keypair (for bootstrapping). + GenerateKey, + /// Emit kind:39000/39002 events for channels missing them. + /// + /// Channels created via direct SQL (seed scripts, pre-migration data) won't + /// have Nostr discovery events. This command creates them so pure-nostr + /// clients can see those channels. Idempotent — safe to run multiple times. + ReconcileChannels { + /// Relay private key (hex) for signing events. Falls back to + /// SPROUT_RELAY_PRIVATE_KEY env var. If neither is set, generates + /// an ephemeral key (events will be unverifiable after restart). + #[arg(long)] + relay_key: Option, }, - /// List all active API tokens. - ListTokens, } #[tokio::main] async fn main() -> Result<()> { let cli = Cli::parse(); + match cli.command { + Command::GenerateKey => { + let keys = Keys::generate(); + println!("Public key: {}", keys.public_key().to_hex()); + println!("Secret key: {}", keys.secret_key().display_secret()); + println!("\nSet SPROUT_PRIVATE_KEY to the secret key to use this identity."); + } + Command::AddMember { pubkey, role } => { + let db = connect_db().await?; + let pk_bytes = hex::decode(&pubkey)?; + if pk_bytes.len() != 32 { + anyhow::bail!("pubkey must be 32 bytes (64 hex chars)"); + } + db.ensure_user(&pk_bytes).await?; + // Add to relay members via DB (admin bootstrap — normally done via kind:9030) + db.add_relay_member(&pubkey, &role, None).await?; + println!("Added {} as {} to relay membership list.", pubkey, role); + } + Command::ListMembers => { + let db = connect_db().await?; + let members = db.list_relay_members().await?; + if members.is_empty() { + println!("No relay members found."); + } else { + println!("{:<66} {:<10}", "Pubkey", "Role"); + println!("{}", "-".repeat(78)); + for m in &members { + println!("{:<66} {:<10}", hex::encode(&m.pubkey), m.role); + } + } + } + Command::ReconcileChannels { relay_key } => { + reconcile_channels(relay_key).await?; + } + } + + Ok(()) +} + +async fn connect_db() -> Result { let db_url = std::env::var("DATABASE_URL") .unwrap_or_else(|_| "postgres://sprout:sprout_dev@localhost:5432/sprout".to_string()); - let db = Db::new(&DbConfig { database_url: db_url, ..DbConfig::default() }) .await?; - - match cli.command { - Command::MintToken { - name, - scopes, - pubkey, - owner_pubkey, - } => mint_token(&db, &name, &scopes, pubkey.as_deref(), owner_pubkey).await?, - Command::ListTokens => list_tokens(&db).await?, - } - - Ok(()) + Ok(db) } -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()) - .collect(); +async fn reconcile_channels(relay_key_arg: Option) -> Result<()> { + use nostr::{EventBuilder, Kind, Tag}; + use sprout_core::kind::KIND_NIP29_GROUP_ADMINS; + use sprout_db::event::EventQuery; - let (pubkey, generated_keys) = match pubkey_hex { - Some(hex) => (PublicKey::from_hex(hex)?, None), + let db = connect_db().await?; + + // Resolve relay signing key: arg > env > ephemeral + let relay_keys = match relay_key_arg.or_else(|| std::env::var("SPROUT_RELAY_PRIVATE_KEY").ok()) + { + Some(key_hex) => { + Keys::parse(&key_hex).map_err(|e| anyhow::anyhow!("invalid relay key: {e}"))? + } None => { - let keys = Keys::generate(); - (keys.public_key(), Some(keys)) + let k = Keys::generate(); + eprintln!( + "Warning: no relay key provided — using ephemeral key {}", + k.public_key().to_hex() + ); + eprintln!("Events signed with this key won't be verifiable after this run."); + eprintln!("Pass --relay-key or set SPROUT_RELAY_PRIVATE_KEY for production use."); + k } }; - let pubkey_bytes = pubkey.serialize().to_vec(); - - // ── Enforce shutdown-required scopes (before any DB writes) ───────────── - // Two triggers, same as the relay path: - // 1. Explicit --owner-pubkey (bootstrap mint) - // 2. Agent already has an owner in the DB (re-mint must preserve controllability) - // Fail closed: DB lookup error → assume owned → enforce scopes. - let has_existing_owner = match db.get_agent_channel_policy(&pubkey_bytes).await { - Ok(Some((_, Some(_)))) => true, - Ok(_) => false, - Err(e) => { - eprintln!("warning: owner lookup failed (assuming owned): {e}"); - true // fail closed - } - }; - if owner_pubkey.is_some() || has_existing_owner { - let required = [ - "users:read", - "messages:read", - "messages:write", - "channels:read", - ]; - for r in &required { - if !scopes.iter().any(|s| s == r) { - anyhow::bail!("owned agents require the '{r}' scope for agent controllability"); - } - } - } - - // ── Validate owner_pubkey (before any DB writes) ───────────────────────── - let validated_owner = 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)"); - } - Some(owner_bytes) - } else { - None - }; - - // ── DB writes (all validation passed) ──────────────────────────────────── - db.ensure_user(&pubkey_bytes).await?; - - if let Some(owner_bytes) = validated_owner { - db.ensure_user(&owner_bytes).await?; - let was_set = db.set_agent_owner(&pubkey_bytes, &owner_bytes).await?; - if !was_set { - let existing = db - .get_agent_channel_policy(&pubkey_bytes) - .await? - .and_then(|(_, owner)| owner); - if existing.as_deref() != Some(owner_bytes.as_slice()) { - anyhow::bail!( - "agent already has a different owner — refusing to mint token for non-owner" - ); - } - eprintln!("note: agent already owned by the requested pubkey — proceeding"); - } - } - - let raw_token = generate_token(); - let token_hash = hash_token(&raw_token); - - let token_id = db - .create_api_token(&token_hash, &pubkey_bytes, name, &scopes, None, None) - .await?; - - println!("╔══════════════════════════════════════════════════════════════╗"); - println!("║ Token minted successfully! ║"); - println!("╠══════════════════════════════════════════════════════════════╣"); - println!("║ Token ID: {:<46} ║", token_id); - println!("║ Name: {:<46} ║", name); - println!("║ Scopes: {:<46} ║", scopes_str); - println!("║ Pubkey: {}...║", &pubkey.to_hex()[..48]); - println!("╠══════════════════════════════════════════════════════════════╣"); - - if let Some(keys) = generated_keys { - println!("║ ⚠️ SAVE THESE — shown only once! ║"); - println!("╠══════════════════════════════════════════════════════════════╣"); - println!("║ Private key (nsec): ║"); - println!( - "║ {} ║", - keys.secret_key() - .to_bech32() - .unwrap_or_else(|_| "error encoding".into()) - ); - println!("║ ║"); - } - - println!("║ API Token: ║"); - println!("║ {} ║", raw_token); - println!("╚══════════════════════════════════════════════════════════════╝"); - - Ok(()) -} - -async fn list_tokens(db: &Db) -> Result<()> { - let tokens = db.list_active_tokens().await?; - - if tokens.is_empty() { - println!("No active tokens found."); + let channels = db.list_channels(None).await?; + if channels.is_empty() { + println!("No channels in database."); return Ok(()); } - println!( - "{:<36} {:<20} {:<40} {:<20}", - "ID", "Name", "Scopes", "Created" - ); - println!("{}", "-".repeat(120)); + let mut reconciled = 0u32; + let mut skipped = 0u32; - for t in &tokens { - let scopes_str = t.scopes.join(","); - let id_str = t.id.to_string(); - println!( - "{:<36} {:<20} {:<40} {:<20}", - &id_str[..36.min(id_str.len())], - &t.name[..20.min(t.name.len())], - &scopes_str[..40.min(scopes_str.len())], - t.created_at.format("%Y-%m-%d %H:%M"), - ); + for channel in &channels { + let channel_id_str = channel.id.to_string(); + + // Check if kind:39000 already exists + let existing = db + .query_events(&EventQuery { + kinds: Some(vec![39000]), + d_tag: Some(channel_id_str.clone()), + limit: Some(1), + ..Default::default() + }) + .await + .unwrap_or_default(); + + if !existing.is_empty() { + skipped += 1; + continue; + } + + let members = db.get_members(channel.id).await?; + + // kind:39000 — channel metadata + { + let mut tags: Vec = vec![Tag::parse(&["d", &channel_id_str])?]; + tags.push(Tag::parse(&["name", &channel.name])?); + if let Some(ref desc) = channel.description { + if !desc.is_empty() { + tags.push(Tag::parse(&["about", desc])?); + } + } + if channel.visibility == "private" { + tags.push(Tag::parse(&["private"])?); + } else { + tags.push(Tag::parse(&["public"])?); + } + if channel.channel_type == "dm" { + tags.push(Tag::parse(&["hidden"])?); + } + tags.push(Tag::parse(&["closed"])?); + tags.push(Tag::parse(&["t", &channel.channel_type])?); + + let event = EventBuilder::new(Kind::Custom(39000), "", tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39000: {e}"))?; + db.replace_addressable_event(&event, Some(channel.id)) + .await?; + } + + // kind:39001 — admins + { + let mut tags: Vec = vec![Tag::parse(&["d", &channel_id_str])?]; + for m in members + .iter() + .filter(|m| m.role == "owner" || m.role == "admin") + { + let pk = hex::encode(&m.pubkey); + tags.push(Tag::parse(&["p", &pk, &m.role])?); + } + let event = EventBuilder::new(Kind::Custom(KIND_NIP29_GROUP_ADMINS as u16), "", tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39001: {e}"))?; + db.replace_addressable_event(&event, Some(channel.id)) + .await?; + } + + // kind:39002 — members + { + let mut tags: Vec = vec![Tag::parse(&["d", &channel_id_str])?]; + for m in &members { + let pk = hex::encode(&m.pubkey); + tags.push(Tag::parse(&["p", &pk, "", &m.role])?); + } + let event = EventBuilder::new(Kind::Custom(39002), "", tags) + .sign_with_keys(&relay_keys) + .map_err(|e| anyhow::anyhow!("sign kind:39002: {e}"))?; + db.replace_addressable_event(&event, Some(channel.id)) + .await?; + } + + reconciled += 1; } + println!( + "Reconciled {reconciled} channels ({skipped} already had events, {} total).", + channels.len() + ); Ok(()) } diff --git a/crates/sprout-agent/Cargo.toml b/crates/sprout-agent/Cargo.toml new file mode 100644 index 0000000000..eebdb3fcf2 --- /dev/null +++ b/crates/sprout-agent/Cargo.toml @@ -0,0 +1,39 @@ +[package] +name = "sprout-agent" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true +description = "Minimal, unbreakable ACP-compliant agent. Non-streaming. Tool-calls-as-output." +readme = "README.md" +keywords = ["acp", "agent", "llm", "mcp", "minimal"] +categories = ["command-line-utilities", "web-programming"] + +[[bin]] +name = "sprout-agent" +path = "src/main.rs" + +# Test-only fake MCP server. Built unconditionally because cargo can't gate +# bins on `cfg(test)`, but it's tiny and only used by integration tests. +[[bin]] +name = "fake-mcp" +path = "tests/bin/fake_mcp.rs" + +[dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time"] } +serde = { workspace = true } +serde_json = { workspace = true } +reqwest = { workspace = true } +rmcp = { version = "1", default-features = false, features = ["client", "transport-child-process"] } +arc-swap = "1" +getrandom = "0.2" +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", default-features = false, features = ["signal", "process"] } + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util", "rt-multi-thread", "macros", "io-std", "io-util", "sync", "process", "time", "net"] } +nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/sprout-agent/README.md b/crates/sprout-agent/README.md new file mode 100644 index 0000000000..ba29dace75 --- /dev/null +++ b/crates/sprout-agent/README.md @@ -0,0 +1,284 @@ +# sprout-agent + +> Minimal, unbreakable ACP-compliant LLM agent. Stdio in, tool calls out. Non-streaming. No persistence. No cleverness. + +[ACP](https://agentclientprotocol.com) is the Agent Client Protocol — JSON-RPC 2.0 over stdio between a client (Zed, JetBrains, sprout-acp, …) and an agent. [MCP](https://modelcontextprotocol.io) is how the agent talks to its tools. + +`sprout-agent` is the agent. + +## What It Is + +``` + +--------+ stdio (JSON-RPC 2.0) +---------------+ + | client | <----------------------> | sprout-agent | + +--------+ ACP frames +---------------+ + │ │ + │ │ rmcp (stdio) + │ ▼ + │ MCP servers + │ (your tools) + ▼ + HTTPS + │ + ▼ + Anthropic Messages API + or any OpenAI-compat + (vLLM, llama.cpp, OpenRouter, + Block Gateway, Ollama, …) +``` + +A client sends `session/prompt`. The agent loops: call the LLM → get tool calls → run them via MCP → feed results back → repeat. The loop terminates when the LLM stops asking for tools, the round cap is hit, or the client cancels. + +The agent's **output is its tool calls**. Generated text is forwarded to the client as `agent_message_chunk` updates, but the real work happens in the tools. The LLM call is non-streaming — one HTTP POST, one response. + +## Quick Start + +```bash +# Build +cargo build --release -p sprout-agent + +# Run against Anthropic +SPROUT_AGENT_PROVIDER=anthropic \ +ANTHROPIC_API_KEY=sk-ant-... \ +ANTHROPIC_MODEL=claude-sonnet-4-5 \ + ./target/release/sprout-agent + +# Or any OpenAI-compatible endpoint +SPROUT_AGENT_PROVIDER=openai \ +OPENAI_COMPAT_API_KEY=sk-... \ +OPENAI_COMPAT_MODEL=gpt-5 \ +OPENAI_COMPAT_BASE_URL=https://api.openai.com/v1 \ + ./target/release/sprout-agent +``` + +That's the whole setup. The agent reads JSON-RPC frames from stdin, writes them to stdout, and logs to stderr. + +## ACP Transcript + +A complete round-trip. Lines starting with `→` are client→agent (stdin); `←` are agent→client (stdout). Each line is one newline-terminated JSON value. Comments are not part of the wire. + +```jsonc +// 1. Handshake. +→ {"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":1,"clientCapabilities":{}}} +← {"jsonrpc":"2.0","id":1,"result":{ + "protocolVersion":1, + "agentCapabilities":{ + "loadSession":false, + "promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}, + "mcpCapabilities":{"http":false,"sse":false} + }, + "agentInfo":{"name":"sprout-agent","version":"0.1.0"} + }} + +// 2. Open a session. The client passes the MCP servers to spawn. +→ {"jsonrpc":"2.0","id":2,"method":"session/new","params":{ + "cwd":"/tmp", + "mcpServers":[{"name":"echo","command":"/usr/local/bin/echo-mcp","args":[],"env":[]}] + }} +← {"jsonrpc":"2.0","id":2,"result":{"sessionId":"ses_a1b2c3d4e5f6a7b8"}} + +// 3. Prompt. The agent loops until the LLM stops calling tools. +→ {"jsonrpc":"2.0","id":3,"method":"session/prompt","params":{ + "sessionId":"ses_a1b2c3d4e5f6a7b8", + "prompt":[{"type":"text","text":"echo hello"}] + }} + +// 4. Agent emits tool_call (status: pending) — visible to the UI. +← {"jsonrpc":"2.0","method":"session/update","params":{ + "sessionId":"ses_a1b2c3d4e5f6a7b8", + "update":{ + "sessionUpdate":"tool_call", + "toolCallId":"toolu_01XYZ", + "title":"echo__say", + "kind":"other", + "status":"pending", + "rawInput":{"text":"hello"} + } + }} + +// 5. Agent moves the call to in_progress, runs the MCP tool, then completed. +← {"jsonrpc":"2.0","method":"session/update","params":{ + "sessionId":"ses_a1b2c3d4e5f6a7b8", + "update":{"sessionUpdate":"tool_call_update","toolCallId":"toolu_01XYZ","status":"in_progress"} + }} +← {"jsonrpc":"2.0","method":"session/update","params":{ + "sessionId":"ses_a1b2c3d4e5f6a7b8", + "update":{ + "sessionUpdate":"tool_call_update", + "toolCallId":"toolu_01XYZ", + "status":"completed", + "content":[{"type":"content","content":{"type":"text","text":"hello"}}] + } + }} + +// 8. The model sees the result, decides it's done, and the prompt resolves. +← {"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} +``` + +That's ACP. Three request methods (`initialize`, `session/new`, `session/prompt`), one inbound notification (`session/cancel`), and three outbound update variants (`agent_message_chunk`, `tool_call`, `tool_call_update`). The full server is hand-rolled in `main.rs`. + +## Configuration + +Everything is environment variables. No flags, no config files. (We are a subprocess; subprocess config is environment.) + +| Variable | Default | Notes | +|---|---|---| +| `SPROUT_AGENT_PROVIDER` | — | Required. `anthropic` or `openai`. | +| `ANTHROPIC_API_KEY` | — | Required when provider=anthropic. | +| `ANTHROPIC_MODEL` | — | Required when provider=anthropic. | +| `ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | | +| `ANTHROPIC_API_VERSION` | `2023-06-01` | | +| `OPENAI_COMPAT_API_KEY` | — | Required when provider=openai. | +| `OPENAI_COMPAT_MODEL` | — | Required when provider=openai. | +| `OPENAI_COMPAT_BASE_URL` | `https://api.openai.com/v1` | Point at vLLM, llama.cpp, OpenRouter, Ollama, etc. | +| `SPROUT_AGENT_SYSTEM_PROMPT` | built-in | Inline system prompt. | +| `SPROUT_AGENT_SYSTEM_PROMPT_FILE` | — | File path. Mutually exclusive with the above. | +| `SPROUT_AGENT_MAX_ROUNDS` | `16` | Tool-loop iteration cap. | +| `SPROUT_AGENT_MAX_OUTPUT_TOKENS` | `4096` | Per LLM call. | +| `SPROUT_AGENT_LLM_TIMEOUT_SECS` | `120` | | +| `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | `660` | Per-tool call timeout in seconds | +| `SPROUT_AGENT_MAX_PARALLEL_TOOLS` | `8` | Max concurrent tool calls per turn (1 = sequential) | +| `SPROUT_AGENT_MAX_SESSIONS` | `8` | Max concurrent ACP sessions | +| `SPROUT_AGENT_MAX_LINE_BYTES` | `4194304` | 4 MiB. Hard cap on inbound JSON-RPC frames. | +| `SPROUT_AGENT_MAX_HISTORY_BYTES` | `1048576` | 1 MiB. Old turns are evicted past this. | + + +## Providers + +`sprout-agent` speaks two HTTP dialects. Pick with `SPROUT_AGENT_PROVIDER`. + +| Provider | `SPROUT_AGENT_PROVIDER` | Endpoint | Tested with | +|---|---|---|---| +| Anthropic | `anthropic` | `POST {base}/v1/messages` | claude-sonnet-4-5, claude-opus-4 | +| OpenAI | `openai` | `POST {base}/chat/completions` | gpt-5, gpt-4o | +| vLLM | `openai` | OpenAI-compatible endpoint | any tool-calling model | +| llama.cpp | `openai` | `--api-server` mode | any tool-calling GGUF | +| Ollama | `openai` | `http://localhost:11434/v1` | llama3.1, qwen2.5-coder | +| OpenRouter | `openai` | `https://openrouter.ai/api/v1` | anything they route | +| Block Gateway | `openai` | internal | gpt-5, claude | + +The "OpenAI" path is wire-compatible with the [Chat Completions API](https://platform.openai.com/docs/api-reference/chat). If a provider claims OpenAI compatibility and supports `tools` + `tool_choice: auto`, it works here. + +`Provider` is a Rust `enum` with one `match` in `Llm::complete`. There is no trait, no `Box`, no async-trait. Adding a third provider is a `match` arm and one `body`/`parse` pair in `llm.rs`. + +## MCP Servers + +The client passes MCP server specs in `session/new`. The agent spawns each one as a stdio subprocess, calls `tools/list`, and merges everything into a single tool catalog the LLM sees. Tool names are namespaced as `server__tool` (double underscore separator). Bare tool names containing `__` are rejected at registration. + +Example: a single echo MCP server. + +```json +{ + "jsonrpc": "2.0", + "id": 2, + "method": "session/new", + "params": { + "cwd": "/work", + "mcpServers": [ + { + "name": "echo", + "command": "/usr/local/bin/echo-mcp", + "args": ["--mode", "stdio"], + "env": [ + { "name": "ECHO_VERBOSE", "value": "1" } + ] + } + ] + } +} +``` + +Multiple servers: just add more entries. Tool calls fan out to the right server by namespace prefix. + +**Transport: stdio only.** No HTTP, no SSE. We advertise this in `agentCapabilities` (`mcpCapabilities.http: false`, `mcpCapabilities.sse: false`); spec-compliant clients won't ask for what we don't have. + +## Security Model + +The trust boundary is **the operator who launched the agent**. The harness, MCP server binaries, and API keys are all trusted. Untrusted input — model output, tool results, prompts — is bounded. + +| Boundary | Mechanism | +|---|---| +| Stdout discipline | Single-consumer `mpsc` channel feeding stdout. No two tasks can interleave bytes. All logs go to stderr. | +| MCP child env | Whitelist (`PATH`, `HOME`, `TERM`, `LANG`, `LC_ALL`, `TMPDIR`) plus what the client explicitly passes. Your `ANTHROPIC_API_KEY` does not leak into MCP children. | +| MCP child lifetime | Process group via `setpgid(0,0)` in `pre_exec`. On transport break or shutdown: `killpg(SIGKILL)`. Grandchildren die too. | +| Server poisoning | After a timeout or transport break, the offending server is marked dead. Future calls trigger a lazy restart with exponential backoff. Other servers keep working. | +| Frame size | `SPROUT_AGENT_MAX_LINE_BYTES` (default 4 MiB). Oversize → connection killed. | +| LLM response size | 16 MiB hard cap. Both `Content-Length` precheck and streaming-buffer cap. | +| Cancellation | `tokio::select! { biased; _ = cancel.changed() => ... }` at every loop boundary. Cancel always wins the race. | +| Session isolation | Up to 8 concurrent sessions (configurable). One prompt per session at a time. Each session gets its own MCP servers. | +| `tool_use ↔ tool_result` pairing | Encoded in the type system. Every `ToolCall` and `ToolResult` carries a `provider_id: String` (not `Option`). | + +### Bounded Everything + +| Limit | Default | Where | +|---|---|---| +| Inbound JSON-RPC frame | 4 MiB | `SPROUT_AGENT_MAX_LINE_BYTES` | +| Single prompt | 1 MiB | `MAX_PROMPT_BYTES` | +| History window | 1 MiB | `SPROUT_AGENT_MAX_HISTORY_BYTES` | +| LLM response body | 16 MiB | `MAX_LLM_RESPONSE_BYTES` | +| LLM error body | 4 KiB | `MAX_LLM_ERROR_BODY_BYTES` | +| Tool result body | 256 KiB | `MAX_TOOL_RESULT_BYTES` | +| MCP servers / session | 16 | `MAX_MCP_SERVERS` | +| Tools / session | 128 | `MAX_TOOLS_PER_SESSION` | +| Tool description bytes | 1 KiB | `MAX_DESCRIPTION_BYTES` | +| Tool schema bytes | 4 KiB | `MAX_SCHEMA_BYTES` (oversize → replaced with `{}`) | +| Tool calls per turn | 64 | `MAX_TOOL_CALLS_PER_TURN` | +| Loop rounds | 16 | `SPROUT_AGENT_MAX_ROUNDS` | +| LLM call timeout | 120 s | `SPROUT_AGENT_LLM_TIMEOUT_SECS` | +| Tool call timeout | 660 s | `SPROUT_AGENT_TOOL_TIMEOUT_SECS` | + +## What This Is NOT + +A short list, because the answer is mostly "no": + +- **Not a framework.** No plugins, no recipes, no slash commands, no modes. MCP servers can participate in agent lifecycle via [hook tools](../../docs/MCP_DRIVEN_HOOKS.md) (`_Stop`, `_PostCompact`), but these are advisory, fail-open, and budget-bounded — not a plugin system. +- **Not streaming.** One non-streaming HTTP POST per round. The LLM's generated text is forwarded to the client as `agent_message_chunk`, but there is no token-level streaming. +- **Not persistent.** Everything is in-memory, per-process. No SQLite. When context fills, the agent summarizes its own history and continues (context handoff). No external persistence. +- **Not an SDK.** This is a binary. The protocol seam is stdin/stdout. Use it from any language. +- **Not a UI.** No TUI, no web, no notifications. The client renders. +- **Not authenticated.** API keys come from env. Use systemd, Docker secrets, or a wrapper. +- **Not networked MCP.** Stdio transport only. No HTTP/SSE MCP transport. +- **Not load-able.** No `session/load`. We advertise `loadSession: false`. +- **Not a router.** No agent-to-agent, no fan-out, no orchestration. One model. One loop. + +**Concurrency model:** + +``` + ┌──── reader task ──────────┐ + │ (stdin → JSON-RPC → ...) │ + │ │ + stdin ─────────┤ dispatch │ + │ │ │ + │ ├── initialize │ (sync reply) + │ ├── session/new │ (sync reply) + │ ├── session/prompt ───┼─── spawn ──> prompt task + │ │ │ │ + │ ├── session/cancel ───┼─> watch::send│ (biased select wins) + │ │ │ │ + └───────────────────────────┘ │ + │ + ┌── writer task ────────────────┐ │ + stdout ────────┤ mpsc consumer │<─────────┘ + │ (the only stdout writer) │ + └───────────────────────────────┘ +``` + +One reader, one writer, up to 8 concurrent prompt tasks (one per session). + +## Building + +```bash +cargo build --release -p sprout-agent +``` + +## Testing + +```bash +cargo test -p sprout-agent +``` + +Test strategy is **real subprocess, no mocks**: + +- **Fake LLM** — `tests/fake_llm.rs` and the helpers in `tests/regressions.rs` spin up a real `tokio::net::TcpListener` on port 0, parse `Content-Length`, and return scripted JSON. No HTTP mocking library. +- **Fake MCP server** — `tests/bin/fake_mcp.rs` is a separate binary controlled by env vars: `FAKE_MCP_HANG_INIT`, `FAKE_MCP_TOOL_DELAY`, `FAKE_MCP_SPAWN_GRANDCHILD`, etc. Each fault path is a real process being abused. +- **Regression tests are the changelog.** Each `#[test]` in `regressions.rs` is named for the bug it locks down: `assistant_text_preserved_across_prompts`, `cancel_leaves_history_valid_for_next_prompt`, `mcp_init_timeout_kills_child`, `oversize_line_kills_connection`. Read them in order to learn the protocol's failure modes. diff --git a/crates/sprout-agent/src/agent.rs b/crates/sprout-agent/src/agent.rs new file mode 100644 index 0000000000..46397431a7 --- /dev/null +++ b/crates/sprout-agent/src/agent.rs @@ -0,0 +1,598 @@ +use std::sync::Arc; + +use serde_json::json; +use tokio::sync::{watch, Semaphore}; +use tokio::task::JoinSet; + +use crate::config::{Config, MAX_PROMPT_BYTES, MAX_TOOL_CALLS_PER_TURN, MAX_TOOL_RESULT_BYTES}; +use crate::handoff::HandoffOutcome; +use crate::llm::Llm; +use crate::mcp::McpRegistry; + +use crate::types::{ + AgentError, ContentBlock, HistoryItem, ProviderStop, StopReason, ToolCall, ToolResult, +}; +use crate::wire::{self, WireSender}; + +const ERROR_REFLECTION_SUFFIX: &str = + "\n\n[Reflect] Before retrying, identify the cause and change your approach."; + +pub struct RunCtx<'a> { + pub cfg: &'a Config, + pub session_id: &'a str, + pub llm: &'a Llm, + pub mcp: &'a Arc, + pub wire: &'a WireSender, + pub cancel: &'a mut watch::Receiver, + pub history: &'a mut Vec, + pub original_task: &'a mut Option, + pub handoff_count: &'a mut usize, + /// Cumulative `_Stop` objection count for this session (persists + /// across `session/prompt` calls). Once it hits + /// `cfg.stop_max_rejections` we stop calling `_Stop` for that + /// session — a runaway hook can't burn rejections on every prompt. + pub stop_rejections: &'a mut u32, +} + +impl RunCtx<'_> { + pub async fn run(&mut self, prompt: Vec) -> Result { + let user_text = prompt_to_text(prompt)?; + if user_text.len() > MAX_PROMPT_BYTES { + return Err(AgentError::InvalidParams(format!( + "prompt: exceeds {MAX_PROMPT_BYTES} bytes" + ))); + } + if self.original_task.is_none() { + *self.original_task = Some(user_text.clone()); + } + self.history.push(HistoryItem::User(user_text)); + + let mut round = 0u32; + // Per-prompt latch: only used to detect "LLM said end_turn twice + // in a row with no tool calls between" within this single prompt. + // The cumulative rejection budget lives on the session. + let mut last_was_end_turn = false; + loop { + if self.cfg.max_rounds > 0 && round >= self.cfg.max_rounds { + return Ok(StopReason::MaxTurnRequests); + } + if *self.cancel.borrow() { + return Ok(StopReason::Cancelled); + } + match self.maybe_handoff().await { + HandoffOutcome::Cancelled => return Ok(StopReason::Cancelled), + HandoffOutcome::Performed => {} + HandoffOutcome::Skipped => { + truncate_history(self.history, self.cfg.max_history_bytes) + } + } + + let tools = self.mcp.tools(); + round = round.saturating_add(1); + let response = tokio::select! { + biased; + _ = self.cancel.changed() => return Ok(StopReason::Cancelled), + r = self.llm.complete(self.cfg, self.history, &tools) => r?, + }; + + if !response.text.is_empty() { + wire::send( + self.wire, + wire::session_update( + self.session_id, + json!({ + "sessionUpdate": "agent_message_chunk", + "content": { "type": "text", "text": &response.text } + }), + ), + ) + .await; + } + + if response.tool_calls.is_empty() { + if response.stop == ProviderStop::ToolUse { + return Err(AgentError::Llm( + "provider: stop=tool_use but zero tool_calls".into(), + )); + } + self.history.push(HistoryItem::Assistant { + text: response.text, + tool_calls: Vec::new(), + }); + let stop = map_stop(response.stop); + // Only gate genuine end_turn — don't override max_tokens/refusal. + if stop == StopReason::EndTurn { + // Consecutive-rejection rule: LLM responded to our last + // objection with no tool calls — accept the end and + // move on rather than loop forever. + if last_was_end_turn { + return Ok(stop); + } + if *self.stop_rejections >= self.cfg.stop_max_rejections { + return Ok(stop); + } + let objections = self + .mcp + .call_hooks( + "_Stop", + &json!({}), + self.cfg.hook_timeout, + &self.cfg.hook_servers, + ) + .await; + if !objections.is_empty() { + *self.stop_rejections = self.stop_rejections.saturating_add(1); + last_was_end_turn = true; + push_hook_outputs_as_tool_results(self.history, "_Stop", &objections); + continue; + } + } + return Ok(stop); + } + + let mut calls = response.tool_calls; + if calls.len() > MAX_TOOL_CALLS_PER_TURN { + tracing::warn!( + "capping tool_calls {} -> {MAX_TOOL_CALLS_PER_TURN}", + calls.len() + ); + calls.truncate(MAX_TOOL_CALLS_PER_TURN); + } + self.history.push(HistoryItem::Assistant { + text: response.text, + tool_calls: calls.clone(), + }); + + // Tool calls executed → reset the consecutive-rejection latch. + last_was_end_turn = false; + + if let Some(stop) = self.execute_calls(&calls).await { + return Ok(stop); + } + } + } + + /// Unified tool-call execution. Three phases: + /// 1. Preflight (sequential): emit `pending`; unknown tools fail fast + /// with a synthetic result. Cancel here fills every still-empty + /// slot as cancelled. + /// 2. Execute: spawn runnable calls into a `JoinSet` bounded by a + /// `Semaphore(max_parallel_tools)`. `select!` between cancel and + /// `join_next`. On cancel: close semaphore, drain in-flight tasks + /// (each sends `notifications/cancelled` internally), synthesize + /// cancelled for unfilled slots and emit `failed`. + /// 3. Append: push results into history in original call order. + /// + /// `max_parallel_tools = 1` makes phase 2 effectively sequential + /// (one in-flight call at a time via the semaphore). Larger values + /// run that many calls concurrently. + async fn execute_calls(&mut self, calls: &[ToolCall]) -> Option { + let mut results: Vec> = vec![None; calls.len()]; + let mut runnable: Vec = Vec::with_capacity(calls.len()); + + for (idx, call) in calls.iter().enumerate() { + if *self.cancel.borrow() { + for (j, c) in calls.iter().enumerate() { + if results[j].is_none() { + // Calls 0..idx already had `pending` emitted; emit + // a terminal `failed` so the client doesn't see + // them stuck. + if j < idx { + emit_failed(self.wire, self.session_id, c, "cancelled").await; + } + results[j] = Some(synthetic_tool_result(c, "cancelled".into())); + } + } + self.append_results(calls, &mut results); + return Some(StopReason::Cancelled); + } + emit_pending(self.wire, self.session_id, call).await; + // Hook tools (bare name starts with `_`) are invisible to the + // LLM and only callable via `call_hooks`. Treat any direct + // invocation as if the tool didn't exist. + if !self.mcp.has(&call.name) || self.mcp.is_hook(&call.name) { + let err = format!("unknown tool: {}", call.name); + emit_failed(self.wire, self.session_id, call, &err).await; + results[idx] = Some(synthetic_tool_result(call, err)); + continue; + } + runnable.push(idx); + } + + self.execute_parallel(calls, &runnable, &mut results).await; + + self.append_results(calls, &mut results); + + if *self.cancel.borrow() { + Some(StopReason::Cancelled) + } else { + None + } + } + + fn append_results(&mut self, calls: &[ToolCall], results: &mut [Option]) { + for (i, call) in calls.iter().enumerate() { + let mut result = results[i].take().unwrap_or_else(|| ToolResult { + provider_id: call.provider_id.clone(), + text: "internal error: missing result".into(), + is_error: true, + }); + // On tool error: append a reflection prompt so the LLM + // diagnoses the failure before blindly retrying. + if result.is_error { + result.text.push_str(ERROR_REFLECTION_SUFFIX); + } + self.history.push(HistoryItem::ToolResult(result)); + } + } + + async fn execute_parallel( + &mut self, + calls: &[ToolCall], + runnable: &[usize], + results: &mut [Option], + ) { + let limit = self.cfg.max_parallel_tools.max(1); + let sem = Arc::new(Semaphore::new(limit)); + let mut set: JoinSet<(usize, InvokeOutcome)> = JoinSet::new(); + + for &i in runnable { + let call = calls[i].clone(); + let mcp = Arc::clone(self.mcp); + let wire = self.wire.clone(); + let session_id = self.session_id.to_owned(); + let timeout = self.cfg.tool_timeout; + let cancel = self.cancel.clone(); + let sem = Arc::clone(&sem); + set.spawn(async move { + // Acquire a permit; if the semaphore is closed (cancel), + // emit a terminal wire update and skip the call. + let _permit = match sem.acquire_owned().await { + Ok(p) => p, + Err(_) => { + emit_failed(&wire, &session_id, &call, "cancelled").await; + return (i, InvokeOutcome::Failed("cancelled".into())); + } + }; + emit_in_progress(&wire, &session_id, &call).await; + let outcome = invoke_tool_inner(&mcp, &call, timeout, cancel).await; + match &outcome { + InvokeOutcome::Done(result) => { + emit_completed(&wire, &session_id, &call, result).await; + } + InvokeOutcome::Failed(msg) => { + emit_failed(&wire, &session_id, &call, msg).await; + } + } + (i, outcome) + }); + } + + let mut cancel_rx = self.cancel.clone(); + let mut cancelled = if *cancel_rx.borrow() { + sem.close(); + true + } else { + false + }; + while !cancelled { + tokio::select! { + biased; + _ = cancel_rx.changed() => { + // Cancel: stop accepting new permits. Do NOT abort + // tasks — each in-flight `mcp.call` observes the same + // cancel receiver via its internal `select!` and + // returns promptly with an "cancelled" error after + // sending `notifications/cancelled` to the server. + sem.close(); + cancelled = true; + break; + } + next = set.join_next() => { + match next { + Some(Ok((i, outcome))) => { + results[i] = Some(outcome_to_result(&calls[i], outcome)); + } + Some(Err(e)) => { + tracing::warn!("tool task join error: {e}"); + } + None => break, + } + } + } + } + + // After cancel, drain in-flight tasks. Each task's internal + // `do_call` observes the cancel receiver and returns promptly + // after sending `notifications/cancelled`. We bound the drain + // to avoid hanging if a task is stuck in restart/reconnect. + if cancelled { + let drain_deadline = tokio::time::Instant::now() + std::time::Duration::from_secs(5); + loop { + match tokio::time::timeout_at(drain_deadline, set.join_next()).await { + Ok(Some(Ok((i, outcome)))) => { + if results[i].is_none() { + results[i] = Some(outcome_to_result(&calls[i], outcome)); + } + } + Ok(Some(Err(e))) => { + tracing::warn!("tool task join error (drain): {e}"); + } + Ok(None) => break, // all tasks drained + Err(_) => { + // Drain timed out — abort remaining tasks. + set.abort_all(); + tracing::warn!("cancel drain timed out; aborting remaining tasks"); + break; + } + } + } + } + + // Fill any remaining unfilled runnable slots as cancelled. Tasks + // that didn't complete (timed out in drain or never started) need + // a terminal wire update so the client doesn't see "pending" forever. + for &i in runnable { + if results[i].is_none() { + results[i] = Some(synthetic_tool_result(&calls[i], "cancelled".into())); + emit_failed(self.wire, self.session_id, &calls[i], "cancelled").await; + } + } + } +} + +/// Outcome of invoking a single tool. The wire notification is emitted by +/// the caller so the spawn loop and the (degenerate, max_parallel=1) path +/// share the same logic. +enum InvokeOutcome { + Done(ToolResult), + Failed(String), +} + +/// Standalone tool invocation. Takes only owned/cloned handles so it can +/// run inside a spawned task. On timeout, kills the offending MCP server's +/// process group and marks it dead; the registry's lazy restart handles it +/// on the next call. +async fn invoke_tool_inner( + mcp: &Arc, + call: &ToolCall, + tool_timeout: std::time::Duration, + mut cancel: watch::Receiver, +) -> InvokeOutcome { + if *cancel.borrow() { + return InvokeOutcome::Failed("cancelled".into()); + } + match tokio::time::timeout( + tool_timeout, + mcp.call( + &call.name, + &call.provider_id, + &call.arguments, + MAX_TOOL_RESULT_BYTES, + &mut cancel, + ), + ) + .await + { + Ok(Ok(result)) => InvokeOutcome::Done(result), + Ok(Err(AgentError::Cancelled)) => InvokeOutcome::Failed("cancelled".into()), + Ok(Err(e)) => InvokeOutcome::Failed(e.to_string()), + Err(_) => { + // If the session was cancelled, the timeout fired because + // do_call returned quickly with "cancelled" and the outer + // timeout raced. Don't kill a healthy server for that. + if *cancel.borrow() { + return InvokeOutcome::Failed("cancelled".into()); + } + if let Some(server) = mcp.server_of(&call.name) { + mcp.kill_server(server, "tool timeout"); + } + let msg = format!( + "tool: timeout after {}s. The command took too long. Try a faster approach.", + tool_timeout.as_secs() + ); + InvokeOutcome::Failed(msg) + } + } +} + +fn outcome_to_result(call: &ToolCall, outcome: InvokeOutcome) -> ToolResult { + match outcome { + InvokeOutcome::Done(r) => r, + InvokeOutcome::Failed(m) => synthetic_tool_result(call, m), + } +} + +async fn emit_pending(wire: &WireSender, sid: &str, call: &ToolCall) { + wire::send( + wire, + wire::session_update( + sid, + json!({ + "sessionUpdate": "tool_call", + "toolCallId": call.provider_id, + "title": call.name, + "kind": "other", + "status": "pending", + "rawInput": call.arguments, + }), + ), + ) + .await; +} + +async fn emit_in_progress(wire: &WireSender, sid: &str, call: &ToolCall) { + wire::send( + wire, + wire::session_update( + sid, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": call.provider_id, + "status": "in_progress", + }), + ), + ) + .await; +} + +async fn emit_completed(wire: &WireSender, sid: &str, call: &ToolCall, result: &ToolResult) { + wire::send( + wire, + wire::session_update( + sid, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": call.provider_id, + "status": "completed", + "content": [{ "type": "content", "content": { "type": "text", "text": result.text } }], + "rawOutput": { "isError": result.is_error }, + }), + ), + ) + .await; +} + +async fn emit_failed(wire: &WireSender, sid: &str, call: &ToolCall, err: &str) { + wire::send( + wire, + wire::session_update( + sid, + json!({ + "sessionUpdate": "tool_call_update", + "toolCallId": call.provider_id, + "status": "failed", + "rawOutput": { "error": err }, + }), + ), + ) + .await; +} + +fn prompt_to_text(prompt: Vec) -> Result { + let mut parts = Vec::with_capacity(prompt.len()); + for block in prompt { + match block { + ContentBlock::Text { text } => parts.push(text), + ContentBlock::ResourceLink { uri } => parts.push(format!("[resource: {uri}]")), + ContentBlock::Unsupported => { + return Err(AgentError::InvalidParams( + "prompt: unsupported content block (only text and resource_link are advertised)".into(), + )); + } + } + } + Ok(parts.join("\n")) +} + +/// Format a single hook output as a structured tool-result body. +/// +/// We emit a JSON object rather than XML-style tags. JSON is unambiguous: +/// the inner `text` field is escaped, so a malicious hook cannot break +/// out by including a literal `` (or any other delimiter) +/// in its output. The LLM still sees the source attribution via the +/// `hook` and `server` fields. +fn format_hook_output_body(hook: &str, server: &str, text: &str) -> String { + // serde_json::to_string never fails on owned strings. + serde_json::to_string(&json!({ + "hook": hook, + "server": server, + "text": text, + })) + .unwrap_or_else(|_| String::from("{\"hook\":\"\",\"server\":\"\",\"text\":\"\"}")) +} + +/// Synthetic provider id for an injected hook tool-call/result pair. Must +/// be unique per pair so the LLM wire format (which keys tool results by +/// id) stays valid across multiple objections in one session. +fn synthetic_hook_id(hook: &str, server: &str, ordinal: u64) -> String { + format!("sprout_hook_{hook}_{server}_{ordinal}") +} + +/// Append a synthetic Assistant tool-call + ToolResult pair for each hook +/// output. Modeling hook output as a tool result (rather than as a User +/// message) means a malicious hook can't impersonate the user or system +/// — the LLM treats tool results as lower-trust, structured data. +/// +/// Each pair uses the hook's qualified tool name (e.g. `fake___Stop`) so +/// attribution is preserved in the wire format. Empty arguments are sent +/// as `{}`. The `Assistant` turn carries no text (tool_calls only). +pub(crate) fn push_hook_outputs_as_tool_results( + history: &mut Vec, + hook: &str, + outputs: &[(String, String)], +) { + for (server, text) in outputs.iter() { + let provider_id = synthetic_hook_id(hook, server, unique_nonce()); + // Tool name is `__` — same shape as a real qname + // for that hook, so the LLM never sees an unknown synthetic name. + let tool_name = format!("{server}__{hook}"); + history.push(HistoryItem::Assistant { + text: String::new(), + tool_calls: vec![ToolCall { + provider_id: provider_id.clone(), + name: tool_name, + arguments: serde_json::json!({}), + }], + }); + history.push(HistoryItem::ToolResult(ToolResult { + provider_id, + text: format_hook_output_body(hook, server, text), + is_error: false, + })); + } +} + +/// Monotonic counter for synthetic hook ids within a single process. The +/// uniqueness target is "no collision within the lifetime of one history +/// vec", which a process-wide counter satisfies trivially. +fn unique_nonce() -> u64 { + use std::sync::atomic::{AtomicU64, Ordering}; + static COUNTER: AtomicU64 = AtomicU64::new(0); + COUNTER.fetch_add(1, Ordering::Relaxed) +} + +fn synthetic_tool_result(call: &ToolCall, msg: String) -> ToolResult { + ToolResult { + provider_id: call.provider_id.clone(), + text: msg, + is_error: true, + } +} + +pub(crate) fn truncate_history(history: &mut Vec, max_bytes: usize) { + let mut total: usize = history.iter().map(HistoryItem::estimated_bytes).sum(); + if total <= max_bytes { + return; + } + let original_len = history.len(); + while total > max_bytes && !history.is_empty() { + let mut end = 1usize; + while end < history.len() && !matches!(history[end], HistoryItem::User(_)) { + end += 1; + } + if end >= history.len() { + break; + } + let dropped: usize = history[..end] + .iter() + .map(HistoryItem::estimated_bytes) + .sum(); + history.drain(..end); + total = total.saturating_sub(dropped); + } + if history.len() < original_len { + tracing::info!( + "history truncated {original_len} -> {} items ({total} bytes)", + history.len() + ); + } +} + +fn map_stop(p: ProviderStop) -> StopReason { + match p { + ProviderStop::EndTurn | ProviderStop::ToolUse | ProviderStop::Other => StopReason::EndTurn, + ProviderStop::MaxTokens => StopReason::MaxTokens, + ProviderStop::Refusal => StopReason::Refusal, + } +} diff --git a/crates/sprout-agent/src/config.rs b/crates/sprout-agent/src/config.rs new file mode 100644 index 0000000000..91bbf169c8 --- /dev/null +++ b/crates/sprout-agent/src/config.rs @@ -0,0 +1,340 @@ +use std::time::Duration; + +pub const PROTOCOL_VERSION: u32 = 1; + +pub const MAX_PROMPT_BYTES: usize = 1024 * 1024; +pub const MAX_TOOL_RESULT_BYTES: usize = 256 * 1024; +pub const MAX_TOOL_CALLS_PER_TURN: usize = 64; + +/// Leaves headroom for the summary call. +pub const HANDOFF_THRESHOLD: f64 = 0.75; + +pub const HANDOFF_MAX_OUTPUT_TOKENS: u32 = 8192; + +pub const HANDOFF_TAIL_ITEMS: usize = 5; + +pub const HANDOFF_ORIGINAL_TASK_MAX_BYTES: usize = 16 * 1024; + +pub const HANDOFF_PROMPT_MAX_BYTES: usize = 32 * 1024; + +pub const HANDOFF_MAX_TOOL_NAMES: usize = 20; + +const DEFAULT_SYSTEM_PROMPT: &str = + "You are sprout-agent. Use the provided tools to act. Tool calls are your only output."; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum Provider { + Anthropic, + OpenAi, +} + +#[derive(Debug, Clone)] +pub struct Config { + pub provider: Provider, + pub system_prompt: String, + pub max_rounds: u32, + pub max_output_tokens: u32, + pub llm_timeout: Duration, + pub tool_timeout: Duration, + pub mcp_init_timeout: Duration, + pub mcp_max_restart_attempts: u32, + pub mcp_restart_base_ms: u64, + pub mcp_restart_max_ms: u64, + pub max_sessions: usize, + pub max_line_bytes: usize, + pub max_history_bytes: usize, + pub max_handoffs: usize, + pub max_parallel_tools: usize, + pub hook_timeout: Duration, + /// Maximum `_Stop` rejections per session. Default 3. Set to 0 to + /// disable `_Stop` hooks entirely (agent always honors end_turn). + pub stop_max_rejections: u32, + /// Hook server allowlist. See [`HookServers`] for variant semantics. + /// Default (env unset/empty) is `None` — hooks are off unless the + /// operator explicitly opts in. + pub hook_servers: HookServers, + pub api_key: String, + pub model: String, + pub base_url: String, + pub anthropic_api_version: String, +} + +impl Config { + pub fn from_env() -> Result { + let provider = match req("SPROUT_AGENT_PROVIDER")?.to_ascii_lowercase().as_str() { + "anthropic" => Provider::Anthropic, + "openai" | "openai-compat" => Provider::OpenAi, + o => return Err(format!("config: SPROUT_AGENT_PROVIDER={o} not supported")), + }; + let (api_key, model, base_url) = match provider { + Provider::Anthropic => ( + req("ANTHROPIC_API_KEY")?, + req("ANTHROPIC_MODEL")?, + env_or("ANTHROPIC_BASE_URL", "https://api.anthropic.com"), + ), + Provider::OpenAi => ( + req("OPENAI_COMPAT_API_KEY")?, + req("OPENAI_COMPAT_MODEL")?, + env_or("OPENAI_COMPAT_BASE_URL", "https://api.openai.com/v1"), + ), + }; + let system_prompt = match (env("SPROUT_AGENT_SYSTEM_PROMPT"), env("SPROUT_AGENT_SYSTEM_PROMPT_FILE")) { + (Some(_), Some(_)) => return Err( + "config: SPROUT_AGENT_SYSTEM_PROMPT and SPROUT_AGENT_SYSTEM_PROMPT_FILE are mutually exclusive".into()), + (Some(s), _) => s, + (_, Some(p)) => std::fs::read_to_string(&p).map_err(|e| format!("config: read {p}: {e}"))?, + _ => DEFAULT_SYSTEM_PROMPT.to_owned(), + }; + let cfg = Config { + provider, + system_prompt, + api_key, + model, + base_url, + anthropic_api_version: env_or("ANTHROPIC_API_VERSION", "2023-06-01"), + max_rounds: parse_env("SPROUT_AGENT_MAX_ROUNDS", 16)?, + max_output_tokens: parse_env("SPROUT_AGENT_MAX_OUTPUT_TOKENS", 4096)?, + llm_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_LLM_TIMEOUT_SECS", 120)?), + tool_timeout: Duration::from_secs(parse_env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", 660)?), + mcp_init_timeout: Duration::from_secs(parse_env( + "SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS", + 30, + )?), + mcp_max_restart_attempts: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS", 3u32)?, + mcp_restart_base_ms: parse_env("SPROUT_AGENT_MCP_RESTART_BASE_MS", 500u64)?, + mcp_restart_max_ms: parse_env("SPROUT_AGENT_MCP_RESTART_MAX_MS", 30_000u64)?, + max_sessions: parse_env("SPROUT_AGENT_MAX_SESSIONS", 8)?, + max_line_bytes: parse_env("SPROUT_AGENT_MAX_LINE_BYTES", 4 * 1024 * 1024)?, + max_history_bytes: parse_env("SPROUT_AGENT_MAX_HISTORY_BYTES", 1024 * 1024)?, + max_handoffs: parse_env("SPROUT_AGENT_MAX_HANDOFFS", 5)?, + max_parallel_tools: parse_env("SPROUT_AGENT_MAX_PARALLEL_TOOLS", 8usize)?, + hook_timeout: Duration::from_millis(parse_env( + "SPROUT_AGENT_HOOK_TIMEOUT_MS", + 2500u64, + )?), + stop_max_rejections: parse_env("SPROUT_AGENT_STOP_MAX_REJECTIONS", 3u32)?, + hook_servers: parse_hook_servers_env("MCP_HOOK_SERVERS"), + }; + cfg.validate()?; + Ok(cfg) + } + + fn validate(&self) -> Result<(), String> { + const MIN_HISTORY_BYTES: usize = 4096; + const MIN_LINE_BYTES: usize = 1024; + const MIN_TIMEOUT: Duration = Duration::from_secs(1); + + if self.max_output_tokens < 1 { + return Err("config: SPROUT_AGENT_MAX_OUTPUT_TOKENS must be >= 1".into()); + } + if self.max_history_bytes < MIN_HISTORY_BYTES { + return Err(format!( + "config: SPROUT_AGENT_MAX_HISTORY_BYTES must be >= {MIN_HISTORY_BYTES}" + )); + } + if self.max_history_bytes < MAX_PROMPT_BYTES { + return Err(format!( + "config: SPROUT_AGENT_MAX_HISTORY_BYTES ({}) must be >= MAX_PROMPT_BYTES ({MAX_PROMPT_BYTES})", + self.max_history_bytes + )); + } + if self.max_line_bytes < MIN_LINE_BYTES { + return Err(format!( + "config: SPROUT_AGENT_MAX_LINE_BYTES must be >= {MIN_LINE_BYTES}" + )); + } + if self.llm_timeout < MIN_TIMEOUT { + return Err("config: SPROUT_AGENT_LLM_TIMEOUT_SECS must be >= 1".into()); + } + if self.tool_timeout < MIN_TIMEOUT { + return Err("config: SPROUT_AGENT_TOOL_TIMEOUT_SECS must be >= 1".into()); + } + if self.mcp_init_timeout < MIN_TIMEOUT { + return Err("config: SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS must be >= 1".into()); + } + if self.max_parallel_tools < 1 { + return Err("config: SPROUT_AGENT_MAX_PARALLEL_TOOLS must be >= 1".into()); + } + if self.mcp_max_restart_attempts < 1 { + return Err("config: SPROUT_AGENT_MCP_RESTART_MAX_ATTEMPTS must be >= 1".into()); + } + if self.mcp_restart_base_ms < 1 { + return Err("config: SPROUT_AGENT_MCP_RESTART_BASE_MS must be >= 1".into()); + } + if self.mcp_restart_max_ms < self.mcp_restart_base_ms { + return Err( + "config: SPROUT_AGENT_MCP_RESTART_MAX_MS must be >= SPROUT_AGENT_MCP_RESTART_BASE_MS".into(), + ); + } + Ok(()) + } +} + +fn env(k: &str) -> Option { + std::env::var(k).ok() +} + +fn env_or(k: &str, d: &str) -> String { + env(k).unwrap_or_else(|| d.into()) +} + +fn req(k: &str) -> Result { + env(k).ok_or_else(|| format!("config: {k} required")) +} + +fn parse_env(key: &str, default: T) -> Result +where + T::Err: std::fmt::Display, +{ + env(key) + .map(|v| v.parse().map_err(|e| format!("config: {key}: {e}"))) + .unwrap_or(Ok(default)) +} + +/// Hook-server allowlist parsed from a comma-separated env var. +/// - unset / empty / whitespace-only → `None` (no hooks enabled) +/// - `*` → `All` (every server eligible) +/// - `a,b,c` → `Only(["a","b","c"])` +#[derive(Debug, Clone)] +pub enum HookServers { + None, + All, + Only(Vec), +} + +impl HookServers { + /// Returns true iff `name` may receive hook calls. + pub fn allows(&self, name: &str) -> bool { + match self { + HookServers::None => false, + HookServers::All => true, + HookServers::Only(v) => v.iter().any(|s| s == name), + } + } + + /// True if no hooks should ever fire — used to short-circuit dispatch. + pub fn is_disabled(&self) -> bool { + matches!(self, HookServers::None) + } +} + +fn parse_hook_servers_env(key: &str) -> HookServers { + parse_hook_servers(env(key).as_deref()) +} + +/// Pure parser exposed for unit tests. `None` (env unset) and `Some("")` +/// (env set but empty) both yield `HookServers::None`. +fn parse_hook_servers(raw: Option<&str>) -> HookServers { + let raw = match raw { + Some(v) => v, + None => return HookServers::None, + }; + let names: Vec = raw + .split(',') + .map(|s| s.trim().to_owned()) + .filter(|s| !s.is_empty()) + .collect(); + if names.is_empty() { + return HookServers::None; + } + // `*` is the wildcard — only honored when it's the sole entry. A mixed + // value like "*,foo" falls through to `Only(["*","foo"])`; "*" is not a + // legal MCP server name (it can't pass `valid_name`), so it never matches + // an actual server. This avoids silently widening scope on typos. + if names.len() == 1 && names[0] == "*" { + return HookServers::All; + } + HookServers::Only(names) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hook_servers_unset_is_none() { + assert!(matches!(parse_hook_servers(None), HookServers::None)); + } + + #[test] + fn hook_servers_empty_string_is_none() { + assert!(matches!(parse_hook_servers(Some("")), HookServers::None)); + } + + #[test] + fn hook_servers_whitespace_only_is_none() { + assert!(matches!( + parse_hook_servers(Some(" ,, ,")), + HookServers::None + )); + } + + #[test] + fn hook_servers_star_is_all() { + assert!(matches!(parse_hook_servers(Some("*")), HookServers::All)); + } + + #[test] + fn hook_servers_star_with_whitespace_is_all() { + assert!(matches!( + parse_hook_servers(Some(" * ")), + HookServers::All + )); + } + + #[test] + fn hook_servers_named_list() { + match parse_hook_servers(Some("foo,bar")) { + HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]), + other => panic!("expected Only, got {other:?}"), + } + } + + #[test] + fn hook_servers_trims_entries() { + match parse_hook_servers(Some(" foo , bar , ")) { + HookServers::Only(v) => assert_eq!(v, vec!["foo".to_owned(), "bar".to_owned()]), + other => panic!("expected Only, got {other:?}"), + } + } + + #[test] + fn hook_servers_star_mixed_is_literal() { + // `*,foo` is NOT a wildcard — it's a literal Only(["*","foo"]). + // No real server can be named `*`, so this never matches anything. + match parse_hook_servers(Some("*,foo")) { + HookServers::Only(v) => assert_eq!(v, vec!["*".to_owned(), "foo".to_owned()]), + other => panic!("expected Only, got {other:?}"), + } + } + + #[test] + fn hook_servers_allows_matches_named_only() { + let hs = parse_hook_servers(Some("foo,bar")); + assert!(hs.allows("foo")); + assert!(hs.allows("bar")); + assert!(!hs.allows("baz")); + } + + #[test] + fn hook_servers_allows_matches_all() { + assert!(parse_hook_servers(Some("*")).allows("anything")); + } + + #[test] + fn hook_servers_allows_blocks_when_none() { + assert!(!parse_hook_servers(None).allows("foo")); + } + + #[test] + fn hook_servers_star_mixed_does_not_match_real_server() { + let hs = parse_hook_servers(Some("*,foo")); + // The literal "*" entry exists in Only, but no real server can + // be named "*" (rejected by the MCP server name validator). + assert!(hs.allows("foo")); + assert!(!hs.allows("bar")); + // Allowed strictly only as a literal match — defense-in-depth + // expectation for callers. + assert!(hs.allows("*")); + } +} diff --git a/crates/sprout-agent/src/handoff.rs b/crates/sprout-agent/src/handoff.rs new file mode 100644 index 0000000000..4cb47e7013 --- /dev/null +++ b/crates/sprout-agent/src/handoff.rs @@ -0,0 +1,210 @@ +use crate::agent::{push_hook_outputs_as_tool_results, RunCtx}; +use crate::config::{ + HANDOFF_MAX_OUTPUT_TOKENS, HANDOFF_MAX_TOOL_NAMES, HANDOFF_ORIGINAL_TASK_MAX_BYTES, + HANDOFF_PROMPT_MAX_BYTES, HANDOFF_TAIL_ITEMS, HANDOFF_THRESHOLD, +}; +use crate::types::HistoryItem; + +pub(crate) enum HandoffOutcome { + Performed, + Skipped, + Cancelled, +} + +const HANDOFF_SYSTEM_PROMPT: &str = "You are generating a context handoff summary for the next \ +turn of an autonomous agent. Be concise but thorough. Cover: what the original task was, what \ +you accomplished, key decisions made, what remains, and one concrete next step. Output plain \ +text only — no tool calls, no JSON. Stay under 8192 tokens."; + +const HANDOFF_SNIPPET_BYTES: usize = 2048; + +impl RunCtx<'_> { + pub(crate) async fn maybe_handoff(&mut self) -> HandoffOutcome { + if !self.should_handoff() { + return HandoffOutcome::Skipped; + } + if *self.handoff_count >= self.cfg.max_handoffs { + tracing::info!( + "handoff cap reached ({}); using truncation", + self.cfg.max_handoffs + ); + return HandoffOutcome::Skipped; + } + let prompt = self.build_handoff_prompt(); + let summary = tokio::select! { + biased; + _ = self.cancel.changed() => return HandoffOutcome::Cancelled, + r = self.llm.summarize( + self.cfg, + HANDOFF_SYSTEM_PROMPT, + &prompt, + HANDOFF_MAX_OUTPUT_TOKENS, + ) => match r { + Ok(s) if !s.trim().is_empty() => s, + Ok(_) => { + tracing::warn!("handoff returned empty summary; truncating"); + return HandoffOutcome::Skipped; + } + Err(e) => { + tracing::warn!("handoff failed: {e}; truncating"); + return HandoffOutcome::Skipped; + } + }, + }; + let current_prompt = self.history.iter().rev().find_map(|item| match item { + HistoryItem::User(s) => Some(s.clone()), + _ => None, + }); + let prior = self.history.len(); + // Reset history first; the _PostCompact hook is meant to inject + // state into the FRESH context, not the old one we're discarding. + self.history.clear(); + let post_compact = self + .mcp + .call_hooks( + "_PostCompact", + &serde_json::json!({}), + self.cfg.hook_timeout, + &self.cfg.hook_servers, + ) + .await; + // Handoff summary is trusted (we generated it). Push as User so + // it anchors the new context. + let handoff_text = format!("[Context Handoff]\n{summary}"); + self.history.push(HistoryItem::User(handoff_text)); + // Hook output is untrusted — inject as synthetic tool results so a + // malicious _PostCompact can't impersonate the user/system. + if !post_compact.is_empty() { + push_hook_outputs_as_tool_results(self.history, "_PostCompact", &post_compact); + } + if let Some(prompt) = current_prompt { + self.history.push(HistoryItem::User(prompt)); + } + *self.handoff_count += 1; + tracing::info!( + "handoff #{} (history {prior} -> {} items)", + *self.handoff_count, + self.history.len() + ); + HandoffOutcome::Performed + } + + fn should_handoff(&self) -> bool { + let usage: usize = self.history.iter().map(HistoryItem::estimated_bytes).sum(); + let threshold = (self.cfg.max_history_bytes as f64 * HANDOFF_THRESHOLD) as usize; + usage > threshold + } + + fn build_handoff_prompt(&self) -> String { + let mut head = String::new(); + head.push_str(&format!( + "[Internal handoff #{} — context reset]\n\n", + *self.handoff_count + 1 + )); + head.push_str("# Original Task\n"); + let task = self.original_task.as_deref().unwrap_or("(unknown)"); + head.push_str(&clamp_bytes(task, HANDOFF_ORIGINAL_TASK_MAX_BYTES)); + head.push_str("\n\n# Available Tools\n"); + let all_tools = self.mcp.tools(); + let total = all_tools.len(); + if total == 0 { + head.push_str("(none)\n"); + } else { + let shown = total.min(HANDOFF_MAX_TOOL_NAMES); + let names: Vec<&str> = all_tools[..shown].iter().map(|t| t.name.as_str()).collect(); + head.push_str(&names.join(", ")); + if shown < total { + head.push_str(&format!(", … (+{} more)", total - shown)); + } + head.push('\n'); + } + let tail = "\n# Instructions\n\ + Produce a context handoff summary covering: (1) original task, \ + (2) what was accomplished, (3) key decisions, (4) what remains, \ + (5) one concrete next step. Be concise but thorough. Plain text.\n"; + let history_header = "\n# Recent History (most recent last)\n"; + + let start = self.history.len().saturating_sub(HANDOFF_TAIL_ITEMS); + let mut snippets: Vec = self.history[start..] + .iter() + .map(|item| { + let mut s = String::new(); + push_history_snippet(&mut s, item); + s + }) + .collect(); + + let fixed = head.len() + history_header.len() + tail.len(); + let mut snippets_bytes: usize = snippets.iter().map(String::len).sum(); + let mut dropped = 0usize; + while fixed + snippets_bytes > HANDOFF_PROMPT_MAX_BYTES && !snippets.is_empty() { + let removed = snippets.remove(0); + snippets_bytes -= removed.len(); + dropped += 1; + } + if dropped > 0 { + tracing::info!("handoff prompt cap, dropped {dropped} oldest snippets"); + } + + let mut out = + String::with_capacity(fixed + snippets_bytes + if dropped > 0 { 32 } else { 0 }); + out.push_str(&head); + out.push_str(history_header); + if dropped > 0 { + out.push_str(&format!("(… {dropped} older items omitted)\n")); + } + for s in &snippets { + out.push_str(s); + } + out.push_str(tail); + out + } +} + +fn push_history_snippet(out: &mut String, item: &HistoryItem) { + match item { + HistoryItem::User(s) => { + out.push_str("[user] "); + out.push_str(&clamp_for_snippet(s)); + out.push('\n'); + } + HistoryItem::Assistant { text, tool_calls } => { + out.push_str("[assistant] "); + if !text.is_empty() { + out.push_str(&clamp_for_snippet(text)); + } + for c in tool_calls { + out.push_str(&format!(" tool:{}", c.name)); + } + out.push('\n'); + } + HistoryItem::ToolResult(r) => { + out.push_str(if r.is_error { "[tool_err] " } else { "[tool] " }); + out.push_str(&clamp_for_snippet(&r.text)); + out.push('\n'); + } + } +} + +fn clamp_for_snippet(s: &str) -> String { + clamp_bytes(s, HANDOFF_SNIPPET_BYTES) +} + +pub(crate) fn clamp_bytes(s: &str, max_bytes: usize) -> String { + if s.len() <= max_bytes { + return s.to_owned(); + } + if max_bytes < 4 { + let mut cut = max_bytes.min(s.len()); + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + return s[..cut].to_owned(); + } + let target = max_bytes - "…".len(); + let mut cut = target; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + format!("{}…", &s[..cut]) +} diff --git a/crates/sprout-agent/src/llm.rs b/crates/sprout-agent/src/llm.rs new file mode 100644 index 0000000000..3fc8abae75 --- /dev/null +++ b/crates/sprout-agent/src/llm.rs @@ -0,0 +1,388 @@ +use reqwest::Client; +use serde_json::{json, Value}; + +use crate::config::{Config, Provider}; +use crate::types::{AgentError, HistoryItem, LlmResponse, ProviderStop, ToolCall, ToolDef}; + +const MAX_LLM_RESPONSE_BYTES: usize = 16 * 1024 * 1024; +const MAX_LLM_ERROR_BODY_BYTES: usize = 4 * 1024; + +pub struct Llm { + http: Client, +} + +impl Llm { + pub fn new(cfg: &Config) -> Result { + let http = Client::builder() + .connect_timeout(std::time::Duration::from_secs(10)) + .timeout(cfg.llm_timeout) + .build() + .map_err(|e| AgentError::Llm(format!("http: {e}")))?; + Ok(Self { http }) + } + + pub async fn complete( + &self, + cfg: &Config, + history: &[HistoryItem], + tools: &[ToolDef], + ) -> Result { + match cfg.provider { + Provider::Anthropic => { + let body = anthropic_body(cfg, history, tools); + let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); + let v = post(&self.http, &url, &body, |r| { + r.header("x-api-key", &cfg.api_key) + .header("anthropic-version", &cfg.anthropic_api_version) + .header("content-type", "application/json") + }) + .await?; + parse_anthropic(v) + } + Provider::OpenAi => { + let body = openai_body(cfg, history, tools); + let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); + let v = post(&self.http, &url, &body, |r| { + r.bearer_auth(&cfg.api_key) + .header("content-type", "application/json") + }) + .await?; + parse_openai(v) + } + } + } + + pub async fn summarize( + &self, + cfg: &Config, + system_prompt: &str, + user_prompt: &str, + max_output_tokens: u32, + ) -> Result { + match cfg.provider { + Provider::Anthropic => { + let body = json!({ + "model": cfg.model, + "max_tokens": max_output_tokens, + "system": system_prompt, + "messages": [{ + "role": "user", + "content": [{ "type": "text", "text": user_prompt }], + }], + }); + let url = format!("{}/v1/messages", cfg.base_url.trim_end_matches('/')); + let v = post(&self.http, &url, &body, |r| { + r.header("x-api-key", &cfg.api_key) + .header("anthropic-version", &cfg.anthropic_api_version) + .header("content-type", "application/json") + }) + .await?; + Ok(parse_anthropic(v)?.text) + } + Provider::OpenAi => { + let body = json!({ + "model": cfg.model, + "stream": false, + "max_tokens": max_output_tokens, + "messages": [ + { "role": "system", "content": system_prompt }, + { "role": "user", "content": user_prompt }, + ], + }); + let url = format!("{}/chat/completions", cfg.base_url.trim_end_matches('/')); + let v = post(&self.http, &url, &body, |r| { + r.bearer_auth(&cfg.api_key) + .header("content-type", "application/json") + }) + .await?; + Ok(parse_openai(v)?.text) + } + } + } +} + +fn anthropic_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value { + let mut messages: Vec = Vec::new(); + let mut pending: Vec = Vec::new(); + let flush = |out: &mut Vec, p: &mut Vec| { + if !p.is_empty() { + out.push(json!({ "role": "user", "content": std::mem::take(p) })); + } + }; + for item in history { + match item { + HistoryItem::User(text) => { + flush(&mut messages, &mut pending); + messages.push(json!({ "role": "user", + "content": [{ "type": "text", "text": text }] })); + } + HistoryItem::Assistant { text, tool_calls } => { + flush(&mut messages, &mut pending); + let mut content: Vec = Vec::new(); + if !text.is_empty() { + content.push(json!({ "type": "text", "text": text })); + } + for c in tool_calls { + content.push(json!({ "type": "tool_use", "id": c.provider_id, + "name": c.name, "input": c.arguments })); + } + if content.is_empty() { + content.push(json!({ "type": "text", "text": "" })); + } + messages.push(json!({ "role": "assistant", "content": content })); + } + HistoryItem::ToolResult(r) => pending.push(json!({ + "type": "tool_result", "tool_use_id": r.provider_id, + "content": [{ "type": "text", "text": r.text }], "is_error": r.is_error })), + } + } + flush(&mut messages, &mut pending); + let tools_json: Vec = tools + .iter() + .map(|t| { + json!({ + "name": t.name, "description": t.description, "input_schema": t.input_schema }) + }) + .collect(); + let mut body = json!({ "model": cfg.model, "max_tokens": cfg.max_output_tokens, + "system": cfg.system_prompt, "messages": messages }); + if !tools_json.is_empty() { + body["tools"] = Value::Array(tools_json); + } + body +} + +fn openai_body(cfg: &Config, history: &[HistoryItem], tools: &[ToolDef]) -> Value { + let mut messages: Vec = vec![json!({ "role": "system", "content": cfg.system_prompt })]; + for item in history { + match item { + HistoryItem::User(text) => messages.push(json!({ "role": "user", "content": text })), + HistoryItem::Assistant { text, tool_calls } => { + let mut msg = serde_json::Map::new(); + msg.insert("role".into(), json!("assistant")); + msg.insert("content".into(), json!(text.as_str())); + if !tool_calls.is_empty() { + let calls: Vec = tool_calls + .iter() + .map(|c| { + json!({ + "id": c.provider_id, "type": "function", + "function": { "name": c.name, + "arguments": serde_json::to_string(&c.arguments) + .unwrap_or_else(|_| "{}".into()) } }) + }) + .collect(); + msg.insert("tool_calls".into(), Value::Array(calls)); + } + messages.push(Value::Object(msg)); + } + HistoryItem::ToolResult(r) => messages.push(json!({ + "role": "tool", "tool_call_id": r.provider_id, "content": r.text })), + } + } + let tools_json: Vec = tools + .iter() + .map(|t| { + json!({ + "type": "function", + "function": { "name": t.name, "description": t.description, + "parameters": t.input_schema } }) + }) + .collect(); + let mut body = json!({ "model": cfg.model, "stream": false, "max_tokens": cfg.max_output_tokens, + "messages": messages }); + if !tools_json.is_empty() { + body["tools"] = Value::Array(tools_json); + body["tool_choice"] = json!("auto"); + } + body +} + +fn map_stop(s: Option<&str>) -> ProviderStop { + match s { + Some("end_turn" | "stop") => ProviderStop::EndTurn, + Some("tool_use" | "tool_calls") => ProviderStop::ToolUse, + Some("max_tokens" | "length") => ProviderStop::MaxTokens, + Some("refusal" | "content_filter") => ProviderStop::Refusal, + _ => ProviderStop::Other, + } +} + +fn str_field(v: &Value, key: &str) -> String { + v.get(key).and_then(Value::as_str).unwrap_or("").to_owned() +} + +fn parse_anthropic(v: Value) -> Result { + let stop = map_stop(v.get("stop_reason").and_then(Value::as_str)); + let mut tool_calls = Vec::new(); + let mut text = String::new(); + if let Some(blocks) = v.get("content").and_then(Value::as_array) { + for b in blocks { + match b.get("type").and_then(Value::as_str) { + Some("text") => { + if let Some(t) = b.get("text").and_then(Value::as_str) { + text.push_str(t); + } + } + Some("tool_use") => tool_calls.push(make_tool_call( + str_field(b, "id"), + str_field(b, "name"), + b.get("input").cloned().unwrap_or(Value::Null), + )?), + _ => {} + } + } + } + Ok(LlmResponse { + text, + tool_calls, + stop, + }) +} + +fn parse_openai(v: Value) -> Result { + let choice = v + .get("choices") + .and_then(Value::as_array) + .and_then(|a| a.first()) + .ok_or_else(|| AgentError::Llm("response missing choices".into()))?; + let stop = map_stop(choice.get("finish_reason").and_then(Value::as_str)); + let msg = choice + .get("message") + .ok_or_else(|| AgentError::Llm("missing message".into()))?; + let text = str_field(msg, "content"); + let mut tool_calls = Vec::new(); + if let Some(arr) = msg.get("tool_calls").and_then(Value::as_array) { + for tc in arr { + let f = tc + .get("function") + .ok_or_else(|| AgentError::Llm("tool_call missing function".into()))?; + let raw = f.get("arguments").and_then(Value::as_str).unwrap_or("{}"); + let args: Value = serde_json::from_str(raw) + .map_err(|e| AgentError::Llm(format!("tool_call.arguments not valid JSON: {e}")))?; + tool_calls.push(make_tool_call( + str_field(tc, "id"), + str_field(f, "name"), + args, + )?); + } + } + Ok(LlmResponse { + text, + tool_calls, + stop, + }) +} + +fn make_tool_call(id: String, name: String, args: Value) -> Result { + if id.is_empty() || name.is_empty() { + return Err(AgentError::Llm("tool_call missing id or name".into())); + } + let arguments = match args { + Value::Object(_) => args, + Value::Null => Value::Object(Default::default()), + _ => { + return Err(AgentError::Llm( + "tool_call arguments must be a JSON object".into(), + )) + } + }; + Ok(ToolCall { + provider_id: id, + name, + arguments, + }) +} + +async fn read_error_body(mut resp: reqwest::Response) -> String { + let mut buf: Vec = Vec::new(); + while buf.len() < MAX_LLM_ERROR_BODY_BYTES { + match resp.chunk().await { + Ok(Some(chunk)) => { + let take = chunk.len().min(MAX_LLM_ERROR_BODY_BYTES - buf.len()); + buf.extend_from_slice(&chunk[..take]); + if take < chunk.len() { + break; + } + } + _ => break, + } + } + String::from_utf8_lossy(&buf).into_owned() +} + +const MAX_RETRIES: u32 = 3; +const BASE_BACKOFF_MS: u64 = 500; +const MAX_BACKOFF_MS: u64 = 8_000; + +async fn backoff_with_jitter(attempt: u32) { + let base = BASE_BACKOFF_MS + .saturating_mul(1u64 << attempt) + .min(MAX_BACKOFF_MS); + let mut buf = [0u8; 8]; + let jitter_range = base / 2; + let delay = if jitter_range > 0 && getrandom::getrandom(&mut buf).is_ok() { + let r = u64::from_le_bytes(buf) % jitter_range; + base - jitter_range + r + } else { + base + }; + tokio::time::sleep(std::time::Duration::from_millis(delay)).await; +} + +async fn post(http: &Client, url: &str, body: &Value, apply: F) -> Result +where + F: Fn(reqwest::RequestBuilder) -> reqwest::RequestBuilder, +{ + for attempt in 0..MAX_RETRIES { + let resp = match apply(http.post(url).json(body)).send().await { + Ok(r) => r, + Err(e) => { + if attempt + 1 < MAX_RETRIES && (e.is_timeout() || e.is_connect()) { + backoff_with_jitter(attempt).await; + continue; + } + return Err(AgentError::Llm(format!("transport: {e}"))); + } + }; + let status = resp.status(); + if status == 401 || status == 403 { + return Err(AgentError::LlmAuth(read_error_body(resp).await)); + } + if (status.is_server_error() || status == 429) && attempt + 1 < MAX_RETRIES { + backoff_with_jitter(attempt).await; + continue; + } + if !status.is_success() { + return Err(AgentError::Llm(format!( + "{status}: {}", + read_error_body(resp).await + ))); + } + if let Some(len) = resp.content_length() { + if len as usize > MAX_LLM_RESPONSE_BYTES { + return Err(AgentError::Llm(format!( + "response too large: {len} > {MAX_LLM_RESPONSE_BYTES}" + ))); + } + } + let mut buf: Vec = Vec::new(); + let mut stream = resp; + loop { + match stream.chunk().await { + Ok(Some(chunk)) => { + if buf.len() + chunk.len() > MAX_LLM_RESPONSE_BYTES { + return Err(AgentError::Llm(format!( + "response exceeded {MAX_LLM_RESPONSE_BYTES} bytes" + ))); + } + buf.extend_from_slice(&chunk); + } + Ok(None) => break, + Err(e) => return Err(AgentError::Llm(format!("read: {e}"))), + } + } + return serde_json::from_slice(&buf).map_err(|e| AgentError::Llm(format!("json: {e}"))); + } + Err(AgentError::Llm("exhausted retries".into())) +} diff --git a/crates/sprout-agent/src/main.rs b/crates/sprout-agent/src/main.rs new file mode 100644 index 0000000000..bc56677f99 --- /dev/null +++ b/crates/sprout-agent/src/main.rs @@ -0,0 +1,361 @@ +#![forbid(unsafe_code)] +mod agent; +mod config; +mod handoff; +mod llm; +mod mcp; +mod types; +mod wire; + +use std::collections::HashMap; +use std::path::Path; +use std::sync::Arc; + +use serde_json::{json, Value}; +use tokio::io::BufReader; +use tokio::sync::{mpsc, watch, Mutex}; + +use crate::agent::RunCtx; +use crate::config::{Config, PROTOCOL_VERSION}; +use crate::llm::Llm; +use crate::mcp::McpRegistry; +use crate::types::HistoryItem; +use crate::wire::{ + classify, Inbound, InitializeParams, SessionCancelParams, SessionNewParams, + SessionPromptParams, WireMsg, WireSender, INVALID_PARAMS, METHOD_NOT_FOUND, PARSE_ERROR, +}; + +struct App { + cfg: Config, + llm: Arc, + sessions: Mutex>, +} + +struct Session { + id: String, + mcp: Arc, + history: Vec, + cancel_tx: watch::Sender, + busy: bool, + original_task: Option, + handoff_count: usize, + stop_rejections: u32, +} + +fn die(msg: String) -> ! { + tracing::error!("{msg}"); + std::process::exit(2); +} + +#[tokio::main(flavor = "multi_thread")] +async fn main() { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + let cfg = Config::from_env().unwrap_or_else(|e| die(e)); + let llm = Arc::new(Llm::new(&cfg).unwrap_or_else(|e| die(e.to_string()))); + let max_line = cfg.max_line_bytes; + let app = Arc::new(App { + cfg, + llm, + sessions: Mutex::new(HashMap::new()), + }); + let (wire_tx, wire_rx) = mpsc::channel::(64); + let writer = tokio::spawn(wire::writer_task(wire_rx)); + if let Err(e) = read_loop( + BufReader::new(tokio::io::stdin()), + app.clone(), + wire_tx, + max_line, + ) + .await + { + tracing::error!("io: reader: {e}"); + } + for session in app.sessions.lock().await.values() { + let _ = session.cancel_tx.send(true); + } + let _ = writer.await; +} + +async fn read_loop( + mut stdin: R, + app: Arc, + wire_tx: WireSender, + max_line: usize, +) -> std::io::Result<()> { + while let Some(line) = wire::read_bounded_line(&mut stdin, max_line).await? { + if line.trim().is_empty() { + continue; + } + match serde_json::from_str::(&line) { + Ok(msg) => dispatch(&app, msg, &wire_tx).await, + Err(e) => { + wire::send( + &wire_tx, + wire::err(Value::Null, PARSE_ERROR, &format!("jsonrpc: parse: {e}")), + ) + .await; + } + } + } + Ok(()) +} + +async fn dispatch(app: &Arc, msg: Value, wire_tx: &WireSender) { + match classify(&msg) { + Inbound::Request { id, method, params } => { + handle_request(app, id, method, params, wire_tx).await + } + Inbound::Notification { method, params } => handle_notification(app, &method, params).await, + Inbound::Ignored => {} + Inbound::Invalid { id, code, message } => { + wire::send(wire_tx, wire::err(id, code, &message)).await + } + } +} + +async fn handle_request( + app: &Arc, + id: Value, + method: String, + params: Value, + wire_tx: &WireSender, +) { + match method.as_str() { + "initialize" => initialize(id, params, wire_tx).await, + "session/new" => { + let app = app.clone(); + let wire_tx = wire_tx.clone(); + tokio::spawn(async move { session_new(&app, id, params, &wire_tx).await }); + } + "session/prompt" => spawn_prompt(app.clone(), id, params, wire_tx.clone()), + "session/cancel" => { + cancel_session(app, params).await; + wire::send(wire_tx, wire::ok(id, Value::Null)).await; + } + _ => { + wire::send( + wire_tx, + wire::err( + id, + METHOD_NOT_FOUND, + &format!("jsonrpc: method not found: {method}"), + ), + ) + .await + } + } +} + +async fn handle_notification(app: &Arc, method: &str, params: Value) { + if method == "session/cancel" { + cancel_session(app, params).await; + } +} + +async fn initialize(id: Value, params: Value, wire_tx: &WireSender) { + let p: InitializeParams = match decode(params, "initialize") { + Ok(p) => p, + Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, + }; + let _ = p.protocol_version; + wire::send( + wire_tx, + wire::ok( + id, + json!({ + "protocolVersion": PROTOCOL_VERSION, + "agentCapabilities": { + "loadSession": false, + "promptCapabilities": { "image": false, "audio": false, "embeddedContext": false }, + "mcpCapabilities": { "http": false, "sse": false }, + }, + "agentInfo": { "name": "sprout-agent", "version": env!("CARGO_PKG_VERSION") }, + }), + ), + ) + .await; +} + +async fn session_new(app: &Arc, id: Value, params: Value, wire_tx: &WireSender) { + let p: SessionNewParams = match decode(params, "session/new") { + Ok(p) => p, + Err(m) => return reject(wire_tx, id, INVALID_PARAMS, &m).await, + }; + if p.cwd.is_empty() || !Path::new(&p.cwd).is_absolute() { + return reject( + wire_tx, + id, + INVALID_PARAMS, + "session/new: cwd must be an absolute path", + ) + .await; + } + // Check cap without holding lock across MCP spawn (which may be slow). + { + let sessions = app.sessions.lock().await; + if sessions.len() >= app.cfg.max_sessions { + return reject( + wire_tx, + id, + INVALID_PARAMS, + "session/new: max sessions reached", + ) + .await; + } + } + let mcp = match McpRegistry::spawn_all(&app.cfg, &p.mcp_servers, &p.cwd).await { + Ok(m) => Arc::new(m), + Err(e) => return reject(wire_tx, id, e.json_rpc_code(), &e.to_string()).await, + }; + let session_id = match session_token() { + Ok(t) => format!("ses_{t}"), + Err(e) => return reject(wire_tx, id, -32000, &e).await, + }; + let (cancel_tx, _) = watch::channel(false); + let mut sessions = app.sessions.lock().await; + // Re-check cap (another session may have been created while we spawned MCP). + if sessions.len() >= app.cfg.max_sessions { + return reject( + wire_tx, + id, + INVALID_PARAMS, + "session/new: max sessions reached", + ) + .await; + } + sessions.insert( + session_id.clone(), + Session { + id: session_id.clone(), + mcp, + history: Vec::new(), + cancel_tx, + busy: false, + original_task: None, + handoff_count: 0, + stop_rejections: 0, + }, + ); + drop(sessions); + wire::send(wire_tx, wire::ok(id, json!({ "sessionId": session_id }))).await; +} + +fn decode(params: Value, stage: &str) -> Result { + serde_json::from_value(params).map_err(|e| format!("{stage}: {e}")) +} + +async fn reject(wire_tx: &WireSender, id: Value, code: i32, message: &str) { + wire::send(wire_tx, wire::err(id, code, message)).await; +} + +async fn cancel_session(app: &Arc, params: Value) { + if let Ok(p) = serde_json::from_value::(params) { + if let Some(s) = app.sessions.lock().await.get(&p.session_id) { + let _ = s.cancel_tx.send(true); + } + } +} + +fn spawn_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { + tokio::spawn(async move { run_prompt(app, id, params, wire_tx).await }); +} + +async fn run_prompt(app: Arc, id: Value, params: Value, wire_tx: WireSender) { + let p: SessionPromptParams = match decode(params, "session/prompt") { + Ok(p) => p, + Err(m) => return reject(&wire_tx, id, INVALID_PARAMS, &m).await, + }; + let ( + sid, + mcp, + mut history, + mut original_task, + mut handoff_count, + mut stop_rejections, + mut cancel_rx, + ) = match acquire_session(&app, &p.session_id).await { + Ok(v) => v, + Err(reason) => { + return reject( + &wire_tx, + id, + INVALID_PARAMS, + &format!("session/prompt: {reason}"), + ) + .await + } + }; + let mut ctx = RunCtx { + cfg: &app.cfg, + session_id: &sid, + llm: &app.llm, + mcp: &mcp, + wire: &wire_tx, + cancel: &mut cancel_rx, + history: &mut history, + original_task: &mut original_task, + handoff_count: &mut handoff_count, + stop_rejections: &mut stop_rejections, + }; + let result = ctx.run(p.prompt).await; + if let Some(s) = app.sessions.lock().await.get_mut(&sid) { + s.busy = false; + s.history = history; + s.original_task = original_task; + s.handoff_count = handoff_count; + s.stop_rejections = stop_rejections; + } + match result { + Ok(stop) => { + wire::send( + &wire_tx, + wire::ok(id, json!({ "stopReason": stop.as_wire() })), + ) + .await + } + Err(e) => wire::send(&wire_tx, wire::err(id, e.json_rpc_code(), &e.to_string())).await, + } +} + +async fn acquire_session( + app: &Arc, + session_id: &str, +) -> Result< + ( + String, + Arc, + Vec, + Option, + usize, + u32, + watch::Receiver, + ), + &'static str, +> { + let mut sessions = app.sessions.lock().await; + let s = sessions.get_mut(session_id).ok_or("unknown session")?; + if s.busy { + return Err("prompt already in flight"); + } + s.busy = true; + let (tx, rx) = watch::channel(false); + s.cancel_tx = tx; + Ok(( + s.id.clone(), + s.mcp.clone(), + std::mem::take(&mut s.history), + s.original_task.take(), + s.handoff_count, + s.stop_rejections, + rx, + )) +} + +fn session_token() -> Result { + let mut b = [0u8; 8]; + getrandom::getrandom(&mut b).map_err(|e| format!("rng: getrandom failed: {e}"))?; + Ok(b.iter().map(|x| format!("{x:02x}")).collect()) +} diff --git a/crates/sprout-agent/src/mcp.rs b/crates/sprout-agent/src/mcp.rs new file mode 100644 index 0000000000..4e6379d733 --- /dev/null +++ b/crates/sprout-agent/src/mcp.rs @@ -0,0 +1,860 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use arc_swap::ArcSwap; +use rmcp::model::CallToolRequestParams; +use rmcp::service::{RoleClient, RunningService}; +use rmcp::transport::TokioChildProcess; +use rmcp::ServiceError; +use rmcp::ServiceExt; +use serde_json::{Map, Value}; +use tokio::process::Command; +use tokio::sync::watch; +use tokio::sync::Mutex as AsyncMutex; + +use crate::config::{Config, HookServers}; +use crate::types::{clamp, AgentError, McpServerStdio, ToolDef, ToolResult}; + +const SEP: &str = "__"; +const MAX_NAME_LEN: usize = 128; +const MAX_QNAME_LEN: usize = 64; +const MAX_TOOLS_PER_SESSION: usize = 128; +const MAX_DESCRIPTION_BYTES: usize = 1024; +const MAX_SCHEMA_BYTES: usize = 4096; +const MARKER_FIELD_MAX: usize = 256; +pub const MAX_MCP_SERVERS: usize = 16; +const MAX_HOOK_RESULT_BYTES: usize = 16 * 1024; + +const PASSTHROUGH_ENV: &[&str] = &["PATH", "HOME", "TERM", "LANG", "LC_ALL", "TMPDIR"]; + +type Client = RunningService; + +#[derive(Clone)] +struct ServerSpec { + name: String, + command: String, + args: Vec, + env: Vec<(String, String)>, + cwd: String, +} + +enum ClientState { + Healthy { + client: Arc, + pgid: Option, + tools: Arc>, + }, + Dead { + attempts: u32, + next_retry: Instant, + reason: String, + // Preserved from the last Healthy state so tools() filtering stays accurate while dead. + tools: Arc>, + }, +} + +struct Server { + name: String, + spec: ServerSpec, + client: ArcSwap, + restart_lock: AsyncMutex<()>, +} + +impl Drop for Server { + fn drop(&mut self) { + if let ClientState::Healthy { pgid: Some(p), .. } = &**self.client.load() { + killpg(*p, &self.name, "drop"); + } + } +} + +enum RestartCheck { + Healthy, + Ready { + attempt_n: u32, + prev_tools: Arc>, + }, +} + +fn check_restart_state(server: &Server, max_attempts: u32) -> Result { + match &**server.client.load() { + ClientState::Healthy { .. } => Ok(RestartCheck::Healthy), + ClientState::Dead { attempts, .. } if *attempts >= max_attempts => { + Err(AgentError::Mcp(format!( + "The MCP server '{}' is unavailable (exhausted). Its tools have been removed for this session.", + server.name + ))) + } + ClientState::Dead { next_retry, reason, .. } if Instant::now() < *next_retry => { + Err(AgentError::Mcp(format!( + "server '{}' is recovering (last error: {reason}). Try again later or use a different tool.", + server.name + ))) + } + ClientState::Dead { attempts, tools, .. } => Ok(RestartCheck::Ready { + attempt_n: attempts + 1, + prev_tools: tools.clone(), + }), + } +} + +struct Entry { + server_idx: usize, + bare: String, +} + +pub struct McpRegistry { + by_qname: HashMap, + defs: Vec, + servers: Vec>, + max_attempts: u32, + backoff_base: Duration, + backoff_max: Duration, + init_timeout: Duration, + /// Consecutive hook timeout count per server. Kill on second consecutive. + hook_timeouts: std::sync::Mutex>, +} + +impl McpRegistry { + pub async fn spawn_all( + cfg: &Config, + servers: &[McpServerStdio], + cwd: &str, + ) -> Result { + if servers.len() > MAX_MCP_SERVERS { + return Err(AgentError::Mcp(format!( + "too many MCP servers: {} > {MAX_MCP_SERVERS}", + servers.len() + ))); + } + let mut reg = Self { + by_qname: HashMap::new(), + defs: Vec::new(), + servers: Vec::new(), + + max_attempts: cfg.mcp_max_restart_attempts.max(1), + backoff_base: Duration::from_millis(cfg.mcp_restart_base_ms.max(1)), + backoff_max: Duration::from_millis(cfg.mcp_restart_max_ms.max(1)), + init_timeout: cfg.mcp_init_timeout, + hook_timeouts: std::sync::Mutex::new(HashMap::new()), + }; + + let mut seen_names = HashSet::new(); + for s in servers { + if !valid_name(&s.name) || s.name.contains("__") { + return Err(AgentError::Mcp(format!("invalid server name: {}", s.name))); + } + if !seen_names.insert(s.name.clone()) { + return Err(AgentError::Mcp(format!( + "duplicate server name: {}", + s.name + ))); + } + let spec = ServerSpec { + name: s.name.clone(), + command: s.command.clone(), + args: s.args.clone(), + env: s + .env + .iter() + .map(|e| (e.name.clone(), e.value.clone())) + .collect(), + cwd: cwd.to_owned(), + }; + let (client, pgid, tool_names, raw_tools) = spawn_one(&spec, reg.init_timeout).await?; + let server_idx = reg.servers.len(); + let server = Arc::new(Server { + name: spec.name.clone(), + spec, + client: ArcSwap::from_pointee(ClientState::Healthy { + client: Arc::new(client), + pgid, + tools: Arc::new(tool_names), + }), + restart_lock: AsyncMutex::new(()), + }); + reg.servers.push(server); + + for t in raw_tools { + if reg.defs.len() >= MAX_TOOLS_PER_SESSION { + return Err(AgentError::Mcp(format!( + "too many tools (>{MAX_TOOLS_PER_SESSION})" + ))); + } + let bare = t.name.to_string(); + if !valid_name(&bare) || bare.contains("__") { + return Err(AgentError::Mcp(format!("invalid tool name: {bare}"))); + } + let qname = format!("{}{SEP}{}", s.name, bare); + if qname.len() > MAX_QNAME_LEN { + return Err(AgentError::Mcp(format!( + "qualified tool name too long: {} ({} > {MAX_QNAME_LEN})", + qname, + qname.len() + ))); + } + if reg.by_qname.contains_key(&qname) { + return Err(AgentError::Mcp(format!("duplicate tool: {qname}"))); + } + reg.defs.push(ToolDef { + name: qname.clone(), + description: clamp( + t.description.as_deref().unwrap_or("").to_owned(), + MAX_DESCRIPTION_BYTES, + ), + input_schema: cap_schema(&qname, Value::Object((*t.input_schema).clone())), + }); + reg.by_qname.insert(qname, Entry { server_idx, bare }); + } + } + Ok(reg) + } + + pub fn server_of(&self, qname: &str) -> Option<&str> { + self.by_qname + .get(qname) + .map(|e| self.servers[e.server_idx].name.as_str()) + } + + pub fn has(&self, qname: &str) -> bool { + self.by_qname.contains_key(qname) + } + + /// True if `qname` resolves to a hidden hook tool (bare name starts + /// with `_`). Used to reject hook calls coming from the LLM path — + /// hooks are only callable via `call_hooks`. + pub fn is_hook(&self, qname: &str) -> bool { + self.by_qname + .get(qname) + .map(|e| e.bare.starts_with('_')) + .unwrap_or(false) + } + + pub fn tools(&self) -> Vec { + self.defs + .iter() + .filter(|d| { + let entry = match self.by_qname.get(&d.name) { + Some(e) => e, + None => return false, + }; + // Bare names starting with `_` are hooks — invisible to the LLM. + if entry.bare.starts_with('_') { + return false; + } + let server = &self.servers[entry.server_idx]; + match &**server.client.load() { + ClientState::Healthy { tools, .. } => tools.iter().any(|t| t == &entry.bare), + ClientState::Dead { + attempts, tools, .. + } => *attempts < self.max_attempts && tools.iter().any(|t| t == &entry.bare), + } + }) + .cloned() + .collect() + } + + /// Call every tool whose bare name equals `hook_name` across all + /// allowlisted servers in parallel, bounded by `timeout`. Returns + /// `(server_name, text)` pairs in **config order** (deterministic), + /// dropping empty/whitespace-only responses, errors and timeouts. + /// Hooks are fail-open and must never block the agent. + pub async fn call_hooks( + self: &Arc, + hook_name: &str, + input: &Value, + timeout: Duration, + allowed: &HookServers, + ) -> Vec<(String, String)> { + if allowed.is_disabled() { + return Vec::new(); + } + // Walk servers in registration order so the result is deterministic + // regardless of HashMap iteration order or task completion order. + let mut targets: Vec<(usize, String, String)> = Vec::new(); + for (idx, server) in self.servers.iter().enumerate() { + if !allowed.allows(&server.name) { + continue; + } + let qname = format!("{}{SEP}{}", server.name, hook_name); + if self.by_qname.contains_key(&qname) { + targets.push((idx, server.name.clone(), qname)); + } + } + if targets.is_empty() { + return Vec::new(); + } + let mut set = tokio::task::JoinSet::new(); + for (idx, server_name, qname) in targets { + let reg = Arc::clone(self); + let args = input.clone(); + set.spawn(async move { + // Hooks are intentionally non-cancellable: they are + // already bounded by their own timeout and are fail-open. + // Session cancel should not interrupt hook evaluation. + let (_dummy_tx, mut dummy_cancel) = watch::channel(false); + let res = tokio::time::timeout( + timeout, + reg.call( + &qname, + "hook", + &args, + MAX_HOOK_RESULT_BYTES, + &mut dummy_cancel, + ), + ) + .await; + drop(_dummy_tx); + (idx, server_name, res) + }); + } + let mut indexed: Vec<(usize, String, String)> = Vec::new(); + while let Some(joined) = set.join_next().await { + // fail-open: drop join errors, timeouts, call errors, + // empty/whitespace-only text. On timeout, also kill the server + // process group so a wedged hook can't poison the next regular + // tool call. The registry's lazy restart handles the rest. + match joined { + Ok((idx, server_name, Ok(Ok(r)))) => { + // Success — reset consecutive timeout counter. + if let Ok(mut counts) = self.hook_timeouts.lock() { + counts.remove(&server_name); + } + if !r.is_error && !r.text.trim().is_empty() { + indexed.push((idx, server_name, r.text)); + } + } + Ok((_idx, server_name, Err(_elapsed))) => { + // Kill only on second consecutive timeout. + let count = { + let mut counts = + self.hook_timeouts.lock().unwrap_or_else(|e| e.into_inner()); + let c = counts.entry(server_name.clone()).or_insert(0); + *c += 1; + *c + }; + if count >= 2 { + tracing::warn!( + "hook: killing server '{}' after {} consecutive timeouts", + server_name, + count + ); + self.kill_server(&server_name, "hook timeout (consecutive)"); + if let Ok(mut counts) = self.hook_timeouts.lock() { + counts.remove(&server_name); + } + } else { + tracing::warn!("hook: server '{}' timed out ({}/2)", server_name, count); + } + } + _ => {} + } + } + indexed.sort_by_key(|(idx, _, _)| *idx); + indexed + .into_iter() + .map(|(_, name, text)| (name, text)) + .collect() + } + + /// Kill the server's process group and mark it dead. Idempotent: + /// if the server is already Dead (or unknown), this is a no-op. + /// Counts as one attempt toward the restart budget so that a + /// pathological server (starts fine, deadlocks on every call) + /// eventually exhausts. + pub fn kill_server(&self, name: &str, reason: &str) { + let server = match self.servers.iter().find(|s| s.name == name) { + Some(s) => s, + None => return, + }; + let current = server.client.load_full(); + let (pgid, tools) = match &*current { + ClientState::Dead { .. } => return, + ClientState::Healthy { pgid, tools, .. } => (*pgid, tools.clone()), + }; + let dead = Arc::new(ClientState::Dead { + attempts: 1, + next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max), + reason: reason.to_owned(), + tools, + }); + // CAS so we don't clobber a concurrent restart that already + // transitioned the state. If the swap fails, the kill below is + // still safe — the pgid we read belonged to a process we observed + // as Healthy, and killpg on an already-reaped pgid is a no-op. + let prev = server.client.compare_and_swap(¤t, dead); + if Arc::ptr_eq(&prev, ¤t) { + if let Some(p) = pgid { + killpg(p, &server.name, "kill_server"); + } + tracing::error!( + "MCP server '{}' killed and marked dead (reason={reason})", + server.name + ); + } + } + + fn kill_and_mark_dead_if_current( + &self, + server: &Server, + failed_client: &Arc, + reason: &str, + ) { + let current = server.client.load_full(); + match &*current { + ClientState::Healthy { + client, + pgid, + tools, + } if Arc::ptr_eq(client, failed_client) => { + if let Some(p) = *pgid { + killpg(p, &server.name, "call_failed"); + } + let dead = Arc::new(ClientState::Dead { + attempts: 1, + next_retry: Instant::now() + backoff(1, self.backoff_base, self.backoff_max), + reason: reason.to_owned(), + tools: tools.clone(), + }); + let _ = server.client.compare_and_swap(¤t, dead); + tracing::error!( + "MCP server '{}' killed and marked dead (reason={reason})", + server.name + ); + } + _ => {} + } + } + + pub async fn call( + &self, + qname: &str, + provider_id: &str, + arguments: &Value, + max_bytes: usize, + cancel: &mut watch::Receiver, + ) -> Result { + let entry = self + .by_qname + .get(qname) + .ok_or_else(|| AgentError::Mcp(format!("unknown tool {qname}")))?; + let server = self.servers[entry.server_idx].clone(); + + let state = server.client.load(); + if let ClientState::Healthy { client, tools, .. } = &**state { + if !tools.iter().any(|t| t == &entry.bare) { + return Err(AgentError::Mcp(format!( + "tool '{qname}': no longer available; the MCP server restarted with a different tool set." + ))); + } + let client = client.clone(); + drop(state); + return self + .do_call( + &server, + &client, + &entry.bare, + qname, + provider_id, + arguments, + max_bytes, + cancel, + ) + .await; + } + drop(state); + + self.maybe_restart(&server).await?; + let state = server.client.load(); + let client = match &**state { + ClientState::Healthy { client, tools, .. } => { + if !tools.iter().any(|t| t == &entry.bare) { + return Err(AgentError::Mcp(format!( + "tool '{qname}': no longer available; the MCP server restarted with a different tool set." + ))); + } + client.clone() + } + ClientState::Dead { reason, .. } => { + return Err(AgentError::Mcp(format!( + "tool '{qname}': server '{}' restart failed: {reason}", + server.name + ))); + } + }; + drop(state); + self.do_call( + &server, + &client, + &entry.bare, + qname, + provider_id, + arguments, + max_bytes, + cancel, + ) + .await + } + + #[allow(clippy::too_many_arguments)] + async fn do_call( + &self, + server: &Server, + client: &Arc, + bare: &str, + qname: &str, + provider_id: &str, + arguments: &Value, + max_bytes: usize, + cancel: &mut watch::Receiver, + ) -> Result { + let arg_obj = match arguments { + Value::Object(m) => Some(m.clone()), + Value::Null => None, + _ => { + return Err(AgentError::Mcp(format!( + "tool {qname} arguments must be a JSON object" + ))) + } + }; + let mut params = CallToolRequestParams::default(); + params.name = bare.to_owned().into(); + params.arguments = arg_obj; + + use rmcp::model::{CallToolRequest, ClientRequest, ServerResult}; + use rmcp::service::PeerRequestOptions; + + let req = ClientRequest::CallToolRequest(CallToolRequest::new(params)); + let mut handle = client + .peer() + .send_cancellable_request(req, PeerRequestOptions::no_options()) + .await + .map_err(|e| AgentError::Mcp(format!("call {qname}: {e}")))?; + + // Early cancel check — watch::changed() only fires on NEW writes. + if *cancel.borrow() { + fire_and_forget_cancel(handle, qname); + return Err(AgentError::Cancelled); + } + + // Poll the inner oneshot directly so we can still own `handle` in + // the cancel branch (await_response would move it). + let raw: Result = tokio::select! { + biased; + _ = cancel.changed() => { + fire_and_forget_cancel(handle, qname); + return Err(AgentError::Cancelled); + } + r = &mut handle.rx => match r { + Ok(inner) => inner, + Err(_) => Err(ServiceError::TransportClosed), + }, + }; + + let res = match raw { + Ok(ServerResult::CallToolResult(r)) => r, + Ok(_) => { + return Err(AgentError::Mcp(format!( + "call {qname}: unexpected response type" + ))) + } + Err(e) => { + if is_transport_error(&e) { + self.kill_and_mark_dead_if_current( + server, + client, + &format!("call failed: {e}"), + ); + return Err(AgentError::Mcp(format!("call {qname}: {e}"))); + } + // Application-level JSON-RPC error (e.g. -32602 invalid params). + // Server is healthy — it correctly rejected bad input. Return to LLM. + return Ok(ToolResult { + provider_id: provider_id.to_owned(), + text: clamp(format!("Tool call rejected: {e}"), max_bytes), + is_error: true, + }); + } + }; + let text = collapse_content(&res.content, max_bytes); + Ok(ToolResult { + provider_id: provider_id.to_owned(), + text: clamp(text, max_bytes), + is_error: res.is_error.unwrap_or(false), + }) + } + + async fn maybe_restart(&self, server: &Server) -> Result<(), AgentError> { + match check_restart_state(server, self.max_attempts)? { + RestartCheck::Healthy => return Ok(()), + RestartCheck::Ready { .. } => {} + } + + let _guard = server.restart_lock.lock().await; + + let (attempt_n, prev_tools) = match check_restart_state(server, self.max_attempts)? { + RestartCheck::Healthy => return Ok(()), + RestartCheck::Ready { + attempt_n, + prev_tools, + } => (attempt_n, prev_tools), + }; + + let started = Instant::now(); + tracing::info!( + "MCP server '{}' restarting (attempt {attempt_n}/{})", + server.name, + self.max_attempts + ); + match spawn_one(&server.spec, self.init_timeout).await { + Ok((client, pgid, tool_names, _raw_tools)) => { + server.client.store(Arc::new(ClientState::Healthy { + client: Arc::new(client), + pgid, + tools: Arc::new(tool_names), + })); + + tracing::info!( + "MCP server '{}' restarted in {}ms (attempt {attempt_n})", + server.name, + started.elapsed().as_millis() + ); + Ok(()) + } + Err(e) => { + let reason = format!("restart failed: {e}"); + let permanent = attempt_n >= self.max_attempts; + let next_retry = if permanent { + Instant::now() + Duration::from_secs(86_400) + } else { + Instant::now() + backoff(attempt_n, self.backoff_base, self.backoff_max) + }; + server.client.store(Arc::new(ClientState::Dead { + attempts: attempt_n, + next_retry, + reason: reason.clone(), + tools: prev_tools, + })); + + tracing::error!( + "MCP server '{}' restart failed (attempt {attempt_n}/{}, permanent={permanent}): {reason}", + server.name, self.max_attempts + ); + Err(AgentError::Mcp(reason)) + } + } + } +} + +async fn spawn_one( + spec: &ServerSpec, + timeout: Duration, +) -> Result<(Client, Option, Vec, Vec), AgentError> { + let mut cmd = Command::new(&spec.command); + cmd.args(&spec.args); + cmd.env_clear(); + for k in PASSTHROUGH_ENV { + if let Ok(v) = std::env::var(k) { + cmd.env(k, v); + } + } + for (k, v) in &spec.env { + cmd.env(k, v); + } + cmd.current_dir(&spec.cwd); + cmd.stderr(std::process::Stdio::inherit()); + + #[cfg(unix)] + cmd.process_group(0); + + let transport = TokioChildProcess::new(cmd) + .map_err(|e| AgentError::Mcp(format!("spawn {}: {e}", spec.name)))?; + let pgid = transport.id(); + + struct PgidGuard { + pgid: Option, + name: String, + } + impl Drop for PgidGuard { + fn drop(&mut self) { + if let Some(p) = self.pgid.take() { + killpg(p, &self.name, "spawn_dropped"); + } + } + } + let mut guard = PgidGuard { + pgid, + name: spec.name.clone(), + }; + + let client: Client = match tokio::time::timeout(timeout, ().serve(transport)).await { + Ok(Ok(c)) => c, + Ok(Err(e)) => { + return Err(AgentError::Mcp(format!("init {}: {e}", spec.name))); + } + Err(_) => { + return Err(AgentError::Mcp(timeout_msg("init", &spec.name, timeout))); + } + }; + + let tools = match tokio::time::timeout(timeout, client.peer().list_all_tools()).await { + Ok(Ok(t)) => t, + Ok(Err(e)) => { + return Err(AgentError::Mcp(format!("list_tools {}: {e}", spec.name))); + } + Err(_) => { + return Err(AgentError::Mcp(timeout_msg( + "list_tools", + &spec.name, + timeout, + ))); + } + }; + let names: Vec = tools.iter().map(|t| t.name.to_string()).collect(); + guard.pgid = None; + Ok((client, pgid, names, tools)) +} + +/// Send `notifications/cancelled` to the MCP server, fire-and-forget. +/// Per MCP spec, cancellation notifications are best-effort; we never +/// block the agent on slow server stdio. +fn fire_and_forget_cancel( + handle: rmcp::service::RequestHandle, + qname: &str, +) { + let qname_owned = qname.to_owned(); + tokio::spawn(async move { + if let Err(e) = handle.cancel(Some("session cancelled".into())).await { + tracing::debug!("cancel notification failed for {qname_owned}: {e}"); + } + }); +} + +/// Returns `true` for errors indicating the MCP server process is dead or +/// unreachable. Returns `false` for application-level JSON-RPC errors where +/// the server is healthy but rejected the request (e.g. invalid params). +fn is_transport_error(e: &ServiceError) -> bool { + matches!( + e, + ServiceError::TransportSend(_) + | ServiceError::TransportClosed + | ServiceError::Timeout { .. } + | ServiceError::UnexpectedResponse + ) +} + +fn backoff(attempt: u32, base: Duration, max: Duration) -> Duration { + let shift = attempt.saturating_sub(1).min(20); + let scaled = base.saturating_mul(1u32 << shift); + let capped = scaled.min(max); + let ms = capped.as_millis() as u64; + let jitter_pct = jitter_percent(); + let jittered = (ms as i64) + ((ms as i64) * jitter_pct / 100); + Duration::from_millis(jittered.max(0) as u64) +} + +fn jitter_percent() -> i64 { + let mut buf = [0u8; 1]; + let _ = getrandom::getrandom(&mut buf); + ((buf[0] as i64) % 41) - 20 +} + +fn timeout_msg(stage: &str, name: &str, t: Duration) -> String { + format!("{stage} {name}: timeout after {}s", t.as_secs()) +} + +fn cap_schema(qname: &str, schema: Value) -> Value { + let size = serde_json::to_vec(&schema).map(|b| b.len()).unwrap_or(0); + if size <= MAX_SCHEMA_BYTES { + return schema; + } + tracing::warn!( + "tool {qname} schema is {size} bytes (>{MAX_SCHEMA_BYTES}); replacing with empty object" + ); + Value::Object(Map::new()) +} + +#[cfg(unix)] +fn killpg(pgid: u32, name: &str, stage: &str) { + use nix::sys::signal::{killpg as nix_killpg, Signal}; + use nix::unistd::Pid; + let result = nix_killpg(Pid::from_raw(pgid as i32), Signal::SIGKILL); + tracing::info!( + "killpg MCP {name} ({stage}) pgid={pgid} ok={}", + result.is_ok() + ); +} +#[cfg(not(unix))] +fn killpg(_pgid: u32, name: &str, stage: &str) { + tracing::info!("relying on Drop to kill MCP {name} ({stage})"); +} + +fn valid_name(s: &str) -> bool { + !s.is_empty() + && s.len() <= MAX_NAME_LEN + && s.bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-') +} + +fn truncate_at_boundary(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut cut = max; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} + +fn push_bounded(out: &mut String, s: &str, max: usize) { + let remaining = max.saturating_sub(out.len()); + if remaining > 0 { + out.push_str(truncate_at_boundary(s, remaining)); + } +} + +fn collapse_content(blocks: &[rmcp::model::Content], max_bytes: usize) -> String { + use rmcp::model::RawContent; + let mut out = String::new(); + let mut truncated = false; + let short = |s: &str| truncate_at_boundary(s, MARKER_FIELD_MAX).to_owned(); + for c in blocks { + if out.len() >= max_bytes { + truncated = true; + break; + } + if !out.is_empty() { + push_bounded(&mut out, "\n", max_bytes); + } + let chunk: String = match &c.raw { + RawContent::Text(t) => t.text.clone(), + RawContent::Image(i) => { + format!( + "[image elided: {}, {} bytes]", + short(&i.mime_type), + i.data.len() + ) + } + RawContent::Audio(a) => { + format!( + "[audio elided: {}, {} bytes]", + short(&a.mime_type), + a.data.len() + ) + } + RawContent::ResourceLink(r) => format!("[resource: {}]", short(&r.uri)), + RawContent::Resource(_) => "[resource elided]".into(), + }; + let before = out.len(); + push_bounded(&mut out, &chunk, max_bytes); + if out.len() - before < chunk.len() { + truncated = true; + } + } + if truncated { + out.push_str("\n[content truncated]"); + } + out +} diff --git a/crates/sprout-agent/src/types.rs b/crates/sprout-agent/src/types.rs new file mode 100644 index 0000000000..d05ec51cc5 --- /dev/null +++ b/crates/sprout-agent/src/types.rs @@ -0,0 +1,170 @@ +use serde::Deserialize; +use serde_json::Value; + +#[derive(Debug, Clone)] +pub enum HistoryItem { + User(String), + Assistant { + text: String, + tool_calls: Vec, + }, + ToolResult(ToolResult), +} + +impl HistoryItem { + pub fn estimated_bytes(&self) -> usize { + match self { + Self::User(s) => s.len(), + Self::Assistant { text, tool_calls } => { + text.len() + + tool_calls + .iter() + .map(|c| { + c.provider_id.len() + + c.name.len() + + serde_json::to_vec(&c.arguments) + .map(|b| b.len()) + .unwrap_or(0) + }) + .sum::() + } + Self::ToolResult(r) => r.provider_id.len() + r.text.len(), + } + } +} + +#[derive(Debug, Clone)] +pub struct ToolCall { + pub provider_id: String, + pub name: String, + pub arguments: Value, +} + +#[derive(Debug, Clone)] +pub struct ToolResult { + pub provider_id: String, + pub text: String, + pub is_error: bool, +} + +#[derive(Debug, Clone)] +pub struct LlmResponse { + pub text: String, + pub tool_calls: Vec, + pub stop: ProviderStop, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ProviderStop { + EndTurn, + ToolUse, + MaxTokens, + Refusal, + Other, +} + +#[derive(Debug, Clone)] +pub struct ToolDef { + pub name: String, + pub description: String, + pub input_schema: Value, +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum StopReason { + EndTurn, + Cancelled, + MaxTokens, + MaxTurnRequests, + Refusal, +} + +impl StopReason { + pub fn as_wire(self) -> &'static str { + match self { + Self::EndTurn => "end_turn", + Self::Cancelled => "cancelled", + Self::MaxTokens => "max_tokens", + Self::MaxTurnRequests => "max_turn_requests", + Self::Refusal => "refusal", + } + } +} + +#[derive(Debug, Deserialize, Clone)] +pub struct McpServerStdio { + pub name: String, + pub command: String, + #[serde(default)] + pub args: Vec, + #[serde(default)] + pub env: Vec, +} + +#[derive(Debug, Deserialize, Clone)] +pub struct EnvVar { + pub name: String, + pub value: String, +} + +#[derive(Debug, Deserialize, Clone)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum ContentBlock { + Text { + text: String, + }, + ResourceLink { + uri: String, + }, + #[serde(other)] + Unsupported, +} + +#[derive(Debug)] +pub enum AgentError { + InvalidParams(String), + Llm(String), + LlmAuth(String), + Mcp(String), + Cancelled, +} + +impl std::fmt::Display for AgentError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::InvalidParams(s) => write!(f, "invalid params: {s}"), + Self::Llm(s) => write!(f, "llm: {s}"), + Self::LlmAuth(s) => write!(f, "llm auth: {s}"), + Self::Mcp(s) => write!(f, "mcp: {s}"), + Self::Cancelled => write!(f, "cancelled"), + } + } +} + +impl std::error::Error for AgentError {} + +impl AgentError { + pub fn json_rpc_code(&self) -> i32 { + match self { + Self::InvalidParams(_) => -32602, + _ => -32000, + } + } +} + +pub fn clamp(mut s: String, max: usize) -> String { + if s.len() <= max { + return s; + } + const MARKER: &str = "\n[truncated]"; + let budget = max.saturating_sub(MARKER.len()); + let mut cut = budget; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + s.truncate(cut); + if max >= MARKER.len() { + s.push_str(MARKER); + } + s +} diff --git a/crates/sprout-agent/src/wire.rs b/crates/sprout-agent/src/wire.rs new file mode 100644 index 0000000000..60494b63a2 --- /dev/null +++ b/crates/sprout-agent/src/wire.rs @@ -0,0 +1,180 @@ +use serde::Deserialize; +use serde_json::{json, Value}; +use tokio::io::{AsyncBufRead, AsyncBufReadExt, AsyncWriteExt}; +use tokio::sync::mpsc; + +use crate::types::{ContentBlock, McpServerStdio}; + +pub const PARSE_ERROR: i32 = -32700; +pub const INVALID_REQUEST: i32 = -32600; +pub const METHOD_NOT_FOUND: i32 = -32601; +pub const INVALID_PARAMS: i32 = -32602; + +pub enum WireMsg { + Notify(Value), +} + +pub type WireSender = mpsc::Sender; + +#[derive(Debug)] +pub enum Inbound { + Request { + id: Value, + method: String, + params: Value, + }, + Notification { + method: String, + params: Value, + }, + Ignored, + Invalid { + id: Value, + code: i32, + message: String, + }, +} + +#[derive(Debug, Deserialize)] +pub struct InitializeParams { + #[serde(rename = "protocolVersion")] + pub protocol_version: u32, + #[serde(default, rename = "clientCapabilities")] + pub _client_capabilities: Value, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionNewParams { + pub cwd: String, + #[serde(default)] + pub mcp_servers: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionPromptParams { + pub session_id: String, + pub prompt: Vec, +} + +#[derive(Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SessionCancelParams { + pub session_id: String, +} + +pub fn classify(msg: &Value) -> Inbound { + if !msg.is_object() || msg.get("jsonrpc").and_then(Value::as_str) != Some("2.0") { + return Inbound::Invalid { + id: msg.get("id").cloned().unwrap_or(Value::Null), + code: INVALID_REQUEST, + message: "jsonrpc: missing or invalid version".into(), + }; + } + let id = msg.get("id").cloned(); + let method = msg.get("method").and_then(Value::as_str).map(str::to_owned); + let params = msg.get("params").cloned().unwrap_or(Value::Null); + + match (method, id) { + (Some(m), Some(id)) => Inbound::Request { + id, + method: m, + params, + }, + (Some(m), None) => Inbound::Notification { method: m, params }, + // Bare responses (id present, no method) are unexpected — sprout-agent + // does not issue requests to the client. Ignore silently. + (None, Some(_)) => Inbound::Ignored, + (None, None) => Inbound::Invalid { + id: Value::Null, + code: INVALID_REQUEST, + message: "jsonrpc: missing method and id".into(), + }, + } +} + +pub fn ok(id: Value, result: Value) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "result": result }) +} + +pub fn err(id: Value, code: i32, message: &str) -> Value { + json!({ "jsonrpc": "2.0", "id": id, "error": { "code": code, "message": message } }) +} + +pub fn session_update(sid: &str, update: Value) -> Value { + json!({ + "jsonrpc": "2.0", + "method": "session/update", + "params": { "sessionId": sid, "update": update }, + }) +} + +pub async fn send(wire: &WireSender, msg: Value) { + let _ = wire.send(WireMsg::Notify(msg)).await; +} + +pub async fn read_bounded_line( + stdin: &mut R, + max: usize, +) -> std::io::Result> { + let mut buf: Vec = Vec::new(); + loop { + let chunk = stdin.fill_buf().await?; + if chunk.is_empty() { + if !buf.is_empty() { + tracing::error!( + "io: unterminated frame at EOF ({} bytes dropped)", + buf.len() + ); + } + return Ok(None); + } + let take = chunk + .iter() + .position(|b| *b == b'\n') + .map_or(chunk.len(), |i| i + 1); + if buf.len().saturating_add(take) > max { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + format!("io: line exceeds max ({max} bytes)"), + )); + } + buf.extend_from_slice(&chunk[..take]); + stdin.consume(take); + if buf.ends_with(b"\n") { + buf.pop(); + if buf.ends_with(b"\r") { + buf.pop(); + } + match String::from_utf8(buf) { + Ok(s) => return Ok(Some(s)), + Err(_) => { + return Err(std::io::Error::new( + std::io::ErrorKind::InvalidData, + "io: frame contains invalid UTF-8", + )) + } + } + } + } +} + +pub async fn writer_task(mut rx: mpsc::Receiver) { + let mut stdout = tokio::io::stdout(); + while let Some(msg) = rx.recv().await { + let WireMsg::Notify(v) = msg; + let mut s = match serde_json::to_string(&v) { + Ok(s) => s, + Err(e) => { + tracing::error!("io: serialize: {e}"); + continue; + } + }; + s.push('\n'); + if stdout.write_all(s.as_bytes()).await.is_err() { + return; + } + let _ = stdout.flush().await; + } +} diff --git a/crates/sprout-agent/tests/bin/fake_mcp.rs b/crates/sprout-agent/tests/bin/fake_mcp.rs new file mode 100644 index 0000000000..47dfa56d7b --- /dev/null +++ b/crates/sprout-agent/tests/bin/fake_mcp.rs @@ -0,0 +1,291 @@ +//! Tiny fake MCP server for integration tests. +//! +//! Reads JSON-RPC line frames on stdin and replies on stdout. Driven by +//! environment variables so tests can simulate misbehavior: +//! +//! FAKE_MCP_HANG_INIT=1 — never reply to `initialize` (init timeout) +//! FAKE_MCP_HANG_TOOLS=1 — never reply to `tools/list` (list timeout) +//! FAKE_MCP_TOOL_COUNT=N — return N tools (default: 1) +//! FAKE_MCP_HUGE_DESC=1 — every tool description is 100 KB +//! FAKE_MCP_DESC_SIZE=N — every tool description is N bytes (overrides HUGE_DESC) +//! FAKE_MCP_TOOL_DELAY=N — `tools/call` sleeps N seconds before replying +//! (use a large value, e.g. 999, to simulate hang) +//! FAKE_MCP_PID_FILE=path — write the child PID to `path` on startup +//! (for tests that want to verify the child died) +//! FAKE_MCP_SPAWN_GRANDCHILD=1 +//! — on `tools/call`, spawn a `sleep 999` +//! grandchild before hanging. Its PID is +//! written to FAKE_MCP_GRANDCHILD_PID_FILE +//! so a test can verify the entire process +//! tree dies on timeout. +//! FAKE_MCP_GRANDCHILD_PID_FILE=path +//! — path to write the grandchild PID to. +//! FAKE_MCP_STOP_HOOK=1 — expose a `_Stop` hook tool +//! FAKE_MCP_STOP_TEXT=text — `_Stop` returns this text (default: "keep going") +//! FAKE_MCP_STOP_DELAY=N — `_Stop` sleeps N seconds before replying +//! (use a large value to simulate hang/timeout) +//! FAKE_MCP_STOP_COUNT=N — `_Stop` returns STOP_TEXT for the first N +//! invocations; empty string thereafter. If +//! unset, every call returns STOP_TEXT. +//! FAKE_MCP_POSTCOMPACT_HOOK=1 +//! — expose a `_PostCompact` hook tool +//! FAKE_MCP_POSTCOMPACT_TEXT=text +//! — `_PostCompact` returns this (default: "") + +use std::io::{BufRead, Write}; + +use serde_json::{json, Value}; + +fn env_flag(k: &str) -> bool { + std::env::var(k).map(|v| v != "0").unwrap_or(false) +} + +fn env_usize(k: &str, default: usize) -> usize { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn env_u64(k: &str, default: u64) -> u64 { + std::env::var(k) + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(default) +} + +fn write_response(id: Value, result: Value) { + let msg = json!({ "jsonrpc": "2.0", "id": id, "result": result }); + let mut s = serde_json::to_string(&msg).expect("serialize"); + s.push('\n'); + let mut out = std::io::stdout().lock(); + out.write_all(s.as_bytes()).expect("write"); + out.flush().expect("flush"); +} + +fn hang_forever() -> ! { + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + } +} + +fn make_tools( + count: usize, + desc: &str, + include_stop_hook: bool, + include_post_compact_hook: bool, +) -> Vec { + let mut tools: Vec = (0..count) + .map(|i| { + json!({ + "name": format!("tool_{i}"), + "description": desc, + "inputSchema": { "type": "object", "properties": {} }, + }) + }) + .collect(); + if include_stop_hook { + tools.push(json!({ + "name": "_Stop", + "description": "stop hook", + "inputSchema": { "type": "object", "properties": {} }, + })); + } + if include_post_compact_hook { + tools.push(json!({ + "name": "_PostCompact", + "description": "post compact hook", + "inputSchema": { "type": "object", "properties": {} }, + })); + } + tools +} + +fn main() { + // Optional: write our own PID so a test can later check the process is gone. + if let Ok(path) = std::env::var("FAKE_MCP_PID_FILE") { + let pid = std::process::id().to_string(); + let _ = std::fs::write(&path, pid); + } + + let hang_init = env_flag("FAKE_MCP_HANG_INIT"); + let hang_tools = env_flag("FAKE_MCP_HANG_TOOLS"); + let tool_count = env_usize("FAKE_MCP_TOOL_COUNT", 1); + // FAKE_MCP_DESC_SIZE wins over FAKE_MCP_HUGE_DESC when set. + let desc: String = if let Some(n) = std::env::var("FAKE_MCP_DESC_SIZE") + .ok() + .and_then(|v| v.parse::().ok()) + { + "x".repeat(n) + } else if env_flag("FAKE_MCP_HUGE_DESC") { + "x".repeat(100_000) + } else { + "fake tool".to_owned() + }; + let tool_delay_secs = env_u64("FAKE_MCP_TOOL_DELAY", 0); + let stop_hook = env_flag("FAKE_MCP_STOP_HOOK"); + let stop_text = std::env::var("FAKE_MCP_STOP_TEXT").unwrap_or_else(|_| "keep going".to_owned()); + let stop_delay_secs = env_u64("FAKE_MCP_STOP_DELAY", 0); + // 0 means "unset" → unlimited; any positive value caps the number of + // calls that return STOP_TEXT before flipping to empty string. + let stop_count_limit: usize = env_usize("FAKE_MCP_STOP_COUNT", usize::MAX); + let mut stop_calls_seen: usize = 0; + let post_compact_hook = env_flag("FAKE_MCP_POSTCOMPACT_HOOK"); + let post_compact_text = std::env::var("FAKE_MCP_POSTCOMPACT_TEXT").unwrap_or_default(); + + // Use a channel-based stdin reader so notifications (which carry no id) + // are captured even while the main thread is sleeping during a tool call. + let cancel_log_path = std::env::var("FAKE_MCP_CANCEL_LOG").ok(); + let (tx, rx) = std::sync::mpsc::channel::<(Value, Option)>(); + let cancel_log_for_thread = cancel_log_path.clone(); + std::thread::spawn(move || { + let stdin = std::io::stdin(); + let lines = stdin.lock().lines(); + for line in lines { + let line = match line { + Ok(l) => l, + Err(_) => return, + }; + if line.trim().is_empty() { + continue; + } + let msg: Value = match serde_json::from_str(&line) { + Ok(v) => v, + Err(_) => continue, + }; + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = msg.get("id").cloned(); + // Notifications carry no id. Log cancellations if configured. + if id.is_none() || id == Some(Value::Null) { + if method == "notifications/cancelled" { + if let Some(ref path) = cancel_log_for_thread { + use std::io::Write as _; + if let Ok(mut f) = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(path) + { + let _ = writeln!(f, "{}", line.trim()); + } + } + } + continue; + } + // Send requests (with id) to the main processing loop. + let _ = tx.send((msg, id)); + } + }); + + while let Ok((msg, id_opt)) = rx.recv() { + let method = msg.get("method").and_then(Value::as_str).unwrap_or(""); + let id = id_opt.unwrap_or(Value::Null); + + match method { + "initialize" => { + if hang_init { + hang_forever(); + } + write_response( + id, + json!({ + "protocolVersion": "2025-06-18", + "capabilities": { "tools": {} }, + "serverInfo": { "name": "fake-mcp", "version": "0.0.0" }, + }), + ); + } + "tools/list" => { + if hang_tools { + hang_forever(); + } + write_response( + id, + json!({ + "tools": make_tools(tool_count, &desc, stop_hook, post_compact_hook) + }), + ); + } + "tools/call" => { + // Signal that the request was received (for tests that + // need to wait until the call is in-flight before cancelling). + // Write the request id so tests can correlate with cancel. + if let Ok(path) = std::env::var("FAKE_MCP_CALL_RECEIVED") { + let id_str = serde_json::to_string(&id).unwrap_or_else(|_| "?".into()); + let _ = std::fs::write(&path, id_str); + } + let called_name = msg + .get("params") + .and_then(|p| p.get("name")) + .and_then(Value::as_str) + .unwrap_or(""); + // Optionally spawn a long-sleeping grandchild so the test + // can verify process-group killing reaches the whole tree. + if env_flag("FAKE_MCP_SPAWN_GRANDCHILD") { + let child = std::process::Command::new("sleep") + .arg("999") + .spawn() + .expect("spawn grandchild"); + if let Ok(path) = std::env::var("FAKE_MCP_GRANDCHILD_PID_FILE") { + let _ = std::fs::write(&path, child.id().to_string()); + } + std::mem::forget(child); + } + if called_name == "_Stop" { + if stop_delay_secs > 0 { + std::thread::sleep(std::time::Duration::from_secs(stop_delay_secs)); + } + // Once we exceed the configured count, return empty + // text so the agent treats it as no objection. This + // lets a test exercise the "objected then cleared" + // path without relying on the rejection budget. + let payload = if stop_calls_seen < stop_count_limit { + stop_text.clone() + } else { + String::new() + }; + stop_calls_seen = stop_calls_seen.saturating_add(1); + write_response( + id, + json!({ + "content": [{ "type": "text", "text": payload }], + "isError": false, + }), + ); + continue; + } + if called_name == "_PostCompact" { + write_response( + id, + json!({ + "content": [{ "type": "text", "text": post_compact_text }], + "isError": false, + }), + ); + continue; + } + if tool_delay_secs > 0 { + std::thread::sleep(std::time::Duration::from_secs(tool_delay_secs)); + } + write_response( + id, + json!({ + "content": [{ "type": "text", "text": "ok" }], + "isError": false, + }), + ); + } + _ => { + // Unknown method: respond with an error so rmcp doesn't hang. + let err = json!({ + "jsonrpc": "2.0", "id": id, + "error": { "code": -32601, "message": format!("method not found: {method}") }, + }); + let mut s = serde_json::to_string(&err).unwrap(); + s.push('\n'); + let mut out = std::io::stdout().lock(); + let _ = out.write_all(s.as_bytes()); + let _ = out.flush(); + } + } + } +} diff --git a/crates/sprout-agent/tests/fake_llm.rs b/crates/sprout-agent/tests/fake_llm.rs new file mode 100644 index 0000000000..8e07b2a771 --- /dev/null +++ b/crates/sprout-agent/tests/fake_llm.rs @@ -0,0 +1,337 @@ +//! Integration test: fake LLM HTTP server + sprout-agent subprocess. +//! +//! Drives the agent through the ACP wire protocol and verifies: +//! - initialize / session/new responses +//! - tool_call (pending) → request_permission → tool_call_update +//! - session/prompt response with stopReason=end_turn +//! - concurrent prompt rejection + +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +// ─── Fake LLM server ──────────────────────────────────────────────────────── + +async fn spawn_fake_llm(responses: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 1_000_000 { + return; + } + } + let body = queue + .lock() + .await + .pop_front() + .unwrap_or_else(|| json!({ "error": "no canned response" })); + let body_s = serde_json::to_string(&body).unwrap(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body_s.len(), body_s, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + url +} + +// ─── ACP harness ──────────────────────────────────────────────────────────── + +struct Harness { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + async fn spawn(base_url: &str) -> Self { + let bin = env!("CARGO_BIN_EXE_sprout-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("SPROUT_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_MAX_ROUNDS", "4") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn sprout-agent"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + async fn send(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + async fn write(&mut self, msg: Value) { + let mut s = serde_json::to_string(&msg).unwrap(); + s.push('\n'); + self.stdin.write_all(s.as_bytes()).await.unwrap(); + self.stdin.flush().await.unwrap(); + } + + async fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line)) + .await + .expect("recv timeout") + .expect("read line"); + assert!(n > 0, "agent EOF"); + serde_json::from_str(&line).expect("non-JSON line") + } + + /// Read messages until one matches `pred`. + async fn recv_until bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if pred(&v) { + return v; + } + } + } + + async fn shutdown(mut self) { + drop(self.stdin); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + let _ = self.child.start_kill(); + } +} + +// ─── Canned LLM responses (OpenAI-compat shape) ───────────────────────────── + +fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { + json!({ + "id": "cc-2", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }], + }, + "finish_reason": "tool_calls", + }], + }) +} + +async fn init_session(h: &mut Harness) -> String { + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let r = h.recv().await; + assert_eq!(r["result"]["protocolVersion"], 1); + assert_eq!(r["result"]["agentInfo"]["name"], "sprout-agent"); + h.send("session/new", json!({"cwd":"/tmp","mcpServers":[]})) + .await; + let r = h.recv().await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + assert!(sid.starts_with("ses_")); + sid +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn text_only_end_turn() { + let url = spawn_fake_llm(vec![openai_text("done")]).await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + let p_id = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ) + .await; + let v = h.recv_until(|v| v["id"] == json!(p_id)).await; + assert_eq!(v["result"]["stopReason"], "end_turn"); + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_call_then_end_turn() { + // Round 1: tool call (will fail with "unknown tool" since no MCP registered). + // Round 2: text response → end_turn. + let url = spawn_fake_llm(vec![ + openai_tool_call("call_xyz", "fake__do_thing", json!({"foo": "bar"})), + openai_text("ok"), + ]) + .await; + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + let p_id = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{"type":"text","text":"do something"}], + }), + ) + .await; + + // Tool unknown: agent emits failed tool_call_update directly (no permission ask). + let v = h + .recv_until(|v| { + v.get("method") == Some(&json!("session/update")) + && v["params"]["update"]["sessionUpdate"] == "tool_call_update" + && v["params"]["update"]["status"] == "failed" + }) + .await; + assert_eq!(v["params"]["update"]["toolCallId"], "call_xyz"); + + // Final response. + let v = h.recv_until(|v| v["id"] == json!(p_id)).await; + assert_eq!(v["result"]["stopReason"], "end_turn"); + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rejects_concurrent_prompts() { + // Slow first response so the second prompt arrives mid-flight. + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = sock.read(&mut tmp).await.unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&tmp[..n]); + } + tokio::time::sleep(Duration::from_millis(500)).await; + let body = openai_text("done").to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + + let mut h = Harness::spawn(&url).await; + let sid = init_session(&mut h).await; + let p1 = h + .send( + "session/prompt", + json!({ + "sessionId": sid, "prompt": [{"type":"text","text":"go"}], + }), + ) + .await; + tokio::time::sleep(Duration::from_millis(50)).await; + let p2 = h + .send( + "session/prompt", + json!({ + "sessionId": sid, "prompt": [{"type":"text","text":"go again"}], + }), + ) + .await; + + let mut saw_p2_err = false; + let mut saw_p1_ok = false; + for _ in 0..10 { + let v = h.recv().await; + if v["id"] == json!(p2) { + assert_eq!(v["error"]["code"], -32602); + saw_p2_err = true; + } else if v["id"] == json!(p1) { + assert_eq!(v["result"]["stopReason"], "end_turn"); + saw_p1_ok = true; + } + if saw_p1_ok && saw_p2_err { + break; + } + } + assert!(saw_p2_err, "expected concurrent prompt rejection"); + assert!(saw_p1_ok, "first prompt didn't complete"); + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn rejects_oversized_line() { + // Set a tiny max line and send something larger; agent must abort with an + // io error and not OOM. + let url = spawn_fake_llm(vec![]).await; + let bin = env!("CARGO_BIN_EXE_sprout-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("SPROUT_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", &url) + .env("SPROUT_AGENT_MAX_LINE_BYTES", "256") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = cmd.spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + // 1024-byte line — agent should reject and exit. + let big = "x".repeat(1024); + let _ = stdin.write_all(big.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("agent didn't exit after oversized line"); +} diff --git a/crates/sprout-agent/tests/golden_transcripts.rs b/crates/sprout-agent/tests/golden_transcripts.rs new file mode 100644 index 0000000000..44c1e2b81d --- /dev/null +++ b/crates/sprout-agent/tests/golden_transcripts.rs @@ -0,0 +1,542 @@ +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::Arc; +use std::time::Duration; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +struct Harness { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + async fn spawn(extra: &[(&str, &str)]) -> Self { + let bin = env!("CARGO_BIN_EXE_sprout-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("SPROUT_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_MAX_ROUNDS", "4") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + for (k, v) in extra { + cmd.env(k, v); + } + let mut child = cmd.spawn().expect("spawn sprout-agent"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + async fn send(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write_json(json!({ + "jsonrpc": "2.0", "id": id, "method": method, "params": params + })) + .await; + id + } + + async fn notify(&mut self, method: &str, params: Value) { + self.write_json(json!({ + "jsonrpc": "2.0", "method": method, "params": params + })) + .await; + } + + async fn write_json(&mut self, msg: Value) { + let mut s = serde_json::to_string(&msg).unwrap(); + s.push('\n'); + self.stdin.write_all(s.as_bytes()).await.unwrap(); + self.stdin.flush().await.unwrap(); + } + + async fn write_raw(&mut self, raw: &[u8]) { + let _ = self.stdin.write_all(raw).await; + let _ = self.stdin.flush().await; + } + + async fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(10), self.stdout.read_line(&mut line)) + .await + .expect("recv timeout") + .expect("read line"); + assert!(n > 0, "agent EOF"); + serde_json::from_str(&line).expect("non-JSON line") + } + + async fn recv_for_id(&mut self, id: i64) -> Value { + loop { + let v = self.recv().await; + if v["id"] == json!(id) { + return v; + } + } + } + + async fn recv_until bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if pred(&v) { + return v; + } + } + } + + async fn shutdown(mut self) { + drop(self.stdin); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + let _ = self.child.start_kill(); + } +} + +async fn spawn_fake_llm(responses: Vec) -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 1_000_000 { + return; + } + } + let body = queue + .lock() + .await + .pop_front() + .unwrap_or_else(|| json!({ "error": "no canned response" })); + let body_s = serde_json::to_string(&body).unwrap(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body_s.len(), + body_s, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + url +} + +fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { + json!({ + "id": "cc-2", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }], + }, + "finish_reason": "tool_calls", + }], + }) +} + +async fn handshake(h: &mut Harness) -> String { + let init_id = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + let init = h.recv_for_id(init_id).await; + assert_eq!(init["result"]["protocolVersion"], 1); + assert_eq!(init["result"]["agentInfo"]["name"], "sprout-agent"); + assert_eq!( + init["result"]["agentCapabilities"]["promptCapabilities"]["image"], + false + ); + + let new_id = h + .send("session/new", json!({ "cwd": "/tmp", "mcpServers": [] })) + .await; + let new = h.recv_for_id(new_id).await; + let sid = new["result"]["sessionId"].as_str().unwrap().to_owned(); + assert!(sid.starts_with("ses_")); + sid +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_text_only_response() { + let url = spawn_fake_llm(vec![openai_text("hello back")]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let sid = handshake(&mut h).await; + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ) + .await; + let result = h.recv_for_id(p).await; + assert_eq!(result["result"]["stopReason"], "end_turn"); + assert!(result.get("error").is_none()); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_full_tool_call_transcript() { + let url = spawn_fake_llm(vec![ + openai_tool_call("call_xyz", "fake__do_thing", json!({ "foo": "bar" })), + openai_text("done"), + ]) + .await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let sid = handshake(&mut h).await; + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "use the tool" }], + }), + ) + .await; + + let failed = h + .recv_until(|v| { + v.get("method") == Some(&json!("session/update")) + && v["params"]["update"]["sessionUpdate"] == "tool_call_update" + && v["params"]["update"]["status"] == "failed" + }) + .await; + assert_eq!(failed["params"]["sessionId"], sid); + assert_eq!(failed["params"]["update"]["toolCallId"], "call_xyz"); + assert_eq!( + failed["params"]["update"]["rawOutput"]["error"], + "unknown tool: fake__do_thing" + ); + + let final_resp = h.recv_for_id(p).await; + assert_eq!(final_resp["result"]["stopReason"], "end_turn"); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_permission_denied_continues() { + let url = spawn_fake_llm(vec![openai_text("ok with no tool")]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let sid = handshake(&mut h).await; + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "text", "text": "hi" }], + }), + ) + .await; + let final_resp = h.recv_for_id(p).await; + assert_eq!(final_resp["result"]["stopReason"], "end_turn"); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_initialize_version_check() { + let url = spawn_fake_llm(vec![]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let id = h + .send( + "initialize", + json!({ "protocolVersion": 99, "clientCapabilities": {} }), + ) + .await; + let resp = h.recv_for_id(id).await; + assert_eq!(resp["result"]["protocolVersion"], 1); + + let id2 = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + let ok = h.recv_for_id(id2).await; + assert_eq!(ok["result"]["protocolVersion"], 1); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_session_new_rejects_relative_cwd() { + let url = spawn_fake_llm(vec![]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let _ = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + let _ = h.recv().await; + + let id = h + .send( + "session/new", + json!({ "cwd": "relative/path", "mcpServers": [] }), + ) + .await; + let resp = h.recv_for_id(id).await; + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("cwd must be an absolute path")); + + let id_empty = h + .send("session/new", json!({ "cwd": "", "mcpServers": [] })) + .await; + let resp = h.recv_for_id(id_empty).await; + assert_eq!(resp["error"]["code"], -32602); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_malformed_json_rpc() { + let url = spawn_fake_llm(vec![]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + h.write_raw(b"this is not json\n").await; + let v = h.recv().await; + assert_eq!(v["error"]["code"], -32700); + assert_eq!(v["id"], Value::Null); + + h.write_json(json!({ "jsonrpc": "1.0", "method": "initialize", "id": 1 })) + .await; + let v = h.recv().await; + assert_eq!(v["error"]["code"], -32600); + + h.write_json(json!({ "jsonrpc": "2.0" })).await; + let v = h.recv().await; + assert_eq!(v["error"]["code"], -32600); + + let init_id = h + .send( + "initialize", + json!({ "protocolVersion": 1, "clientCapabilities": {} }), + ) + .await; + let ok = h.recv_for_id(init_id).await; + assert_eq!(ok["result"]["protocolVersion"], 1); + + let bad_id = h.send("nonsense/method", json!({})).await; + let v = h.recv_for_id(bad_id).await; + assert_eq!(v["error"]["code"], -32601); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_unsupported_content_block() { + let url = spawn_fake_llm(vec![openai_text("ok")]).await; + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + + let sid = handshake(&mut h).await; + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{ "type": "image", "data": "..." }], + }), + ) + .await; + let resp = h.recv_for_id(p).await; + assert_eq!(resp["error"]["code"], -32602); + assert!(resp["error"]["message"] + .as_str() + .unwrap() + .contains("unsupported content block")); + + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_concurrent_prompt_rejected() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = sock.read(&mut tmp).await.unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&tmp[..n]); + } + tokio::time::sleep(Duration::from_millis(500)).await; + let body = openai_text("done").to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + let sid = handshake(&mut h).await; + + let p1 = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }), + ) + .await; + tokio::time::sleep(Duration::from_millis(50)).await; + let p2 = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{"type":"text","text":"again"}] }), + ) + .await; + + let mut p1_ok = false; + let mut p2_err = false; + for _ in 0..10 { + let v = h.recv().await; + if v["id"] == json!(p1) { + assert_eq!(v["result"]["stopReason"], "end_turn"); + p1_ok = true; + } else if v["id"] == json!(p2) { + assert_eq!(v["error"]["code"], -32602); + p2_err = true; + } + if p1_ok && p2_err { + break; + } + } + assert!(p1_ok && p2_err, "expected p1=ok, p2=busy"); + h.shutdown().await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_oversized_line_kills_agent() { + let url = spawn_fake_llm(vec![]).await; + let bin = env!("CARGO_BIN_EXE_sprout-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("SPROUT_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", &url) + .env("SPROUT_AGENT_MAX_LINE_BYTES", "256") + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::null()) + .kill_on_drop(true); + let mut child = cmd.spawn().unwrap(); + let mut stdin = child.stdin.take().unwrap(); + let big = "x".repeat(1024); + let _ = stdin.write_all(big.as_bytes()).await; + let _ = stdin.write_all(b"\n").await; + drop(stdin); + let _ = tokio::time::timeout(Duration::from_secs(5), child.wait()) + .await + .expect("agent did not exit on oversized line"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_cancel_notification_no_reply() { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + tokio::spawn(async move { + let (mut sock, _) = listener.accept().await.unwrap(); + let mut buf = Vec::new(); + let mut tmp = [0u8; 4096]; + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + let n = sock.read(&mut tmp).await.unwrap_or(0); + if n == 0 { + return; + } + buf.extend_from_slice(&tmp[..n]); + } + tokio::time::sleep(Duration::from_millis(800)).await; + let body = openai_text("done").to_string(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + + let mut h = Harness::spawn(&[("OPENAI_COMPAT_BASE_URL", &url)]).await; + let sid = handshake(&mut h).await; + + let p = h + .send( + "session/prompt", + json!({ "sessionId": sid, "prompt": [{"type":"text","text":"go"}] }), + ) + .await; + tokio::time::sleep(Duration::from_millis(50)).await; + h.notify("session/cancel", json!({ "sessionId": sid })) + .await; + + let final_resp = h.recv_for_id(p).await; + let stop = final_resp["result"]["stopReason"].as_str().unwrap_or(""); + assert!( + stop == "cancelled" || stop == "end_turn", + "unexpected stopReason {stop}" + ); + + h.shutdown().await; +} diff --git a/crates/sprout-agent/tests/regressions.rs b/crates/sprout-agent/tests/regressions.rs new file mode 100644 index 0000000000..1f2afcfd57 --- /dev/null +++ b/crates/sprout-agent/tests/regressions.rs @@ -0,0 +1,1420 @@ +//! Regression tests for round 4-6 hardening: +//! - assistant text preserved in history +//! - MCP init timeout (with explicit child kill) +//! - tool metadata caps (description bytes, count) +//! - cancellation leaves history valid for the next prompt +//! - empty-content assistant turn doesn't poison OpenAI history + +use std::collections::VecDeque; +use std::process::Stdio; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use serde_json::{json, Value}; +use tokio::io::{AsyncBufReadExt, AsyncReadExt, AsyncWriteExt, BufReader}; +use tokio::net::TcpListener; +use tokio::sync::Mutex; + +// ─── Fake LLM that captures requests so we can inspect history ────────────── + +struct CapturingLlm { + url: String, + captured: Arc>>, +} + +async fn spawn_capturing_llm(responses: Vec) -> CapturingLlm { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let url = format!("http://{}", listener.local_addr().unwrap()); + let queue = Arc::new(Mutex::new(VecDeque::from(responses))); + let captured: Arc>> = Arc::new(Mutex::new(Vec::new())); + let cap2 = captured.clone(); + tokio::spawn(async move { + loop { + let (mut sock, _) = match listener.accept().await { + Ok(p) => p, + Err(_) => return, + }; + let queue = queue.clone(); + let captured = cap2.clone(); + tokio::spawn(async move { + let mut buf = Vec::new(); + let mut tmp = [0u8; 8192]; + // Read until headers complete. + while !buf.windows(4).any(|w| w == b"\r\n\r\n") { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + if buf.len() > 4_000_000 { + return; + } + } + // Parse Content-Length and read body. + let header_end = buf.windows(4).position(|w| w == b"\r\n\r\n").unwrap() + 4; + let headers = &buf[..header_end]; + let mut body_len = 0usize; + for line in headers.split(|b| *b == b'\n') { + let line = std::str::from_utf8(line).unwrap_or(""); + if let Some(rest) = line.to_ascii_lowercase().strip_prefix("content-length:") { + body_len = rest.trim().trim_end_matches('\r').parse().unwrap_or(0); + } + } + while buf.len() < header_end + body_len { + match sock.read(&mut tmp).await { + Ok(0) | Err(_) => return, + Ok(n) => buf.extend_from_slice(&tmp[..n]), + } + } + if let Ok(req) = serde_json::from_slice::(&buf[header_end..]) { + captured.lock().await.push(req); + } + let body = queue + .lock() + .await + .pop_front() + .unwrap_or_else(|| json!({ "error": "no canned response" })); + let body_s = serde_json::to_string(&body).unwrap(); + let resp = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body_s.len(), body_s, + ); + let _ = sock.write_all(resp.as_bytes()).await; + let _ = sock.shutdown().await; + }); + } + }); + CapturingLlm { url, captured } +} + +// ─── Harness (minimal copy — keeping per-test independence) ───────────────── + +struct Harness { + child: tokio::process::Child, + stdin: tokio::process::ChildStdin, + stdout: BufReader, + next_id: i64, +} + +impl Harness { + async fn spawn_with_env(base_url: &str, extra: &[(&str, &str)]) -> Self { + let bin = env!("CARGO_BIN_EXE_sprout-agent"); + let mut cmd = tokio::process::Command::new(bin); + cmd.env("SPROUT_AGENT_PROVIDER", "openai") + .env("OPENAI_COMPAT_API_KEY", "test") + .env("OPENAI_COMPAT_MODEL", "fake-model") + .env("OPENAI_COMPAT_BASE_URL", base_url) + .env("SPROUT_AGENT_LLM_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_TOOL_TIMEOUT_SECS", "5") + .env("SPROUT_AGENT_MAX_ROUNDS", "8") + .env("SPROUT_AGENT_MCP_INIT_TIMEOUT_SECS", "2"); + for (k, v) in extra { + cmd.env(k, v); + } + cmd.stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::inherit()) + .kill_on_drop(true); + let mut child = cmd.spawn().expect("spawn sprout-agent"); + let stdin = child.stdin.take().unwrap(); + let stdout = BufReader::new(child.stdout.take().unwrap()); + Self { + child, + stdin, + stdout, + next_id: 1, + } + } + + async fn spawn(base_url: &str) -> Self { + Self::spawn_with_env(base_url, &[]).await + } + + async fn send(&mut self, method: &str, params: Value) -> i64 { + let id = self.next_id; + self.next_id += 1; + self.write(json!({ "jsonrpc": "2.0", "id": id, "method": method, "params": params })) + .await; + id + } + + async fn notify(&mut self, method: &str, params: Value) { + self.write(json!({ "jsonrpc": "2.0", "method": method, "params": params })) + .await; + } + + async fn write(&mut self, msg: Value) { + let mut s = serde_json::to_string(&msg).unwrap(); + s.push('\n'); + self.stdin.write_all(s.as_bytes()).await.unwrap(); + self.stdin.flush().await.unwrap(); + } + + async fn recv(&mut self) -> Value { + let mut line = String::new(); + let n = tokio::time::timeout(Duration::from_secs(15), self.stdout.read_line(&mut line)) + .await + .expect("recv timeout") + .expect("read line"); + assert!(n > 0, "agent EOF"); + serde_json::from_str(&line).expect("non-JSON line") + } + + async fn recv_until bool>(&mut self, mut pred: F) -> Value { + loop { + let v = self.recv().await; + if pred(&v) { + return v; + } + } + } + + async fn shutdown(mut self) { + drop(self.stdin); + let _ = tokio::time::timeout(Duration::from_secs(2), self.child.wait()).await; + let _ = self.child.start_kill(); + } +} + +fn openai_text(content: &str) -> Value { + json!({ + "id": "cc-1", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": content }, + "finish_reason": "stop", + }], + }) +} + +fn openai_tool_call(id: &str, name: &str, args: Value) -> Value { + json!({ + "id": "cc-2", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { + "role": "assistant", "content": null, + "tool_calls": [{ + "id": id, "type": "function", + "function": { "name": name, "arguments": args.to_string() }, + }], + }, + "finish_reason": "tool_calls", + }], + }) +} + +async fn init_session(h: &mut Harness, mcp_servers: Value) -> String { + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({"cwd":"/tmp","mcpServers": mcp_servers}), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + r["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_owned() +} + +// ─── Tests ────────────────────────────────────────────────────────────────── + +/// After a text-only assistant response, the next prompt's request must +/// include that assistant text in `messages` history. Round 4 fix. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn assistant_text_preserved_across_prompts() { + let llm = spawn_capturing_llm(vec![openai_text("hello world"), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + // Prompt 1. + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Prompt 2 — should carry assistant text from prompt 1. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p2)).await; + + let captured = llm.captured.lock().await; + assert_eq!(captured.len(), 2, "expected 2 LLM requests"); + let msgs = captured[1]["messages"].as_array().unwrap(); + let assistants: Vec<&Value> = msgs.iter().filter(|m| m["role"] == "assistant").collect(); + assert!( + assistants.iter().any(|m| m["content"] == "hello world"), + "assistant text was dropped: messages={msgs:?}" + ); + h.shutdown().await; +} + +/// MCP init that hangs forever must time out within ~2s, surface an error, +/// and the child process must be killed (not lingering). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_init_timeout_kills_child() { + let llm = spawn_capturing_llm(vec![]).await; + let mut h = Harness::spawn(&llm.url).await; + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + + let start = Instant::now(); + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "stuck", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_HANG_INIT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let elapsed = start.elapsed(); + + assert!(r.get("error").is_some(), "expected error, got {r}"); + let msg = r["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("timeout"), "error not a timeout: {msg}"); + // 2s timeout + small slack. Generous to cover slow CI. + assert!( + elapsed < Duration::from_secs(8), + "timeout took too long: {elapsed:?}" + ); + h.shutdown().await; +} + +/// A real MCP server that returns 200 tools with 100KB descriptions must +/// be capped: tool count ≤ MAX_TOOLS_PER_SESSION (128) — we expect spawn_all +/// to either reject (too many) OR truncate. We assert the spawn succeeds with +/// a bounded count, and that descriptions sent to the LLM are ≤ 1024 bytes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn tool_metadata_caps_enforced() { + let llm = spawn_capturing_llm(vec![openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "many", + "command": fake_mcp, + "args": [], + "env": [ + { "name": "FAKE_MCP_TOOL_COUNT", "value": "200" }, + { "name": "FAKE_MCP_HUGE_DESC", "value": "1" }, + ], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + + // Either spawn rejects (200 > 128 cap) — that's acceptable hardening — + // OR it accepts and we verify the LLM request stays bounded. + if r.get("error").is_some() { + let msg = r["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("too many"), "unexpected error: {msg}"); + h.shutdown().await; + return; + } + + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p)).await; + + let captured = llm.captured.lock().await; + assert!(!captured.is_empty(), "no LLM request captured"); + let tools = captured[0]["tools"].as_array().unwrap(); + assert!(tools.len() <= 128, "tool count not capped: {}", tools.len()); + for t in tools { + let desc = t["function"]["description"].as_str().unwrap_or(""); + assert!( + desc.len() <= 1024, + "description not capped: {} bytes", + desc.len() + ); + } + h.shutdown().await; +} + +/// Cap on MCP server count: 17 servers must be rejected. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn mcp_server_count_cap() { + let llm = spawn_capturing_llm(vec![]).await; + let mut h = Harness::spawn(&llm.url).await; + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let servers: Vec = (0..17) + .map(|i| { + json!({ + "name": format!("s{i}"), + "command": fake_mcp, + "args": [], + "env": [], + }) + }) + .collect(); + h.send("session/new", json!({"cwd":"/tmp","mcpServers": servers})) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + assert!(r.get("error").is_some(), "expected error for 17 servers"); + let msg = r["error"]["message"].as_str().unwrap_or(""); + assert!(msg.contains("too many"), "wrong error: {msg}"); + h.shutdown().await; +} + +/// After cancelling mid-tool-loop, the next prompt must succeed without +/// the LLM seeing a malformed history (assistant tool_use with no +/// matching tool_result). Round 5 fix. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_leaves_history_valid_for_next_prompt() { + // Round 1: tool call (unknown — fails fast, no permission flow). + // Round 2: text "ok". + // After cancel, prompt 2 returns text immediately. + let llm = spawn_capturing_llm(vec![ + openai_tool_call("tc1", "fake__t", json!({})), + openai_text("after-cancel"), + openai_text("p2-done"), + ]) + .await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"first"}]}), + ) + .await; + // Cancel right away; the agent races between cancellation and the LLM + // round trip — either way history must remain valid. + h.notify("session/cancel", json!({"sessionId": sid})).await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Prompt 2 — must NOT error from a malformed history. + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"second"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p2)).await; + assert!(r.get("result").is_some(), "p2 errored: {r}"); + h.shutdown().await; +} + +/// Empty assistant content + no tool_calls must serialize as "" (not null) +/// for OpenAI, so subsequent prompts don't get rejected. Round 7 fix 6. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn empty_assistant_serializes_as_empty_string() { + // First call returns content="" finish_reason=stop — agent records an + // empty assistant turn. Second call's request body is what we inspect. + let llm = spawn_capturing_llm(vec![openai_text(""), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session(&mut h, json!([])).await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"a"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + let p2 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"b"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p2)).await; + + let captured = llm.captured.lock().await; + let msgs = captured[1]["messages"].as_array().unwrap(); + let empty_assistant = msgs + .iter() + .find(|m| m["role"] == "assistant" && m.get("tool_calls").is_none()) + .expect("no plain assistant turn"); + // Must be empty string, NOT null. + assert_eq!( + empty_assistant["content"], + json!(""), + "expected empty string content, got {empty_assistant}" + ); + h.shutdown().await; +} + +fn openai_n_tool_calls(n: usize) -> Value { + let calls: Vec = (0..n) + .map(|i| { + json!({ + "id": format!("c{i}"), + "type": "function", + "function": { "name": "many__tool_0", "arguments": "{}" }, + }) + }) + .collect(); + json!({ + "id": "cc-n", "object": "chat.completion", "model": "fake-model", + "choices": [{ + "index": 0, + "message": { "role": "assistant", "content": null, "tool_calls": calls }, + "finish_reason": "tool_calls", + }], + }) +} + +// ─── New round-8 regression tests ────────────────────────────────────────── + +/// History budget evicts old turns: after many prompts, the LLM request +/// body stays below a sane bound. Round 7 fix; round 8 test. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn history_budget_evicts_old_turns() { + // Budget = 1 MB (MIN allowed by config). Each prompt is ~200 KB, so + // 12 prompts × 200 KB = ~2.4 MB blows the cap and forces eviction. + // We expect the captured request body to stay under 3× the cap. + const BUDGET: usize = 1024 * 1024; // 1 MB — must be >= MAX_PROMPT_BYTES + const PROMPT_BYTES: usize = 200 * 1024; // 200 KB per turn + let responses: Vec = (0..12).map(|_| openai_text(&"y".repeat(200))).collect(); + let llm = spawn_capturing_llm(responses).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("SPROUT_AGENT_MAX_HISTORY_BYTES", &BUDGET.to_string()), + ("SPROUT_AGENT_MAX_HANDOFFS", "0"), // exercise truncation, not handoff + ], + ) + .await; + let sid = init_session(&mut h, json!([])).await; + + for i in 0..12 { + let user = "x".repeat(PROMPT_BYTES); + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text": format!("{i}:{user}")}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p)).await; + } + + let captured = llm.captured.lock().await; + assert_eq!(captured.len(), 12); + // The last request must show eviction: body well under unbounded 12 × 200 KB = 2.4 MB. + let last = &captured[captured.len() - 1]; + let body_bytes = serde_json::to_vec(last).unwrap().len(); + assert!( + body_bytes < BUDGET * 3, + "history not evicted: request body is {body_bytes} bytes" + ); + let msgs = last["messages"].as_array().unwrap(); + // We must NEVER drop the latest user prompt. + assert!( + msgs.iter() + .any(|m| m["role"] == "user" && m["content"].as_str().unwrap_or("").starts_with("11:")), + "newest user turn missing" + ); + h.shutdown().await; +} + +/// Per-turn tool-call cap: an LLM that returns 100 tool_calls in one +/// response must only have 64 (MAX_TOOL_CALLS_PER_TURN) executed. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn per_turn_tool_call_cap_enforced() { + let llm = spawn_capturing_llm(vec![openai_n_tool_calls(100), openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "many", + "command": fake_mcp, + "args": [], + "env": [{ "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + + // Count distinct tool_call (pending) notifications until final response. + let mut tool_call_ids = std::collections::HashSet::new(); + loop { + let v = h.recv().await; + if v.get("method") == Some(&json!("session/request_permission")) { + let id = v["id"].clone(); + h.write(json!({ + "jsonrpc": "2.0", + "id": id, + "result": { "outcome": { "outcome": "selected", "optionId": "allow" } }, + })) + .await; + continue; + } + if v.get("method") == Some(&json!("session/update")) + && v["params"]["update"]["sessionUpdate"] == "tool_call" + { + if let Some(id) = v["params"]["update"]["toolCallId"].as_str() { + tool_call_ids.insert(id.to_owned()); + } + continue; + } + if v["id"] == json!(p) { + break; + } + } + // MAX_TOOL_CALLS_PER_TURN = 64. + assert_eq!( + tool_call_ids.len(), + 64, + "expected 64 tool_calls, got {}", + tool_call_ids.len() + ); + h.shutdown().await; +} + +/// Description clamping: a 5000-byte description from MCP must be +/// truncated to ≤ 1024 bytes in the LLM request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn description_clamping_enforced() { + let llm = spawn_capturing_llm(vec![openai_text("done")]).await; + let mut h = Harness::spawn(&llm.url).await; + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "big", + "command": fake_mcp, + "args": [], + "env": [ + { "name": "FAKE_MCP_TOOL_COUNT", "value": "1" }, + { "name": "FAKE_MCP_DESC_SIZE", "value": "5000" }, + ], + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + let sid = r["result"]["sessionId"].as_str().unwrap().to_owned(); + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p)).await; + + let captured = llm.captured.lock().await; + let tools = captured[0]["tools"].as_array().unwrap(); + assert_eq!(tools.len(), 1); + let desc = tools[0]["function"]["description"].as_str().unwrap_or(""); + assert!( + desc.len() <= 1024, + "description not clamped: {} bytes (expected ≤ 1024)", + desc.len() + ); + // Sanity: the original was 5000 bytes, so we did clamp something. + assert!( + desc.len() < 5000, + "description not actually truncated: {} bytes", + desc.len() + ); + h.shutdown().await; +} + +// ─── Hook system regression tests ────────────────────────────────────────── + +/// Helper: spawn a session with a fake MCP server exposing one regular tool +/// plus an optional `_Stop` hook controlled by env vars. +async fn init_session_with_fake_mcp(h: &mut Harness, extra_mcp_env: &[(&str, &str)]) -> String { + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + let env: Vec = extra_mcp_env + .iter() + .map(|(k, v)| json!({ "name": k, "value": v })) + .collect(); + h.send( + "initialize", + json!({"protocolVersion":1,"clientCapabilities":{}}), + ) + .await; + let _ = h.recv().await; + h.send( + "session/new", + json!({ + "cwd": "/tmp", + "mcpServers": [{ + "name": "fake", + "command": fake_mcp, + "args": [], + "env": env, + }], + }), + ) + .await; + let r = h + .recv_until(|v| v.get("result").is_some() || v.get("error").is_some()) + .await; + r["result"]["sessionId"] + .as_str() + .expect("sessionId") + .to_owned() +} + +/// `_Stop` hook objects on the first end_turn → agent must NOT stop. +/// The hook returns an objection only on its first invocation; on the +/// second end_turn (after a tool round), the hook stays silent so the +/// agent ends cleanly. Verifies that the gate rerolls the LLM at least +/// once, and that the objection appears in history as a tool-role +/// message with the JSON-encoded source attribution. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_stop_blocks_premature_end() { + // LLM sequence: + // 1. text "premature" (triggers _Stop objection — call #1) + // 2. tool_call to fake__tool_0 (regular tool, resets latch) + // 3. text "really done" (hook returns empty on call #2 → end) + let llm = spawn_capturing_llm(vec![ + openai_text("premature"), + openai_tool_call("tc1", "fake__tool_0", json!({})), + openai_text("really done"), + ]) + .await; + // stop_max_rejections=10 so the budget never trips. The hook itself + // stays silent on its second call (FAKE_MCP_STOP_COUNT=1) so the + // second end_turn is accepted by the agent — this exercises the + // genuine "objected then later cleared" path, not a budget cap. + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "you have open work"), + // Objection text returned for the first STOP_COUNT calls; + // empty string thereafter. + ("FAKE_MCP_STOP_COUNT", "1"), + ], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p)).await; + assert!(r.get("result").is_some(), "errored: {r}"); + assert_eq!(r["result"]["stopReason"], "end_turn"); + + // Agent must have called LLM ≥2 times (initial end_turn was rejected, + // forcing another LLM round). We expect exactly 3 here: text → tool → text. + let captured = llm.captured.lock().await; + assert!( + captured.len() >= 2, + "agent did not loop after objection: {} LLM calls", + captured.len() + ); + + // Round 2's request must carry the objection as a tool-role message + // (synthetic tool result), not a user/assistant message. Content is + // a JSON object with hook/server/text fields — never escapable. + let msgs = captured[1]["messages"].as_array().unwrap(); + let objection_present = msgs.iter().any(|m| { + if m["role"] != "tool" { + return false; + } + let content = m["content"].as_str().unwrap_or(""); + let parsed: Value = match serde_json::from_str(content) { + Ok(v) => v, + Err(_) => return false, + }; + parsed["hook"] == "_Stop" + && parsed["server"] == "fake" + && parsed["text"] + .as_str() + .unwrap_or("") + .contains("you have open work") + }); + assert!( + objection_present, + "objection (role=tool, JSON-encoded) missing from messages: {msgs:?}" + ); + h.shutdown().await; +} + +/// After `stop_max_rejections` objections, the agent honors end_turn +/// even if `_Stop` would still object. Set max=1 so it trips quickly. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_stop_budget_exhausted() { + // LLM sequence: + // 1. text → triggers _Stop objection (rejections: 0→1) + // 2. tool_call (resets last_was_end_turn) + // 3. text → gate sees rejections>=max, returns end_turn (no _Stop call) + let llm = spawn_capturing_llm(vec![ + openai_text("first"), + openai_tool_call("tc1", "fake__tool_0", json!({})), + openai_text("second"), + ]) + .await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "1"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "still working"), + ], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p)).await; + assert!(r.get("result").is_some(), "errored: {r}"); + assert_eq!(r["result"]["stopReason"], "end_turn"); + + // Three LLM calls expected: budget cap stops the loop on the 3rd end_turn. + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 3, + "expected exactly 3 LLM calls (budget cap), got {}", + captured.len() + ); + h.shutdown().await; +} + +/// Consecutive-rejection rule: if the LLM responds to an objection with +/// no tool calls and end_turn again, the agent accepts the end (avoids +/// infinite loops with an unreasonable hook). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_stop_consecutive_end_turn() { + // LLM sequence: + // 1. text → _Stop objects (rejections: 0→1, last_was_end_turn=true) + // 2. text again, no tool calls → consecutive rule fires, return end_turn + let llm = spawn_capturing_llm(vec![openai_text("done-1"), openai_text("done-2")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + // Set high so we don't trip the budget instead. + ("SPROUT_AGENT_STOP_MAX_REJECTIONS", "10"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "keep going"), + ], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p)).await; + assert!(r.get("result").is_some(), "errored: {r}"); + assert_eq!(r["result"]["stopReason"], "end_turn"); + + // Exactly 2 LLM calls — consecutive rule prevented a 3rd round. + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "expected 2 LLM calls (consecutive rule), got {}", + captured.len() + ); + h.shutdown().await; +} + +/// Regression: an LLM that tries to call a hidden hook tool (e.g. +/// `fake___Stop`) directly must get an "unknown tool" error result — +/// the MCP server must NOT be invoked. This guarantees a malicious or +/// confused model can't trigger lifecycle hooks itself. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_tools_hidden_from_llm() { + // LLM sequence: + // 1. tool_call to fake___Stop (hidden hook, must fail closed) + // 2. text "done" + let llm = spawn_capturing_llm(vec![ + openai_tool_call("tc1", "fake___Stop", json!({})), + openai_text("done"), + ]) + .await; + // We deliberately leave MCP_HOOK_SERVERS unset so the + // agent's hook gate is disabled — hook-tool hiding must hold even + // when hooks aren't allowlisted (defense in depth). + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + // Distinct text we can scan for. If the MCP server is ever + // invoked, this string would appear in the captured history. + ("FAKE_MCP_STOP_TEXT", "HOOK_LEAKED_TO_LLM"), + ], + ) + .await; + + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p)).await; + assert!(r.get("result").is_some(), "errored: {r}"); + + // The tool result fed back to the LLM (round 2) must be the + // synthetic "unknown tool" error, not the hook's actual output. + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 2, + "expected 2 LLM calls, got {}", + captured.len() + ); + let msgs = captured[1]["messages"].as_array().unwrap(); + let tool_msg = msgs + .iter() + .find(|m| m["role"] == "tool") + .expect("expected a tool result message in round 2"); + let content = tool_msg["content"].as_str().unwrap_or(""); + assert!( + content.contains("unknown tool"), + "expected unknown-tool error, got: {content}" + ); + assert!( + !content.contains("HOOK_LEAKED_TO_LLM"), + "MCP hook was invoked from the LLM path: {content}" + ); + + // Defense-in-depth: also confirm the *advertised* tools never + // included the hook in the first place. + let round1_tools = captured[0]["tools"].as_array().unwrap(); + for t in round1_tools { + let name = t["function"]["name"].as_str().unwrap_or(""); + assert!( + !name.contains("_Stop"), + "hook tool advertised to LLM: {name}" + ); + } + h.shutdown().await; +} + +/// `_PostCompact` hook fires after a context-handoff and its output is +/// re-injected into the fresh history as a synthetic tool result. The +/// next LLM request must therefore see the post-compact text in +/// `messages` (role=tool) — proving the hook ran on the *new* context, +/// not the discarded one. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_post_compact_injects_after_handoff() { + // Sequence of canned LLM responses consumed in order: + // 1-3. Three `session/prompt` rounds returning short text. Each + // prompt body is ~300 KB, so by the 4th prompt we'll be over + // the 75% (= 768 KB) threshold of a 1 MB budget. + // 4. Handoff `summarize()` call returns the summary text. + // 5. Next regular `complete()` call after the handoff returns + // a final "done" message; we inspect this request's body. + let llm = spawn_capturing_llm(vec![ + openai_text("ack-1"), + openai_text("ack-2"), + openai_text("ack-3"), + openai_text("handoff summary text"), + openai_text("done"), + ]) + .await; + // 1 MB budget = MIN allowed. Threshold = 768 KB. Each ~300 KB prompt + // fills the budget on the 4th turn, triggering handoff. + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + ("SPROUT_AGENT_MAX_HISTORY_BYTES", &(1024 * 1024).to_string()), + // Allow at least one handoff. + ("SPROUT_AGENT_MAX_HANDOFFS", "3"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + // No _Stop hook here — _PostCompact only. + ("FAKE_MCP_POSTCOMPACT_HOOK", "1"), + ("FAKE_MCP_POSTCOMPACT_TEXT", "todo state here"), + ], + ) + .await; + + // Drive prompts until we observe a handoff. We detect it by counting + // captured LLM requests: a handoff inserts one extra `summarize` call + // that we didn't issue ourselves. We send up to 6 prompts. + let big = "x".repeat(300 * 1024); + let mut prompts_sent = 0usize; + let mut handoff_observed = false; + for i in 0..6 { + let p = h + .send( + "session/prompt", + json!({ + "sessionId": sid, + "prompt": [{"type":"text","text": format!("{i}:{big}")}], + }), + ) + .await; + let _ = h.recv_until(|v| v["id"] == json!(p)).await; + prompts_sent += 1; + let captured_now = llm.captured.lock().await.len(); + // After N prompts we'd normally see N requests; an extra request + // means a handoff summarize() ran. + if captured_now > prompts_sent { + handoff_observed = true; + break; + } + } + assert!( + handoff_observed, + "no handoff observed after {prompts_sent} prompts (captured={})", + llm.captured.lock().await.len() + ); + + // The first LLM call AFTER the handoff is the one we inspect. Find it: + // it's the one where the messages array is short (history just reset) + // and contains a tool-role message with the _PostCompact payload. + let captured = llm.captured.lock().await; + let post_compact_visible = captured.iter().any(|req| { + let msgs = match req["messages"].as_array() { + Some(m) => m, + None => return false, + }; + msgs.iter().any(|m| { + if m["role"] != "tool" { + return false; + } + let content = m["content"].as_str().unwrap_or(""); + let parsed: Value = match serde_json::from_str(content) { + Ok(v) => v, + Err(_) => return false, + }; + parsed["hook"] == "_PostCompact" + && parsed["server"] == "fake" + && parsed["text"] == "todo state here" + }) + }); + assert!( + post_compact_visible, + "_PostCompact tool result not visible to LLM after handoff" + ); + h.shutdown().await; +} + +/// `_Stop` hook that takes longer than `SPROUT_AGENT_HOOK_TIMEOUT_MS` +/// must be treated as no-objection (fail-open). Agent stops normally. +/// +/// Note on server-kill-on-timeout: `call_hooks` calls `kill_server` on a +/// timed-out hook so a wedged server can't poison subsequent calls. We +/// don't add a separate per-test assertion for this — the +/// `mcp_init_timeout_kills_child` test already exercises the same +/// kill-on-timeout codepath through `kill_server`, and the harness here +/// (spawn-then-shutdown) makes a follow-up "tool still works" check +/// fragile because the server we just killed is the only one in the +/// session. The timeout assertion below (elapsed < 2.5s) implicitly +/// covers the kill: if the hook child kept running past the timeout, +/// we'd block on it during shutdown. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn hook_stop_timeout_failopen() { + let llm = spawn_capturing_llm(vec![openai_text("done")]).await; + let mut h = Harness::spawn_with_env( + &llm.url, + &[ + ("MCP_HOOK_SERVERS", "fake"), + // Hook delay (3s) >> hook timeout (200ms) → fail-open. + ("SPROUT_AGENT_HOOK_TIMEOUT_MS", "200"), + ], + ) + .await; + let sid = init_session_with_fake_mcp( + &mut h, + &[ + ("FAKE_MCP_TOOL_COUNT", "1"), + ("FAKE_MCP_STOP_HOOK", "1"), + ("FAKE_MCP_STOP_TEXT", "would object"), + ("FAKE_MCP_STOP_DELAY", "3"), + ], + ) + .await; + + let started = Instant::now(); + let p = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + let r = h.recv_until(|v| v["id"] == json!(p)).await; + let elapsed = started.elapsed(); + + assert!(r.get("result").is_some(), "errored: {r}"); + assert_eq!(r["result"]["stopReason"], "end_turn"); + // Hook delay is 3s; if we waited for it the test would take ≥3s. + // 1.5s gives slack for CI without masking a regression. + assert!( + elapsed < Duration::from_millis(2500), + "did not fail-open: prompt took {elapsed:?}" + ); + + // Only the initial LLM call — agent did NOT loop after the timeout. + let captured = llm.captured.lock().await; + assert_eq!( + captured.len(), + 1, + "expected 1 LLM call, got {}", + captured.len() + ); + h.shutdown().await; +} + +/// When a session is cancelled while a tool call is in-flight, the agent +/// sends `notifications/cancelled` to the MCP server. With sprout-dev-mcp, +/// this cancels the CancellationToken and kills the running shell process +/// group. We verify: +/// 1. The prompt completes in under 5s (not 60s). +/// 2. The `sleep 60` process is actually dead after cancel. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_kills_inflight_tool_via_mcp_notification() { + // sprout-dev-mcp is a separate crate; locate its binary relative to + // the sprout-agent test binary (they share the same target dir). + let self_bin = std::path::PathBuf::from(env!("CARGO_BIN_EXE_sprout-agent")); + let dev_mcp_bin = self_bin.parent().unwrap().join("sprout-dev-mcp"); + if !dev_mcp_bin.exists() { + eprintln!( + "SKIP: sprout-dev-mcp not built at {}; run `cargo build -p sprout-dev-mcp` first", + dev_mcp_bin.display() + ); + return; + } + let dev_mcp_bin = dev_mcp_bin.to_string_lossy().to_string(); + + // Use a unique marker (PID + timestamp) to avoid stale-file collisions. + let marker = format!( + "sprout_cancel_test_{}_{:x}", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + ); + let pid_file = format!("/tmp/{marker}.pid"); + let _ = std::fs::remove_file(&pid_file); // clean any stale file + let cmd = format!("echo $$ > /tmp/{marker}.pid && exec sleep 60"); + + // LLM returns a shell tool call, then text after cancel. + let llm = spawn_capturing_llm(vec![ + openai_tool_call("tc1", "dev__shell", json!({"command": cmd})), + openai_text("done"), + ]) + .await; + + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session( + &mut h, + json!([{ + "name": "dev", + "command": &dev_mcp_bin, + "args": [], + "env": [] + }]), + ) + .await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"run"}]}), + ) + .await; + + // Wait for the tool call to be in-progress. + h.recv_until(|v| { + v.get("params") + .and_then(|p| p.get("update")) + .and_then(|u| u.get("status")) + .and_then(Value::as_str) + == Some("in_progress") + }) + .await; + + // Wait for the shell to spawn and write its PID (bounded). + let pid_deadline = Instant::now() + Duration::from_secs(3); + let shell_pid: u32 = loop { + if let Ok(content) = std::fs::read_to_string(&pid_file) { + if let Ok(pid) = content.trim().parse::() { + break pid; + } + } + assert!( + Instant::now() < pid_deadline, + "shell did not write PID file within 3s" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + // Cancel the session — measure latency from here. + let cancel_start = Instant::now(); + h.notify("session/cancel", json!({"sessionId": sid})).await; + + // Wait for prompt to complete. + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + let cancel_latency = cancel_start.elapsed(); + // Cancellation itself should complete in well under 3s. The 60s sleep + // must NOT run to completion. We allow generous CI slack. + assert!( + cancel_latency < Duration::from_secs(3), + "cancel latency too high: {cancel_latency:?} (expected < 3s)" + ); + + // Verify the shell process is actually dead (bounded poll). + let kill_deadline = Instant::now() + Duration::from_secs(2); + loop { + let alive = std::process::Command::new("kill") + .args(["-0", &shell_pid.to_string()]) + .status() + .map(|s| s.success()) + .unwrap_or(false); + if !alive { + break; + } + assert!( + Instant::now() < kill_deadline, + "shell process {shell_pid} still alive 2s after cancel" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + + // Cleanup. + let _ = std::fs::remove_file(&pid_file); + h.shutdown().await; +} + +/// Protocol-level test: verify that `notifications/cancelled` is sent to +/// any MCP server (not just sprout-dev-mcp) when a session is cancelled +/// during an in-flight tool call. Uses fake_mcp with FAKE_MCP_CANCEL_LOG +/// to capture the raw notification on stdin. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn cancel_sends_notifications_cancelled_to_any_mcp_server() { + let cancel_log = std::env::temp_dir() + .join(format!( + "sprout_cancel_proto_{}_{:x}.log", + std::process::id(), + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_nanos() + )) + .to_string_lossy() + .to_string(); + let _ = std::fs::remove_file(&cancel_log); + let call_received_marker = format!("{cancel_log}.call_received"); + let _ = std::fs::remove_file(&call_received_marker); + + let fake_mcp = env!("CARGO_BIN_EXE_fake-mcp"); + + // LLM returns a tool call; fake_mcp will delay 999s (never responds). + let llm = spawn_capturing_llm(vec![ + openai_tool_call("tc1", "fake__tool_0", json!({})), + openai_text("done"), + ]) + .await; + + let mut h = Harness::spawn(&llm.url).await; + let sid = init_session( + &mut h, + json!([{ + "name": "fake", + "command": fake_mcp, + "args": [], + "env": [ + {"name": "FAKE_MCP_TOOL_DELAY", "value": "999"}, + {"name": "FAKE_MCP_CANCEL_LOG", "value": &cancel_log}, + {"name": "FAKE_MCP_CALL_RECEIVED", "value": &call_received_marker}, + ] + }]), + ) + .await; + + let p1 = h + .send( + "session/prompt", + json!({"sessionId": sid, "prompt": [{"type":"text","text":"go"}]}), + ) + .await; + + // Wait for tool call to be in-progress. + h.recv_until(|v| { + v.get("params") + .and_then(|p| p.get("update")) + .and_then(|u| u.get("status")) + .and_then(Value::as_str) + == Some("in_progress") + }) + .await; + + // Wait until fake_mcp has received the tools/call request (bounded). + // The marker file contains the JSON-RPC request id. + let call_deadline = Instant::now() + Duration::from_secs(3); + let call_request_id: Value = loop { + if let Ok(content) = std::fs::read_to_string(&call_received_marker) { + if let Ok(id) = serde_json::from_str::(content.trim()) { + break id; + } + } + assert!( + Instant::now() < call_deadline, + "fake_mcp did not receive tools/call within 3s" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + }; + + // Cancel the session. + h.notify("session/cancel", json!({"sessionId": sid})).await; + + // Wait for prompt to complete. + let _ = h.recv_until(|v| v["id"] == json!(p1)).await; + + // Poll the cancel log with bounded timeout (replaces fixed sleep). + let poll_deadline = Instant::now() + Duration::from_secs(2); + let log_content = loop { + let content = std::fs::read_to_string(&cancel_log).unwrap_or_default(); + if content.contains("notifications/cancelled") { + break content; + } + assert!( + Instant::now() < poll_deadline, + "cancel notification not received within 2s; log: {content:?}" + ); + tokio::time::sleep(Duration::from_millis(50)).await; + }; + + // Parse the logged notification and verify requestId matches the + // actual tools/call request id that fake_mcp received. + let cancel_msg: Value = serde_json::from_str(log_content.trim()).unwrap_or(json!(null)); + let cancelled_id = &cancel_msg["params"]["requestId"]; + assert!( + cancelled_id.is_number(), + "expected numeric requestId in cancel notification, got: {cancel_msg}" + ); + assert_eq!( + cancelled_id, &call_request_id, + "cancelled requestId ({cancelled_id}) != tools/call id ({call_request_id})" + ); + + // Cleanup. + let _ = std::fs::remove_file(&cancel_log); + let _ = std::fs::remove_file(&call_received_marker); + h.shutdown().await; +} diff --git a/crates/sprout-auth/Cargo.toml b/crates/sprout-auth/Cargo.toml index 6952269193..2328b54d99 100644 --- a/crates/sprout-auth/Cargo.toml +++ b/crates/sprout-auth/Cargo.toml @@ -17,14 +17,10 @@ nostr = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } tokio = { workspace = true } -chrono = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } -jsonwebtoken = { workspace = true } sha2 = { workspace = true } hex = { workspace = true } -reqwest = { workspace = true } rand = { workspace = true } uuid = { workspace = true } -subtle = "2" url = { workspace = true } diff --git a/crates/sprout-auth/src/error.rs b/crates/sprout-auth/src/error.rs index b968a73921..d060f6bc34 100644 --- a/crates/sprout-auth/src/error.rs +++ b/crates/sprout-auth/src/error.rs @@ -23,31 +23,6 @@ pub enum AuthError { #[error("auth event timestamp outside ±60s window")] EventExpired, - /// JWT validation failed (bad signature, expired, wrong issuer/audience, missing claim, etc.). - /// - /// The inner string provides diagnostics for server logs. Do **not** forward - /// this detail to unauthenticated WebSocket clients. - #[error("invalid JWT: {0}")] - InvalidJwt(String), - - /// The API token hash does not match, or the token has expired. - #[error("api token invalid or expired")] - TokenInvalid, - - /// The API token was found in the database but has been revoked. - /// - /// Distinct from [`TokenInvalid`] so the relay can return `401 token_revoked` - /// rather than the generic `invalid_token` error code. - #[error("api token has been revoked")] - TokenRevoked, - - /// The API token was found in the database but has passed its expiry timestamp. - /// - /// Distinct from [`TokenInvalid`] so the relay can return `401 token_expired` - /// rather than the generic `invalid_token` error code. - #[error("api token has expired")] - TokenExpired, - /// NIP-98 HTTP Auth event (kind:27235) failed verification. /// /// The inner string describes the specific failure (signature, timestamp, URL, etc.) @@ -55,7 +30,7 @@ pub enum AuthError { #[error("NIP-98 HTTP Auth verification failed: {0}")] Nip98Invalid(String), - /// The pubkey in the NIP-42 event does not match the identity in the JWT or API token. + /// The pubkey in the auth event does not match the expected identity. #[error("pubkey mismatch: event pubkey does not match authenticated identity")] PubkeyMismatch, @@ -72,12 +47,6 @@ pub enum AuthError { #[error("channel access denied")] ChannelAccessDenied, - /// The JWKS endpoint returned an error or an unparseable response. - /// - /// The inner string provides diagnostics for server logs. - #[error("jwks fetch error: {0}")] - JwksFetchError(String), - /// An unexpected internal error occurred (e.g. a `spawn_blocking` panic). #[error("internal auth error: {0}")] Internal(String), diff --git a/crates/sprout-auth/src/lib.rs b/crates/sprout-auth/src/lib.rs index e0912fee93..30ff51ba7f 100644 --- a/crates/sprout-auth/src/lib.rs +++ b/crates/sprout-auth/src/lib.rs @@ -7,13 +7,13 @@ //! | Path | Transport | Description | //! |------|-----------|-------------| //! | NIP-42 | WebSocket | Challenge/response; client signs kind:22242 event | -//! | Okta JWT | NIP-42 `auth_token` tag | SSO via Okta JWKS validation | -//! | API token | NIP-42 `auth_token` tag | Hash stored in DB; see below | +//! | NIP-98 | HTTP | Signed kind:27235 event in `Authorization: Nostr` header | //! //! ## Security invariants //! //! - **AUTH events (kind:22242) are NEVER stored or logged.** -//! - All paths produce an [`AuthContext`] bound to the WebSocket connection. +//! - All paths produce an [`AuthContext`] bound to the connection. +//! - No JWT validation, no token management, no IdP runtime dependency. /// Channel access checking trait and helpers. pub mod access; @@ -23,25 +23,19 @@ pub mod error; pub mod nip42; /// NIP-98 HTTP Auth verification (kind:27235). pub mod nip98; -/// Okta OIDC integration and JWKS validation. -pub mod okta; /// Per-connection rate limiting. pub mod rate_limit; /// OAuth scope parsing and enforcement. pub mod scope; -/// API token hashing and verification. -pub mod token; pub use access::{check_read_access, check_write_access, require_scope, ChannelAccessChecker}; pub use error::AuthError; pub use nip42::{generate_challenge, verify_nip42_event}; pub use nip98::verify_nip98_event; -pub use okta::{CachedJwks, Jwks, JwksCache, OktaConfig}; pub use rate_limit::{ ip_rate_limit_key, rate_limit_key, LimitType, RateLimitConfig, RateLimitResult, RateLimiter, }; -pub use scope::{is_self_mintable, parse_scopes, Scope, SELF_MINTABLE_SCOPES}; -pub use token::{generate_token, hash_token, verify_token_hash}; +pub use scope::{parse_scopes, Scope}; #[cfg(any(test, feature = "test-utils"))] pub use access::MockAccessChecker; @@ -51,29 +45,30 @@ pub use rate_limit::AlwaysAllowRateLimiter; /// How the connection was authenticated. #[derive(Debug, Clone, PartialEq, Eq)] pub enum AuthMethod { - /// NIP-42 challenge/response only — no JWT or API token present. - /// - /// Only possible when `require_token = false` (dev/open-relay mode). - Nip42PubkeyOnly, - /// NIP-42 with an Okta JWT bearer token in the `auth_token` tag. - Nip42Okta, - /// NIP-42 with a `sprout_` API token in the `auth_token` tag. - Nip42ApiToken, + /// NIP-42 challenge/response — Schnorr signature over kind:22242. + Nip42, + /// NIP-98 HTTP Auth — Schnorr signature over kind:27235. + Nip98, } -/// The result of a successful authentication, bound to a WebSocket connection. +/// The result of a successful authentication, bound to a connection. #[derive(Debug, Clone)] pub struct AuthContext { /// The authenticated Nostr public key. pub pubkey: nostr::PublicKey, /// Permission scopes granted to this connection. pub scopes: Vec, - /// Token-level channel restriction, if authentication used a scoped API token. + /// Channel restriction (reserved for future per-channel access control). /// - /// `None` means unrestricted or not token-authenticated. + /// `None` means unrestricted. pub channel_ids: Option>, /// How the connection was authenticated. pub auth_method: AuthMethod, + /// NIP-OA verified owner pubkey (if authenticated via owner attestation). + /// + /// `None` for direct relay members or non-NIP-OA auth paths. + /// Set by the relay membership gate when NIP-OA fallback succeeds. + pub agent_owner_pubkey: Option, } impl AuthContext { @@ -86,56 +81,39 @@ impl AuthContext { /// Top-level authentication configuration, typically loaded from the relay's TOML config file. #[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)] pub struct AuthConfig { - /// Okta OIDC settings (issuer, audience, JWKS URI, etc.). - #[serde(default)] - pub okta: OktaConfig, /// Per-user and per-IP rate limit thresholds. #[serde(default)] pub rate_limits: RateLimitConfig, } -/// Primary authentication service. -/// -/// Holds shared state (JWKS cache, HTTP client, config). Clone-cheap (Arc internals). -/// -/// **API token auth** is not handled here — `AuthService` has no database access. -/// The relay layer must intercept API tokens from the `auth_token` tag and call -/// [`AuthService::verify_api_token_against_hash`] after fetching the token record. +/// Simplified auth service — NIP-42 and NIP-98 only. +/// No JWT validation, no token management, no IdP runtime dependency. #[derive(Debug, Clone)] pub struct AuthService { config: AuthConfig, - jwks_cache: std::sync::Arc, - http_client: reqwest::Client, } impl AuthService { /// Create a new `AuthService` with the given configuration. - /// - /// Initialises a fresh JWKS cache and a shared `reqwest::Client`. - /// Intended to be constructed once at startup and shared via `Arc`. pub fn new(config: AuthConfig) -> Self { - Self { - config, - jwks_cache: JwksCache::new(), - http_client: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .connect_timeout(std::time::Duration::from_secs(5)) - .build() - .expect("SAFETY: default builder with timeout config cannot fail"), - } + Self { config } + } + + /// Return a reference to the auth configuration. + pub fn config(&self) -> &AuthConfig { + &self.config } /// Verify a NIP-42 AUTH event and return an [`AuthContext`]. /// - /// Validates event structure, signature, challenge, relay URL, timestamp, - /// then dispatches to Okta JWT validation if a bearer token is present. - /// The `auth_event` is **not** retained after this call. + /// Pure cryptographic verification — no network calls, no JWT, no tokens. pub async fn verify_auth_event( &self, auth_event: nostr::Event, expected_challenge: &str, relay_url: &str, ) -> Result { + // Verify NIP-42 signature (spawn_blocking for CPU-bound Schnorr verify) let event_clone = auth_event.clone(); let challenge_owned = expected_challenge.to_string(); let relay_owned = relay_url.to_string(); @@ -145,189 +123,23 @@ impl AuthService { .await .map_err(|_| AuthError::Internal("spawn_blocking panicked".into()))??; - // ⚠️ SECURITY: Do NOT log auth_token — it contains a bearer token. - let auth_token = auth_event - .tags - .iter() - .find(|t| t.kind().to_string() == "auth_token") - .and_then(|t| t.content()) - .map(|s| s.to_string()); - - let (verified_pubkey, scopes, auth_method) = match auth_token.as_deref() { - Some(token) if token.starts_with("eyJ") => { - let (pk, sc) = self.verify_okta_jwt(token, &auth_event.pubkey).await?; - (pk, sc, AuthMethod::Nip42Okta) - } - Some(_) => { - // API tokens require a DB lookup the relay must perform before - // calling verify_auth_event. Reaching here means the relay - // hasn't intercepted the token. - return Err(AuthError::TokenInvalid); - } - None => { - if self.config.okta.require_token { - return Err(AuthError::InvalidJwt( - "auth_token tag required in production mode".into(), - )); - } - // Default-open: no token present and require_token=false. - // Grant all scopes so the connection is fully usable in dev mode. - // The ingest pipeline enforces per-kind scope checks, so NIP-42 - // pubkey-only connections need the full set — including admin - // scopes for kind:9000 (add member), kind:9001 (remove member), - // kind:9008 (delete group), etc. - ( - auth_event.pubkey, - Scope::all_known(), - AuthMethod::Nip42PubkeyOnly, - ) - } - }; - - if verified_pubkey != auth_event.pubkey { - return Err(AuthError::PubkeyMismatch); - } - + // In pure Nostr mode, all authenticated connections get full scopes. + // Per-channel access is enforced by the relay's membership checks (NIP-29). Ok(AuthContext { - pubkey: verified_pubkey, - scopes, + pubkey: auth_event.pubkey, + scopes: Scope::all_known(), channel_ids: None, - auth_method, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, // Set later by relay membership gate if NIP-OA }) } - - async fn verify_okta_jwt( - &self, - jwt: &str, - claimed_pubkey: &nostr::PublicKey, - ) -> Result<(nostr::PublicKey, Vec), AuthError> { - let cached = self - .jwks_cache - .get_or_refresh( - &self.config.okta.jwks_uri, - self.config.okta.jwks_refresh_secs, - &self.http_client, - ) - .await?; - - let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?; - - let pubkey_hex = claims - .get(&self.config.okta.pubkey_claim) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AuthError::InvalidJwt(format!( - "missing '{}' claim in JWT", - self.config.okta.pubkey_claim - )) - })?; - - let pubkey = nostr::PublicKey::from_hex(pubkey_hex) - .map_err(|_| AuthError::InvalidJwt("invalid pubkey hex in JWT claim".into()))?; - - if &pubkey != claimed_pubkey { - return Err(AuthError::PubkeyMismatch); - } - - let scopes = extract_scopes_from_claims(&claims); - Ok((pubkey, scopes)) - } - - /// Validate a raw JWT Bearer token (no Nostr event wrapper). - /// - /// Returns the authenticated pubkey and scopes. Used by HTTP REST API endpoints - /// where there is no NIP-42 Nostr event to compare against — only a raw JWT. - /// - /// Reuses the existing JWKS validation logic but skips the pubkey cross-check - /// (there is no claimed_pubkey from a Nostr event in the HTTP path). - pub async fn validate_bearer_jwt( - &self, - jwt: &str, - ) -> Result<(nostr::PublicKey, Vec), AuthError> { - let cached = self - .jwks_cache - .get_or_refresh( - &self.config.okta.jwks_uri, - self.config.okta.jwks_refresh_secs, - &self.http_client, - ) - .await?; - - let claims = cached.validate(jwt, &self.config.okta.issuer, &self.config.okta.audience)?; - - let pubkey_hex = claims - .get(&self.config.okta.pubkey_claim) - .and_then(|v| v.as_str()) - .ok_or_else(|| { - AuthError::InvalidJwt(format!( - "missing '{}' claim in JWT", - self.config.okta.pubkey_claim - )) - })?; - - let pubkey = nostr::PublicKey::from_hex(pubkey_hex) - .map_err(|_| AuthError::InvalidJwt("invalid pubkey hex in JWT claim".into()))?; - - // Extract scopes from the JWT claims. `extract_scopes_from_claims` returns - // `[MessagesRead]` when no scope claim is present (read-only safe default). - // HTTP REST callers additionally need `ChannelsRead` to list channels, so we - // always ensure that scope is present regardless of what the token says. - let scopes = { - let mut extracted = extract_scopes_from_claims(&claims); - if !extracted.contains(&Scope::ChannelsRead) { - extracted.push(Scope::ChannelsRead); - } - extracted - }; - - Ok((pubkey, scopes)) - } - - /// Verify a raw API token against a pre-fetched hash from the database. - /// - /// The relay layer is responsible for fetching the token record (hash, owner pubkey, - /// expiry, scopes) from the database before calling this method. This keeps - /// `sprout-auth` free of database dependencies. - /// - /// Returns `(owner_pubkey, scopes)` on success. - /// - /// # Errors - /// - /// - [`AuthError::TokenInvalid`] — hash mismatch or token expired. - /// - [`AuthError::PubkeyMismatch`] — `claimed_pubkey` does not match the token owner. - pub fn verify_api_token_against_hash( - &self, - raw_token: &str, - stored_hash: &[u8], - owner_pubkey: &nostr::PublicKey, - claimed_pubkey: &nostr::PublicKey, - expires_at: Option>, - scopes_raw: &[String], - ) -> Result<(nostr::PublicKey, Vec), AuthError> { - if !verify_token_hash(raw_token, stored_hash) { - return Err(AuthError::TokenInvalid); - } - - if let Some(exp) = expires_at { - if exp < chrono::Utc::now() { - return Err(AuthError::TokenInvalid); - } - } - - if owner_pubkey != claimed_pubkey { - return Err(AuthError::PubkeyMismatch); - } - - let scopes = parse_scopes(scopes_raw); - Ok((*owner_pubkey, scopes)) - } } /// Derive a deterministic Nostr pubkey from a username string. /// /// Uses `SHA-256("sprout-test-key:{username}")` as the secret key material. /// This matches the derivation used by the desktop's `set_test_identity` function, -/// allowing the relay to resolve Keycloak usernames to Nostr pubkeys in dev mode. +/// allowing the relay to resolve usernames to Nostr pubkeys in dev mode. /// /// # ⚠️ SECURITY — Dev/test only /// @@ -336,23 +148,6 @@ impl AuthService { /// /// - The derived keys are deterministic and predictable from the username alone. /// - Any attacker who knows a username can compute the corresponding private key. -/// - In production, JWTs must contain a real `nostr_pubkey` claim issued by Okta. -/// -/// ## When it is compiled in -/// -/// | Build command | Included? | Reason | -/// |---|---|---| -/// | `cargo test` | ✅ Yes | `test` cfg | -/// | `cargo build` (debug) | ❌ No | Not included without `dev` feature | -/// | `cargo build --release` | ❌ No | Neither `test` nor `dev` feature | -/// | `cargo build --release --features dev` | ✅ Yes | `dev` feature — use only for integration harnesses | -/// -/// ## The `dev` feature -/// -/// The `dev` feature exists solely to enable this function (and other dev-mode -/// helpers) in release-mode integration test harnesses. It must **not** be -/// enabled in production relay deployments. Check `sprout-relay/Cargo.toml` to -/// ensure `sprout-auth` is not listed with `features = ["dev"]` in production. #[cfg(any(test, feature = "dev"))] pub fn derive_pubkey_from_username(username: &str) -> Result { use sha2::{Digest, Sha256}; @@ -363,44 +158,9 @@ pub fn derive_pubkey_from_username(username: &str) -> Result, -) -> Vec { - if let Some(scp) = claims.get("scp").and_then(|v| v.as_array()) { - let raw: Vec = scp - .iter() - .filter_map(|v| v.as_str().map(str::to_string)) - .collect(); - return parse_scopes(&raw); - } - if let Some(scope_str) = claims.get("scope").and_then(|v| v.as_str()) { - let raw: Vec = scope_str.split_whitespace().map(str::to_string).collect(); - return parse_scopes(&raw); - } - // JWT is valid (signature verified) but contains no scope claim. - // Default to read-only — never silently grant write access from a scopeless token. - // Production Okta configs must include explicit `scp` or `scope` claims. - vec![Scope::MessagesRead] -} - #[cfg(test)] mod tests { use super::*; - use crate::token; use nostr::{EventBuilder, Keys, Kind, Url}; fn make_auth_event(keys: &Keys, challenge: &str, relay_url: &str) -> nostr::Event { @@ -410,10 +170,8 @@ mod tests { .expect("signing failed") } - fn open_mode_service() -> AuthService { - let mut config = AuthConfig::default(); - config.okta.require_token = false; - AuthService::new(config) + fn test_service() -> AuthService { + AuthService::new(AuthConfig::default()) } #[test] @@ -423,26 +181,27 @@ mod tests { pubkey: keys.public_key(), scopes: vec![Scope::MessagesRead, Scope::ChannelsRead], channel_ids: None, - auth_method: AuthMethod::Nip42PubkeyOnly, + auth_method: AuthMethod::Nip42, + agent_owner_pubkey: None, }; assert!(ctx.has_scope(&Scope::MessagesRead)); assert!(!ctx.has_scope(&Scope::MessagesWrite)); } #[tokio::test] - async fn open_mode_auth_succeeds() { + async fn nip42_auth_succeeds() { let keys = Keys::generate(); let challenge = generate_challenge(); let relay = "wss://relay.example.com"; let event = make_auth_event(&keys, &challenge, relay); - let ctx = open_mode_service() + let ctx = test_service() .verify_auth_event(event, &challenge, relay) .await - .expect("open-mode auth should succeed"); + .expect("NIP-42 auth should succeed"); assert_eq!(ctx.pubkey, keys.public_key()); - assert_eq!(ctx.auth_method, AuthMethod::Nip42PubkeyOnly); + assert_eq!(ctx.auth_method, AuthMethod::Nip42); assert!(ctx.has_scope(&Scope::MessagesRead)); assert!(ctx.has_scope(&Scope::MessagesWrite)); } @@ -454,7 +213,7 @@ mod tests { let relay = "wss://relay.example.com"; let event = make_auth_event(&keys, &challenge, relay); - let result = open_mode_service() + let result = test_service() .verify_auth_event(event, "wrong-challenge", relay) .await; assert!(matches!(result, Err(AuthError::ChallengeMismatch))); @@ -467,133 +226,9 @@ mod tests { .sign_with_keys(&keys) .expect("sign"); - let result = open_mode_service() + let result = test_service() .verify_auth_event(event, &generate_challenge(), "wss://relay.example.com") .await; assert!(matches!(result, Err(AuthError::InvalidSignature))); } - - #[tokio::test] - async fn require_token_enforced() { - let keys = Keys::generate(); - let challenge = generate_challenge(); - let relay = "wss://relay.example.com"; - let event = make_auth_event(&keys, &challenge, relay); - - let result = AuthService::new(AuthConfig::default()) - .verify_auth_event(event, &challenge, relay) - .await; - assert!(matches!(result, Err(AuthError::InvalidJwt(_)))); - } - - #[test] - fn extract_scopes_from_scp_array() { - let mut claims = std::collections::HashMap::new(); - claims.insert( - "scp".to_string(), - serde_json::json!(["messages:read", "channels:write"]), - ); - let scopes = extract_scopes_from_claims(&claims); - assert!(scopes.contains(&Scope::MessagesRead)); - assert!(scopes.contains(&Scope::ChannelsWrite)); - } - - #[test] - fn extract_scopes_from_scope_string() { - let mut claims = std::collections::HashMap::new(); - claims.insert( - "scope".to_string(), - serde_json::json!("messages:read messages:write"), - ); - let scopes = extract_scopes_from_claims(&claims); - assert!(scopes.contains(&Scope::MessagesRead)); - assert!(scopes.contains(&Scope::MessagesWrite)); - } - - #[test] - fn extract_scopes_defaults_when_absent() { - // A JWT with no scope claim should default to read-only, NOT read+write. - // Silently granting write access from a scopeless token would be a privilege escalation. - let scopes = extract_scopes_from_claims(&std::collections::HashMap::new()); - assert!(scopes.contains(&Scope::MessagesRead)); - assert!( - !scopes.contains(&Scope::MessagesWrite), - "scopeless JWT must NOT grant write access" - ); - assert_eq!(scopes.len(), 1, "default is exactly [MessagesRead]"); - } - - #[test] - fn verify_api_token_valid() { - let service = open_mode_service(); - let keys = Keys::generate(); - let pubkey = keys.public_key(); - - let raw = token::generate_token(); - let hash = token::hash_token(&raw); - let scopes_raw = vec!["messages:read".to_string(), "messages:write".to_string()]; - - let result = - service.verify_api_token_against_hash(&raw, &hash, &pubkey, &pubkey, None, &scopes_raw); - assert!(result.is_ok()); - let (pk, scopes) = result.unwrap(); - assert_eq!(pk, pubkey); - assert!(scopes.contains(&Scope::MessagesRead)); - assert!(scopes.contains(&Scope::MessagesWrite)); - } - - #[test] - fn verify_api_token_wrong_hash_rejected() { - let service = open_mode_service(); - let keys = Keys::generate(); - let pubkey = keys.public_key(); - - let raw = token::generate_token(); - let wrong_hash = token::hash_token("not-the-right-token"); - - let result = - service.verify_api_token_against_hash(&raw, &wrong_hash, &pubkey, &pubkey, None, &[]); - assert!(matches!(result, Err(AuthError::TokenInvalid))); - } - - #[test] - fn verify_api_token_expired_rejected() { - let service = open_mode_service(); - let keys = Keys::generate(); - let pubkey = keys.public_key(); - - let raw = token::generate_token(); - let hash = token::hash_token(&raw); - let expired = chrono::Utc::now() - chrono::Duration::seconds(1); - - let result = service.verify_api_token_against_hash( - &raw, - &hash, - &pubkey, - &pubkey, - Some(expired), - &[], - ); - assert!(matches!(result, Err(AuthError::TokenInvalid))); - } - - #[test] - fn verify_api_token_pubkey_mismatch_rejected() { - let service = open_mode_service(); - let owner_keys = Keys::generate(); - let claimed_keys = Keys::generate(); - - let raw = token::generate_token(); - let hash = token::hash_token(&raw); - - let result = service.verify_api_token_against_hash( - &raw, - &hash, - &owner_keys.public_key(), - &claimed_keys.public_key(), - None, - &[], - ); - assert!(matches!(result, Err(AuthError::PubkeyMismatch))); - } } diff --git a/crates/sprout-auth/src/nip98.rs b/crates/sprout-auth/src/nip98.rs index ccad6c16b8..b3dd59342c 100644 --- a/crates/sprout-auth/src/nip98.rs +++ b/crates/sprout-auth/src/nip98.rs @@ -291,36 +291,4 @@ mod tests { let result = verify_nip98_event(&json, loopback_url, TEST_METHOD, None); assert!(result.is_ok()); } - - #[test] - fn is_self_mintable_all_eight() { - use crate::scope::{is_self_mintable, Scope}; - assert!(is_self_mintable(&Scope::MessagesRead)); - assert!(is_self_mintable(&Scope::MessagesWrite)); - assert!(is_self_mintable(&Scope::ChannelsRead)); - assert!(is_self_mintable(&Scope::ChannelsWrite)); - assert!(is_self_mintable(&Scope::UsersRead)); - assert!(is_self_mintable(&Scope::UsersWrite)); - assert!(is_self_mintable(&Scope::FilesRead)); - assert!(is_self_mintable(&Scope::FilesWrite)); - } - - #[test] - fn is_self_mintable_admin_scopes_false() { - use crate::scope::{is_self_mintable, Scope}; - assert!(!is_self_mintable(&Scope::AdminChannels)); - assert!(!is_self_mintable(&Scope::AdminUsers)); - assert!(!is_self_mintable(&Scope::JobsRead)); - assert!(!is_self_mintable(&Scope::JobsWrite)); - assert!(!is_self_mintable(&Scope::SubscriptionsRead)); - assert!(!is_self_mintable(&Scope::SubscriptionsWrite)); - } - - #[test] - fn is_self_mintable_unknown_false() { - use crate::scope::{is_self_mintable, Scope}; - assert!(!is_self_mintable(&Scope::Unknown( - "future:scope".to_string() - ))); - } } diff --git a/crates/sprout-auth/src/okta.rs b/crates/sprout-auth/src/okta.rs deleted file mode 100644 index 35a40f8fa7..0000000000 --- a/crates/sprout-auth/src/okta.rs +++ /dev/null @@ -1,354 +0,0 @@ -//! Okta JWT validation via JWKS. -//! -//! Fetches and caches the JWKS, validates JWTs (signature, expiry, issuer, audience). - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use jsonwebtoken::{decode, decode_header, Algorithm, DecodingKey, Validation}; -use serde::{Deserialize, Serialize}; -use serde_json::Value; -use tokio::sync::RwLock; -use tracing::{debug, warn}; - -use crate::error::AuthError; - -/// Default TTL for the JWKS cache in seconds (5 minutes). -/// -/// After this interval the next auth attempt will trigger a background re-fetch. -/// Tune via [`OktaConfig::jwks_refresh_secs`] for environments with faster key rotation. -pub const JWKS_CACHE_TTL_SECS: u64 = 300; - -/// A JSON Web Key Set as returned by the OIDC `/keys` endpoint. -#[derive(Debug, Clone, Deserialize)] -pub struct Jwks { - /// The list of public keys in this set. - pub keys: Vec, -} - -/// A single JSON Web Key (RSA or EC public key). -/// -/// Only the fields required for signature verification are used. -/// Unknown fields are ignored during deserialization. -#[derive(Debug, Clone, Deserialize)] -pub struct Jwk { - /// Key type: `"RSA"` or `"EC"`. - pub kty: String, - /// Key ID — matched against the JWT `kid` header to select the right key. - pub kid: Option, - /// Algorithm hint (e.g. `"RS256"`, `"ES256"`). - pub alg: Option, - /// RSA modulus (base64url-encoded). - pub n: Option, - /// RSA public exponent (base64url-encoded). - pub e: Option, - /// EC curve name (e.g. `"P-256"`). - pub crv: Option, - /// EC public key x-coordinate (base64url-encoded). - pub x: Option, - /// EC public key y-coordinate (base64url-encoded). - pub y: Option, -} - -/// A fetched JWKS together with the [`Instant`] it was retrieved. -/// -/// Used by [`JwksCache`] to determine whether the cached keys are still fresh. -#[derive(Debug, Clone)] -pub struct CachedJwks { - /// The fetched key set. - pub jwks: Jwks, - /// Wall-clock time at which this entry was populated. - pub fetched_at: Instant, -} - -impl CachedJwks { - /// Validate a JWT and return decoded claims. - pub fn validate( - &self, - jwt: &str, - issuer: &str, - audience: &str, - ) -> Result, AuthError> { - let header = decode_header(jwt) - .map_err(|e| AuthError::InvalidJwt(format!("bad jwt header: {e}")))?; - - let kid = header.kid.as_deref(); - let jwk = self - .find_key(kid, &header.alg) - .ok_or_else(|| AuthError::InvalidJwt("no matching key in JWKS".into()))?; - - let decoding_key = Self::decoding_key_from_jwk(jwk)?; - - let mut validation = Validation::new(header.alg); - validation.set_issuer(&[issuer]); - validation.set_audience(&[audience]); - - let token_data = decode::>(jwt, &decoding_key, &validation) - .map_err(|e| AuthError::InvalidJwt(format!("jwt validation failed: {e}")))?; - - Ok(token_data.claims) - } - - fn find_key(&self, kid: Option<&str>, alg: &Algorithm) -> Option<&Jwk> { - self.jwks.keys.iter().find(|k| { - let kid_match = kid.is_none_or(|id| k.kid.as_deref() == Some(id)); - let alg_match = k.alg.as_ref().is_none_or(|a| matches_algorithm(a, alg)); - kid_match && alg_match - }) - } - - fn decoding_key_from_jwk(jwk: &Jwk) -> Result { - match jwk.kty.as_str() { - "RSA" => { - let n = jwk - .n - .as_deref() - .ok_or_else(|| AuthError::InvalidJwt("RSA key missing 'n'".into()))?; - let e = jwk - .e - .as_deref() - .ok_or_else(|| AuthError::InvalidJwt("RSA key missing 'e'".into()))?; - DecodingKey::from_rsa_components(n, e) - .map_err(|e| AuthError::InvalidJwt(format!("invalid RSA key: {e}"))) - } - "EC" => { - let x = jwk - .x - .as_deref() - .ok_or_else(|| AuthError::InvalidJwt("EC key missing 'x'".into()))?; - let y = jwk - .y - .as_deref() - .ok_or_else(|| AuthError::InvalidJwt("EC key missing 'y'".into()))?; - DecodingKey::from_ec_components(x, y) - .map_err(|e| AuthError::InvalidJwt(format!("invalid EC key: {e}"))) - } - other => Err(AuthError::InvalidJwt(format!( - "unsupported key type: {other}" - ))), - } - } - - /// Returns `true` if this entry was fetched within the last `ttl_secs` seconds. - pub fn is_fresh(&self, ttl_secs: u64) -> bool { - self.fetched_at.elapsed() < Duration::from_secs(ttl_secs) - } -} - -/// Returns `true` if the string `alg_str` (from a JWK's `alg` field) matches -/// the [`Algorithm`] decoded from the JWT header. -fn matches_algorithm(alg_str: &str, alg: &Algorithm) -> bool { - match alg { - Algorithm::RS256 => alg_str == "RS256", - Algorithm::RS384 => alg_str == "RS384", - Algorithm::RS512 => alg_str == "RS512", - Algorithm::ES256 => alg_str == "ES256", - Algorithm::ES384 => alg_str == "ES384", - Algorithm::PS256 => alg_str == "PS256", - Algorithm::PS384 => alg_str == "PS384", - Algorithm::PS512 => alg_str == "PS512", - _ => false, - } -} - -/// Thread-safe in-process JWKS cache. Wrap in `Arc` and share across tasks. -/// -/// Uses double-checked locking with an **unlocked HTTP fetch** to prevent two -/// failure modes simultaneously: -/// -/// 1. **Thundering herd**: N concurrent cache misses each triggering N HTTP -/// requests. The final write-lock re-check ensures only one result is stored. -/// -/// 2. **Global DoS via lock-held fetch** *(the bug this design avoids)*: holding -/// the write lock across the HTTP call would block every reader (every in-flight -/// auth attempt) for the full duration of the OIDC endpoint round-trip. If the -/// endpoint is slow or unreachable, the relay becomes completely unavailable. -/// -/// The trade-off: two concurrent stale-cache threads may both fetch from the OIDC -/// endpoint. This is safe — fetches are idempotent and the second writer simply -/// finds a fresh entry and discards its result. -#[derive(Debug, Default)] -pub struct JwksCache { - inner: RwLock>, -} - -impl JwksCache { - /// Create a new empty cache wrapped in an `Arc`. - pub fn new() -> Arc { - Arc::new(Self { - inner: RwLock::new(None), - }) - } - - /// Return cached JWKS if fresh, otherwise fetch and cache a new one. - /// - /// # Locking protocol - /// - /// 1. Acquire **read** lock → return if fresh (fast path, no contention). - /// 2. Drop read lock. - /// 3. Acquire **read** lock again → re-check freshness (another thread may - /// have already refreshed while we were waiting). - /// 4. Drop read lock. - /// 5. Fetch JWKS with **no lock held** — readers are never blocked. - /// 6. Acquire **write** lock → re-check one final time, then store if still stale. - /// - /// Step 5 is the critical fix: the HTTP fetch never holds the write lock, - /// so readers are never blocked by a slow or hung OIDC endpoint. - /// Two concurrent threads may both fetch; that is intentional and safe - /// (idempotent). The write-lock re-check in step 6 ensures only one result - /// is stored. - pub async fn get_or_refresh( - &self, - jwks_uri: &str, - ttl_secs: u64, - client: &reqwest::Client, - ) -> Result { - { - let guard = self.inner.read().await; - if let Some(cached) = guard.as_ref() { - if cached.is_fresh(ttl_secs) { - debug!("JWKS cache hit"); - return Ok(cached.clone()); - } - } - } - - // Pre-fetch re-check: another thread may have refreshed between our - // first read-lock drop and now. Use a second read lock (not write) so - // we don't block other readers while deciding whether to fetch. - { - let guard = self.inner.read().await; - if let Some(cached) = guard.as_ref() { - if cached.is_fresh(ttl_secs) { - debug!("JWKS cache hit (pre-fetch re-check)"); - return Ok(cached.clone()); - } - } - } - - // *** CRITICAL: fetch with NO lock held *** - // - // Holding the write lock across an HTTP call would block ALL readers - // (every in-flight auth attempt) for the entire round-trip duration. - // A slow or hung OIDC endpoint would cause a global relay DoS. - // - // Two concurrent threads may both reach this point and both issue a - // fetch. That is intentional — fetches are idempotent. The write-lock - // re-check below ensures only one result is stored. - debug!("JWKS cache miss — fetching from {jwks_uri}"); - let jwks = fetch_jwks(jwks_uri, client).await?; - let fetched = CachedJwks { - jwks, - fetched_at: Instant::now(), - }; - - // Final re-check: another thread may have stored a fresh entry while - // we were fetching. If so, discard our result and return theirs. - let mut guard = self.inner.write().await; - if let Some(cached) = guard.as_ref() { - if cached.is_fresh(ttl_secs) { - debug!("JWKS cache hit (stored by concurrent fetcher — discarding our result)"); - return Ok(cached.clone()); - } - } - *guard = Some(fetched.clone()); - Ok(fetched) - } -} - -async fn fetch_jwks(uri: &str, client: &reqwest::Client) -> Result { - let response = client - .get(uri) - .send() - .await - .map_err(|e| AuthError::JwksFetchError(format!("request failed: {e}")))?; - - if !response.status().is_success() { - let status = response.status(); - warn!("JWKS fetch returned HTTP {status}"); - return Err(AuthError::JwksFetchError(format!( - "HTTP {status} from JWKS endpoint" - ))); - } - - response - .json::() - .await - .map_err(|e| AuthError::JwksFetchError(format!("failed to parse JWKS: {e}"))) -} - -/// Okta OIDC configuration for JWT validation. -/// -/// Loaded from relay config (TOML/env). All fields except `pubkey_claim`, -/// `jwks_refresh_secs`, and `require_token` are required in production. -#[derive(Debug, Clone, Serialize, Deserialize)] -pub struct OktaConfig { - /// Expected `iss` claim in incoming JWTs (e.g. `https://example.okta.com/oauth2/default`). - pub issuer: String, - /// Expected `aud` claim in incoming JWTs (the Okta application client ID or custom audience). - pub audience: String, - /// URL of the OIDC JWKS endpoint (e.g. `https://example.okta.com/oauth2/default/v1/keys`). - pub jwks_uri: String, - /// JWT claim name that holds the user's Nostr public key (hex). Default: `"nostr_pubkey"`. - pub pubkey_claim: String, - /// How often to refresh the JWKS cache, in seconds. Default: 300 (5 minutes). - #[serde(default = "default_jwks_refresh_secs")] - pub jwks_refresh_secs: u64, - /// If `true` (production default), every NIP-42 AUTH event must include an `auth_token` tag - /// containing a valid JWT or API token. If `false` (dev/open-relay mode), connections without - /// a token are accepted and granted baseline `[MessagesRead, MessagesWrite]` scopes. - /// - /// ⚠️ **Never set `require_token = false` in production.** It disables all token-based - /// authentication and allows any Nostr keypair to connect and send messages. - #[serde(default = "default_require_token")] - pub require_token: bool, -} - -fn default_jwks_refresh_secs() -> u64 { - 300 -} -fn default_require_token() -> bool { - true -} - -impl Default for OktaConfig { - fn default() -> Self { - Self { - issuer: String::new(), - audience: String::new(), - jwks_uri: String::new(), - pubkey_claim: "nostr_pubkey".into(), - jwks_refresh_secs: 300, - require_token: true, - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn okta_config_defaults() { - let cfg = OktaConfig::default(); - assert_eq!(cfg.pubkey_claim, "nostr_pubkey"); - assert_eq!(cfg.jwks_refresh_secs, 300); - assert!(cfg.require_token); - } - - #[test] - fn jwks_freshness() { - let fresh = CachedJwks { - jwks: Jwks { keys: vec![] }, - fetched_at: Instant::now(), - }; - assert!(fresh.is_fresh(300)); - - let stale = CachedJwks { - jwks: Jwks { keys: vec![] }, - fetched_at: Instant::now() - Duration::from_secs(400), - }; - assert!(!stale.is_fresh(300)); - } -} diff --git a/crates/sprout-auth/src/scope.rs b/crates/sprout-auth/src/scope.rs index e3197da212..2bd92a924d 100644 --- a/crates/sprout-auth/src/scope.rs +++ b/crates/sprout-auth/src/scope.rs @@ -1,6 +1,8 @@ -//! API token scopes. +//! Authorization scopes. //! -//! Stored as `TEXT[]` in the database so new scopes don't require migrations. +//! Scopes control what operations an authenticated connection may perform. +//! In pure Nostr mode, all NIP-42 authenticated connections receive the full +//! scope set; per-channel access is enforced by NIP-29 membership checks. use std::fmt; use std::str::FromStr; @@ -40,6 +42,18 @@ pub enum Scope { FilesRead, /// Upload files and attachments. FilesWrite, + /// Clone git repositories. + /// + /// Reserved for future use. Not currently enforced by git HTTP routes — + /// those use NIP-98 auth directly. Will be enforced when collaborator + /// access (read-only, maintainer) is added in v2. + ReposRead, + /// Push to git repositories and create repos (kind:30617). + /// + /// Enforced for kind:30617/30618 events via WebSocket ingest, but NOT + /// enforced by git HTTP push routes (which use NIP-98 + owner check). + /// Full enforcement deferred to v2 collaborator model. + ReposWrite, /// Submit events on behalf of other pubkeys (proxy service accounts only). ProxySubmit, /// A scope string not recognised by this version of the relay. @@ -69,6 +83,8 @@ impl Scope { Self::SubscriptionsWrite, Self::FilesRead, Self::FilesWrite, + Self::ReposRead, + Self::ReposWrite, ] } @@ -91,6 +107,8 @@ impl Scope { Self::SubscriptionsWrite, Self::FilesRead, Self::FilesWrite, + Self::ReposRead, + Self::ReposWrite, ] } @@ -111,6 +129,8 @@ impl Scope { Self::SubscriptionsWrite => "subscriptions:write", Self::FilesRead => "files:read", Self::FilesWrite => "files:write", + Self::ReposRead => "repos:read", + Self::ReposWrite => "repos:write", Self::ProxySubmit => "proxy:submit", Self::Unknown(s) => s.as_str(), } @@ -142,51 +162,14 @@ impl FromStr for Scope { "subscriptions:write" => Self::SubscriptionsWrite, "files:read" => Self::FilesRead, "files:write" => Self::FilesWrite, + "repos:read" => Self::ReposRead, + "repos:write" => Self::ReposWrite, "proxy:submit" => Self::ProxySubmit, other => Self::Unknown(other.to_string()), }) } } -/// Scopes that can be self-minted via `POST /api/tokens`. -/// -/// Admin-only scopes (`AdminChannels`, `AdminUsers`, `JobsRead`, `JobsWrite`, -/// `SubscriptionsRead`, `SubscriptionsWrite`) are intentionally excluded — they require -/// `sprout-admin mint-token`. -/// -/// `UsersWrite` is included because it guards self-profile endpoints -/// (`PUT /api/users/me/profile`, `PUT /api/users/me/channel-add-policy`) -/// and contact list (kind:3) publishing. -pub const SELF_MINTABLE_SCOPES: &[Scope] = &[ - Scope::MessagesRead, - Scope::MessagesWrite, - Scope::ChannelsRead, - Scope::ChannelsWrite, - Scope::UsersRead, - Scope::UsersWrite, - Scope::FilesRead, - Scope::FilesWrite, -]; - -/// Returns `true` if the given scope may be requested via `POST /api/tokens`. -/// -/// Admin-only scopes and `Scope::Unknown` always return `false`. -/// Unknown scope strings are rejected at mint time rather than silently accepted — -/// a client sending an unrecognised scope string likely has a bug. -pub fn is_self_mintable(scope: &Scope) -> bool { - matches!( - scope, - Scope::MessagesRead - | Scope::MessagesWrite - | Scope::ChannelsRead - | Scope::ChannelsWrite - | Scope::UsersRead - | Scope::UsersWrite - | Scope::FilesRead - | Scope::FilesWrite - ) -} - /// Parse a slice of scope strings into `Vec`. pub fn parse_scopes(raw: &[impl AsRef]) -> Vec { raw.iter() @@ -226,12 +209,12 @@ mod tests { #[test] fn all_non_admin_excludes_admin_scopes() { let scopes = Scope::all_non_admin(); - assert_eq!(scopes.len(), 12, "expected 12 non-admin scope variants"); + assert_eq!(scopes.len(), 14, "expected 14 non-admin scope variants"); // Verify no duplicates let unique: std::collections::HashSet<_> = scopes.iter().map(|s| s.as_str()).collect(); assert_eq!( unique.len(), - 12, + 14, "all_non_admin() must not contain duplicates" ); // Verify no Unknown variants @@ -255,10 +238,10 @@ mod tests { #[test] fn all_known_returns_all_14_variants() { let all = Scope::all_known(); - assert_eq!(all.len(), 14, "expected 14 known scope variants"); + assert_eq!(all.len(), 16, "expected 16 known scope variants"); // Verify no duplicates let unique: std::collections::HashSet<_> = all.iter().map(|s| s.as_str()).collect(); - assert_eq!(unique.len(), 14, "all_known() must not contain duplicates"); + assert_eq!(unique.len(), 16, "all_known() must not contain duplicates"); // Verify no Unknown variants for scope in &all { assert!( diff --git a/crates/sprout-auth/src/token.rs b/crates/sprout-auth/src/token.rs deleted file mode 100644 index 362bf1e732..0000000000 --- a/crates/sprout-auth/src/token.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! API token creation, hashing, and validation. -//! -//! Only the SHA-256 hash is stored — the raw token is shown once at creation. -//! Format: `sprout_<32-random-bytes-as-hex>` (71 characters). - -use sha2::{Digest, Sha256}; -use subtle::ConstantTimeEq; - -const TOKEN_PREFIX: &str = "sprout_"; - -/// Generate a new random API token (CSPRNG, 32 bytes, hex-encoded with prefix). -pub fn generate_token() -> String { - let bytes: [u8; 32] = rand::random(); - format!("{}{}", TOKEN_PREFIX, hex::encode(bytes)) -} - -/// SHA-256 hash of a raw token (the value stored in `api_tokens.token_hash`). -pub fn hash_token(token: &str) -> Vec { - let mut hasher = Sha256::new(); - hasher.update(token.as_bytes()); - hasher.finalize().to_vec() -} - -/// Constant-time verification that `raw_token` matches `expected_hash`. -pub fn verify_token_hash(raw_token: &str, expected_hash: &[u8]) -> bool { - let computed = hash_token(raw_token); - computed.ct_eq(expected_hash).into() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn token_format_and_length() { - let token = generate_token(); - assert!(token.starts_with("sprout_")); - assert_eq!(token.len(), 7 + 64); - } - - #[test] - fn tokens_are_unique() { - assert_ne!(generate_token(), generate_token()); - } - - #[test] - fn hash_verify_round_trip() { - let token = generate_token(); - let hash = hash_token(&token); - assert_eq!(hash.len(), 32); - assert!(verify_token_hash(&token, &hash)); - } - - #[test] - fn wrong_token_rejected() { - let hash = hash_token(&generate_token()); - assert!(!verify_token_hash(&generate_token(), &hash)); - } - - #[test] - fn hash_is_deterministic() { - let token = "sprout_test_abc123"; - assert_eq!(hash_token(token), hash_token(token)); - } -} diff --git a/crates/sprout-cli/Cargo.toml b/crates/sprout-cli/Cargo.toml index 7668f1e030..3149305217 100644 --- a/crates/sprout-cli/Cargo.toml +++ b/crates/sprout-cli/Cargo.toml @@ -7,6 +7,10 @@ license.workspace = true repository.workspace = true description = "Agent-first CLI for Sprout relay" +[lib] +name = "sprout_cli" +path = "src/lib.rs" + [[bin]] name = "sprout" path = "src/main.rs" @@ -40,8 +44,17 @@ sprout-sdk = { workspace = true } # Base64 encoding — NIP-98 event serialization for Authorization header base64 = "0.22" -# SHA-256 — NIP-98 payload hash tag +# SHA-256 — NIP-98 payload hash tag and Blossom file hash sha2 = "0.11" +# Hex encoding — SHA-256 hash output for Blossom uploads +hex = { workspace = true } + +# MIME type detection via magic bytes — file upload validation +infer = "0.19" + +# URL parsing — extract server domain for Blossom auth tag +url = { workspace = true } + # Persona pack parsing, validation, and resolution sprout-persona = { path = "../sprout-persona" } diff --git a/crates/sprout-cli/README.md b/crates/sprout-cli/README.md index d1c6cda004..51cd866622 100644 --- a/crates/sprout-cli/README.md +++ b/crates/sprout-cli/README.md @@ -10,25 +10,21 @@ cargo install --path crates/sprout-cli ## Authentication -Three modes, checked in order: +Two modes, checked in order: | Priority | Env Var | Mode | Use Case | |----------|---------|------|----------| -| 1 | `SPROUT_API_TOKEN` | Bearer token | Production — fastest, no extra HTTP call | -| 2 | `SPROUT_PRIVATE_KEY` | Auto-mint short-lived token via NIP-98 | Agents with a keypair | -| 3 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development | +| 1 | `SPROUT_PRIVATE_KEY` | NIP-98 Schnorr signature | Agents with a keypair | +| 2 | `SPROUT_PUBKEY` | X-Pubkey header (dev relay only) | Local development | ```bash -# Option 1: Pre-minted token -export SPROUT_API_TOKEN="sprout_tok_..." -sprout list-channels - -# Option 2: Private key (auto-mints a 1-day token at startup) +# Option 1: Private key (NIP-98 signed requests) export SPROUT_PRIVATE_KEY="nsec1..." sprout list-channels -# Option 3: Mint a long-lived token explicitly -export SPROUT_API_TOKEN=$(SPROUT_PRIVATE_KEY=nsec1... sprout auth) +# Option 2: Dev mode (no auth) +export SPROUT_PUBKEY="" +sprout list-channels ``` ## Usage @@ -83,17 +79,11 @@ sprout vote-on-post --event --direction up sprout get-canvas --channel sprout set-canvas --channel --content "# Welcome" -# Tokens -sprout auth # mint token, print to stdout -sprout list-tokens -sprout delete-token --id -sprout delete-all-tokens - # Pipe to jq sprout list-channels | jq '.[].name' ``` -## All 48 Commands +## All 44 Commands | Command | Description | |---------|-------------| @@ -141,10 +131,6 @@ sprout list-channels | jq '.[].name' | `approve-step` | Approve/deny a workflow step | | `get-feed` | Get your activity feed | | `vote-on-post` | Vote on a forum post | -| `auth` | Mint a long-lived API token | -| `list-tokens` | List your API tokens | -| `delete-token` | Delete a token | -| `delete-all-tokens` | Delete all tokens | ## Architecture diff --git a/crates/sprout-cli/src/client.rs b/crates/sprout-cli/src/client.rs index 4c6aa0e444..9789e9be20 100644 --- a/crates/sprout-cli/src/client.rs +++ b/crates/sprout-cli/src/client.rs @@ -1,16 +1,118 @@ use std::time::Duration; -use nostr::Keys; +use base64::engine::general_purpose::STANDARD as B64; +use base64::Engine; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; +use sha2::{Digest, Sha256}; use crate::error::CliError; // --------------------------------------------------------------------------- -// Auth +// Blob / Media types // --------------------------------------------------------------------------- -pub enum Auth { - Bearer(String), // SPROUT_API_TOKEN or auto-minted via SPROUT_PRIVATE_KEY - DevMode(String), // SPROUT_PUBKEY — X-Pubkey header, relay must have require_auth_token=false +/// Descriptor returned by the relay after a successful upload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BlobDescriptor { + /// Public URL of the uploaded blob. + pub url: String, + /// Hex-encoded SHA-256 of the file content. + pub sha256: String, + /// File size in bytes. + pub size: u64, + /// MIME type (e.g. `image/jpeg`). + #[serde(rename = "type")] + pub mime_type: String, + /// Unix timestamp when the file was uploaded. + pub uploaded: i64, + /// Image dimensions as `x` (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub dim: Option, + /// Blurhash placeholder string (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub blurhash: Option, + /// Thumbnail URL (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, + /// Duration in seconds for video/audio (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +/// Build an `imeta` tag array from a BlobDescriptor (NIP-92 media metadata). +pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { + let mut tag = vec![ + "imeta".to_string(), + format!("url {}", d.url), + format!("m {}", d.mime_type), + format!("x {}", d.sha256), + format!("size {}", d.size), + ]; + if let Some(ref dim) = d.dim { + tag.push(format!("dim {dim}")); + } + if let Some(ref bh) = d.blurhash { + tag.push(format!("blurhash {bh}")); + } + if let Some(ref th) = d.thumb { + tag.push(format!("thumb {th}")); + } + if let Some(dur) = d.duration { + tag.push(format!("duration {dur}")); + } + tag +} + +/// MIME types accepted for upload. +const ALLOWED_MIMES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", +]; + +/// Maximum file size for image uploads (50 MB). +const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +/// Maximum file size for video uploads (500 MB). +const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + +// --------------------------------------------------------------------------- +// NIP-98 HTTP Auth +// --------------------------------------------------------------------------- + +/// Sign a NIP-98 HTTP auth event (kind:27235) and return the Authorization header value. +/// +/// The event includes: +/// - `u` tag: the full request URL +/// - `method` tag: HTTP method (GET, POST, PUT, DELETE) +/// - `payload` tag: SHA-256 hex of the request body (if present) +fn sign_nip98( + keys: &Keys, + method: &str, + url: &str, + body: Option<&[u8]>, +) -> Result { + let mut tags = vec![ + Tag::parse(&["u", url]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + Tag::parse(&["method", method]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + // Nonce prevents replay rejection for rapid-fire requests with identical bodies. + Tag::parse(&["nonce", &uuid::Uuid::new_v4().to_string()]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?, + ]; + if let Some(b) = body { + let hash = hex::encode(Sha256::digest(b)); + tags.push( + Tag::parse(&["payload", &hash]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?, + ); + } + let event = EventBuilder::new(Kind::Custom(27235), "", tags) + .sign_with_keys(keys) + .map_err(|e| CliError::Other(format!("NIP-98 signing failed: {e}")))?; + let json = event.as_json(); + Ok(format!("Nostr {}", B64.encode(json.as_bytes()))) } // --------------------------------------------------------------------------- @@ -20,12 +122,20 @@ pub enum Auth { pub struct SproutClient { http: reqwest::Client, relay_url: String, // base URL, no trailing slash, e.g. "https://relay.sprout.place" - auth: Auth, - keys: Option, // retained for event signing (write operations) + keys: Keys, + /// Optional NIP-OA auth tag injected into every signed event. + auth_tag: Option, + /// Raw JSON of the auth tag for the `x-auth-tag` HTTP header. + auth_tag_json: Option, } impl SproutClient { - pub fn new(relay_url: String, auth: Auth) -> Result { + pub fn new( + relay_url: String, + keys: Keys, + auth_tag: Option, + auth_tag_json: Option, + ) -> Result { let http = reqwest::Client::builder() .timeout(Duration::from_secs(10)) .connect_timeout(Duration::from_secs(5)) @@ -34,47 +144,246 @@ impl SproutClient { Ok(Self { http, relay_url, - auth, - keys: None, + keys, + auth_tag, + auth_tag_json, }) } - /// Attach a keypair for signing write operations. - pub fn with_keys(mut self, keys: Keys) -> Self { - self.keys = Some(keys); - self + /// Get the keypair. + pub fn keys(&self) -> &Keys { + &self.keys } - /// Get the retained keypair, if available. - pub fn keys(&self) -> Option<&Keys> { - self.keys.as_ref() + /// Get the relay base URL. + #[allow(dead_code)] + pub fn relay_url(&self) -> &str { + &self.relay_url } - // ----------------------------------------------------------------------- - // Core request method - // ----------------------------------------------------------------------- - - async fn request( - &self, - method: reqwest::Method, - path: &str, - body: Option<&serde_json::Value>, - ) -> Result { - let url = format!("{}{}", self.relay_url, path); - - let builder = self.http.request(method, &url); - let builder = self.apply_auth(builder); - let builder = match body { - Some(b) => builder.json(b), - None => builder, + /// Sign an event builder, injecting the NIP-OA auth tag if configured. + /// + /// All event creation should go through this method to ensure consistent + /// auth tag injection. Callers MUST NOT add `auth` tags to the builder + /// before calling this method. + pub fn sign_event(&self, builder: EventBuilder) -> Result { + let builder = if let Some(ref tag) = self.auth_tag { + builder.add_tags([tag.clone()]) + } else { + builder }; + let event = builder + .sign_with_keys(&self.keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; - let resp = builder.send().await?; // reqwest::Error → CliError::Network via From + // Enforce: auth tags may only come from self.auth_tag injection. + let auth_count = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .count(); + let expected = if self.auth_tag.is_some() { 1 } else { 0 }; + if auth_count != expected { + return Err(CliError::Other(format!( + "event has {auth_count} auth tags — expected {expected}; \ + callers must not add auth tags manually" + ))); + } + Ok(event) + } + + /// Attach the `x-auth-tag` header if configured (NIP-OA relay membership delegation). + fn with_auth_tag(&self, req: reqwest::RequestBuilder) -> reqwest::RequestBuilder { + match self.auth_tag_json { + Some(ref json) => req.header("x-auth-tag", json), + None => req, + } + } + + // ----------------------------------------------------------------------- + // HTTP Bridge: POST /query + // ----------------------------------------------------------------------- + + /// Execute a one-shot query via the HTTP bridge. + /// `filter` is a Nostr filter object (will be wrapped in an array). + /// Returns the raw JSON response (array of events). + pub async fn query(&self, filter: &serde_json::Value) -> Result { + let url = format!("{}/query", self.relay_url); + let body_bytes = serde_json::to_vec(&[filter]) + .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?; + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + + let req = self + .http + .post(&url) + .header("Authorization", &auth) + .header("Content-Type", "application/json") + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; + + self.handle_response(resp).await + } + + /// Execute a one-shot count via the HTTP bridge. + /// Returns the count as a JSON string. + #[allow(dead_code)] + pub async fn count(&self, filter: &serde_json::Value) -> Result { + let url = format!("{}/count", self.relay_url); + let body_bytes = serde_json::to_vec(&[filter]) + .map_err(|e| CliError::Other(format!("filter serialization failed: {e}")))?; + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + + let req = self + .http + .post(&url) + .header("Authorization", &auth) + .header("Content-Type", "application/json") + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; + + self.handle_response(resp).await + } + + // ----------------------------------------------------------------------- + // HTTP Bridge: POST /events + // ----------------------------------------------------------------------- + + /// Submit a signed Nostr event via POST /events. + pub async fn submit_event(&self, event: nostr::Event) -> Result { + let url = format!("{}/events", self.relay_url); + let body_bytes = serde_json::to_vec(&event) + .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?; + let auth = sign_nip98(&self.keys, "POST", &url, Some(&body_bytes))?; + + let req = self + .http + .post(&url) + .header("Authorization", &auth) + .header("Content-Type", "application/json") + .body(body_bytes); + let resp = self.with_auth_tag(req).send().await?; + + self.handle_response(resp).await + } + + // ----------------------------------------------------------------------- + // File upload (Blossom protocol) + // ----------------------------------------------------------------------- + + /// Upload a file to the relay's Blossom endpoint. + /// Returns a BlobDescriptor on success. + pub async fn upload_file(&self, file_path: &str) -> Result { + // 1. Read file — validate it exists and is a regular file + let metadata = std::fs::metadata(file_path) + .map_err(|e| CliError::Other(format!("cannot access {file_path}: {e}")))?; + if !metadata.is_file() { + return Err(CliError::Usage(format!("{file_path} is not a file"))); + } + + let bytes = std::fs::read(file_path) + .map_err(|e| CliError::Other(format!("failed to read {file_path}: {e}")))?; + + // 2. Detect MIME from magic bytes + let mime = infer::get(&bytes) + .map(|t| t.mime_type().to_string()) + .unwrap_or_else(|| "application/octet-stream".to_string()); + + if !ALLOWED_MIMES.contains(&mime.as_str()) { + return Err(CliError::Usage(format!("unsupported file type: {mime}"))); + } + + // 3. Size check + let max = if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + if bytes.len() as u64 > max { + return Err(CliError::Usage(format!( + "file too large: {} bytes (max {})", + bytes.len(), + max + ))); + } + + // 4. SHA-256 + let sha256 = hex::encode(Sha256::digest(&bytes)); + + // 5. Sign Blossom auth event (kind:24242) + use nostr::Timestamp; + let now = Timestamp::now().as_u64(); + let expiry = if mime.starts_with("video/") { + 3600 + } else { + 600 + }; + let exp_str = (now + expiry).to_string(); + + let mut blossom_tags = vec![ + Tag::parse(&["t", "upload"]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(&["x", &sha256]).map_err(|e| CliError::Other(e.to_string()))?, + Tag::parse(&["expiration", &exp_str]).map_err(|e| CliError::Other(e.to_string()))?, + ]; + // Extract server domain from relay URL for BUD-11 server tag + if let Ok(parsed) = url::Url::parse(&self.relay_url) { + if let Some(host) = parsed.host_str() { + let domain = match parsed.port() { + Some(port) => format!("{host}:{port}"), + None => host.to_string(), + }; + blossom_tags.push( + Tag::parse(&["server", &domain]).map_err(|e| CliError::Other(e.to_string()))?, + ); + } + } + + let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", blossom_tags) + .sign_with_keys(&self.keys) + .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + + // 6. Base64url encode the auth event for the header + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + // 7. PUT request to /media/upload — with generous per-request timeout. + let upload_timeout = if mime.starts_with("video/") { + Duration::from_secs(600) + } else { + Duration::from_secs(120) + }; + let url = format!("{}/media/upload", self.relay_url); + let req = self + .http + .put(&url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", &mime) + .header("X-SHA-256", &sha256); + + let resp = self.with_auth_tag(req).body(bytes).send().await?; + if !resp.status().is_success() { + let status = resp.status().as_u16(); + let body = resp.text().await.unwrap_or_default(); + return Err(CliError::Relay { status, body }); + } + + resp.json::() + .await + .map_err(|e| CliError::Other(format!("invalid upload response: {e}"))) + } + + // ----------------------------------------------------------------------- + // Response handling + // ----------------------------------------------------------------------- + + async fn handle_response(&self, resp: reqwest::Response) -> Result { if !resp.status().is_success() { let status = resp.status().as_u16(); let body = resp.text().await.unwrap_or_default(); - // Try to extract relay's error message from JSON body let message = serde_json::from_str::(&body) .ok() .and_then(|v| { @@ -89,90 +398,8 @@ impl SproutClient { body: message, }); } - Ok(resp.text().await?) } - - pub(crate) fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - match &self.auth { - Auth::Bearer(token) => builder.header("Authorization", format!("Bearer {}", token)), - Auth::DevMode(pk) => builder.header("X-Pubkey", pk), - } - } - - // ----------------------------------------------------------------------- - // Signed event submission - // ----------------------------------------------------------------------- - - /// Submit a signed Nostr event via POST /api/events. - pub async fn submit_event(&self, event: nostr::Event) -> Result { - let body = serde_json::to_vec(&event) - .map_err(|e| CliError::Other(format!("event serialization failed: {e}")))?; - let url = format!("{}/api/events", self.relay_url); - let builder = self.http.post(&url); - let builder = self.apply_auth(builder); - let resp = builder - .header("content-type", "application/json") - .body(body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp.text().await.unwrap_or_default(); - let message = serde_json::from_str::(&body) - .ok() - .and_then(|v| { - v.get("error") - .and_then(|m| m.as_str()) - .map(|s| s.to_string()) - }) - .unwrap_or(body); - return Err(CliError::Relay { - status, - body: message, - }); - } - Ok(resp.text().await?) - } - - // ----------------------------------------------------------------------- - // Convenience wrappers — print response to stdout - // ----------------------------------------------------------------------- - - pub async fn run_get(&self, path: &str) -> Result<(), CliError> { - let resp = self.request(reqwest::Method::GET, path, None).await?; - println!("{resp}"); - Ok(()) - } - - pub async fn run_post(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> { - let resp = self - .request(reqwest::Method::POST, path, Some(body)) - .await?; - println!("{resp}"); - Ok(()) - } - - pub async fn run_put(&self, path: &str, body: &serde_json::Value) -> Result<(), CliError> { - let resp = self.request(reqwest::Method::PUT, path, Some(body)).await?; - println!("{resp}"); - Ok(()) - } - - pub async fn run_delete(&self, path: &str) -> Result<(), CliError> { - let resp = self.request(reqwest::Method::DELETE, path, None).await?; - println!("{resp}"); - Ok(()) - } - - // For commands that need the raw response string (e.g. get_users multi-dispatch) - pub async fn get_raw(&self, path: &str) -> Result { - self.request(reqwest::Method::GET, path, None).await - } - - pub async fn post_raw(&self, path: &str, body: &serde_json::Value) -> Result { - self.request(reqwest::Method::POST, path, Some(body)).await - } } // --------------------------------------------------------------------------- @@ -187,105 +414,3 @@ pub fn normalize_relay_url(url: &str) -> String { .trim_end_matches('/') .to_string() } - -// --------------------------------------------------------------------------- -// Auto-mint token (NIP-98) -// --------------------------------------------------------------------------- - -/// Mint a short-lived Bearer token using NIP-98 HTTP auth. -/// Called at startup when SPROUT_PRIVATE_KEY is set. -/// Returns `(token, keys)` so the caller can retain the keypair for signed writes. -pub async fn auto_mint_token( - relay_url: &str, - private_key_str: &str, -) -> Result<(String, Keys), CliError> { - use base64::engine::general_purpose::STANDARD as B64; - use base64::Engine; - use nostr::{EventBuilder, JsonUtil, Kind, Tag}; - use sha2::{Digest, Sha256}; - - let keys = Keys::parse(private_key_str) - .map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?; - - let token_url = format!("{}/api/tokens", relay_url); - - // Body bytes for payload hash - let body = serde_json::json!({ - "name": "sprout-cli-auto", - "scopes": [ - "messages:read", "messages:write", - "channels:read", "channels:write", - "users:read", "users:write", - "files:read", "files:write" - ], - "expires_in_days": 1 - }); - let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?; - - // SHA-256 hex of body bytes (NIP-98 payload tag) - let hash = Sha256::digest(&body_bytes); - let sha256_hex = hash - .iter() - .map(|b| format!("{:02x}", b)) - .collect::(); - - // Build NIP-98 event - let event = EventBuilder::new( - Kind::HttpAuth, - "", - [ - Tag::parse(&["u", &token_url]).map_err(|e| CliError::Key(format!("tag error: {e}")))?, - Tag::parse(&["method", "POST"]) - .map_err(|e| CliError::Key(format!("tag error: {e}")))?, - Tag::parse(&["payload", &sha256_hex]) - .map_err(|e| CliError::Key(format!("tag error: {e}")))?, - ], - ) - .sign_with_keys(&keys) - .map_err(|e| CliError::Key(format!("signing failed: {e}")))?; - - let auth_header = format!("Nostr {}", B64.encode(event.as_json())); - - let http = reqwest::Client::builder() - .timeout(Duration::from_secs(10)) - .build() - .map_err(|e| CliError::Other(e.to_string()))?; - - // Tell the relay which scheme we signed so the canonical URL matches. - // The relay defaults x-forwarded-proto to "https"; without this header, - // http:// URLs fail NIP-98 verification on localhost. - let proto = if token_url.starts_with("https://") { - "https" - } else { - "http" - }; - - let resp = http - .post(&token_url) - .header("Authorization", auth_header) - .header("x-forwarded-proto", proto) - .json(&body) - .send() - .await?; - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body = resp.text().await.unwrap_or_default(); - return Err(CliError::Auth(format!( - "auto-mint failed ({status}): {body}" - ))); - } - - let json: serde_json::Value = resp - .json() - .await - .map_err(|e| CliError::Other(format!("invalid auto-mint response: {e}")))?; - - let token = json - .get("token") - .and_then(|t| t.as_str()) - .map(|s| s.to_string()) - .ok_or_else(|| CliError::Other("auto-mint response missing 'token' field".into()))?; - - Ok((token, keys)) -} diff --git a/crates/sprout-cli/src/commands/auth.rs b/crates/sprout-cli/src/commands/auth.rs deleted file mode 100644 index 7fdb372bbe..0000000000 --- a/crates/sprout-cli/src/commands/auth.rs +++ /dev/null @@ -1,124 +0,0 @@ -use crate::error::CliError; -use crate::validate::validate_uuid; -use base64::engine::general_purpose::STANDARD as B64; -use base64::Engine; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use sha2::{Digest, Sha256}; - -/// Mint a long-lived (7-day) API token via NIP-98 and print it to stdout. -/// -/// `private_key` is the --private-key CLI flag value; falls back to SPROUT_PRIVATE_KEY env var. -/// Caller stores the printed token (e.g. `export SPROUT_API_TOKEN=$(sprout auth)`). -pub async fn cmd_auth(relay_url: &str, private_key: Option<&str>) -> Result<(), CliError> { - let env_key; - let private_key_str = match private_key { - Some(k) => k, - None => { - env_key = std::env::var("SPROUT_PRIVATE_KEY").map_err(|_| { - CliError::Auth( - "SPROUT_PRIVATE_KEY not set (use --private-key or set env var)".into(), - ) - })?; - &env_key - } - }; - - let keys = Keys::parse(private_key_str) - .map_err(|e| CliError::Key(format!("invalid private key: {e}")))?; - - let token_url = format!("{}/api/tokens", relay_url); - - // Request body — long-lived token, 7 days - let body = serde_json::json!({ - "name": "sprout-cli", - "scopes": [ - "messages:read", "messages:write", - "channels:read", "channels:write", - "users:read", "users:write", - "files:read", "files:write" - ], - "expires_in_days": 7 - }); - let body_bytes = serde_json::to_vec(&body).map_err(|e| CliError::Other(e.to_string()))?; - - // SHA-256 hex of body bytes (NIP-98 payload tag) - let hash = Sha256::digest(&body_bytes); - let sha256_hex: String = hash.iter().map(|b| format!("{:02x}", b)).collect(); - - // Build NIP-98 event - let event = EventBuilder::new( - Kind::HttpAuth, - "", - [ - Tag::parse(&["u", &token_url]) - .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, - Tag::parse(&["method", "POST"]) - .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, - Tag::parse(&["payload", &sha256_hex]) - .map_err(|e| CliError::Key(format!("tag build failed: {e}")))?, - ], - ) - .sign_with_keys(&keys) - .map_err(|e| CliError::Key(format!("signing failed: {e}")))?; - - let auth_header = format!("Nostr {}", B64.encode(event.as_json())); - - // POST /api/tokens - let http = reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) - .build() - .map_err(|e| CliError::Other(e.to_string()))?; - - let resp = http - .post(&token_url) - .header("Authorization", auth_header) - .header( - "x-forwarded-proto", - if token_url.starts_with("https://") { - "https" - } else { - "http" - }, - ) - .json(&body) - .send() - .await - .map_err(CliError::Network)?; - - if !resp.status().is_success() { - let status = resp.status().as_u16(); - let body_text = resp.text().await.unwrap_or_default(); - return Err(CliError::Auth(format!( - "token mint failed ({status}): {body_text}" - ))); - } - - let resp_body: serde_json::Value = resp - .json() - .await - .map_err(|e| CliError::Other(format!("invalid response: {e}")))?; - - let token = resp_body - .get("token") - .and_then(|t| t.as_str()) - .ok_or_else(|| CliError::Other("response missing 'token' field".into()))?; - - println!("{token}"); - Ok(()) -} - -pub async fn cmd_list_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> { - client.run_get("/api/tokens").await -} - -pub async fn cmd_delete_token( - client: &crate::client::SproutClient, - id: &str, -) -> Result<(), CliError> { - validate_uuid(id)?; - client.run_delete(&format!("/api/tokens/{}", id)).await -} - -pub async fn cmd_delete_all_tokens(client: &crate::client::SproutClient) -> Result<(), CliError> { - client.run_delete("/api/tokens").await -} diff --git a/crates/sprout-cli/src/commands/channels.rs b/crates/sprout-cli/src/commands/channels.rs index 7660e16c7e..f7c08bb364 100644 --- a/crates/sprout-cli/src/commands/channels.rs +++ b/crates/sprout-cli/src/commands/channels.rs @@ -2,62 +2,58 @@ use uuid::Uuid; use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{percent_encode, read_or_stdin, validate_hex64, validate_uuid}; +use crate::validate::{read_or_stdin, validate_hex64, validate_uuid}; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/// Require keys on the client — fail fast with a clear error if absent. -macro_rules! require_keys { - ($client:expr) => { - $client.keys().ok_or_else(|| { - CliError::Key( - "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), - ) - })? - }; -} - fn parse_uuid(s: &str) -> Result { Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}"))) } fn sign_and_submit_builder( builder: nostr::EventBuilder, - keys: &nostr::Keys, + client: &SproutClient, ) -> Result { - builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}"))) + client.sign_event(builder) } // --------------------------------------------------------------------------- -// Read commands (unchanged) +// Read commands — POST /query // --------------------------------------------------------------------------- pub async fn cmd_list_channels( client: &SproutClient, - visibility: Option<&str>, - member: Option, + _visibility: Option<&str>, + _member: Option, ) -> Result<(), CliError> { - let mut path = "/api/channels".to_string(); - let mut sep = '?'; - if let Some(v) = visibility { - path.push_str(&format!("{}visibility={}", sep, percent_encode(v))); - sep = '&'; + // Query kind:39002 channel metadata events. + // If member=true, filter by #p tag containing our pubkey. + let my_pk = client.keys().public_key().to_hex(); + let mut filter = serde_json::json!({ + "kinds": [39002] + }); + // When member filter is requested, query channels where we're a participant + if _member == Some(true) { + filter["#p"] = serde_json::json!([my_pk]); } - if let Some(m) = member { - path.push_str(&format!("{}member={}", sep, m)); - } - client.run_get(&path).await + // Visibility filtering is done client-side from the returned events + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } pub async fn cmd_get_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - client - .run_get(&format!("/api/channels/{}", channel_id)) - .await + // Query kind:39002 with #h tag matching the channel UUID + let filter = serde_json::json!({ + "kinds": [39002], + "#h": [channel_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } pub async fn cmd_list_channel_members( @@ -65,20 +61,30 @@ pub async fn cmd_list_channel_members( channel_id: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - client - .run_get(&format!("/api/channels/{}/members", channel_id)) - .await + // Query kind:39002 channel metadata — members are in the p-tags + let filter = serde_json::json!({ + "kinds": [39002], + "#h": [channel_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } pub async fn cmd_get_canvas(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - client - .run_get(&format!("/api/channels/{}/canvas", channel_id)) - .await + // Canvas is kind:40100 with #h tag + let filter = serde_json::json!({ + "kinds": [40100], + "#h": [channel_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } // --------------------------------------------------------------------------- -// Write commands — signed events +// Write commands — signed events via POST /events // --------------------------------------------------------------------------- pub async fn cmd_create_channel( @@ -105,25 +111,23 @@ pub async fn cmd_create_channel( } } - let keys = require_keys!(client); - // Generate a new UUID client-side for the channel let channel_uuid = Uuid::new_v4(); let vis = match visibility { "open" => sprout_sdk::Visibility::Open, "private" => sprout_sdk::Visibility::Private, - _ => unreachable!(), // validated above + _ => unreachable!(), }; let ct = match channel_type { "stream" => sprout_sdk::ChannelKind::Stream, "forum" => sprout_sdk::ChannelKind::Forum, - _ => unreachable!(), // validated above + _ => unreachable!(), }; let builder = sprout_sdk::build_create_channel(channel_uuid, name, Some(vis), Some(ct), description) .map_err(|e| CliError::Other(format!("build_create_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -141,13 +145,12 @@ pub async fn cmd_update_channel( )); } validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_update_channel(channel_uuid, name, description) .map_err(|e| CliError::Other(format!("build_update_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -159,13 +162,12 @@ pub async fn cmd_set_channel_topic( topic: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_topic(channel_uuid, topic) .map_err(|e| CliError::Other(format!("build_set_topic failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -177,13 +179,12 @@ pub async fn cmd_set_channel_purpose( purpose: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_purpose(channel_uuid, purpose) .map_err(|e| CliError::Other(format!("build_set_purpose failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -191,13 +192,12 @@ pub async fn cmd_set_channel_purpose( pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_join(channel_uuid) .map_err(|e| CliError::Other(format!("build_join failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -205,13 +205,12 @@ pub async fn cmd_join_channel(client: &SproutClient, channel_id: &str) -> Result pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_leave(channel_uuid) .map_err(|e| CliError::Other(format!("build_leave failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -219,13 +218,12 @@ pub async fn cmd_leave_channel(client: &SproutClient, channel_id: &str) -> Resul pub async fn cmd_archive_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_archive(channel_uuid) .map_err(|e| CliError::Other(format!("build_archive failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -236,13 +234,12 @@ pub async fn cmd_unarchive_channel( channel_id: &str, ) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_unarchive(channel_uuid) .map_err(|e| CliError::Other(format!("build_unarchive failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -250,13 +247,12 @@ pub async fn cmd_unarchive_channel( pub async fn cmd_delete_channel(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_delete_channel(channel_uuid) .map_err(|e| CliError::Other(format!("build_delete_channel failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -270,7 +266,6 @@ pub async fn cmd_add_channel_member( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(pubkey)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let typed_role = match role { @@ -289,7 +284,7 @@ pub async fn cmd_add_channel_member( let builder = sprout_sdk::build_add_member(channel_uuid, pubkey, typed_role) .map_err(|e| CliError::Other(format!("build_add_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -302,13 +297,12 @@ pub async fn cmd_remove_channel_member( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(pubkey)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_remove_member(channel_uuid, pubkey) .map_err(|e| CliError::Other(format!("build_remove_member failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) @@ -321,13 +315,12 @@ pub async fn cmd_set_canvas( ) -> Result<(), CliError> { validate_uuid(channel_id)?; let content = read_or_stdin(content)?; - let keys = require_keys!(client); let channel_uuid = parse_uuid(channel_id)?; let builder = sprout_sdk::build_set_canvas(channel_uuid, &content) .map_err(|e| CliError::Other(format!("build_set_canvas failed: {e}")))?; - let event = sign_and_submit_builder(builder, keys)?; + let event = sign_and_submit_builder(builder, client)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) diff --git a/crates/sprout-cli/src/commands/dms.rs b/crates/sprout-cli/src/commands/dms.rs index a84d550b36..b92e24ee33 100644 --- a/crates/sprout-cli/src/commands/dms.rs +++ b/crates/sprout-cli/src/commands/dms.rs @@ -1,24 +1,24 @@ +use nostr::{EventBuilder, Kind, Tag}; + use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{percent_encode, validate_hex64, validate_uuid}; +use crate::validate::{validate_hex64, validate_uuid}; -pub async fn cmd_list_dms( - client: &SproutClient, - cursor: Option<&str>, - limit: Option, -) -> Result<(), CliError> { - let mut path = "/api/dms".to_string(); - let mut sep = '?'; - if let Some(c) = cursor { - path.push_str(&format!("{}cursor={}", sep, percent_encode(c))); - sep = '&'; - } - if let Some(l) = limit { - path.push_str(&format!("{}limit={}", sep, l)); - } - client.run_get(&path).await +/// List DM conversations by querying kind:41010 (DM open) events authored by us. +pub async fn cmd_list_dms(client: &SproutClient, limit: Option) -> Result<(), CliError> { + let my_pk = client.keys().public_key().to_hex(); + let limit = limit.unwrap_or(50).min(200); + let filter = serde_json::json!({ + "kinds": [41010], + "authors": [my_pk], + "limit": limit + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } +/// Open a DM with one or more users — sign and submit a kind:41010 event. pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> { if pubkeys.is_empty() || pubkeys.len() > 8 { return Err(CliError::Usage("--pubkey: must provide 1–8 pubkeys".into())); @@ -26,11 +26,21 @@ pub async fn cmd_open_dm(client: &SproutClient, pubkeys: &[String]) -> Result<() for pk in pubkeys { validate_hex64(pk)?; } - client - .run_post("/api/dms", &serde_json::json!({ "pubkeys": pubkeys })) - .await + + let mut tags: Vec = Vec::new(); + for pk in pubkeys { + tags.push(Tag::parse(&["p", pk]).map_err(|e| CliError::Other(format!("tag error: {e}")))?); + } + + let builder = EventBuilder::new(Kind::Custom(41010), "", tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) } +/// Add a member to a DM group — sign and submit a kind:41011 event. pub async fn cmd_add_dm_member( client: &SproutClient, channel_id: &str, @@ -38,10 +48,16 @@ pub async fn cmd_add_dm_member( ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(pubkey)?; - client - .run_post( - &format!("/api/dms/{}/members", channel_id), - &serde_json::json!({ "pubkeys": [pubkey] }), - ) - .await + + let tags = vec![ + Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + Tag::parse(&["p", pubkey]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + ]; + + let builder = EventBuilder::new(Kind::Custom(41011), "", tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/commands/feed.rs b/crates/sprout-cli/src/commands/feed.rs index db0f6d5f41..44cc58817f 100644 --- a/crates/sprout-cli/src/commands/feed.rs +++ b/crates/sprout-cli/src/commands/feed.rs @@ -1,20 +1,26 @@ use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::percent_encode; +/// Get activity feed — query events mentioning our pubkey (via p-tag). pub async fn cmd_get_feed( client: &SproutClient, since: Option, limit: Option, - types: Option<&str>, + _types: Option<&str>, ) -> Result<(), CliError> { + let my_pk = client.keys().public_key().to_hex(); let limit = limit.unwrap_or(20).min(50); - let mut path = format!("/api/feed?limit={}", limit); + + let mut filter = serde_json::json!({ + "#p": [my_pk], + "limit": limit + }); + if let Some(s) = since { - path.push_str(&format!("&since={s}")); + filter["since"] = serde_json::json!(s); } - if let Some(t) = types { - path.push_str(&format!("&types={}", percent_encode(t))); - } - client.run_get(&path).await + + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/commands/messages.rs b/crates/sprout-cli/src/commands/messages.rs index 4d8f90ab29..7ee69cedcd 100644 --- a/crates/sprout-cli/src/commands/messages.rs +++ b/crates/sprout-cli/src/commands/messages.rs @@ -5,26 +5,14 @@ use uuid::Uuid; use crate::client::SproutClient; use crate::error::CliError; use crate::validate::{ - extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, percent_encode, - read_or_stdin, truncate_diff, validate_content_size, validate_hex64, validate_uuid, - MAX_DIFF_BYTES, + extract_at_names, infer_language, merge_mentions, normalize_mention_pubkeys, read_or_stdin, + truncate_diff, validate_content_size, validate_hex64, validate_uuid, MAX_DIFF_BYTES, }; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- -/// Require keys on the client — fail fast with a clear error if absent. -macro_rules! require_keys { - ($client:expr) => { - $client.keys().ok_or_else(|| { - CliError::Key( - "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), - ) - })? - }; -} - /// Parse a 64-char hex string into a nostr::EventId. fn parse_event_id(hex: &str) -> Result { EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}"))) @@ -35,16 +23,25 @@ fn parse_uuid(s: &str) -> Result { Uuid::parse_str(s).map_err(|e| CliError::Usage(format!("invalid channel UUID: {e}"))) } -/// Resolve the channel UUID for an event by fetching GET /api/events/{id}. -/// Extracts the `h` tag value from the returned tags array. +/// Resolve the channel UUID for an event by querying for it via POST /query. +/// Extracts the `h` tag value from the returned event's tags. async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result { - let raw = client.get_raw(&format!("/api/events/{}", event_id)).await?; - let v: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("failed to parse event response: {e}")))?; - let tags = v + let filter = serde_json::json!({ + "ids": [event_id] + }); + let raw = client.query(&filter).await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse query response: {e}")))?; + let arr = events + .as_array() + .ok_or_else(|| CliError::Other("query response is not an array".into()))?; + let event = arr + .first() + .ok_or_else(|| CliError::Other(format!("event {event_id} not found")))?; + let tags = event .get("tags") .and_then(|t| t.as_array()) - .ok_or_else(|| CliError::Other("event response missing 'tags' field".into()))?; + .ok_or_else(|| CliError::Other("event missing 'tags' field".into()))?; for tag in tags { if let Some(arr) = tag.as_array() { if arr.first().and_then(|v| v.as_str()) == Some("h") { @@ -61,7 +58,7 @@ async fn resolve_channel_id(client: &SproutClient, event_id: &str) -> Result Result<(), CliError> { validate_uuid(channel_id)?; let limit = limit.unwrap_or(50).min(200); - let mut path = format!("/api/channels/{}/messages?limit={}", channel_id, limit); + + let mut filter = serde_json::json!({ + "kinds": [9, 40002], + "#h": [channel_id], + "limit": limit + }); + + // If specific kinds requested, override + if let Some(k) = kinds { + let kind_list: Vec = k.split(',').filter_map(|s| s.trim().parse().ok()).collect(); + if !kind_list.is_empty() { + filter["kinds"] = serde_json::json!(kind_list); + } + } + if let Some(b) = before { - path.push_str(&format!("&before={b}")); + filter["until"] = serde_json::json!(b); } if let Some(s) = since { - path.push_str(&format!("&since={s}")); + filter["since"] = serde_json::json!(s); } - if let Some(k) = kinds { - path.push_str(&format!("&kinds={}", percent_encode(k))); - } - client.run_get(&path).await + + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } pub async fn cmd_get_thread( client: &SproutClient, channel_id: &str, event_id: &str, - depth_limit: Option, + _depth_limit: Option, limit: Option, - cursor: Option<&str>, ) -> Result<(), CliError> { validate_uuid(channel_id)?; validate_hex64(event_id)?; let limit = limit.unwrap_or(100).min(500); - let mut path = format!( - "/api/channels/{}/threads/{}?limit={}", - channel_id, event_id, limit - ); - if let Some(d) = depth_limit { - path.push_str(&format!("&depth_limit={d}")); - } - if let Some(c) = cursor { - path.push_str(&format!("&cursor={}", percent_encode(c))); - } - client.run_get(&path).await + + // Get the root event and all replies referencing it via e-tag + let filter = serde_json::json!({ + "kinds": [9, 40002], + "#h": [channel_id], + "#e": [event_id], + "limit": limit + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } pub async fn cmd_search( @@ -151,37 +162,67 @@ pub async fn cmd_search( limit: Option, ) -> Result<(), CliError> { let limit = limit.unwrap_or(20).min(100); - let path = format!("/api/search?q={}&limit={}", percent_encode(query), limit); - client.run_get(&path).await + let filter = serde_json::json!({ + "search": query, + "limit": limit + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } // --------------------------------------------------------------------------- -// Write commands — signed events +// Write commands — signed events via POST /events // --------------------------------------------------------------------------- -pub async fn cmd_send_message( - client: &SproutClient, - channel_id: &str, - content: &str, - _kind: Option, - reply_to: Option<&str>, - broadcast: bool, - mentions: &[String], -) -> Result<(), CliError> { - validate_uuid(channel_id)?; - validate_content_size(content)?; - if let Some(r) = reply_to { +pub struct SendMessageParams { + pub channel_id: String, + pub content: String, + #[allow(dead_code)] // reserved for future kind routing + pub kind: Option, + pub reply_to: Option, + pub broadcast: bool, + pub mentions: Vec, + pub files: Vec, +} + +pub async fn cmd_send_message(client: &SproutClient, p: SendMessageParams) -> Result<(), CliError> { + validate_uuid(&p.channel_id)?; + validate_content_size(&p.content)?; + if let Some(ref r) = p.reply_to { validate_hex64(r)?; } - for m in mentions { + for m in &p.mentions { validate_hex64(m)?; } - let keys = require_keys!(client); - let channel_uuid = parse_uuid(channel_id)?; + let channel_uuid = parse_uuid(&p.channel_id)?; + + // Upload files and build imeta tags + let mut media_tags: Vec> = Vec::new(); + let mut media_content = String::new(); + for file_path in &p.files { + let desc = client + .upload_file(file_path) + .await + .map_err(|e| CliError::Other(format!("upload failed for {file_path}: {e}")))?; + media_tags.push(crate::client::build_imeta_tag(&desc)); + if desc.mime_type.starts_with("video/") { + media_content.push_str("\n![video]("); + } else { + media_content.push_str("\n![image]("); + } + media_content.push_str(&desc.url); + media_content.push(')'); + } + let final_content = if media_content.is_empty() { + p.content.clone() + } else { + format!("{}{media_content}", p.content) + }; // Build thread ref if replying - let thread_ref = if let Some(r) = reply_to { + let thread_ref = if let Some(ref r) = p.reply_to { let eid = parse_event_id(r)?; Some(ThreadRef { root_event_id: eid, @@ -192,24 +233,22 @@ pub async fn cmd_send_message( }; // Normalize explicit mentions, then merge auto-resolved up to SDK cap of 50. - let mut merged: Vec = normalize_mention_pubkeys(mentions, ""); - let auto_resolved = resolve_content_mentions(client, channel_id, content).await; + let mut merged: Vec = normalize_mention_pubkeys(&p.mentions, ""); + let auto_resolved = resolve_content_mentions(client, &p.channel_id, &final_content).await; merge_mentions(&mut merged, &auto_resolved, 50); let mention_refs: Vec<&str> = merged.iter().map(|s| s.as_str()).collect(); let builder = sprout_sdk::build_message( channel_uuid, - content, + &final_content, thread_ref.as_ref(), &mention_refs, - broadcast, - &[], + p.broadcast, + &media_tags, ) .map_err(|e| CliError::Other(format!("build_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -250,7 +289,6 @@ pub async fn cmd_send_diff_message( _ => {} } - let keys = require_keys!(client); let channel_uuid = parse_uuid(&p.channel_id)?; // Read diff from stdin if "--diff -" @@ -304,9 +342,7 @@ pub async fn cmd_send_diff_message( sprout_sdk::build_diff_message(channel_uuid, &diff, &diff_meta, thread_ref.as_ref()) .map_err(|e| CliError::Other(format!("build_diff_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -315,18 +351,15 @@ pub async fn cmd_send_diff_message( pub async fn cmd_delete_message(client: &SproutClient, event_id: &str) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = require_keys!(client); - // Resolve channel_id from the event's h-tag (needed by build_delete_message) + // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; let builder = sprout_sdk::build_delete_message(channel_uuid, target_eid) .map_err(|e| CliError::Other(format!("build_delete_message failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -341,18 +374,15 @@ pub async fn cmd_edit_message( ) -> Result<(), CliError> { validate_hex64(event_id)?; validate_content_size(content)?; - let keys = require_keys!(client); - // Resolve channel_id from the event's h-tag (needed by build_edit) + // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; let builder = sprout_sdk::build_edit(channel_uuid, target_eid, content) .map_err(|e| CliError::Other(format!("build_edit failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -376,18 +406,14 @@ pub async fn cmd_vote_on_post( } }; - let keys = require_keys!(client); - - // Resolve channel_id from the event's h-tag (needed by build_vote) + // Resolve channel_id from the event's h-tag let channel_uuid = resolve_channel_id(client, event_id).await?; let target_eid = parse_event_id(event_id)?; let builder = sprout_sdk::build_vote(channel_uuid, target_eid, vote_dir) .map_err(|e| CliError::Other(format!("build_vote failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); diff --git a/crates/sprout-cli/src/commands/mod.rs b/crates/sprout-cli/src/commands/mod.rs index a3dc7523de..38e91ee89f 100644 --- a/crates/sprout-cli/src/commands/mod.rs +++ b/crates/sprout-cli/src/commands/mod.rs @@ -1,4 +1,3 @@ -pub mod auth; pub mod channels; pub mod dms; pub mod feed; diff --git a/crates/sprout-cli/src/commands/pack.rs b/crates/sprout-cli/src/commands/pack.rs index f080d449b8..fe893c50c9 100644 --- a/crates/sprout-cli/src/commands/pack.rs +++ b/crates/sprout-cli/src/commands/pack.rs @@ -3,7 +3,6 @@ //! These commands operate on local pack directories. No relay connection needed. use std::path::Path; -use std::process; use crate::error::CliError; @@ -16,12 +15,10 @@ use crate::error::CliError; pub fn cmd_validate(path: &str) -> Result<(), CliError> { let pack_dir = Path::new(path); if !pack_dir.exists() { - eprintln!("error: path does not exist: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("path does not exist: {path}"))); } if !pack_dir.is_dir() { - eprintln!("error: not a directory: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("not a directory: {path}"))); } let report = sprout_persona::validate::validate_pack(pack_dir); @@ -38,8 +35,7 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> { } if report.has_errors() { - eprintln!("\nValidation failed."); - process::exit(1); + return Err(CliError::Usage("Validation failed.".into())); } else if report.has_warnings() { println!("Valid (with warnings)."); } else { @@ -56,12 +52,10 @@ pub fn cmd_validate(path: &str) -> Result<(), CliError> { pub fn cmd_inspect(path: &str) -> Result<(), CliError> { let pack_dir = Path::new(path); if !pack_dir.exists() { - eprintln!("error: path does not exist: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("path does not exist: {path}"))); } if !pack_dir.is_dir() { - eprintln!("error: not a directory: {path}"); - process::exit(1); + return Err(CliError::Usage(format!("not a directory: {path}"))); } // Resolve the pack — shows fully effective config (post-merge, post-split). diff --git a/crates/sprout-cli/src/commands/reactions.rs b/crates/sprout-cli/src/commands/reactions.rs index 1bfbff9dc5..b1d36b3a6e 100644 --- a/crates/sprout-cli/src/commands/reactions.rs +++ b/crates/sprout-cli/src/commands/reactions.rs @@ -2,18 +2,7 @@ use nostr::EventId; use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{percent_encode, validate_hex64}; - -/// Require keys on the client — fail fast with a clear error if absent. -macro_rules! require_keys { - ($client:expr) => { - $client.keys().ok_or_else(|| { - CliError::Key( - "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), - ) - })? - }; -} +use crate::validate::validate_hex64; pub async fn cmd_add_reaction( client: &SproutClient, @@ -21,17 +10,13 @@ pub async fn cmd_add_reaction( emoji: &str, ) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = require_keys!(client); - let target_eid = EventId::parse(event_id).map_err(|e| CliError::Usage(format!("invalid event ID: {e}")))?; let builder = sprout_sdk::build_reaction(target_eid, emoji) .map_err(|e| CliError::Other(format!("build_reaction failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -44,94 +29,54 @@ pub async fn cmd_remove_reaction( emoji: &str, ) -> Result<(), CliError> { validate_hex64(event_id)?; - let keys = require_keys!(client); + let keys = client.keys(); - // To build a NIP-05 deletion event we need the original reaction event ID. - // Fetch the reactions list and find the caller's reaction for this emoji. - let reaction_event_id = find_my_reaction_event_id(client, event_id, emoji).await?; + // Find our reaction event by querying kind:7 reactions on this event from us + let my_pk = keys.public_key().to_hex(); + let filter = serde_json::json!({ + "kinds": [7], + "#e": [event_id], + "authors": [my_pk] + }); + let raw = client.query(&filter).await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse reactions query: {e}")))?; + let arr = events + .as_array() + .ok_or_else(|| CliError::Other("reactions query response is not an array".into()))?; - let builder = sprout_sdk::build_remove_reaction(reaction_event_id) + // Find the reaction event matching the emoji + let reaction_event_id = arr + .iter() + .find(|ev| ev.get("content").and_then(|c| c.as_str()) == Some(emoji)) + .and_then(|ev| ev.get("id").and_then(|id| id.as_str())) + .ok_or_else(|| { + CliError::Other(format!( + "no reaction with emoji '{emoji}' found for your pubkey on event {event_id}" + )) + })?; + + let reaction_eid = EventId::parse(reaction_event_id) + .map_err(|e| CliError::Other(format!("invalid reaction event ID: {e}")))?; + + let builder = sprout_sdk::build_remove_reaction(reaction_eid) .map_err(|e| CliError::Other(format!("build_remove_reaction failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } -/// Fetch GET /api/messages/{id}/reactions and find the caller's reaction event ID -/// for the given emoji. The caller's pubkey is inferred from the retained keys. -/// -/// The reactions list groups by emoji and includes per-user `reaction_event_id` if -/// the relay exposes it. If the field is absent, falls back to REST DELETE. -async fn find_my_reaction_event_id( - client: &SproutClient, - message_event_id: &str, - emoji: &str, -) -> Result { - let keys = require_keys!(client); - let my_pubkey = keys.public_key().to_hex(); - - let raw = client - .get_raw(&format!( - "/api/messages/{}/reactions", - percent_encode(message_event_id) - )) - .await?; - - let v: serde_json::Value = serde_json::from_str(&raw) - .map_err(|e| CliError::Other(format!("failed to parse reactions response: {e}")))?; - - // Response shape: { "reactions": [ { "emoji": "👍", "users": [ { "pubkey": "...", "reaction_event_id": "..." } ] } ] } - let reactions = v - .get("reactions") - .and_then(|r| r.as_array()) - .ok_or_else(|| CliError::Other("reactions response missing 'reactions' array".into()))?; - - let empty_vec = vec![]; - for group in reactions { - let group_emoji = group.get("emoji").and_then(|e| e.as_str()).unwrap_or(""); - if group_emoji != emoji { - continue; - } - let users = group - .get("users") - .and_then(|u| u.as_array()) - .unwrap_or(&empty_vec); - for user in users { - let pubkey = user.get("pubkey").and_then(|p| p.as_str()).unwrap_or(""); - if !pubkey.eq_ignore_ascii_case(&my_pubkey) { - continue; - } - // Found our reaction — extract the reaction_event_id if present - if let Some(reid) = user.get("reaction_event_id").and_then(|r| r.as_str()) { - return EventId::parse(reid) - .map_err(|e| CliError::Other(format!("invalid reaction_event_id: {e}"))); - } - // TODO: relay does not yet expose reaction_event_id in list response. - // When it does, this branch will be unreachable. - return Err(CliError::Other( - "relay does not expose reaction_event_id in reactions list; \ - cannot build signed deletion event" - .into(), - )); - } - } - - Err(CliError::Other(format!( - "no reaction with emoji '{emoji}' found for your pubkey on event {message_event_id}" - ))) -} - pub async fn cmd_get_reactions(client: &SproutClient, event_id: &str) -> Result<(), CliError> { validate_hex64(event_id)?; - client - .run_get(&format!( - "/api/messages/{}/reactions", - percent_encode(event_id), - )) - .await + // Query kind:7 reactions referencing this event + let filter = serde_json::json!({ + "kinds": [7], + "#e": [event_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/commands/social.rs b/crates/sprout-cli/src/commands/social.rs index 607be4b63e..efc3b68ce1 100644 --- a/crates/sprout-cli/src/commands/social.rs +++ b/crates/sprout-cli/src/commands/social.rs @@ -5,18 +5,7 @@ use crate::client::SproutClient; use crate::error::CliError; use crate::validate::validate_hex64; -/// Each command module defines its own `require_keys!` macro. -macro_rules! require_keys { - ($client:expr) => { - $client.keys().ok_or_else(|| { - CliError::Key( - "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), - ) - })? - }; -} - -/// Per-module helper (same pattern as messages.rs). +/// Per-module helper. fn parse_event_id(hex: &str) -> Result { EventId::parse(hex).map_err(|e| CliError::Usage(format!("invalid event ID: {e}"))) } @@ -40,15 +29,12 @@ pub async fn cmd_publish_note( validate_hex64(r)?; } - let keys = require_keys!(client); let reply_id = reply_to.map(parse_event_id).transpose()?; let builder = sprout_sdk::build_note(content, reply_id) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); @@ -59,7 +45,6 @@ pub async fn cmd_set_contact_list( client: &SproutClient, contacts_json: &str, ) -> Result<(), CliError> { - let keys = require_keys!(client); let entries: Vec = serde_json::from_str(contacts_json) .map_err(|e| CliError::Usage(format!("invalid contacts JSON: {e}")))?; @@ -77,55 +62,58 @@ pub async fn cmd_set_contact_list( let builder = sprout_sdk::build_contact_list(&contacts) .map_err(|e| CliError::Other(format!("build error: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } +/// Get a single event by ID via POST /query. pub async fn cmd_get_event(client: &SproutClient, event_id: &str) -> Result<(), CliError> { validate_hex64(event_id)?; - let path = format!("/api/events/{event_id}"); - client.run_get(&path).await + let filter = serde_json::json!({ + "ids": [event_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } +/// Get user notes (kind:1) by author pubkey. pub async fn cmd_get_user_notes( client: &SproutClient, pubkey: &str, limit: Option, before: Option, - before_id: Option<&str>, ) -> Result<(), CliError> { validate_hex64(pubkey)?; - if let Some(bid) = before_id { - validate_hex64(bid)?; - } - if before_id.is_some() && before.is_none() { - return Err(CliError::Usage("before_id requires before".to_string())); - } - let mut path = format!("/api/users/{pubkey}/notes"); - let mut params = vec![]; - if let Some(l) = limit { - params.push(format!("limit={l}")); - } + let limit = limit.unwrap_or(50).min(100); + + let mut filter = serde_json::json!({ + "kinds": [1], + "authors": [pubkey], + "limit": limit + }); + if let Some(b) = before { - params.push(format!("before={b}")); + filter["until"] = serde_json::json!(b); } - if let Some(bid) = before_id { - params.push(format!("before_id={bid}")); - } - if !params.is_empty() { - path.push('?'); - path.push_str(¶ms.join("&")); - } - client.run_get(&path).await + + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } +/// Get a user's contact list (kind:3) by pubkey. pub async fn cmd_get_contact_list(client: &SproutClient, pubkey: &str) -> Result<(), CliError> { validate_hex64(pubkey)?; - let path = format!("/api/users/{pubkey}/contact-list"); - client.run_get(&path).await + let filter = serde_json::json!({ + "kinds": [3], + "authors": [pubkey], + "limit": 1 + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/commands/users.rs b/crates/sprout-cli/src/commands/users.rs index 529d432203..97737fea83 100644 --- a/crates/sprout-cli/src/commands/users.rs +++ b/crates/sprout-cli/src/commands/users.rs @@ -1,22 +1,13 @@ +use nostr::{EventBuilder, Kind, Tag}; + use crate::client::SproutClient; use crate::error::CliError; -use crate::validate::{percent_encode, validate_hex64}; +use crate::validate::validate_hex64; -/// Require keys on the client — fail fast with a clear error if absent. -macro_rules! require_keys { - ($client:expr) => { - $client.keys().ok_or_else(|| { - CliError::Key( - "private key required for write operations (set SPROUT_PRIVATE_KEY)".into(), - ) - })? - }; -} - -/// 3-way dispatch based on pubkey count: -/// 0 pubkeys → GET /api/users/me/profile -/// 1 pubkey → GET /api/users/{pk}/profile -/// 2+ pubkeys → POST /api/users/batch +/// Get user profiles (kind:0 metadata events). +/// 0 pubkeys → query our own profile +/// 1 pubkey → query that user's profile +/// 2+ pubkeys → query batch pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result<(), CliError> { for pk in pubkeys { validate_hex64(pk)?; @@ -25,25 +16,19 @@ pub async fn cmd_get_users(client: &SproutClient, pubkeys: &[String]) -> Result< return Err(CliError::Usage("--pubkey: maximum 200 pubkeys".into())); } - let resp = match pubkeys.len() { - 0 => client.get_raw("/api/users/me/profile").await?, - 1 => { - client - .get_raw(&format!( - "/api/users/{}/profile", - percent_encode(&pubkeys[0]) - )) - .await? - } - _ => { - client - .post_raw( - "/api/users/batch", - &serde_json::json!({ "pubkeys": pubkeys }), - ) - .await? - } + let my_pk = client.keys().public_key().to_hex(); + let authors: Vec<&str> = if pubkeys.is_empty() { + vec![my_pk.as_str()] + } else { + pubkeys.iter().map(|s| s.as_str()).collect() }; + + let filter = serde_json::json!({ + "kinds": [0], + "authors": authors, + "limit": authors.len() + }); + let resp = client.query(&filter).await?; println!("{resp}"); Ok(()) } @@ -61,11 +46,7 @@ pub async fn cmd_set_profile( )); } - let keys = require_keys!(client); - // Read-merge-write: fetch current profile, merge in the new fields, then sign. - // This preserves fields the caller didn't specify (e.g. existing avatar stays - // intact when only --name is passed). let current = fetch_current_profile(client).await?; // Merge: caller-supplied fields win; fall back to current profile values. @@ -104,60 +85,77 @@ pub async fn cmd_set_profile( let builder = sprout_sdk::build_profile( merged_name.as_deref(), - None, // `name` field (username) — not exposed by CLI; preserve via display_name + None, // `name` field (username) — not exposed by CLI merged_picture.as_deref(), merged_about.as_deref(), merged_nip05.as_deref(), ) .map_err(|e| CliError::Other(format!("build_profile failed: {e}")))?; - let event = builder - .sign_with_keys(keys) - .map_err(|e| CliError::Other(format!("signing failed: {e}")))?; + let event = client.sign_event(builder)?; let resp = client.submit_event(event).await?; println!("{resp}"); Ok(()) } -/// Fetch the current user's profile metadata from GET /api/users/me/profile. -/// Returns the parsed JSON object (kind:0 content fields), or an empty object -/// if the profile hasn't been set yet. +/// Fetch the current user's profile metadata via POST /query (kind:0). +/// Returns the parsed content JSON object, or an empty object if no profile exists. async fn fetch_current_profile( client: &SproutClient, ) -> Result, CliError> { - let raw = client.get_raw("/api/users/me/profile").await; - match raw { - Ok(body) => { - let v: serde_json::Value = serde_json::from_str(&body) - .map_err(|e| CliError::Other(format!("failed to parse profile response: {e}")))?; - // The relay may return the profile fields at top level or nested under "profile" - let obj = if let Some(profile) = v.get("profile").and_then(|p| p.as_object()) { - profile.clone() - } else if let Some(obj) = v.as_object() { - obj.clone() - } else { - serde_json::Map::new() - }; - Ok(obj) - } - // 404 = no profile yet — start fresh - Err(CliError::Relay { status: 404, .. }) => Ok(serde_json::Map::new()), - Err(e) => Err(e), - } + let my_pk = client.keys().public_key().to_hex(); + let filter = serde_json::json!({ + "kinds": [0], + "authors": [my_pk], + "limit": 1 + }); + let raw = client.query(&filter).await?; + let events: serde_json::Value = serde_json::from_str(&raw) + .map_err(|e| CliError::Other(format!("failed to parse profile query: {e}")))?; + + let Some(arr) = events.as_array() else { + return Ok(serde_json::Map::new()); + }; + let Some(event) = arr.first() else { + return Ok(serde_json::Map::new()); + }; + // kind:0 content is a JSON string containing the profile fields + let content_str = event + .get("content") + .and_then(|c| c.as_str()) + .unwrap_or("{}"); + let content: serde_json::Value = serde_json::from_str(content_str).unwrap_or_default(); + Ok(content.as_object().cloned().unwrap_or_default()) } +/// Get presence status for users — query kind:40902 presence snapshot events. pub async fn cmd_get_presence(client: &SproutClient, pubkeys_csv: &str) -> Result<(), CliError> { - for pk in pubkeys_csv.split(',') { - let pk = pk.trim(); - if !pk.is_empty() { - validate_hex64(pk)?; - } + let pubkeys: Vec<&str> = pubkeys_csv + .split(',') + .map(|s| s.trim()) + .filter(|s| !s.is_empty()) + .collect(); + for pk in &pubkeys { + validate_hex64(pk)?; } - let path = format!("/api/presence?pubkeys={}", percent_encode(pubkeys_csv)); - client.run_get(&path).await + + let filter = serde_json::json!({ + "kinds": [40902], + "authors": pubkeys, + "limit": pubkeys.len() + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } +/// Set presence status — sign and submit a kind:20001 presence update event. +/// +/// NOTE: Kind 20001 is ephemeral and only accepted via WebSocket connections. +/// The CLI uses the HTTP bridge (POST /events) which rejects ephemeral kinds. +/// This will fail until the CLI gains a WS publish path. The kind is correct +/// per the protocol spec (KIND_PRESENCE_UPDATE = 20001). pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), CliError> { match status { "online" | "away" | "offline" => {} @@ -167,27 +165,17 @@ pub async fn cmd_set_presence(client: &SproutClient, status: &str) -> Result<(), ))) } } - client - .run_put("/api/presence", &serde_json::json!({ "status": status })) - .await -} -pub async fn cmd_set_channel_add_policy( - client: &SproutClient, - policy: &str, -) -> Result<(), CliError> { - match policy { - "anyone" | "owner_only" | "nobody" => {} - _ => { - return Err(CliError::Usage(format!( - "--policy must be one of: anyone, owner_only, nobody (got: {policy})" - ))) - } - } - client - .run_put( - "/api/users/me/channel-add-policy", - &serde_json::json!({ "channel_add_policy": policy }), - ) - .await + let tags = + vec![Tag::parse(&["status", status]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; + + // KIND_PRESENCE_UPDATE (20001) — ephemeral, WS-only. HTTP bridge will reject this + // until the CLI gains a WebSocket publish path. + let builder = EventBuilder::new(Kind::Custom(20001), "", tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/commands/workflows.rs b/crates/sprout-cli/src/commands/workflows.rs index e8ce542bec..5d0e3f8e13 100644 --- a/crates/sprout-cli/src/commands/workflows.rs +++ b/crates/sprout-cli/src/commands/workflows.rs @@ -1,64 +1,39 @@ +use nostr::{EventBuilder, Kind, Tag}; +use sha2::{Digest, Sha256}; + use crate::client::SproutClient; use crate::error::CliError; use crate::validate::{read_or_stdin, validate_uuid}; +// --------------------------------------------------------------------------- +// Read commands — POST /query +// --------------------------------------------------------------------------- + +/// List workflows in a channel — query kind:30620 workflow definition events. pub async fn cmd_list_workflows(client: &SproutClient, channel_id: &str) -> Result<(), CliError> { validate_uuid(channel_id)?; - client - .run_get(&format!("/api/channels/{}/workflows", channel_id)) - .await + let filter = serde_json::json!({ + "kinds": [30620], + "#h": [channel_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } -pub async fn cmd_create_workflow( - client: &SproutClient, - channel_id: &str, - yaml: &str, -) -> Result<(), CliError> { - validate_uuid(channel_id)?; - let yaml_definition = read_or_stdin(yaml)?; - client - .run_post( - &format!("/api/channels/{}/workflows", channel_id), - &serde_json::json!({ "yaml_definition": yaml_definition }), - ) - .await -} - -pub async fn cmd_update_workflow( - client: &SproutClient, - workflow_id: &str, - yaml: &str, -) -> Result<(), CliError> { +/// Get a single workflow definition. +pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { validate_uuid(workflow_id)?; - let yaml_definition = read_or_stdin(yaml)?; - client - .run_put( - &format!("/api/workflows/{}", workflow_id), - &serde_json::json!({ "yaml_definition": yaml_definition }), - ) - .await -} - -pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { - validate_uuid(workflow_id)?; - client - .run_delete(&format!("/api/workflows/{}", workflow_id)) - .await -} - -pub async fn cmd_trigger_workflow( - client: &SproutClient, - workflow_id: &str, -) -> Result<(), CliError> { - validate_uuid(workflow_id)?; - client - .run_post( - &format!("/api/workflows/{}/trigger", workflow_id), - &serde_json::json!({}), - ) - .await + let filter = serde_json::json!({ + "kinds": [30620], + "#d": [workflow_id] + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } +/// Get workflow run history — query kind:46020 trigger events for this workflow. pub async fn cmd_get_workflow_runs( client: &SproutClient, workflow_id: &str, @@ -66,18 +41,105 @@ pub async fn cmd_get_workflow_runs( ) -> Result<(), CliError> { validate_uuid(workflow_id)?; let limit = limit.unwrap_or(20).min(100); - let path = format!("/api/workflows/{}/runs?limit={}", workflow_id, limit); - client.run_get(&path).await + let filter = serde_json::json!({ + "kinds": [46020], + "#d": [workflow_id], + "limit": limit + }); + let resp = client.query(&filter).await?; + println!("{resp}"); + Ok(()) } -pub async fn cmd_get_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { +// --------------------------------------------------------------------------- +// Write commands — signed events via POST /events +// --------------------------------------------------------------------------- + +/// Create a workflow — sign and submit a kind:30620 event. +pub async fn cmd_create_workflow( + client: &SproutClient, + channel_id: &str, + yaml: &str, +) -> Result<(), CliError> { + validate_uuid(channel_id)?; + let yaml_definition = read_or_stdin(yaml)?; + + // Generate a unique d-tag for this workflow + let workflow_id = uuid::Uuid::new_v4().to_string(); + let tags = vec![ + Tag::parse(&["d", &workflow_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + Tag::parse(&["h", channel_id]).map_err(|e| CliError::Other(format!("tag error: {e}")))?, + ]; + + let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +/// Update a workflow — sign and submit an updated kind:30620 event with same d-tag. +pub async fn cmd_update_workflow( + client: &SproutClient, + workflow_id: &str, + yaml: &str, +) -> Result<(), CliError> { validate_uuid(workflow_id)?; - client - .run_get(&format!("/api/workflows/{}", workflow_id)) - .await + let yaml_definition = read_or_stdin(yaml)?; + + let tags = + vec![Tag::parse(&["d", workflow_id]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; + + let builder = EventBuilder::new(Kind::Custom(30620), &yaml_definition, tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) } -/// Route is /grant or /deny based on the `approved` flag. +/// Delete a workflow — sign and submit a kind:5 deletion event. +pub async fn cmd_delete_workflow(client: &SproutClient, workflow_id: &str) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + let keys = client.keys(); + + // NIP-09 deletion targeting the parameterized replaceable event + let tags = vec![Tag::parse(&[ + "a", + &format!("30620:{}:{}", keys.public_key().to_hex(), workflow_id), + ]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; + + let builder = EventBuilder::new(Kind::Custom(5), "", tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +/// Trigger a workflow — sign and submit a kind:46020 event. +pub async fn cmd_trigger_workflow( + client: &SproutClient, + workflow_id: &str, +) -> Result<(), CliError> { + validate_uuid(workflow_id)?; + + let tags = + vec![Tag::parse(&["d", workflow_id]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; + + let builder = EventBuilder::new(Kind::Custom(46020), "", tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) +} + +/// Approve or deny a workflow step — sign and submit a kind:46030 (grant) or 46031 (deny) event. pub async fn cmd_approve_step( client: &SproutClient, approval_token: &str, @@ -85,15 +147,20 @@ pub async fn cmd_approve_step( note: Option<&str>, ) -> Result<(), CliError> { validate_uuid(approval_token)?; - let route = if approved { "grant" } else { "deny" }; - let mut body = serde_json::json!({}); - if let Some(n) = note { - body["note"] = n.into(); - } - client - .run_post( - &format!("/api/approvals/{}/{}", approval_token, route), - &body, - ) - .await + + let kind = if approved { 46030 } else { 46031 }; + let content = note.unwrap_or(""); + + // The relay expects d-tag = hex(SHA256(token)), not the raw token UUID. + let token_hash = hex::encode(Sha256::digest(approval_token.as_bytes())); + let tags = + vec![Tag::parse(&["d", &token_hash]) + .map_err(|e| CliError::Other(format!("tag error: {e}")))?]; + + let builder = EventBuilder::new(Kind::Custom(kind), content, tags); + let event = client.sign_event(builder)?; + + let resp = client.submit_event(event).await?; + println!("{resp}"); + Ok(()) } diff --git a/crates/sprout-cli/src/lib.rs b/crates/sprout-cli/src/lib.rs new file mode 100644 index 0000000000..e435d8913d --- /dev/null +++ b/crates/sprout-cli/src/lib.rs @@ -0,0 +1,946 @@ +mod client; +mod commands; +mod error; +mod validate; + +use clap::{Parser, Subcommand}; +use client::SproutClient; +use error::CliError; +use nostr::Keys; + +/// Run the Sprout CLI from raw arguments (including `argv[0]`). +/// +/// Returns a process exit code (0 = success). +/// +/// # Example +/// +/// ```ignore +/// let code = sprout_cli::run_from_args(std::env::args()).await; +/// std::process::exit(code); +/// ``` +pub async fn run_from_args(args: I) -> i32 +where + I: IntoIterator, + S: Into + Clone, +{ + let cli = match Cli::try_parse_from(args) { + Ok(cli) => cli, + Err(e) => { + if e.use_stderr() { + error::print_error(&CliError::Usage(e.to_string())); + return 1; + } else { + // --help and --version: print normally (intentional human output) + let _ = e.print(); + return 0; + } + } + }; + match run(cli).await { + Ok(()) => 0, + Err(e) => { + error::print_error(&e); + error::exit_code(&e) + } + } +} + +// --------------------------------------------------------------------------- +// Top-level CLI +// --------------------------------------------------------------------------- + +#[derive(Parser)] +#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")] +struct Cli { + #[arg( + long, + env = "SPROUT_RELAY_URL", + default_value = "http://localhost:3000" + )] + relay: String, + + /// Nostr private key (hex or nsec). This is the CLI's identity. + #[arg(long, env = "SPROUT_PRIVATE_KEY")] + private_key: Option, + + /// NIP-OA auth tag JSON (owner attestation). Injected into every signed event. + #[arg(long, env = "SPROUT_AUTH_TAG")] + auth_tag: Option, + + #[command(subcommand)] + command: Cmd, +} + +// --------------------------------------------------------------------------- +// Subcommands +// --------------------------------------------------------------------------- + +#[derive(Subcommand)] +enum Cmd { + // ---- Messages ---------------------------------------------------------- + /// Send a message to a channel + SendMessage { + #[arg(long)] + channel: String, + #[arg(long)] + content: String, + #[arg(long)] + kind: Option, + #[arg(long)] + reply_to: Option, + #[arg(long, default_value_t = false)] + broadcast: bool, + #[arg(long = "mention")] + mentions: Vec, + /// Attach file(s) — uploads and includes as imeta tags + #[arg(long = "file")] + files: Vec, + }, + /// Send a diff/code-review message + SendDiffMessage { + #[arg(long)] + channel: String, + #[arg(long)] + diff: String, + #[arg(long)] + repo: String, + #[arg(long)] + commit: String, + #[arg(long)] + file: Option, + #[arg(long)] + parent_commit: Option, + #[arg(long)] + source_branch: Option, + #[arg(long)] + target_branch: Option, + #[arg(long)] + pr: Option, + #[arg(long)] + lang: Option, + #[arg(long)] + description: Option, + #[arg(long)] + reply_to: Option, + }, + /// Delete a message by event ID + DeleteMessage { + #[arg(long)] + event: String, + }, + /// Get messages from a channel + GetMessages { + #[arg(long)] + channel: String, + #[arg(long)] + limit: Option, + #[arg(long)] + before: Option, + #[arg(long)] + since: Option, + #[arg(long)] + kinds: Option, + }, + /// Get a message thread + GetThread { + #[arg(long)] + channel: String, + #[arg(long)] + event: String, + #[arg(long)] + depth_limit: Option, + #[arg(long)] + limit: Option, + }, + /// Search messages + Search { + #[arg(long)] + query: String, + #[arg(long)] + limit: Option, + }, + /// Edit a message you previously sent + EditMessage { + /// Event ID of the message to edit (64-char hex) + #[arg(long)] + event: String, + /// New message content + #[arg(long)] + content: String, + }, + /// Vote on a forum post or comment (up or down) + VoteOnPost { + /// Event ID of the post to vote on (64-char hex) + #[arg(long)] + event: String, + /// Vote direction: "up" or "down" + #[arg(long)] + direction: String, + }, + + /// Upload a file to the relay (returns BlobDescriptor JSON) + UploadFile { + /// Path to the file to upload + #[arg(long)] + file: String, + }, + + // ---- Channels ---------------------------------------------------------- + /// List channels + ListChannels { + #[arg(long)] + visibility: Option, + #[arg(long, default_value_t = false)] + member: bool, + }, + /// Get a channel by ID + GetChannel { + #[arg(long)] + channel: String, + }, + /// Create a new channel + CreateChannel { + #[arg(long)] + name: String, + #[arg(long = "type")] + channel_type: String, + #[arg(long)] + visibility: String, + #[arg(long)] + description: Option, + }, + /// Update a channel's name or description + UpdateChannel { + #[arg(long)] + channel: String, + #[arg(long)] + name: Option, + #[arg(long)] + description: Option, + }, + /// Set a channel's topic + SetChannelTopic { + #[arg(long)] + channel: String, + #[arg(long)] + topic: String, + }, + /// Set a channel's purpose + SetChannelPurpose { + #[arg(long)] + channel: String, + #[arg(long)] + purpose: String, + }, + /// Join a channel + JoinChannel { + #[arg(long)] + channel: String, + }, + /// Leave a channel + LeaveChannel { + #[arg(long)] + channel: String, + }, + /// Archive a channel + ArchiveChannel { + #[arg(long)] + channel: String, + }, + /// Unarchive a channel + UnarchiveChannel { + #[arg(long)] + channel: String, + }, + /// Delete a channel + DeleteChannel { + #[arg(long)] + channel: String, + }, + /// List channel members + ListChannelMembers { + #[arg(long)] + channel: String, + }, + /// Add a member to a channel + AddChannelMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + #[arg(long)] + role: Option, + }, + /// Remove a member from a channel + RemoveChannelMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + }, + /// Get a channel's canvas + GetCanvas { + #[arg(long)] + channel: String, + }, + /// Set a channel's canvas content + SetCanvas { + #[arg(long)] + channel: String, + #[arg(long)] + content: String, + }, + + // ---- Reactions --------------------------------------------------------- + /// Add a reaction to a message + AddReaction { + #[arg(long)] + event: String, + #[arg(long)] + emoji: String, + }, + /// Remove a reaction from a message + RemoveReaction { + #[arg(long)] + event: String, + #[arg(long)] + emoji: String, + }, + /// Get reactions on a message + GetReactions { + #[arg(long)] + event: String, + }, + + // ---- DMs --------------------------------------------------------------- + /// List DM conversations + ListDms { + #[arg(long)] + limit: Option, + }, + /// Open a DM with one or more users (1–8 pubkeys) + OpenDm { + #[arg(long = "pubkey")] + pubkeys: Vec, + }, + /// Add a member to a DM group + AddDmMember { + #[arg(long)] + channel: String, + #[arg(long)] + pubkey: String, + }, + + // ---- Users ------------------------------------------------------------- + /// Get user profiles (0 = self, 1 = single, 2+ = batch) + GetUsers { + #[arg(long = "pubkey")] + pubkeys: Vec, + }, + /// Update your profile + SetProfile { + #[arg(long)] + name: Option, + #[arg(long)] + avatar: Option, + #[arg(long)] + about: Option, + #[arg(long)] + nip05: Option, + }, + /// Get presence status for users (comma-separated pubkeys) + GetPresence { + #[arg(long)] + pubkeys: String, + }, + /// Set your presence status + SetPresence { + #[arg(long)] + status: String, + }, + + // ---- Workflows --------------------------------------------------------- + /// List workflows in a channel + ListWorkflows { + #[arg(long)] + channel: String, + }, + /// Create a workflow in a channel + CreateWorkflow { + #[arg(long)] + channel: String, + #[arg(long)] + yaml: String, + }, + /// Update a workflow + UpdateWorkflow { + #[arg(long)] + workflow: String, + #[arg(long)] + yaml: String, + }, + /// Delete a workflow + DeleteWorkflow { + #[arg(long)] + workflow: String, + }, + /// Trigger a workflow manually + TriggerWorkflow { + #[arg(long)] + workflow: String, + }, + /// Get workflow run history + GetWorkflowRuns { + #[arg(long)] + workflow: String, + #[arg(long)] + limit: Option, + }, + /// Get a workflow definition + GetWorkflow { + #[arg(long)] + workflow: String, + }, + /// Approve or deny a workflow approval step + ApproveStep { + /// The approval token UUID (from the approval request) + #[arg(long)] + token: String, + /// Whether to approve: "true" or "false" + #[arg(long)] + approved: String, + #[arg(long)] + note: Option, + }, + + // ---- Feed -------------------------------------------------------------- + /// Get your activity feed + GetFeed { + #[arg(long)] + since: Option, + #[arg(long)] + limit: Option, + #[arg(long)] + types: Option, + }, + + // Social + /// Publish a short text note (kind:1) to the global feed. + #[command(name = "publish-note")] + PublishNote { + /// Text content of the note. + #[arg(long)] + content: String, + /// 64-char hex event ID to reply to. + #[arg(long)] + reply_to: Option, + }, + + /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. + #[command(name = "set-contact-list")] + SetContactList { + /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] + #[arg(long)] + contacts: String, + }, + + /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). + #[command(name = "get-event")] + GetEvent { + /// 64-char hex event ID. + #[arg(long)] + event: String, + }, + + /// List kind:1 text notes by a specific user. + #[command(name = "get-user-notes")] + GetUserNotes { + /// 64-char hex pubkey of the author. + #[arg(long)] + pubkey: String, + /// Maximum number of notes to return (default 50, max 100). + #[arg(long)] + limit: Option, + /// Unix timestamp cursor — return notes created before this time. + #[arg(long)] + before: Option, + }, + + /// Get a user's contact/follow list (kind:3) by hex pubkey. + #[command(name = "get-contact-list")] + GetContactList { + /// 64-char hex pubkey. + #[arg(long)] + pubkey: String, + }, + + // ---- Pack (local) ------------------------------------------------------ + /// Persona pack operations (local, no relay connection needed) + #[command(subcommand)] + Pack(PackCmd), +} + +/// Subcommands for `sprout pack`. +#[derive(Subcommand)] +enum PackCmd { + /// Validate a persona pack directory + Validate { + /// Path to the pack directory + path: String, + }, + /// Inspect a persona pack — show metadata and effective config + Inspect { + /// Path to the pack directory + path: String, + }, +} + +// --------------------------------------------------------------------------- +// Internal helpers +// --------------------------------------------------------------------------- + +/// Parse a string flag that must be "true" or "false". +fn parse_bool_flag(flag_name: &str, value: &str) -> Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + other => Err(CliError::Usage(format!( + "{flag_name} must be 'true' or 'false' (got: {other})" + ))), + } +} + +async fn run(cli: Cli) -> Result<(), CliError> { + let relay_url = client::normalize_relay_url(&cli.relay); + + // Pack commands are local-only — no relay connection needed. + if let Cmd::Pack(ref sub) = cli.command { + return match sub { + PackCmd::Validate { path } => commands::pack::cmd_validate(path), + PackCmd::Inspect { path } => commands::pack::cmd_inspect(path), + }; + } + + // Auth: private key is required for all relay operations. + // The keypair IS the identity — no tokens, no other auth. + let private_key_str = cli.private_key.ok_or_else(|| { + CliError::Auth("SPROUT_PRIVATE_KEY is required (use --private-key or set env var)".into()) + })?; + let keys = Keys::parse(&private_key_str) + .map_err(|e| CliError::Key(format!("invalid SPROUT_PRIVATE_KEY: {e}")))?; + + // NIP-OA: parse and verify the auth tag if provided. + let (auth_tag, auth_tag_json) = match cli.auth_tag { + Some(ref json) if !json.is_empty() => { + let tag = sprout_sdk::nip_oa::parse_auth_tag(json) + .map_err(|e| CliError::Auth(format!("SPROUT_AUTH_TAG is malformed: {e}")))?; + sprout_sdk::nip_oa::verify_auth_tag(json, &keys.public_key()).map_err(|e| { + CliError::Auth(format!( + "SPROUT_AUTH_TAG verification failed for pubkey {}: {e}", + keys.public_key().to_hex() + )) + })?; + (Some(tag), Some(json.clone())) + } + _ => (None, None), + }; + + let client = SproutClient::new(relay_url, keys, auth_tag, auth_tag_json)?; + + match cli.command { + // ---- Messages ------------------------------------------------------ + Cmd::SendMessage { + channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + } => { + commands::messages::cmd_send_message( + &client, + commands::messages::SendMessageParams { + channel_id: channel, + content, + kind, + reply_to, + broadcast, + mentions, + files, + }, + ) + .await + } + Cmd::SendDiffMessage { + channel, + diff, + repo, + commit, + file, + parent_commit, + source_branch, + target_branch, + pr, + lang, + description, + reply_to, + } => { + commands::messages::cmd_send_diff_message( + &client, + commands::messages::SendDiffParams { + channel_id: channel, + diff, + repo_url: repo, + commit_sha: commit, + file_path: file, + parent_commit_sha: parent_commit, + source_branch, + target_branch, + pr_number: pr, + language: lang, + description, + reply_to, + }, + ) + .await + } + Cmd::DeleteMessage { event } => { + commands::messages::cmd_delete_message(&client, &event).await + } + Cmd::GetMessages { + channel, + limit, + before, + since, + kinds, + } => { + commands::messages::cmd_get_messages( + &client, + &channel, + limit, + before, + since, + kinds.as_deref(), + ) + .await + } + Cmd::GetThread { + channel, + event, + depth_limit, + limit, + } => { + commands::messages::cmd_get_thread(&client, &channel, &event, depth_limit, limit).await + } + Cmd::Search { query, limit } => { + commands::messages::cmd_search(&client, &query, limit).await + } + Cmd::EditMessage { event, content } => { + commands::messages::cmd_edit_message(&client, &event, &content).await + } + Cmd::VoteOnPost { event, direction } => { + commands::messages::cmd_vote_on_post(&client, &event, &direction).await + } + Cmd::UploadFile { file } => { + let desc = client.upload_file(&file).await?; + println!( + "{}", + serde_json::to_string_pretty(&desc).map_err(|e| CliError::Other(e.to_string()))? + ); + Ok(()) + } + + // ---- Channels ------------------------------------------------------ + Cmd::ListChannels { visibility, member } => { + commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) + .await + } + Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, + Cmd::CreateChannel { + name, + channel_type, + visibility, + description, + } => { + commands::channels::cmd_create_channel( + &client, + &name, + &channel_type, + &visibility, + description.as_deref(), + ) + .await + } + Cmd::UpdateChannel { + channel, + name, + description, + } => { + commands::channels::cmd_update_channel( + &client, + &channel, + name.as_deref(), + description.as_deref(), + ) + .await + } + Cmd::SetChannelTopic { channel, topic } => { + commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await + } + Cmd::SetChannelPurpose { channel, purpose } => { + commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await + } + Cmd::JoinChannel { channel } => { + commands::channels::cmd_join_channel(&client, &channel).await + } + Cmd::LeaveChannel { channel } => { + commands::channels::cmd_leave_channel(&client, &channel).await + } + Cmd::ArchiveChannel { channel } => { + commands::channels::cmd_archive_channel(&client, &channel).await + } + Cmd::UnarchiveChannel { channel } => { + commands::channels::cmd_unarchive_channel(&client, &channel).await + } + Cmd::DeleteChannel { channel } => { + commands::channels::cmd_delete_channel(&client, &channel).await + } + Cmd::ListChannelMembers { channel } => { + commands::channels::cmd_list_channel_members(&client, &channel).await + } + Cmd::AddChannelMember { + channel, + pubkey, + role, + } => { + commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) + .await + } + Cmd::RemoveChannelMember { channel, pubkey } => { + commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await + } + Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, + Cmd::SetCanvas { channel, content } => { + commands::channels::cmd_set_canvas(&client, &channel, &content).await + } + + // ---- Reactions ----------------------------------------------------- + Cmd::AddReaction { event, emoji } => { + commands::reactions::cmd_add_reaction(&client, &event, &emoji).await + } + Cmd::RemoveReaction { event, emoji } => { + commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await + } + Cmd::GetReactions { event } => { + commands::reactions::cmd_get_reactions(&client, &event).await + } + + // ---- DMs ----------------------------------------------------------- + Cmd::ListDms { limit } => commands::dms::cmd_list_dms(&client, limit).await, + Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, + Cmd::AddDmMember { channel, pubkey } => { + commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await + } + + // ---- Users --------------------------------------------------------- + Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await, + Cmd::SetProfile { + name, + avatar, + about, + nip05, + } => { + commands::users::cmd_set_profile( + &client, + name.as_deref(), + avatar.as_deref(), + about.as_deref(), + nip05.as_deref(), + ) + .await + } + Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await, + Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await, + + // ---- Workflows ----------------------------------------------------- + Cmd::ListWorkflows { channel } => { + commands::workflows::cmd_list_workflows(&client, &channel).await + } + Cmd::CreateWorkflow { channel, yaml } => { + commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await + } + Cmd::UpdateWorkflow { workflow, yaml } => { + commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await + } + Cmd::DeleteWorkflow { workflow } => { + commands::workflows::cmd_delete_workflow(&client, &workflow).await + } + Cmd::TriggerWorkflow { workflow } => { + commands::workflows::cmd_trigger_workflow(&client, &workflow).await + } + Cmd::GetWorkflowRuns { workflow, limit } => { + commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await + } + Cmd::GetWorkflow { workflow } => { + commands::workflows::cmd_get_workflow(&client, &workflow).await + } + Cmd::ApproveStep { + token, + approved, + note, + } => { + let approved = parse_bool_flag("--approved", &approved)?; + commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await + } + + // ---- Feed ---------------------------------------------------------- + Cmd::GetFeed { + since, + limit, + types, + } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, + + // ---- Social -------------------------------------------------------- + Cmd::PublishNote { content, reply_to } => { + commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await + } + Cmd::SetContactList { contacts } => { + commands::social::cmd_set_contact_list(&client, &contacts).await + } + Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, + Cmd::GetUserNotes { + pubkey, + limit, + before, + } => commands::social::cmd_get_user_notes(&client, &pubkey, limit, before).await, + Cmd::GetContactList { pubkey } => { + commands::social::cmd_get_contact_list(&client, &pubkey).await + } + + // ---- Pack (local) -------------------------------------------------- + Cmd::Pack(_) => unreachable!("handled above"), + } +} + +// --------------------------------------------------------------------------- +// Tests +// --------------------------------------------------------------------------- + +#[cfg(test)] +mod tests { + use super::*; + use clap::CommandFactory; + + /// Smoke test: CLI definition is valid and parseable. + #[test] + fn cli_definition_is_valid() { + Cli::command().debug_assert(); + } + + /// Regression: parse_bool_flag rejects values other than "true"/"false". + #[test] + fn parse_bool_flag_accepts_true() { + assert!(super::parse_bool_flag("--approved", "true").unwrap()); + } + + #[test] + fn parse_bool_flag_accepts_false() { + assert!(!super::parse_bool_flag("--approved", "false").unwrap()); + } + + #[test] + fn parse_bool_flag_rejects_invalid() { + let err = super::parse_bool_flag("--approved", "maybe").unwrap_err(); + match err { + super::CliError::Usage(msg) => { + assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}"); + assert!(msg.contains("maybe"), "got: {msg}"); + } + other => panic!("expected Usage error, got: {other:?}"), + } + } + + #[test] + fn parse_bool_flag_rejects_empty() { + assert!(super::parse_bool_flag("--approved", "").is_err()); + } + + /// Parity: the CLI exposes exactly the expected set of commands. + /// Token commands removed (auth, list-tokens, delete-token, delete-all-tokens). + /// SetChannelAddPolicy removed (relay-side policy now). + /// Cursor removed from get-thread, list-dms. before_id removed from get-user-notes. + #[test] + fn command_inventory_is_stable() { + let expected: Vec<&str> = vec![ + "add-channel-member", + "add-dm-member", + "add-reaction", + "approve-step", + "archive-channel", + "create-channel", + "create-workflow", + "delete-channel", + "delete-message", + "delete-workflow", + "edit-message", + "get-canvas", + "get-channel", + "get-contact-list", + "get-event", + "get-feed", + "get-messages", + "get-presence", + "get-reactions", + "get-thread", + "get-user-notes", + "get-users", + "get-workflow", + "get-workflow-runs", + "join-channel", + "leave-channel", + "list-channel-members", + "list-channels", + "list-dms", + "list-workflows", + "open-dm", + "pack", + "publish-note", + "remove-channel-member", + "remove-reaction", + "search", + "send-diff-message", + "send-message", + "set-canvas", + "set-channel-purpose", + "set-channel-topic", + "set-contact-list", + "set-presence", + "set-profile", + "trigger-workflow", + "unarchive-channel", + "update-channel", + "update-workflow", + "upload-file", + "vote-on-post", + ]; + + let cmd = Cli::command(); + let mut actual: Vec = cmd + .get_subcommands() + .map(|s| s.get_name().to_string()) + .filter(|n| n != "help") // clap auto-adds "help" + .collect(); + actual.sort(); + + assert_eq!( + actual.len(), + expected.len(), + "Expected {} commands, got {}. Actual: {:?}", + expected.len(), + actual.len(), + actual + ); + assert_eq!(actual, expected, "Command inventory drift detected"); + } +} diff --git a/crates/sprout-cli/src/main.rs b/crates/sprout-cli/src/main.rs index 03279d6bd4..e98f4715a3 100644 --- a/crates/sprout-cli/src/main.rs +++ b/crates/sprout-cli/src/main.rs @@ -1,974 +1,4 @@ -mod client; -mod commands; -mod error; -mod validate; - -use clap::{Parser, Subcommand}; -use client::{Auth, SproutClient}; -use error::CliError; - -// --------------------------------------------------------------------------- -// Top-level CLI -// --------------------------------------------------------------------------- - -#[derive(Parser)] -#[command(name = "sprout", about = "Sprout CLI — interact with a Sprout relay")] -struct Cli { - #[arg( - long, - env = "SPROUT_RELAY_URL", - default_value = "http://localhost:3000" - )] - relay: String, - - #[arg(long, env = "SPROUT_API_TOKEN")] - token: Option, - - #[arg(long, env = "SPROUT_PRIVATE_KEY", hide = true)] - private_key: Option, - - #[arg(long, env = "SPROUT_PUBKEY")] - pubkey: Option, - - #[command(subcommand)] - command: Cmd, -} - -// --------------------------------------------------------------------------- -// Subcommands -// --------------------------------------------------------------------------- - -#[derive(Subcommand)] -enum Cmd { - // ---- Messages ---------------------------------------------------------- - /// Send a message to a channel - SendMessage { - #[arg(long)] - channel: String, - #[arg(long)] - content: String, - #[arg(long)] - kind: Option, - #[arg(long)] - reply_to: Option, - #[arg(long, default_value_t = false)] - broadcast: bool, - #[arg(long = "mention")] - mentions: Vec, - }, - /// Send a diff/code-review message - SendDiffMessage { - #[arg(long)] - channel: String, - #[arg(long)] - diff: String, - #[arg(long)] - repo: String, - #[arg(long)] - commit: String, - #[arg(long)] - file: Option, - #[arg(long)] - parent_commit: Option, - #[arg(long)] - source_branch: Option, - #[arg(long)] - target_branch: Option, - #[arg(long)] - pr: Option, - #[arg(long)] - lang: Option, - #[arg(long)] - description: Option, - #[arg(long)] - reply_to: Option, - }, - /// Delete a message by event ID - DeleteMessage { - #[arg(long)] - event: String, - }, - /// Get messages from a channel - GetMessages { - #[arg(long)] - channel: String, - #[arg(long)] - limit: Option, - #[arg(long)] - before: Option, - #[arg(long)] - since: Option, - #[arg(long)] - kinds: Option, - }, - /// Get a message thread - GetThread { - #[arg(long)] - channel: String, - #[arg(long)] - event: String, - #[arg(long)] - depth_limit: Option, - #[arg(long)] - limit: Option, - #[arg(long)] - cursor: Option, - }, - /// Search messages - Search { - #[arg(long)] - query: String, - #[arg(long)] - limit: Option, - }, - /// Edit a message you previously sent - EditMessage { - /// Event ID of the message to edit (64-char hex) - #[arg(long)] - event: String, - /// New message content - #[arg(long)] - content: String, - }, - /// Vote on a forum post or comment (up or down) - VoteOnPost { - /// Event ID of the post to vote on (64-char hex) - #[arg(long)] - event: String, - /// Vote direction: "up" or "down" - #[arg(long)] - direction: String, - }, - - // ---- Channels ---------------------------------------------------------- - /// List channels - ListChannels { - #[arg(long)] - visibility: Option, - #[arg(long, default_value_t = false)] - member: bool, - }, - /// Get a channel by ID - GetChannel { - #[arg(long)] - channel: String, - }, - /// Create a new channel - CreateChannel { - #[arg(long)] - name: String, - #[arg(long = "type")] - channel_type: String, - #[arg(long)] - visibility: String, - #[arg(long)] - description: Option, - }, - /// Update a channel's name or description - UpdateChannel { - #[arg(long)] - channel: String, - #[arg(long)] - name: Option, - #[arg(long)] - description: Option, - }, - /// Set a channel's topic - SetChannelTopic { - #[arg(long)] - channel: String, - #[arg(long)] - topic: String, - }, - /// Set a channel's purpose - SetChannelPurpose { - #[arg(long)] - channel: String, - #[arg(long)] - purpose: String, - }, - /// Join a channel - JoinChannel { - #[arg(long)] - channel: String, - }, - /// Leave a channel - LeaveChannel { - #[arg(long)] - channel: String, - }, - /// Archive a channel - ArchiveChannel { - #[arg(long)] - channel: String, - }, - /// Unarchive a channel - UnarchiveChannel { - #[arg(long)] - channel: String, - }, - /// Delete a channel - DeleteChannel { - #[arg(long)] - channel: String, - }, - /// List channel members - ListChannelMembers { - #[arg(long)] - channel: String, - }, - /// Add a member to a channel - AddChannelMember { - #[arg(long)] - channel: String, - #[arg(long)] - pubkey: String, - #[arg(long)] - role: Option, - }, - /// Remove a member from a channel - RemoveChannelMember { - #[arg(long)] - channel: String, - #[arg(long)] - pubkey: String, - }, - /// Get a channel's canvas - GetCanvas { - #[arg(long)] - channel: String, - }, - /// Set a channel's canvas content - SetCanvas { - #[arg(long)] - channel: String, - #[arg(long)] - content: String, - }, - - // ---- Reactions --------------------------------------------------------- - /// Add a reaction to a message - AddReaction { - #[arg(long)] - event: String, - #[arg(long)] - emoji: String, - }, - /// Remove a reaction from a message - RemoveReaction { - #[arg(long)] - event: String, - #[arg(long)] - emoji: String, - }, - /// Get reactions on a message - GetReactions { - #[arg(long)] - event: String, - }, - - // ---- DMs --------------------------------------------------------------- - /// List DM conversations - ListDms { - #[arg(long)] - cursor: Option, - #[arg(long)] - limit: Option, - }, - /// Open a DM with one or more users (1–8 pubkeys) - OpenDm { - #[arg(long = "pubkey")] - pubkeys: Vec, - }, - /// Add a member to a DM group - AddDmMember { - #[arg(long)] - channel: String, - #[arg(long)] - pubkey: String, - }, - - // ---- Users ------------------------------------------------------------- - /// Get user profiles (0 = self, 1 = single, 2+ = batch) - GetUsers { - #[arg(long = "pubkey")] - pubkeys: Vec, - }, - /// Update your profile - SetProfile { - #[arg(long)] - name: Option, - #[arg(long)] - avatar: Option, - #[arg(long)] - about: Option, - #[arg(long)] - nip05: Option, - }, - /// Get presence status for users (comma-separated pubkeys) - GetPresence { - #[arg(long)] - pubkeys: String, - }, - /// Set your presence status - SetPresence { - #[arg(long)] - status: String, - }, - /// Set who can add you to channels - SetChannelAddPolicy { - #[arg(long)] - policy: String, - }, - - // ---- Workflows --------------------------------------------------------- - /// List workflows in a channel - ListWorkflows { - #[arg(long)] - channel: String, - }, - /// Create a workflow in a channel - CreateWorkflow { - #[arg(long)] - channel: String, - #[arg(long)] - yaml: String, - }, - /// Update a workflow - UpdateWorkflow { - #[arg(long)] - workflow: String, - #[arg(long)] - yaml: String, - }, - /// Delete a workflow - DeleteWorkflow { - #[arg(long)] - workflow: String, - }, - /// Trigger a workflow manually - TriggerWorkflow { - #[arg(long)] - workflow: String, - }, - /// Get workflow run history - GetWorkflowRuns { - #[arg(long)] - workflow: String, - #[arg(long)] - limit: Option, - }, - /// Get a workflow definition - GetWorkflow { - #[arg(long)] - workflow: String, - }, - /// Approve or deny a workflow approval step - ApproveStep { - #[arg(long)] - token: String, - /// Whether to approve: "true" or "false" - #[arg(long)] - approved: String, - #[arg(long)] - note: Option, - }, - - // ---- Feed -------------------------------------------------------------- - /// Get your activity feed - GetFeed { - #[arg(long)] - since: Option, - #[arg(long)] - limit: Option, - #[arg(long)] - types: Option, - }, - - // ---- Auth & Tokens ----------------------------------------------------- - /// Mint a long-lived API token (prints token to stdout) - Auth, - /// List your API tokens - ListTokens, - /// Delete an API token by ID - DeleteToken { - #[arg(long)] - id: String, - }, - /// Delete all your API tokens - DeleteAllTokens, - - // Social - /// Publish a short text note (kind:1) to the global feed. - #[command(name = "publish-note")] - PublishNote { - /// Text content of the note. - #[arg(long)] - content: String, - /// 64-char hex event ID to reply to. - #[arg(long)] - reply_to: Option, - }, - - /// Set the authenticated user's contact/follow list (kind:3). Replaces the entire list. - #[command(name = "set-contact-list")] - SetContactList { - /// JSON array of contacts: [{"pubkey":"hex","relay_url":"...","petname":"..."}] - #[arg(long)] - contacts: String, - }, - - /// Get a single event by event ID (notes, profiles, contacts, articles, channel events). - #[command(name = "get-event")] - GetEvent { - /// 64-char hex event ID. - #[arg(long)] - event: String, - }, - - /// List kind:1 text notes by a specific user. - #[command(name = "get-user-notes")] - GetUserNotes { - /// 64-char hex pubkey of the author. - #[arg(long)] - pubkey: String, - /// Maximum number of notes to return (default 50, max 100). - #[arg(long)] - limit: Option, - /// Unix timestamp cursor — return notes created before this time. - /// Use with --before-id for stable composite cursor pagination. - #[arg(long)] - before: Option, - /// Hex event ID cursor for composite keyset pagination. Use together with - /// --before to avoid skipping same-second events. Pass the before_id value - /// from the previous page's next_cursor response. - #[arg(long)] - before_id: Option, - }, - - /// Get a user's contact/follow list (kind:3) by hex pubkey. - #[command(name = "get-contact-list")] - GetContactList { - /// 64-char hex pubkey. - #[arg(long)] - pubkey: String, - }, - - // ---- Pack (local) ------------------------------------------------------ - /// Persona pack operations (local, no relay connection needed) - #[command(subcommand)] - Pack(PackCmd), -} - -/// Subcommands for `sprout pack`. -#[derive(Subcommand)] -enum PackCmd { - /// Validate a persona pack directory - Validate { - /// Path to the pack directory - path: String, - }, - /// Inspect a persona pack — show metadata and effective config - Inspect { - /// Path to the pack directory - path: String, - }, -} - -// --------------------------------------------------------------------------- -// Entry point -// --------------------------------------------------------------------------- - #[tokio::main] async fn main() { - let cli = match Cli::try_parse() { - Ok(cli) => cli, - Err(e) => { - if e.use_stderr() { - error::print_error(&CliError::Usage(e.to_string())); - std::process::exit(1); - } else { - // --help and --version: print normally (intentional human output) - let _ = e.print(); - std::process::exit(0); - } - } - }; - match run(cli).await { - Ok(()) => {} - Err(e) => { - error::print_error(&e); - std::process::exit(error::exit_code(&e)); - } - } -} - -/// Parse a string flag that must be "true" or "false". -fn parse_bool_flag(flag_name: &str, value: &str) -> Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - other => Err(CliError::Usage(format!( - "{flag_name} must be 'true' or 'false' (got: {other})" - ))), - } -} - -async fn run(cli: Cli) -> Result<(), CliError> { - let relay_url = client::normalize_relay_url(&cli.relay); - - // Auth command is special — runs before SproutClient creation. - // Passes --private-key flag; cmd_auth falls back to SPROUT_PRIVATE_KEY env var. - if let Cmd::Auth = &cli.command { - return commands::auth::cmd_auth(&relay_url, cli.private_key.as_deref()).await; - } - - // Pack commands are local-only — no relay connection needed. - if let Cmd::Pack(ref sub) = cli.command { - return match sub { - PackCmd::Validate { path } => commands::pack::cmd_validate(path), - PackCmd::Inspect { path } => commands::pack::cmd_inspect(path), - }; - } - - // Auth resolution: token > private_key (auto-mint) > pubkey > error - // - // When SPROUT_PRIVATE_KEY is set, auto_mint_token returns (token, keys). - // The keys are retained on the client for signing write operations. - let (auth, retained_keys) = if let Some(token) = cli.token { - (Auth::Bearer(token), None) - } else if let Some(key) = cli.private_key { - let (minted, keys) = client::auto_mint_token(&relay_url, &key).await?; - (Auth::Bearer(minted), Some(keys)) - } else if let Some(pk) = cli.pubkey { - (Auth::DevMode(pk), None) - } else { - return Err(CliError::Auth( - "Set SPROUT_API_TOKEN, SPROUT_PRIVATE_KEY, or SPROUT_PUBKEY".into(), - )); - }; - - let client = { - let c = SproutClient::new(relay_url, auth)?; - match retained_keys { - Some(k) => c.with_keys(k), - None => c, - } - }; - - match cli.command { - // ---- Messages ------------------------------------------------------ - Cmd::SendMessage { - channel, - content, - kind, - reply_to, - broadcast, - mentions, - } => { - commands::messages::cmd_send_message( - &client, - &channel, - &content, - kind, - reply_to.as_deref(), - broadcast, - &mentions, - ) - .await - } - Cmd::SendDiffMessage { - channel, - diff, - repo, - commit, - file, - parent_commit, - source_branch, - target_branch, - pr, - lang, - description, - reply_to, - } => { - commands::messages::cmd_send_diff_message( - &client, - commands::messages::SendDiffParams { - channel_id: channel, - diff, - repo_url: repo, - commit_sha: commit, - file_path: file, - parent_commit_sha: parent_commit, - source_branch, - target_branch, - pr_number: pr, - language: lang, - description, - reply_to, - }, - ) - .await - } - Cmd::DeleteMessage { event } => { - commands::messages::cmd_delete_message(&client, &event).await - } - Cmd::GetMessages { - channel, - limit, - before, - since, - kinds, - } => { - commands::messages::cmd_get_messages( - &client, - &channel, - limit, - before, - since, - kinds.as_deref(), - ) - .await - } - Cmd::GetThread { - channel, - event, - depth_limit, - limit, - cursor, - } => { - commands::messages::cmd_get_thread( - &client, - &channel, - &event, - depth_limit, - limit, - cursor.as_deref(), - ) - .await - } - Cmd::Search { query, limit } => { - commands::messages::cmd_search(&client, &query, limit).await - } - Cmd::EditMessage { event, content } => { - commands::messages::cmd_edit_message(&client, &event, &content).await - } - Cmd::VoteOnPost { event, direction } => { - commands::messages::cmd_vote_on_post(&client, &event, &direction).await - } - - // ---- Channels ------------------------------------------------------ - Cmd::ListChannels { visibility, member } => { - commands::channels::cmd_list_channels(&client, visibility.as_deref(), Some(member)) - .await - } - Cmd::GetChannel { channel } => commands::channels::cmd_get_channel(&client, &channel).await, - Cmd::CreateChannel { - name, - channel_type, - visibility, - description, - } => { - commands::channels::cmd_create_channel( - &client, - &name, - &channel_type, - &visibility, - description.as_deref(), - ) - .await - } - Cmd::UpdateChannel { - channel, - name, - description, - } => { - commands::channels::cmd_update_channel( - &client, - &channel, - name.as_deref(), - description.as_deref(), - ) - .await - } - Cmd::SetChannelTopic { channel, topic } => { - commands::channels::cmd_set_channel_topic(&client, &channel, &topic).await - } - Cmd::SetChannelPurpose { channel, purpose } => { - commands::channels::cmd_set_channel_purpose(&client, &channel, &purpose).await - } - Cmd::JoinChannel { channel } => { - commands::channels::cmd_join_channel(&client, &channel).await - } - Cmd::LeaveChannel { channel } => { - commands::channels::cmd_leave_channel(&client, &channel).await - } - Cmd::ArchiveChannel { channel } => { - commands::channels::cmd_archive_channel(&client, &channel).await - } - Cmd::UnarchiveChannel { channel } => { - commands::channels::cmd_unarchive_channel(&client, &channel).await - } - Cmd::DeleteChannel { channel } => { - commands::channels::cmd_delete_channel(&client, &channel).await - } - Cmd::ListChannelMembers { channel } => { - commands::channels::cmd_list_channel_members(&client, &channel).await - } - Cmd::AddChannelMember { - channel, - pubkey, - role, - } => { - commands::channels::cmd_add_channel_member(&client, &channel, &pubkey, role.as_deref()) - .await - } - Cmd::RemoveChannelMember { channel, pubkey } => { - commands::channels::cmd_remove_channel_member(&client, &channel, &pubkey).await - } - Cmd::GetCanvas { channel } => commands::channels::cmd_get_canvas(&client, &channel).await, - Cmd::SetCanvas { channel, content } => { - commands::channels::cmd_set_canvas(&client, &channel, &content).await - } - - // ---- Reactions ----------------------------------------------------- - Cmd::AddReaction { event, emoji } => { - commands::reactions::cmd_add_reaction(&client, &event, &emoji).await - } - Cmd::RemoveReaction { event, emoji } => { - commands::reactions::cmd_remove_reaction(&client, &event, &emoji).await - } - Cmd::GetReactions { event } => { - commands::reactions::cmd_get_reactions(&client, &event).await - } - - // ---- DMs ----------------------------------------------------------- - Cmd::ListDms { cursor, limit } => { - commands::dms::cmd_list_dms(&client, cursor.as_deref(), limit).await - } - Cmd::OpenDm { pubkeys } => commands::dms::cmd_open_dm(&client, &pubkeys).await, - Cmd::AddDmMember { channel, pubkey } => { - commands::dms::cmd_add_dm_member(&client, &channel, &pubkey).await - } - - // ---- Users --------------------------------------------------------- - Cmd::GetUsers { pubkeys } => commands::users::cmd_get_users(&client, &pubkeys).await, - Cmd::SetProfile { - name, - avatar, - about, - nip05, - } => { - commands::users::cmd_set_profile( - &client, - name.as_deref(), - avatar.as_deref(), - about.as_deref(), - nip05.as_deref(), - ) - .await - } - Cmd::GetPresence { pubkeys } => commands::users::cmd_get_presence(&client, &pubkeys).await, - Cmd::SetPresence { status } => commands::users::cmd_set_presence(&client, &status).await, - Cmd::SetChannelAddPolicy { policy } => { - commands::users::cmd_set_channel_add_policy(&client, &policy).await - } - - // ---- Workflows ----------------------------------------------------- - Cmd::ListWorkflows { channel } => { - commands::workflows::cmd_list_workflows(&client, &channel).await - } - Cmd::CreateWorkflow { channel, yaml } => { - commands::workflows::cmd_create_workflow(&client, &channel, &yaml).await - } - Cmd::UpdateWorkflow { workflow, yaml } => { - commands::workflows::cmd_update_workflow(&client, &workflow, &yaml).await - } - Cmd::DeleteWorkflow { workflow } => { - commands::workflows::cmd_delete_workflow(&client, &workflow).await - } - Cmd::TriggerWorkflow { workflow } => { - commands::workflows::cmd_trigger_workflow(&client, &workflow).await - } - Cmd::GetWorkflowRuns { workflow, limit } => { - commands::workflows::cmd_get_workflow_runs(&client, &workflow, limit).await - } - Cmd::GetWorkflow { workflow } => { - commands::workflows::cmd_get_workflow(&client, &workflow).await - } - Cmd::ApproveStep { - token, - approved, - note, - } => { - let approved = parse_bool_flag("--approved", &approved)?; - commands::workflows::cmd_approve_step(&client, &token, approved, note.as_deref()).await - } - - // ---- Feed ---------------------------------------------------------- - Cmd::GetFeed { - since, - limit, - types, - } => commands::feed::cmd_get_feed(&client, since, limit, types.as_deref()).await, - - // ---- Social -------------------------------------------------------- - Cmd::PublishNote { content, reply_to } => { - commands::social::cmd_publish_note(&client, &content, reply_to.as_deref()).await - } - Cmd::SetContactList { contacts } => { - commands::social::cmd_set_contact_list(&client, &contacts).await - } - Cmd::GetEvent { event } => commands::social::cmd_get_event(&client, &event).await, - Cmd::GetUserNotes { - pubkey, - limit, - before, - before_id, - } => { - commands::social::cmd_get_user_notes( - &client, - &pubkey, - limit, - before, - before_id.as_deref(), - ) - .await - } - Cmd::GetContactList { pubkey } => { - commands::social::cmd_get_contact_list(&client, &pubkey).await - } - - // ---- Pack (local) -------------------------------------------------- - Cmd::Pack(_) => unreachable!("handled above"), - - // ---- Auth & Tokens ------------------------------------------------- - Cmd::Auth => unreachable!("handled above"), - Cmd::ListTokens => commands::auth::cmd_list_tokens(&client).await, - Cmd::DeleteToken { id } => commands::auth::cmd_delete_token(&client, &id).await, - Cmd::DeleteAllTokens => commands::auth::cmd_delete_all_tokens(&client).await, - } -} - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -#[cfg(test)] -mod tests { - use super::*; - use clap::CommandFactory; - - /// Smoke test: CLI definition is valid and parseable. - #[test] - fn cli_definition_is_valid() { - Cli::command().debug_assert(); - } - - /// Regression: parse_bool_flag rejects values other than "true"/"false". - #[test] - fn parse_bool_flag_accepts_true() { - assert!(super::parse_bool_flag("--approved", "true").unwrap()); - } - - #[test] - fn parse_bool_flag_accepts_false() { - assert!(!super::parse_bool_flag("--approved", "false").unwrap()); - } - - #[test] - fn parse_bool_flag_rejects_invalid() { - let err = super::parse_bool_flag("--approved", "maybe").unwrap_err(); - match err { - super::CliError::Usage(msg) => { - assert!(msg.contains("must be 'true' or 'false'"), "got: {msg}"); - assert!(msg.contains("maybe"), "got: {msg}"); - } - other => panic!("expected Usage error, got: {other:?}"), - } - } - - #[test] - fn parse_bool_flag_rejects_empty() { - assert!(super::parse_bool_flag("--approved", "").is_err()); - } - - /// Parity: the CLI exposes exactly the expected 54 commands. - /// If a command is added or removed, this test forces a conscious update. - #[test] - fn command_inventory_is_54() { - let expected: Vec<&str> = vec![ - "add-channel-member", - "add-dm-member", - "add-reaction", - "approve-step", - "archive-channel", - "auth", - "create-channel", - "create-workflow", - "delete-all-tokens", - "delete-channel", - "delete-message", - "delete-token", - "delete-workflow", - "edit-message", - "get-canvas", - "get-channel", - "get-contact-list", - "get-event", - "get-feed", - "get-messages", - "get-presence", - "get-reactions", - "get-thread", - "get-user-notes", - "get-users", - "get-workflow", - "get-workflow-runs", - "join-channel", - "leave-channel", - "list-channel-members", - "list-channels", - "list-dms", - "list-tokens", - "list-workflows", - "open-dm", - "pack", - "publish-note", - "remove-channel-member", - "remove-reaction", - "search", - "send-diff-message", - "send-message", - "set-canvas", - "set-channel-add-policy", - "set-channel-purpose", - "set-channel-topic", - "set-contact-list", - "set-presence", - "set-profile", - "trigger-workflow", - "unarchive-channel", - "update-channel", - "update-workflow", - "vote-on-post", - ]; - - let cmd = Cli::command(); - let mut actual: Vec = cmd - .get_subcommands() - .map(|s| s.get_name().to_string()) - .filter(|n| n != "help") // clap auto-adds "help" - .collect(); - actual.sort(); - - assert_eq!( - actual.len(), - 54, - "Expected 54 commands, got {}. Actual: {:?}", - actual.len(), - actual - ); - assert_eq!(actual, expected, "Command inventory drift detected"); - } + std::process::exit(sprout_cli::run_from_args(std::env::args()).await); } diff --git a/crates/sprout-cli/src/validate.rs b/crates/sprout-cli/src/validate.rs index 2c9c4dbcc5..9deff826f6 100644 --- a/crates/sprout-cli/src/validate.rs +++ b/crates/sprout-cli/src/validate.rs @@ -36,6 +36,7 @@ pub fn validate_content_size(content: &str) -> Result<(), CliError> { /// Percent-encode for URL path segments and query parameter values. /// Encodes all bytes except RFC 3986 unreserved: A-Z a-z 0-9 - _ . ~ +#[cfg(test)] pub fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for byte in s.bytes() { diff --git a/crates/sprout-core/src/channel.rs b/crates/sprout-core/src/channel.rs index 74e1366bab..e2caffc2a8 100644 --- a/crates/sprout-core/src/channel.rs +++ b/crates/sprout-core/src/channel.rs @@ -96,6 +96,10 @@ impl FromStr for ChannelType { // ── Member role ────────────────────────────────────────────────────────────── /// A member's role within a channel. +/// +/// The hierarchy for permission checks is: Owner > Admin > Member > Guest. +/// Bot is a **separate designation** — it is not part of the linear hierarchy. +/// Use [`MemberRole::permission_level`] for numeric comparisons in authorization. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MemberRole { /// Full control — can manage members and delete the channel. @@ -106,7 +110,7 @@ pub enum MemberRole { Member, /// Read-only external participant. Guest, - /// Automated agent or integration. + /// Automated agent or integration (not in the role hierarchy). Bot, } @@ -126,6 +130,27 @@ impl MemberRole { pub fn is_elevated(&self) -> bool { matches!(self, Self::Owner | Self::Admin) } + + /// Numeric permission level for authorization comparisons. + /// + /// Higher = more privileged. Bot returns 0 (must use explicit grants). + /// Use `role.permission_level() >= required.permission_level()` for checks. + pub fn permission_level(self) -> u8 { + match self { + Self::Owner => 4, + Self::Admin => 3, + Self::Member => 2, + Self::Guest => 1, + Self::Bot => 0, + } + } + + /// Returns true if this role meets or exceeds the required role's permission level. + /// + /// Bot never meets any requirement (returns false for all non-Bot requirements). + pub fn has_at_least(self, required: MemberRole) -> bool { + self.permission_level() >= required.permission_level() + } } impl fmt::Display for MemberRole { diff --git a/crates/sprout-core/src/git_perms.rs b/crates/sprout-core/src/git_perms.rs new file mode 100644 index 0000000000..865462e360 --- /dev/null +++ b/crates/sprout-core/src/git_perms.rs @@ -0,0 +1,1029 @@ +//! Git permission types — ref patterns, protection rules, and policy evaluation inputs. +//! +//! This module defines the core data types for the Sprout git permission system. +//! The permission model: channel role = repo role; `sprout-protect` tags on +//! kind:30617 add constraints that apply to everyone (including the owner). +//! +//! # Architecture +//! +//! ```text +//! kind:30617 tags → parse → Vec +//! ↓ +//! push arrives → classify refs → match patterns → union rules → enforce +//! ``` + +use crate::channel::MemberRole; +use std::fmt; + +// ── Limits (DoS prevention for untrusted kind:30617 input) ─────────────────── + +/// Maximum number of `sprout-protect` tags per repo. +pub const MAX_PROTECTION_RULES: usize = 50; +/// Maximum character length of a ref pattern. +pub const MAX_PATTERN_LENGTH: usize = 256; +/// Maximum number of wildcard segments per pattern. +pub const MAX_WILDCARDS_PER_PATTERN: usize = 3; + +// ── Ref Pattern ────────────────────────────────────────────────────────────── + +/// A validated ref pattern for matching git refs. +/// +/// Grammar: `segment ("/" segment)*` where segment is either a literal +/// `[a-zA-Z0-9._-]+` or `*` (matches exactly one path segment). +/// +/// Patterns MUST start with `refs/`. No `**`, `?`, `[...]`, or partial globs. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefPattern { + /// The original pattern string (e.g., "refs/heads/*"). + raw: String, + /// Pre-split segments for matching. + segments: Vec, +} + +/// A single segment in a ref pattern. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PatternSegment { + /// Matches exactly this literal string. + Literal(String), + /// Matches any single path segment. + Wildcard, + /// Matches one or more path segments (recursive). Must be the last segment. + RecursiveWildcard, +} + +/// Errors from parsing a ref pattern. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum PatternError { + /// Pattern is empty. + Empty, + /// Pattern exceeds maximum length. + TooLong, + /// Pattern doesn't start with `refs/`. + MissingRefsPrefix, + /// A segment contains invalid characters or is a partial glob. + InvalidSegment(String), + /// Too many wildcard segments. + TooManyWildcards, +} + +impl fmt::Display for PatternError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::Empty => write!(f, "pattern is empty"), + Self::TooLong => write!(f, "pattern exceeds {MAX_PATTERN_LENGTH} chars"), + Self::MissingRefsPrefix => write!(f, "pattern must start with 'refs/'"), + Self::InvalidSegment(s) => write!(f, "invalid segment: {s:?}"), + Self::TooManyWildcards => { + write!(f, "pattern exceeds {MAX_WILDCARDS_PER_PATTERN} wildcards") + } + } + } +} + +impl std::error::Error for PatternError {} + +impl RefPattern { + /// Parse and validate a ref pattern string. + pub fn parse(pattern: &str) -> Result { + if pattern.is_empty() { + return Err(PatternError::Empty); + } + if pattern.len() > MAX_PATTERN_LENGTH { + return Err(PatternError::TooLong); + } + if !pattern.starts_with("refs/") { + return Err(PatternError::MissingRefsPrefix); + } + + let mut segments = Vec::new(); + let mut wildcard_count = 0; + + let parts: Vec<&str> = pattern.split('/').collect(); + for (i, part) in parts.iter().enumerate() { + if *part == "**" { + // `**` must be the last segment (recursive match). + if i != parts.len() - 1 { + return Err(PatternError::InvalidSegment( + "** must be the last segment".to_string(), + )); + } + wildcard_count += 1; + if wildcard_count > MAX_WILDCARDS_PER_PATTERN { + return Err(PatternError::TooManyWildcards); + } + segments.push(PatternSegment::RecursiveWildcard); + } else if *part == "*" { + wildcard_count += 1; + if wildcard_count > MAX_WILDCARDS_PER_PATTERN { + return Err(PatternError::TooManyWildcards); + } + segments.push(PatternSegment::Wildcard); + } else if part.is_empty() { + return Err(PatternError::InvalidSegment(String::new())); + } else if part.contains('*') + || part.contains('?') + || part.contains('[') + || part.contains(']') + { + // Partial globs (e.g., "v*") are not allowed. + return Err(PatternError::InvalidSegment(part.to_string())); + } else if !part + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') + { + return Err(PatternError::InvalidSegment(part.to_string())); + } else { + segments.push(PatternSegment::Literal(part.to_string())); + } + } + + Ok(Self { + raw: pattern.to_string(), + segments, + }) + } + + /// Test whether this pattern matches a given ref name. + /// + /// Matching is segment-by-segment: + /// - `*` matches exactly one path segment + /// - `**` (must be last) matches one or more remaining segments + pub fn matches(&self, ref_name: &str) -> bool { + let ref_segments: Vec<&str> = ref_name.split('/').collect(); + + // Check for recursive wildcard (must be last segment). + if let Some(PatternSegment::RecursiveWildcard) = self.segments.last() { + let prefix_len = self.segments.len() - 1; + // Ref must have at least as many segments as the prefix (+ 1 for the **) + if ref_segments.len() <= prefix_len { + return false; + } + // All prefix segments must match. + return self.segments[..prefix_len] + .iter() + .zip(ref_segments[..prefix_len].iter()) + .all(|(pat, seg)| match pat { + PatternSegment::Wildcard => true, + PatternSegment::Literal(lit) => lit == *seg, + PatternSegment::RecursiveWildcard => unreachable!(), + }); + } + + // Non-recursive: exact segment count match required. + if ref_segments.len() != self.segments.len() { + return false; + } + self.segments + .iter() + .zip(ref_segments.iter()) + .all(|(pat, seg)| match pat { + PatternSegment::Wildcard => true, + PatternSegment::Literal(lit) => lit == *seg, + PatternSegment::RecursiveWildcard => unreachable!(), + }) + } + + /// The raw pattern string. + pub fn as_str(&self) -> &str { + &self.raw + } +} + +impl fmt::Display for RefPattern { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.write_str(&self.raw) + } +} + +// ── Update Classification ──────────────────────────────────────────────────── + +/// The type of ref update in a push. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UpdateKind { + /// New ref (old_oid is zero). + Create, + /// Existing ref updated, new commit is a descendant of old (fast-forward). + FastForward, + /// Existing ref updated, new commit is NOT a descendant of old. + NonFastForward, + /// Ref deleted (new_oid is zero). + Delete, +} + +impl UpdateKind { + /// Classify a ref update from old/new OIDs. + /// + /// `is_ancestor` should be the result of `git merge-base --is-ancestor old new`. + /// For creates/deletes, the value is ignored. + pub fn classify(old_oid: &str, new_oid: &str, is_ancestor: bool) -> Self { + const ZERO_OID: &str = "0000000000000000000000000000000000000000"; + if old_oid == ZERO_OID { + Self::Create + } else if new_oid == ZERO_OID { + Self::Delete + } else if is_ancestor { + Self::FastForward + } else { + Self::NonFastForward + } + } +} + +/// A single ref update within a push. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct RefUpdate { + /// The ref being updated (e.g., "refs/heads/main"). + pub ref_name: String, + /// The type of update. + pub kind: UpdateKind, + /// Old OID (hex, 40 chars). Zero OID for creates. + pub old_oid: String, + /// New OID (hex, 40 chars). Zero OID for deletes. + pub new_oid: String, +} + +// ── Protection Rules ───────────────────────────────────────────────────────── + +/// A single protection rule parsed from a `sprout-protect` tag on kind:30617. +/// +/// Format: `["sprout-protect", "", "", ...]` +/// Multiple rules per tag are allowed. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ProtectionRule { + /// The ref pattern this rule applies to. + pub pattern: RefPattern, + /// Minimum role required to push (if specified). + pub push_role: Option, + /// Whether non-fast-forward updates are forbidden. + pub no_force_push: bool, + /// Whether ref deletion is forbidden. + pub no_delete: bool, + /// Whether direct push is denied (must use NIP-34 patch). + /// + /// NOTE: This blocks ALL ref update kinds (create, FF, NFF, delete) — not just + /// fast-forward pushes. If set on a ref pattern, that ref can only be modified + /// via the NIP-34 patch workflow. This is intentional: the ref is fully governed + /// by the patch review process. + pub require_patch: bool, +} + +/// Errors from parsing a `sprout-protect` tag. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum RuleParseError { + /// Tag has fewer than 2 values (need at least pattern + one rule). + TooFewValues, + /// Too many protection rules on this repo. + TooManyRules, + /// Invalid ref pattern. + InvalidPattern(PatternError), + /// Unknown rule string. + UnknownRule(String), + /// Invalid role in `push:`. + InvalidRole(String), +} + +impl fmt::Display for RuleParseError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::TooFewValues => write!(f, "sprout-protect tag needs pattern + at least one rule"), + Self::TooManyRules => write!(f, "exceeds max {MAX_PROTECTION_RULES} rules per repo"), + Self::InvalidPattern(e) => write!(f, "invalid pattern: {e}"), + Self::UnknownRule(r) => write!(f, "unknown rule: {r:?}"), + Self::InvalidRole(r) => write!(f, "invalid role in push rule: {r:?}"), + } + } +} + +impl std::error::Error for RuleParseError {} + +/// Parse a single `sprout-protect` tag into a `ProtectionRule`. +/// +/// Tag format: `["sprout-protect", "", "", "", ...]` +/// The first element ("sprout-protect") should already be stripped — pass +/// the remaining values starting with the pattern. +/// Parse a single `sprout-protect` tag (simple API, discards unknown rules). +pub fn parse_protection_tag(values: &[&str]) -> Result { + let (rule, _unknowns) = parse_protection_tag_with_warnings(values)?; + Ok(rule) +} + +/// Parse a single `sprout-protect` tag, returning unknown rules for logging. +pub fn parse_protection_tag_with_warnings( + values: &[&str], +) -> Result<(ProtectionRule, Vec), RuleParseError> { + if values.len() < 2 { + return Err(RuleParseError::TooFewValues); + } + + let pattern = RefPattern::parse(values[0]).map_err(RuleParseError::InvalidPattern)?; + + let mut push_role: Option = None; + let mut no_force_push = false; + let mut no_delete = false; + let mut require_patch = false; + let mut unknown_rules = Vec::new(); + + for &rule_str in &values[1..] { + if let Some(role_str) = rule_str.strip_prefix("push:") { + let role: MemberRole = role_str + .parse() + .map_err(|_| RuleParseError::InvalidRole(role_str.to_string()))?; + // Reject push:bot and push:guest — nonsensical rules. + // Bot is promoted to Member at the policy layer; push:bot is meaningless. + // Guest cannot push regardless; push:guest would be confusing. + if matches!(role, MemberRole::Bot | MemberRole::Guest) { + return Err(RuleParseError::InvalidRole(role_str.to_string())); + } + // Take the strictest (highest permission level). + push_role = Some(match push_role { + None => role, + Some(existing) => { + if role.permission_level() > existing.permission_level() { + role + } else { + existing + } + } + }); + } else { + match rule_str { + "no-force-push" => no_force_push = true, + "no-delete" => no_delete = true, + "require-patch" => require_patch = true, + // Forward-compatibility: unknown rules are skipped but reported. + other => unknown_rules.push(other.to_string()), + } + } + } + + Ok(( + ProtectionRule { + pattern, + push_role, + no_force_push, + no_delete, + require_patch, + }, + unknown_rules, + )) +} + +/// Result of parsing protection tags — includes rules and any warnings. +#[derive(Debug, Clone)] +pub struct ParsedProtection { + /// Successfully parsed protection rules. + pub rules: Vec, + /// Unknown rule strings that were skipped (potential typos or future rules). + /// Callers should log these as warnings. + pub unknown_rules: Vec, +} + +/// Parse all `sprout-protect` tags from a kind:30617 event's tag list. +/// +/// Returns an error if any `sprout-protect` tag is structurally malformed. +/// Unknown rule strings are skipped but reported in `ParsedProtection::unknown_rules` +/// so callers can log warnings (helps catch typos while maintaining forward-compat). +/// Enforces the per-repo rule count limit. +pub fn parse_protection_tags(tags: &[Vec]) -> Result { + let mut rules = Vec::new(); + let mut unknown_rules = Vec::new(); + + for tag in tags { + if tag.first().map(|s| s.as_str()) != Some("sprout-protect") { + continue; + } + if rules.len() >= MAX_PROTECTION_RULES { + return Err(RuleParseError::TooManyRules); + } + let values: Vec<&str> = tag[1..].iter().map(|s| s.as_str()).collect(); + let (rule, unknowns) = parse_protection_tag_with_warnings(&values)?; + rules.push(rule); + unknown_rules.extend(unknowns); + } + + Ok(ParsedProtection { + rules, + unknown_rules, + }) +} + +// ── Built-in Defaults ──────────────────────────────────────────────────────── + +/// Built-in default minimum role for an operation when no `sprout-protect` tag matches. +pub fn default_min_role(ref_name: &str, kind: UpdateKind) -> MemberRole { + let is_branch = ref_name.starts_with("refs/heads/"); + let is_tag = ref_name.starts_with("refs/tags/"); + + match kind { + UpdateKind::Create => { + if is_branch || is_tag { + MemberRole::Member + } else { + MemberRole::Admin + } + } + UpdateKind::FastForward => { + if is_branch { + MemberRole::Member + } else if is_tag { + // Tag "move" (overwrite) = Admin. + MemberRole::Admin + } else { + MemberRole::Admin + } + } + UpdateKind::NonFastForward => MemberRole::Admin, + UpdateKind::Delete => MemberRole::Admin, + } +} + +// ── Effective Rules (union of all matching patterns) ───────────────────────── + +/// The effective constraints for a ref after unioning all matching rules. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EffectiveRules { + /// Strictest `push:` from all matching patterns (if any). + pub push_role: Option, + /// Whether non-fast-forward is forbidden (any match sets this). + pub no_force_push: bool, + /// Whether deletion is forbidden (any match sets this). + pub no_delete: bool, + /// Whether direct push is denied (any match sets this). + pub require_patch: bool, + /// Whether any explicit rule matched (vs. using defaults). + pub has_explicit_match: bool, +} + +impl EffectiveRules { + /// Compute effective rules by unioning all protection rules that match a ref. + pub fn for_ref(ref_name: &str, rules: &[ProtectionRule]) -> Self { + let mut push_role: Option = None; + let mut no_force_push = false; + let mut no_delete = false; + let mut require_patch = false; + let mut has_explicit_match = false; + + for rule in rules { + if !rule.pattern.matches(ref_name) { + continue; + } + has_explicit_match = true; + + // Union: take strictest push role. + if let Some(role) = rule.push_role { + push_role = Some(match push_role { + None => role, + Some(existing) => { + if role.permission_level() > existing.permission_level() { + role + } else { + existing + } + } + }); + } + + // Union: any match sets these flags. + no_force_push = no_force_push || rule.no_force_push; + no_delete = no_delete || rule.no_delete; + require_patch = require_patch || rule.require_patch; + } + + Self { + push_role, + no_force_push, + no_delete, + require_patch, + has_explicit_match, + } + } +} + +// ── Policy Denial ──────────────────────────────────────────────────────────── + +/// A single denial reason from the policy engine. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct Denial { + /// The ref that was denied. + pub ref_name: String, + /// Human-readable reason. + pub reason: String, +} + +impl fmt::Display for Denial { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}: {}", self.ref_name, self.reason) + } +} + +/// Evaluate a single ref update against effective rules and the pusher's role. +/// +/// Returns `Ok(())` if allowed, `Err(Denial)` if denied. +pub fn evaluate_ref_update( + update: &RefUpdate, + role: MemberRole, + rules: &[ProtectionRule], +) -> Result<(), Denial> { + let effective = EffectiveRules::for_ref(&update.ref_name, rules); + + // If no explicit rules match, use built-in defaults. + if !effective.has_explicit_match { + let min_role = default_min_role(&update.ref_name, update.kind); + if !role.has_at_least(min_role) { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: format!( + "requires {} role (you have {}), using built-in defaults", + min_role, role + ), + }); + } + return Ok(()); + } + + // Check require-patch (blocks all direct pushes). + if effective.require_patch { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "direct push denied: require-patch is set, submit a NIP-34 patch".to_string(), + }); + } + + // Check push role. + // Explicit push:role can NEVER weaken the built-in default. Always take the + // HIGHER of (explicit, default). This prevents `push:member` from accidentally + // allowing Members to force-push, delete, or overwrite tags. + let default_role = default_min_role(&update.ref_name, update.kind); + let min_role = match effective.push_role { + Some(explicit) => { + // Take the stricter (higher permission level) of explicit vs default. + if explicit.permission_level() >= default_role.permission_level() { + explicit + } else { + default_role + } + } + None => default_role, + }; + if !role.has_at_least(min_role) { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: format!("requires {} role (you have {})", min_role, role), + }); + } + + // Check no-force-push. + if effective.no_force_push && update.kind == UpdateKind::NonFastForward { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "non-fast-forward update denied: no-force-push is set".to_string(), + }); + } + + // Check no-delete. + if effective.no_delete && update.kind == UpdateKind::Delete { + return Err(Denial { + ref_name: update.ref_name.clone(), + reason: "ref deletion denied: no-delete is set".to_string(), + }); + } + + Ok(()) +} + +/// Evaluate an entire push (multiple ref updates) against protection rules. +/// +/// Returns `Ok(())` if ALL refs are allowed, `Err(Vec)` if any are denied. +/// A push is atomic — if any ref fails, the entire push is rejected. +pub fn evaluate_push( + updates: &[RefUpdate], + role: MemberRole, + rules: &[ProtectionRule], +) -> Result<(), Vec> { + let denials: Vec = updates + .iter() + .filter_map(|update| evaluate_ref_update(update, role, rules).err()) + .collect(); + + if denials.is_empty() { + Ok(()) + } else { + Err(denials) + } +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + // ── RefPattern tests ───────────────────────────────────────────────── + + #[test] + fn pattern_parse_valid() { + let p = RefPattern::parse("refs/heads/main").unwrap(); + assert_eq!(p.segments.len(), 3); + assert!(p.matches("refs/heads/main")); + assert!(!p.matches("refs/heads/develop")); + } + + #[test] + fn pattern_wildcard_matches_one_segment() { + let p = RefPattern::parse("refs/heads/*").unwrap(); + assert!(p.matches("refs/heads/main")); + assert!(p.matches("refs/heads/feature")); + assert!(!p.matches("refs/heads/feature/sub")); + assert!(!p.matches("refs/tags/v1")); + } + + #[test] + fn pattern_multi_wildcard() { + let p = RefPattern::parse("refs/*/release/*").unwrap(); + assert!(p.matches("refs/heads/release/v1")); + assert!(!p.matches("refs/heads/release/v1/hotfix")); + } + + #[test] + fn pattern_rejects_partial_glob() { + assert!(matches!( + RefPattern::parse("refs/tags/v*"), + Err(PatternError::InvalidSegment(_)) + )); + } + + #[test] + fn pattern_rejects_missing_refs_prefix() { + assert!(matches!( + RefPattern::parse("heads/main"), + Err(PatternError::MissingRefsPrefix) + )); + } + + #[test] + fn pattern_rejects_empty() { + assert!(matches!(RefPattern::parse(""), Err(PatternError::Empty))); + } + + #[test] + fn pattern_rejects_too_many_wildcards() { + assert!(matches!( + RefPattern::parse("refs/*/*/*/*"), + Err(PatternError::TooManyWildcards) + )); + } + + #[test] + fn pattern_recursive_wildcard_matches_nested() { + let p = RefPattern::parse("refs/heads/**").unwrap(); + assert!(p.matches("refs/heads/main")); + assert!(p.matches("refs/heads/feature/foo")); + assert!(p.matches("refs/heads/feature/foo/bar")); + assert!(!p.matches("refs/tags/v1")); + } + + #[test] + fn pattern_recursive_wildcard_must_be_last() { + assert!(matches!( + RefPattern::parse("refs/**/heads"), + Err(PatternError::InvalidSegment(_)) + )); + } + + #[test] + fn pattern_recursive_requires_at_least_one_segment() { + let p = RefPattern::parse("refs/heads/**").unwrap(); + // Must match at least one segment after prefix + assert!(!p.matches("refs/heads")); + } + + // ── UpdateKind tests ───────────────────────────────────────────────── + + #[test] + fn classify_create() { + let zero = "0000000000000000000000000000000000000000"; + assert_eq!( + UpdateKind::classify(zero, "abc123abc123abc123abc123abc123abc123abcd", false), + UpdateKind::Create + ); + } + + #[test] + fn classify_delete() { + let zero = "0000000000000000000000000000000000000000"; + assert_eq!( + UpdateKind::classify("abc123abc123abc123abc123abc123abc123abcd", zero, false), + UpdateKind::Delete + ); + } + + #[test] + fn classify_fast_forward() { + assert_eq!( + UpdateKind::classify("aaa", "bbb", true), + UpdateKind::FastForward + ); + } + + #[test] + fn classify_non_fast_forward() { + assert_eq!( + UpdateKind::classify("aaa", "bbb", false), + UpdateKind::NonFastForward + ); + } + + // ── Protection rule parsing ────────────────────────────────────────── + + #[test] + fn parse_protection_tag_basic() { + let rule = + parse_protection_tag(&["refs/heads/main", "push:admin", "no-force-push"]).unwrap(); + assert_eq!(rule.push_role, Some(MemberRole::Admin)); + assert!(rule.no_force_push); + assert!(!rule.no_delete); + assert!(!rule.require_patch); + } + + #[test] + fn parse_protection_tag_all_rules() { + let rule = parse_protection_tag(&[ + "refs/heads/main", + "push:owner", + "no-force-push", + "no-delete", + "require-patch", + ]) + .unwrap(); + assert_eq!(rule.push_role, Some(MemberRole::Owner)); + assert!(rule.no_force_push); + assert!(rule.no_delete); + assert!(rule.require_patch); + } + + #[test] + fn parse_protection_tag_unknown_rule_skipped() { + // Forward-compatibility: unknown rules are silently skipped. + let rule = parse_protection_tag(&["refs/heads/main", "yolo", "no-force-push"]).unwrap(); + // "yolo" was skipped, but "no-force-push" was still applied. + assert!(rule.no_force_push); + assert!(rule.push_role.is_none()); + } + + #[test] + fn parse_protection_tag_invalid_role() { + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:superadmin"]), + Err(RuleParseError::InvalidRole(_)) + )); + } + + #[test] + fn parse_protection_tag_rejects_push_bot_and_guest() { + // push:bot and push:guest are rejected — they're almost certainly user errors. + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:bot"]), + Err(RuleParseError::InvalidRole(_)) + )); + assert!(matches!( + parse_protection_tag(&["refs/heads/main", "push:guest"]), + Err(RuleParseError::InvalidRole(_)) + )); + } + + // ── Effective rules (union semantics) ──────────────────────────────── + + #[test] + fn effective_rules_union_strictest_role() { + let rules = vec![ + parse_protection_tag(&["refs/heads/*", "push:member", "no-force-push"]).unwrap(), + parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap(), + ]; + let eff = EffectiveRules::for_ref("refs/heads/main", &rules); + assert_eq!(eff.push_role, Some(MemberRole::Admin)); // strictest + assert!(eff.no_force_push); // from the wildcard rule + assert!(eff.has_explicit_match); + } + + #[test] + fn effective_rules_no_match_uses_defaults() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let eff = EffectiveRules::for_ref("refs/heads/develop", &rules); + assert!(!eff.has_explicit_match); + } + + // ── Policy evaluation ──────────────────────────────────────────────── + + #[test] + fn evaluate_owner_passes_push_role() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_ok()); + } + + #[test] + fn evaluate_member_denied_push_admin() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_no_force_push_blocks_owner() { + let rules = + vec![ + parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(), + ]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Owner is blocked by no-force-push! + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err()); + } + + #[test] + fn evaluate_no_force_push_allows_fast_forward() { + let rules = + vec![ + parse_protection_tag(&["refs/heads/main", "push:member", "no-force-push"]).unwrap(), + ]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // no-force-push should NOT block fast-forward pushes. + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_no_delete_blocks_admin() { + let rules = + vec![parse_protection_tag(&["refs/heads/main", "push:member", "no-delete"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::Delete, + old_oid: "a".repeat(40), + new_oid: "0".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Admin, &rules).is_err()); + } + + #[test] + fn evaluate_require_patch_blocks_all() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "require-patch"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Owner, &rules).is_err()); + } + + #[test] + fn evaluate_defaults_member_can_ff_branch() { + let rules = vec![]; // No explicit rules + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_defaults_member_cannot_force_push() { + let rules = vec![]; // No explicit rules — defaults apply + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Default: non-fast-forward requires Admin + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_defaults_guest_cannot_push() { + let rules = vec![]; + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err()); + } + + #[test] + fn evaluate_bot_cannot_push_without_explicit_grant() { + let rules = vec![]; + let update = RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Bot has permission_level 0 at the core evaluator level. + // NOTE: The policy layer (policy.rs) promotes Bot → Member before calling + // evaluate_push, so bots in a channel CAN push in practice. This test + // verifies the raw evaluator behavior; the promotion is tested in policy. + assert!(evaluate_ref_update(&update, MemberRole::Bot, &rules).is_err()); + } + + #[test] + fn evaluate_guest_denied_even_with_only_no_force_push_rule() { + // Regression test: a rule that only sets no-force-push (no push:role) + // should NOT let a Guest bypass the built-in default (Member required). + let rules = vec![parse_protection_tag(&["refs/heads/main", "no-force-push"]).unwrap()]; + let update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + // Guest should be denied — built-in default requires Member for FF push. + assert!(evaluate_ref_update(&update, MemberRole::Guest, &rules).is_err()); + // Member should be allowed (meets default requirement). + assert!(evaluate_ref_update(&update, MemberRole::Member, &rules).is_ok()); + } + + #[test] + fn evaluate_push_member_cannot_weaken_destructive_defaults() { + // push:member should NOT allow Members to force-push or delete. + // The built-in default for NFF/Delete is Admin — explicit push:member + // can't weaken that for destructive operations. + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:member"]).unwrap()]; + // Member can FF push (non-destructive, explicit overrides default). + let ff_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&ff_update, MemberRole::Member, &rules).is_ok()); + + // Member CANNOT force-push (destructive, default Admin still enforced). + let nff_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::NonFastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }; + assert!(evaluate_ref_update(&nff_update, MemberRole::Member, &rules).is_err()); + + // Admin CAN force-push (meets the default Admin requirement). + assert!(evaluate_ref_update(&nff_update, MemberRole::Admin, &rules).is_ok()); + + // Member CANNOT delete (destructive, default Admin still enforced). + let del_update = RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::Delete, + old_oid: "a".repeat(40), + new_oid: "0".repeat(40), + }; + assert!(evaluate_ref_update(&del_update, MemberRole::Member, &rules).is_err()); + } + + #[test] + fn evaluate_push_multiple_refs_partial_deny() { + let rules = vec![parse_protection_tag(&["refs/heads/main", "push:admin"]).unwrap()]; + let updates = vec![ + RefUpdate { + ref_name: "refs/heads/feature".to_string(), + kind: UpdateKind::FastForward, + old_oid: "a".repeat(40), + new_oid: "b".repeat(40), + }, + RefUpdate { + ref_name: "refs/heads/main".to_string(), + kind: UpdateKind::FastForward, + old_oid: "c".repeat(40), + new_oid: "d".repeat(40), + }, + ]; + // Member can push to feature but not main + let result = evaluate_push(&updates, MemberRole::Member, &rules); + assert!(result.is_err()); + let denials = result.unwrap_err(); + assert_eq!(denials.len(), 1); + assert_eq!(denials[0].ref_name, "refs/heads/main"); + } +} diff --git a/crates/sprout-core/src/kind.rs b/crates/sprout-core/src/kind.rs index 27507c471d..a0f03f8016 100644 --- a/crates/sprout-core/src/kind.rs +++ b/crates/sprout-core/src/kind.rs @@ -25,8 +25,25 @@ pub const KIND_FILE_METADATA: u32 = 1063; /// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. /// Stored globally (channel_id = NULL); author-owned, not channel-scoped. pub const KIND_LONG_FORM: u32 = 30023; +/// NIP-38: User status (general, music, or custom d-tag). +/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. +/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. +pub const KIND_USER_STATUS: u32 = 30315; +/// NIP-78 / NIP-RS: Per-client read state blob for cross-device read position sync. +/// Parameterized replaceable (NIP-33, 30000–39999 range) — keyed by `(pubkey, kind, d_tag)`. +/// Stored globally (channel_id = NULL); user-owned personal data, not channel-scoped. +/// Content is NIP-44 encrypted to the user's own keypair. +pub const KIND_READ_STATE: u32 = 30078; /// NIP-42 auth event — never stored (carries bearer tokens). pub const KIND_AUTH: u32 = 22242; +/// BUD-01: Blossom upload auth (used in upload.rs, not stored). +pub const KIND_BLOSSOM_AUTH: u32 = 24242; +/// NIP-98: HTTP auth event (used in nip98.rs, not stored). +pub const KIND_HTTP_AUTH: u32 = 27235; + +// NEW: Sprout command kinds (Pure Nostr plan) +/// Agent metadata + owner reference (replaceable, agent-authored). +pub const KIND_AGENT_PROFILE: u32 = 10100; // NIP-29 group admin events /// NIP-29: Add a user to a group. @@ -48,7 +65,25 @@ pub const KIND_NIP29_JOIN_REQUEST: u32 = 9021; /// NIP-29: Request to leave a group. pub const KIND_NIP29_LEAVE_REQUEST: u32 = 9022; -// System / admin (9031–9999) +// NIP-43 relay membership admin commands +/// NIP-43: Add a pubkey to the relay member list. +pub const RELAY_ADMIN_ADD_MEMBER: u32 = 9030; +/// NIP-43: Remove a pubkey from the relay member list. +pub const RELAY_ADMIN_REMOVE_MEMBER: u32 = 9031; +/// NIP-43: Change the role of an existing relay member. +pub const RELAY_ADMIN_CHANGE_ROLE: u32 = 9032; + +// NIP-43 relay membership announcement events (relay-signed) +/// NIP-43: Relay membership list snapshot (relay-signed, replaceable by convention). +pub const KIND_NIP43_MEMBERSHIP_LIST: u32 = 13534; +/// NIP-43: Member added announcement (relay-signed). +pub const KIND_NIP43_MEMBER_ADDED: u32 = 8000; +/// NIP-43: Member removed announcement (relay-signed). +pub const KIND_NIP43_MEMBER_REMOVED: u32 = 8001; +/// NIP-43: User leave request (user-signed, ephemeral). +pub const KIND_NIP43_LEAVE_REQUEST: u32 = 28936; + +// System / admin (9100–9999) /// V1 used kind:9001 — moved here due to NIP-29 conflict. pub const KIND_SYSTEM_TIMER_FIRED: u32 = 9100; /// V1 used kind:9010 — moved here for NIP-29 range safety. @@ -66,6 +101,9 @@ pub const KIND_NIP29_GROUP_MEMBERS: u32 = 39002; /// NIP-29: Addressable group roles definition. pub const KIND_NIP29_GROUP_ROLES: u32 = 39003; +/// Workflow definition (parameterized replaceable, d=workflow_uuid). +pub const KIND_WORKFLOW_DEF: u32 = 30620; + /// Lower bound of the NIP-33 parameterized replaceable range (30000–39999). pub const PARAM_REPLACEABLE_KIND_MIN: u32 = 30000; /// Upper bound of the NIP-33 parameterized replaceable range (30000–39999). @@ -83,6 +121,8 @@ pub const KIND_PRESENCE_UPDATE: u32 = 20001; pub const KIND_PAIRING: u32 = 24134; /// Ephemeral: typing indicator for a channel. pub const KIND_TYPING_INDICATOR: u32 = 20002; +/// Ephemeral: owner-scoped encrypted agent observer telemetry and control frame. +pub const KIND_AGENT_OBSERVER_FRAME: u32 = 24200; // Stream messaging /// NIP-29 group chat message kind. V1 used kind:10001 (replaceable range — wrong), then 40001. @@ -110,7 +150,21 @@ pub const KIND_CANVAS: u32 = 40100; /// System message for channel state changes (join, leave, rename, etc.). pub const KIND_SYSTEM_MESSAGE: u32 = 40099; +// Relay-only enrichment kinds (never client-submitted) +/// Thread summaries, reaction rollups (relay-signed sidecar). +pub const KIND_ENRICHMENT: u32 = 40900; +/// Channel metadata with computed fields (relay-signed sidecar). +pub const KIND_CHANNEL_SUMMARY: u32 = 40901; +/// Bulk presence state (relay-signed sidecar). +pub const KIND_PRESENCE_SNAPSHOT: u32 = 40902; + // Direct messages (41000–41999) +/// Open/create DM (p-tags = participants). +pub const KIND_DM_OPEN: u32 = 41010; +/// Add member to group DM. +pub const KIND_DM_ADD_MEMBER: u32 = 41011; +/// Hide DM from sidebar. +pub const KIND_DM_HIDE: u32 = 41012; /// A new direct-message conversation was created. pub const KIND_DM_CREATED: u32 = 41001; /// A member was added to a DM conversation. @@ -169,6 +223,12 @@ pub const KIND_FORUM_VOTE: u32 = 45002; pub const KIND_FORUM_COMMENT: u32 = 45003; // Workflow engine (46000–46999) +/// Trigger workflow execution. +pub const KIND_WORKFLOW_TRIGGER: u32 = 46020; +/// Grant pending approval. +pub const KIND_APPROVAL_GRANT: u32 = 46030; +/// Deny pending approval. +pub const KIND_APPROVAL_DENY: u32 = 46031; /// A workflow was triggered by a matching event. pub const KIND_WORKFLOW_TRIGGERED: u32 = 46001; /// A workflow step began execution. @@ -228,6 +288,28 @@ pub const KIND_HUDDLE_GUIDELINES: u32 = 48106; /// Internal kind for media upload audit entries. Not a relay event kind. pub const KIND_MEDIA_UPLOAD: u32 = 49001; +// ── NIP-34: Git repository events ──────────────────────────────────────────── +/// NIP-34: Repository announcement (parameterized replaceable, d-tag = repo-id). +pub const KIND_GIT_REPO_ANNOUNCEMENT: u32 = 30617; +/// NIP-34: Repository state — current branch/tag refs (parameterized replaceable, d-tag = repo-id). +pub const KIND_GIT_REPO_STATE: u32 = 30618; +/// NIP-34: Patch (git format-patch output). +pub const KIND_GIT_PATCH: u32 = 1617; +/// NIP-34: Pull request. +pub const KIND_GIT_PULL_REQUEST: u32 = 1618; +/// NIP-34: Pull request update (tip commit change). +pub const KIND_GIT_PR_UPDATE: u32 = 1619; +/// NIP-34: Issue. +pub const KIND_GIT_ISSUE: u32 = 1621; +/// NIP-34: Status — Open. +pub const KIND_GIT_STATUS_OPEN: u32 = 1630; +/// NIP-34: Status — Applied / Merged. +pub const KIND_GIT_STATUS_MERGED: u32 = 1631; +/// NIP-34: Status — Closed. +pub const KIND_GIT_STATUS_CLOSED: u32 = 1632; +/// NIP-34: Status — Draft. +pub const KIND_GIT_STATUS_DRAFT: u32 = 1633; + /// All registered kind constants — used for duplicate detection and iteration. pub const ALL_KINDS: &[u32] = &[ KIND_PROFILE, @@ -238,6 +320,7 @@ pub const ALL_KINDS: &[u32] = &[ KIND_REACTION, KIND_GIFT_WRAP, KIND_FILE_METADATA, + KIND_AGENT_PROFILE, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP29_EDIT_METADATA, @@ -247,6 +330,13 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_CREATE_INVITE, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + RELAY_ADMIN_ADD_MEMBER, + RELAY_ADMIN_REMOVE_MEMBER, + RELAY_ADMIN_CHANGE_ROLE, + KIND_NIP43_MEMBERSHIP_LIST, + KIND_NIP43_MEMBER_ADDED, + KIND_NIP43_MEMBER_REMOVED, + KIND_NIP43_LEAVE_REQUEST, KIND_SYSTEM_TIMER_FIRED, KIND_SYSTEM_SLASH_COMMAND, KIND_SYSTEM_FLAG, @@ -256,7 +346,10 @@ pub const ALL_KINDS: &[u32] = &[ KIND_NIP29_GROUP_ROLES, KIND_PRESENCE_UPDATE, KIND_TYPING_INDICATOR, + KIND_BLOSSOM_AUTH, KIND_PAIRING, + KIND_AGENT_OBSERVER_FRAME, + KIND_HTTP_AUTH, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_V2, KIND_STREAM_MESSAGE_EDIT, @@ -267,6 +360,12 @@ pub const ALL_KINDS: &[u32] = &[ KIND_STREAM_MESSAGE_DIFF, KIND_CANVAS, KIND_SYSTEM_MESSAGE, + KIND_ENRICHMENT, + KIND_CHANNEL_SUMMARY, + KIND_PRESENCE_SNAPSHOT, + KIND_DM_OPEN, + KIND_DM_ADD_MEMBER, + KIND_DM_HIDE, KIND_DM_CREATED, KIND_DM_MEMBER_ADDED, KIND_DM_MEMBER_REMOVED, @@ -285,10 +384,16 @@ pub const ALL_KINDS: &[u32] = &[ KIND_SUBSCRIPTION_RESUMED, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_WORKFLOW_DEF, KIND_LONG_FORM, + KIND_USER_STATUS, + KIND_READ_STATE, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_FORUM_COMMENT, + KIND_WORKFLOW_TRIGGER, + KIND_APPROVAL_GRANT, + KIND_APPROVAL_DENY, KIND_WORKFLOW_TRIGGERED, KIND_WORKFLOW_STEP_STARTED, KIND_WORKFLOW_STEP_COMPLETED, @@ -313,7 +418,18 @@ pub const ALL_KINDS: &[u32] = &[ KIND_HUDDLE_ENDED, KIND_HUDDLE_TRACK_PUBLISHED, KIND_HUDDLE_RECORDING_AVAILABLE, + KIND_HUDDLE_GUIDELINES, KIND_MEDIA_UPLOAD, + KIND_GIT_REPO_ANNOUNCEMENT, + KIND_GIT_REPO_STATE, + KIND_GIT_PATCH, + KIND_GIT_PULL_REQUEST, + KIND_GIT_PR_UPDATE, + KIND_GIT_ISSUE, + KIND_GIT_STATUS_OPEN, + KIND_GIT_STATUS_MERGED, + KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, ]; /// Returns `true` if `kind` is in the ephemeral range (20000–29999). @@ -341,6 +457,37 @@ pub const fn is_workflow_execution_kind(kind: u32) -> bool { kind >= KIND_WORKFLOW_TRIGGERED && kind <= KIND_WORKFLOW_APPROVAL_DENIED } +/// Returns `true` if `kind` is a NIP-43 relay membership admin command (9030–9032). +pub const fn is_relay_admin_kind(kind: u32) -> bool { + matches!( + kind, + RELAY_ADMIN_ADD_MEMBER | RELAY_ADMIN_REMOVE_MEMBER | RELAY_ADMIN_CHANGE_ROLE + ) +} + +/// Returns `true` if `kind` is a Sprout command kind that requires transactional execution. +pub const fn is_command_kind(kind: u32) -> bool { + matches!( + kind, + KIND_WORKFLOW_DEF + | KIND_DM_OPEN + | KIND_DM_ADD_MEMBER + | KIND_DM_HIDE + | KIND_WORKFLOW_TRIGGER + | KIND_APPROVAL_GRANT + | KIND_APPROVAL_DENY + ) +} + +/// Returns `true` if `kind` is a relay-only enrichment kind (40900–40902). +/// Client submission of these kinds must be rejected. +pub const fn is_relay_only_kind(kind: u32) -> bool { + matches!( + kind, + KIND_ENRICHMENT | KIND_CHANNEL_SUMMARY | KIND_PRESENCE_SNAPSHOT + ) +} + /// Extract the kind from a nostr Event as u32. /// NIP-01 specifies kind as an unsigned integer; u32 covers the full range. pub fn event_kind_u32(event: &nostr::Event) -> u32 { @@ -353,6 +500,20 @@ pub fn event_kind_i32(event: &nostr::Event) -> i32 { event.kind.as_u16() as i32 } +// Compile-time: new kinds are in the expected ranges. +const _: () = assert!(is_replaceable(KIND_AGENT_PROFILE)); // 10100 ∈ 10000–19999 +const _: () = assert!(is_parameterized_replaceable(KIND_WORKFLOW_DEF)); // 30620 ∈ 30000–39999 + +// Compile-time: NIP-34 parameterized replaceable kinds are in the correct range. +const _: () = assert!( + KIND_GIT_REPO_ANNOUNCEMENT >= PARAM_REPLACEABLE_KIND_MIN + && KIND_GIT_REPO_ANNOUNCEMENT <= PARAM_REPLACEABLE_KIND_MAX +); +const _: () = assert!( + KIND_GIT_REPO_STATE >= PARAM_REPLACEABLE_KIND_MIN + && KIND_GIT_REPO_STATE <= PARAM_REPLACEABLE_KIND_MAX +); + // Compile-time: all Sprout kind constants fit in nostr's u16-backed Kind. const _: () = assert!(KIND_AUTH <= u16::MAX as u32); const _: () = assert!(KIND_CANVAS <= u16::MAX as u32); diff --git a/crates/sprout-core/src/lib.rs b/crates/sprout-core/src/lib.rs index 736c04767c..d3eeecb72a 100644 --- a/crates/sprout-core/src/lib.rs +++ b/crates/sprout-core/src/lib.rs @@ -13,10 +13,14 @@ pub mod error; pub mod event; /// NIP-01 subscription filter matching. pub mod filter; +/// Git permission types — ref patterns, protection rules, policy evaluation. +pub mod git_perms; /// Sprout kind number registry — custom event type constants. pub mod kind; /// Network utilities — SSRF-safe IP classification. pub mod network; +/// Agent observer frame helpers. +pub mod observer; /// NIP-AB device pairing — crypto primitives, message types, and errors. pub mod pairing; /// Presence status types shared across crates. diff --git a/crates/sprout-core/src/observer.rs b/crates/sprout-core/src/observer.rs new file mode 100644 index 0000000000..b4972e9246 --- /dev/null +++ b/crates/sprout-core/src/observer.rs @@ -0,0 +1,156 @@ +//! Agent observer frame helpers. +//! +//! Observer frames are transient, owner-scoped agent telemetry/control messages. +//! They use a Sprout ephemeral event kind and carry NIP-44 encrypted JSON in the +//! event content so relays can route frames without reading ACP internals. + +use nostr::{nips::nip44, Event, Keys, PublicKey}; +use serde::{de::DeserializeOwned, Serialize}; +use thiserror::Error; +use zeroize::Zeroize; + +/// Tag name that identifies the agent pubkey the observer frame belongs to. +pub const OBSERVER_AGENT_TAG: &str = "agent"; +/// Tag name that identifies the cleartext frame direction. +pub const OBSERVER_FRAME_TAG: &str = "frame"; +/// Frame value for agent-to-owner observer telemetry. +pub const OBSERVER_FRAME_TELEMETRY: &str = "telemetry"; +/// Frame value for owner-to-agent observer control commands. +pub const OBSERVER_FRAME_CONTROL: &str = "control"; +/// Minimum plausible NIP-44 v2 ciphertext length. +pub const NIP44_MIN_CONTENT_LEN: usize = 132; +/// Maximum NIP-44 v2 ciphertext length. +pub const NIP44_MAX_CONTENT_LEN: usize = 87_472; +/// Maximum observer plaintext JSON size accepted by helpers. +pub const OBSERVER_MAX_PLAINTEXT_LEN: usize = 65_535; + +/// Errors returned by observer payload encryption/decryption helpers. +#[derive(Debug, Error)] +pub enum ObserverPayloadError { + /// NIP-44 encryption or decryption failed. + #[error("NIP-44 error: {0}")] + Nip44(#[from] nip44::Error), + /// JSON serialization or deserialization failed. + #[error("JSON error: {0}")] + Json(#[from] serde_json::Error), + /// Ciphertext did not fit the expected NIP-44 v2 length envelope. + #[error("invalid NIP-44 ciphertext length: {0}")] + InvalidCiphertextLength(usize), + /// Decrypted JSON exceeded the observer plaintext size limit. + #[error("observer plaintext exceeds {max} bytes (got {got})")] + PlaintextTooLarge { + /// Maximum accepted plaintext bytes. + max: usize, + /// Actual plaintext byte count. + got: usize, + }, +} + +/// Returns true when `content` fits the NIP-44 v2 ciphertext length envelope. +pub fn content_looks_like_nip44(content: &str) -> bool { + (NIP44_MIN_CONTENT_LEN..=NIP44_MAX_CONTENT_LEN).contains(&content.len()) +} + +/// Serialize and NIP-44 encrypt an observer payload for `recipient`. +pub fn encrypt_observer_payload( + sender_keys: &Keys, + recipient: &PublicKey, + payload: &T, +) -> Result { + let mut plaintext = serde_json::to_string(payload)?; + if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN { + let got = plaintext.len(); + plaintext.zeroize(); + return Err(ObserverPayloadError::PlaintextTooLarge { + max: OBSERVER_MAX_PLAINTEXT_LEN, + got, + }); + } + + let encrypted = nip44::encrypt( + sender_keys.secret_key(), + recipient, + &plaintext, + nip44::Version::V2, + )?; + plaintext.zeroize(); + Ok(encrypted) +} + +/// NIP-44 decrypt and deserialize an observer payload from `event`. +pub fn decrypt_observer_payload( + recipient_keys: &Keys, + event: &Event, +) -> Result { + if !content_looks_like_nip44(&event.content) { + return Err(ObserverPayloadError::InvalidCiphertextLength( + event.content.len(), + )); + } + + let mut plaintext = nip44::decrypt( + recipient_keys.secret_key(), + &event.pubkey, + event.content.as_str(), + )?; + if plaintext.len() > OBSERVER_MAX_PLAINTEXT_LEN { + let got = plaintext.len(); + plaintext.zeroize(); + return Err(ObserverPayloadError::PlaintextTooLarge { + max: OBSERVER_MAX_PLAINTEXT_LEN, + got, + }); + } + + let result = serde_json::from_str(&plaintext); + plaintext.zeroize(); + Ok(result?) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Kind, Tag}; + + #[test] + fn observer_payload_round_trips_with_nip44() { + let sender = Keys::generate(); + let recipient = Keys::generate(); + let payload = serde_json::json!({ + "type": "turn_started", + "turnId": "turn-1" + }); + let encrypted = encrypt_observer_payload(&sender, &recipient.public_key(), &payload) + .expect("encrypt payload"); + assert!(content_looks_like_nip44(&encrypted)); + + let event = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16), + encrypted, + [Tag::public_key(recipient.public_key())], + ) + .sign_with_keys(&sender) + .expect("sign event"); + let decrypted: serde_json::Value = + decrypt_observer_payload(&recipient, &event).expect("decrypt payload"); + assert_eq!(decrypted, payload); + } + + #[test] + fn observer_payload_rejects_short_ciphertext() { + let sender = Keys::generate(); + let recipient = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(crate::kind::KIND_AGENT_OBSERVER_FRAME as u16), + "not encrypted", + [Tag::public_key(recipient.public_key())], + ) + .sign_with_keys(&sender) + .expect("sign event"); + + assert!(matches!( + decrypt_observer_payload::(&recipient, &event), + Err(ObserverPayloadError::InvalidCiphertextLength(_)) + )); + } +} diff --git a/crates/sprout-db/src/event.rs b/crates/sprout-db/src/event.rs index b06b21117f..043a320930 100644 --- a/crates/sprout-db/src/event.rs +++ b/crates/sprout-db/src/event.rs @@ -37,6 +37,9 @@ pub struct EventQuery { /// Restrict to events with this exact `d_tag` value (NIP-33). /// Pushed into SQL via the `idx_events_parameterized` index. pub d_tag: Option, + /// Restrict to events with any of these `d_tag` values (multi-value NIP-33 pushdown). + /// Used when a filter has multiple `#d` values and targets only NIP-33 kinds. + pub d_tags: Option>, /// Composite keyset cursor: exclude events at or "after" this (created_at, id) pair. /// Used with `until` for stable pagination: events where /// `created_at < until OR (created_at = until AND id > before_id)`. @@ -48,6 +51,20 @@ pub struct EventQuery { /// invariant (`is_global_only_kind`) ever changes. /// Mutually exclusive with `channel_id`. pub global_only: bool, + /// Restrict results to events from any of these pubkeys (multi-author `IN` pushdown). + pub authors: Option>>, + /// Restrict results to events with any of these IDs (multi-id `IN` pushdown). + pub ids: Option>>, + /// Restrict results to events with an `e` tag referencing any of these event IDs (hex). + /// Uses JSONB containment (`tags @> ...`) against the `tags` column. + pub e_tags: Option>, + /// Restrict results to events in any of these channels (multi-channel `IN` pushdown). + /// Used by NIP-45 COUNT to enforce channel access without fetching all rows. + pub channel_ids: Option>, + /// Override the default limit clamp (1000). Used by COUNT fallback path + /// which needs to fetch all matching events for post-filter counting. + /// When None, the default clamp of 1000 applies. + pub max_limit: Option, } /// Maximum length for a `d_tag` value (bytes). NIP-33 d-tags are short identifiers; @@ -155,12 +172,22 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result = if let Some(ref p_hex) = q.p_tag_hex { @@ -191,6 +218,28 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result '[["e",""]]'. + // Multiple e-tags use OR (any match). No GIN index yet — acceptable at + // current scale; add `CREATE INDEX ... USING gin(tags)` if this becomes hot. + if let Some(ref e_tags) = q.e_tags { + if !e_tags.is_empty() { + qb.push(" AND ("); + for (i, hex_id) in e_tags.iter().enumerate() { + if i > 0 { + qb.push(" OR "); + } + // Build the JSONB literal: [["e",""]] + let containment = serde_json::json!([["e", hex_id]]); + qb.push(format!("{col_prefix}tags @> ")); + qb.push_bind(containment); + } + qb.push(")"); + } + } + if let Some(s) = q.since { qb.push(format!(" AND {col_prefix}created_at >= ")) .push_bind(s); @@ -229,6 +322,15 @@ pub async fn query_events(pool: &PgPool, q: &EventQuery) -> Result Result Result { + // Empty list means "match nothing" — return 0 immediately. + if q.kinds.as_deref().is_some_and(|k| k.is_empty()) { + return Ok(0); + } + if q.authors.as_deref().is_some_and(|a| a.is_empty()) { + return Ok(0); + } + if q.ids.as_deref().is_some_and(|i| i.is_empty()) { + return Ok(0); + } + if q.e_tags.as_deref().is_some_and(|e| e.is_empty()) { + return Ok(0); + } + + let mut qb: QueryBuilder = if let Some(ref p_hex) = q.p_tag_hex { + let mut b = QueryBuilder::new( + "SELECT COUNT(*) as cnt FROM events e \ + INNER JOIN event_mentions m ON e.id = m.event_id \ + WHERE e.deleted_at IS NULL AND m.pubkey_hex = ", + ); + b.push_bind(p_hex.to_ascii_lowercase()); + b + } else { + QueryBuilder::new("SELECT COUNT(*) as cnt FROM events WHERE deleted_at IS NULL") + }; + + let col_prefix = if q.p_tag_hex.is_some() { "e." } else { "" }; + + if let Some(ch) = q.channel_id { + qb.push(format!(" AND {col_prefix}channel_id = ")) + .push_bind(ch); + } else if q.global_only { + qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + } + + // Multi-channel IN pushdown for COUNT: restrict to accessible channels + global. + // SECURITY: Some(empty vec) = no channel access → global events only. + if let Some(ref ch_ids) = q.channel_ids { + if ch_ids.is_empty() { + qb.push(format!(" AND {col_prefix}channel_id IS NULL")); + } else { + qb.push(format!( + " AND ({col_prefix}channel_id IS NULL OR {col_prefix}channel_id IN (" + )); + let mut sep = qb.separated(", "); + for ch in ch_ids { + sep.push_bind(*ch); + } + qb.push("))"); + } + } + + if let Some(ks) = q.kinds.as_deref().filter(|k| !k.is_empty()) { + qb.push(format!(" AND {col_prefix}kind IN (")); + let mut sep = qb.separated(", "); + for k in ks { + sep.push_bind(*k); + } + qb.push(")"); + } + + if let Some(ref pk) = q.pubkey { + qb.push(format!(" AND {col_prefix}pubkey = ")) + .push_bind(pk.clone()); + } + + if let Some(ref authors) = q.authors { + if !authors.is_empty() { + qb.push(format!(" AND {col_prefix}pubkey IN (")); + let mut sep = qb.separated(", "); + for a in authors { + sep.push_bind(a.clone()); + } + qb.push(")"); + } + } + + if let Some(ref ids) = q.ids { + if !ids.is_empty() { + qb.push(format!(" AND {col_prefix}id IN (")); + let mut sep = qb.separated(", "); + for id in ids { + sep.push_bind(id.clone()); + } + qb.push(")"); + } + } + + if let Some(ref e_tags) = q.e_tags { + if !e_tags.is_empty() { + qb.push(" AND ("); + for (i, hex_id) in e_tags.iter().enumerate() { + if i > 0 { + qb.push(" OR "); + } + let containment = serde_json::json!([["e", hex_id]]); + qb.push(format!("{col_prefix}tags @> ")); + qb.push_bind(containment); + } + qb.push(")"); + } + } + + if let Some(s) = q.since { + qb.push(format!(" AND {col_prefix}created_at >= ")) + .push_bind(s); + } + if let Some(u) = q.until { + qb.push(format!(" AND {col_prefix}created_at <= ")) + .push_bind(u); + } + + if let Some(ref d) = q.d_tag { + qb.push(format!(" AND {col_prefix}d_tag = ")) + .push_bind(d.clone()); + } else if let Some(ref ds) = q.d_tags { + if !ds.is_empty() { + qb.push(format!(" AND {col_prefix}d_tag IN (")); + let mut sep = qb.separated(", "); + for d in ds { + sep.push_bind(d.clone()); + } + qb.push(")"); + } + } + + let row = qb.build().fetch_one(pool).await?; + let cnt: i64 = row.try_get("cnt")?; + + Ok(cnt) +} + /// Soft-delete an event by setting `deleted_at = NOW()`. /// /// Returns `Ok(true)` if the event was deleted, `Ok(false)` if already deleted diff --git a/crates/sprout-db/src/feed.rs b/crates/sprout-db/src/feed.rs index 1ed6a702b1..f90e95df14 100644 --- a/crates/sprout-db/src/feed.rs +++ b/crates/sprout-db/src/feed.rs @@ -28,6 +28,7 @@ pub const FEED_MAX_LIMIT: i64 = 100; use chrono::{DateTime, Utc}; +use sqlx::postgres::PgRow; use sqlx::{PgPool, QueryBuilder}; use uuid::Uuid; @@ -41,6 +42,39 @@ use sprout_core::StoredEvent; use crate::error::Result; use crate::event::row_to_stored_event; +/// Column list shared by every feed subquery that aliases the `events` table as `e`. +const EVENT_COLS: &str = + "e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, e.received_at, e.channel_id"; + +/// Column list for queries that select directly from `events` (no table alias). +const EVENT_COLS_UNALIASED: &str = + "id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id"; + +/// Append `AND IN ($1, $2, …)` for the given channel IDs. +/// +/// No-ops when the slice is empty so callers don't need a guard. +fn push_channel_id_filter(qb: &mut QueryBuilder, col: &str, ids: &[Uuid]) { + if !ids.is_empty() { + qb.push(format!(" AND {col} IN (")); + let mut sep = qb.separated(", "); + for id in ids { + sep.push_bind(*id); + } + qb.push(")"); + } +} + +/// Convert fetched rows into `Vec`, skipping any that fail conversion. +fn collect_stored_events(rows: Vec) -> Result> { + let mut out = Vec::with_capacity(rows.len()); + for row in rows { + if let Some(ev) = row_to_stored_event(row)? { + out.push(ev); + } + } + Ok(out) +} + /// Find events that @mention the given pubkey (have `["p", pubkey_hex]` in tags). /// /// Joins against the `event_mentions` table -- Phase 2 implementation. @@ -58,44 +92,26 @@ pub async fn query_mentions( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ - e.received_at, e.channel_id \ - FROM events e \ + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = ", - ); + WHERE m.pubkey_hex = " + )); qb.push_bind(&pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); - qb.push(format!( - " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" + " AND e.kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, \ + {KIND_FORUM_POST}, {KIND_FORUM_COMMENT})" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND e.channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } /// Find events that require action from the given pubkey: @@ -117,44 +133,25 @@ pub async fn query_needs_action( let limit = limit.min(FEED_MAX_LIMIT); let pubkey_hex = hex::encode(pubkey_bytes); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT e.id, e.pubkey, e.created_at, e.kind, e.tags, e.content, e.sig, \ - e.received_at, e.channel_id \ - FROM events e \ + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS} FROM events e \ INNER JOIN event_mentions m ON e.id = m.event_id \ - WHERE m.pubkey_hex = ", - ); + WHERE m.pubkey_hex = " + )); qb.push_bind(&pubkey_hex); qb.push(" AND e.deleted_at IS NULL"); - qb.push(format!( " AND e.kind IN ({KIND_WORKFLOW_APPROVAL_REQUESTED}, {KIND_STREAM_REMINDER})" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND e.channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + push_channel_id_filter(&mut qb, "e.channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND m.event_created_at >= ").push_bind(s); } - qb.push(" ORDER BY m.event_created_at DESC LIMIT ") .push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } /// Find recent activity across accessible channels (for watched topics / agent activity). @@ -170,40 +167,21 @@ pub async fn query_activity( limit: i64, ) -> Result> { let limit = limit.min(FEED_MAX_LIMIT); - let mut qb: QueryBuilder = QueryBuilder::new( - "SELECT id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id \ - FROM events WHERE 1=1", - ); - - qb.push(" AND deleted_at IS NULL"); - - qb.push(format!( - " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" + let mut qb: QueryBuilder = QueryBuilder::new(format!( + "SELECT {EVENT_COLS_UNALIASED} FROM events WHERE deleted_at IS NULL" )); - - if !accessible_channel_ids.is_empty() { - qb.push(" AND channel_id IN ("); - let mut sep = qb.separated(", "); - for id in accessible_channel_ids { - sep.push_bind(*id); - } - qb.push(")"); - } - + qb.push(format!( + " AND kind IN ({KIND_STREAM_MESSAGE}, {KIND_STREAM_MESSAGE_V2}, {KIND_FORUM_POST}, \ + {KIND_JOB_REQUEST}, {KIND_JOB_PROGRESS}, {KIND_JOB_RESULT})" + )); + push_channel_id_filter(&mut qb, "channel_id", accessible_channel_ids); if let Some(s) = since { qb.push(" AND created_at >= ").push_bind(s); } - qb.push(" ORDER BY created_at DESC LIMIT ").push_bind(limit); let rows = qb.build().fetch_all(pool).await?; - let mut out = Vec::with_capacity(rows.len()); - for row in rows { - if let Some(ev) = row_to_stored_event(row)? { - out.push(ev); - } - } - Ok(out) + collect_stored_events(rows) } // -- Tests -------------------------------------------------------------------- diff --git a/crates/sprout-db/src/lib.rs b/crates/sprout-db/src/lib.rs index b9f1326a08..8678751129 100644 --- a/crates/sprout-db/src/lib.rs +++ b/crates/sprout-db/src/lib.rs @@ -25,6 +25,8 @@ pub mod feed; pub mod partition; /// Reaction persistence. pub mod reaction; +/// Relay-level membership persistence (NIP-43). +pub mod relay_members; /// Thread metadata persistence. pub mod thread; /// User profile persistence. @@ -197,6 +199,14 @@ impl Db { sqlx::query("SELECT 1").execute(&self.pool).await.is_ok() } + /// Begin a database transaction for atomic multi-statement operations. + /// + /// Returns a `'static` transaction because `PgPool` is `Arc`-backed internally. + /// The transaction holds an owned pool handle, not a borrow. + pub async fn begin_transaction(&self) -> Result> { + self.pool.begin().await.map_err(Into::into) + } + // ── Events ─────────────────────────────────────────────────────────────── /// Inserts an event. Returns `(StoredEvent, was_inserted)` — `false` on duplicate. @@ -219,6 +229,11 @@ impl Db { event::query_events(&self.pool, q).await } + /// Count events matching the given query (NIP-45 COUNT support). + pub async fn count_events(&self, q: &EventQuery) -> Result { + event::count_events(&self.pool, q).await + } + /// Fetch the latest replaceable event for a (kind, pubkey) pair. /// /// Uses canonical NIP-16 ordering: `created_at DESC, id ASC`. @@ -1123,6 +1138,16 @@ impl Db { workflow::delete_workflow(&self.pool, id).await } + /// Find a workflow by owner pubkey and name. Used for NIP-09 a-tag deletion + /// where the d-tag is the workflow name (not UUID). + pub async fn find_workflow_by_owner_and_name( + &self, + owner_pubkey: &[u8], + name: &str, + ) -> Result> { + workflow::find_by_owner_and_name(&self.pool, owner_pubkey, name).await + } + /// Create a new workflow run. pub async fn create_workflow_run( &self, @@ -1311,6 +1336,75 @@ impl Db { Ok(out) } + // ── Relay Members (NIP-43) ─────────────────────────────────────────────── + + /// Returns `true` if `pubkey` (64-char hex) is in the relay member list. + pub async fn is_relay_member(&self, pubkey: &str) -> Result { + relay_members::is_relay_member(&self.pool, pubkey).await + } + + /// Returns the relay member record for `pubkey`, or `None` if not found. + pub async fn get_relay_member( + &self, + pubkey: &str, + ) -> Result> { + relay_members::get_relay_member(&self.pool, pubkey).await + } + + /// Returns all relay members ordered by `created_at` ascending. + pub async fn list_relay_members(&self) -> Result> { + relay_members::list_relay_members(&self.pool).await + } + + /// Adds a new relay member. No-ops silently if the pubkey already exists (idempotent). + /// Adds a new relay member. + /// + /// Returns `true` if the row was actually inserted, `false` if the pubkey + /// already existed (idempotent — `ON CONFLICT DO NOTHING`). + pub async fn add_relay_member( + &self, + pubkey: &str, + role: &str, + added_by: Option<&str>, + ) -> Result { + relay_members::add_relay_member(&self.pool, pubkey, role, added_by).await + } + + /// Removes a relay member atomically, refusing to delete the owner. + pub async fn remove_relay_member(&self, pubkey: &str) -> Result { + relay_members::remove_relay_member(&self.pool, pubkey).await + } + + /// Removes a relay member only if their current role matches `expected_role`. + /// + /// Atomic conditional delete — eliminates the TOCTOU race between a + /// prior role read and the delete. See [`relay_members::remove_relay_member_if_role`]. + pub async fn remove_relay_member_if_role( + &self, + pubkey: &str, + expected_role: &str, + ) -> Result { + relay_members::remove_relay_member_if_role(&self.pool, pubkey, expected_role).await + } + + /// Updates the role of an existing relay member. Returns `true` if updated. + pub async fn update_relay_member_role(&self, pubkey: &str, new_role: &str) -> Result { + relay_members::update_relay_member_role(&self.pool, pubkey, new_role).await + } + + /// Ensures the owner pubkey exists with role `"owner"`. Called at startup. + pub async fn bootstrap_owner(&self, owner_pubkey: &str) -> Result<()> { + relay_members::bootstrap_owner(&self.pool, owner_pubkey).await + } + + /// Migrates existing `pubkey_allowlist` entries into `relay_members`. + /// + /// Idempotent — uses `ON CONFLICT DO NOTHING`. Returns the number of rows + /// inserted, or 0 if the `pubkey_allowlist` table doesn't exist. + pub async fn backfill_from_allowlist(&self) -> Result { + relay_members::backfill_from_allowlist(&self.pool).await + } + // ── Discovery events ───────────────────────────────────────────────────── /// Soft-delete NIP-29 discovery events for a channel created by a specific relay pubkey. diff --git a/crates/sprout-db/src/relay_members.rs b/crates/sprout-db/src/relay_members.rs new file mode 100644 index 0000000000..8a7dc6d824 --- /dev/null +++ b/crates/sprout-db/src/relay_members.rs @@ -0,0 +1,283 @@ +//! Relay-level membership persistence (NIP-43). +//! +//! The `relay_members` table stores pubkeys (hex), roles, and audit metadata. +//! All pubkey values are 64-char lowercase hex strings. + +use chrono::{DateTime, Utc}; +use sqlx::{PgPool, Row as _}; + +use crate::error::Result; + +/// A single relay member record. +#[derive(Debug, Clone)] +pub struct RelayMember { + /// 64-char lowercase hex pubkey. + pub pubkey: String, + /// Role: `"owner"`, `"admin"`, or `"member"`. + pub role: String, + /// Hex pubkey of who added this member, or `None` for bootstrap entries. + pub added_by: Option, + /// When the member was added. + pub created_at: DateTime, + /// When the record was last updated. + pub updated_at: DateTime, +} + +/// Returns `true` if `pubkey` (64-char hex) is in the relay member list. +pub async fn is_relay_member(pool: &PgPool, pubkey: &str) -> Result { + let row = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1") + .bind(pubkey) + .fetch_optional(pool) + .await?; + Ok(row.is_some()) +} + +/// Returns the relay member record for `pubkey`, or `None` if not found. +pub async fn get_relay_member(pool: &PgPool, pubkey: &str) -> Result> { + let row = sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at \ + FROM relay_members WHERE pubkey = $1", + ) + .bind(pubkey) + .fetch_optional(pool) + .await?; + + row.map(|r| -> std::result::Result { + Ok(RelayMember { + pubkey: r.try_get("pubkey")?, + role: r.try_get("role")?, + added_by: r.try_get("added_by")?, + created_at: r.try_get("created_at")?, + updated_at: r.try_get("updated_at")?, + }) + }) + .transpose() + .map_err(crate::error::DbError::from) +} + +/// Returns all relay members ordered by `created_at` ascending. +pub async fn list_relay_members(pool: &PgPool) -> Result> { + let rows = sqlx::query( + "SELECT pubkey, role, added_by, created_at, updated_at \ + FROM relay_members ORDER BY created_at ASC", + ) + .fetch_all(pool) + .await?; + + rows.into_iter() + .map(|r| -> std::result::Result { + Ok(RelayMember { + pubkey: r.try_get("pubkey")?, + role: r.try_get("role")?, + added_by: r.try_get("added_by")?, + created_at: r.try_get("created_at")?, + updated_at: r.try_get("updated_at")?, + }) + }) + .collect::, sqlx::Error>>() + .map_err(crate::error::DbError::from) +} + +/// Adds a new relay member. +/// +/// Returns `true` if the row was actually inserted, `false` if the pubkey +/// already existed (idempotent — `ON CONFLICT DO NOTHING`). +pub async fn add_relay_member( + pool: &PgPool, + pubkey: &str, + role: &str, + added_by: Option<&str>, +) -> Result { + let result = sqlx::query( + "INSERT INTO relay_members (pubkey, role, added_by) \ + VALUES ($1, $2, $3) ON CONFLICT (pubkey) DO NOTHING", + ) + .bind(pubkey) + .bind(role) + .bind(added_by) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// The result of a relay member removal attempt. +#[derive(Debug, PartialEq)] +pub enum RemoveResult { + /// Member was successfully removed. + Removed, + /// The pubkey belongs to the relay owner — removal is forbidden. + IsOwner, + /// No member with the given pubkey exists. + NotFound, + /// The member exists but their role doesn't match the expected role. + RoleMismatch, +} + +/// Removes a relay member atomically, refusing to delete the owner. +/// +/// Uses a single conditional `DELETE … WHERE role <> 'owner'` so the +/// owner-protection check and the deletion are one atomic operation — +/// no TOCTOU race between a separate read and delete. +pub async fn remove_relay_member(pool: &PgPool, pubkey: &str) -> Result { + let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role <> 'owner'") + .bind(pubkey) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + + // rows_affected == 0: either not found or is owner. One cheap read to + // distinguish the two cases so callers can return the right error message. + let exists = sqlx::query("SELECT 1 FROM relay_members WHERE pubkey = $1") + .bind(pubkey) + .fetch_optional(pool) + .await?; + + if exists.is_some() { + Ok(RemoveResult::IsOwner) + } else { + Ok(RemoveResult::NotFound) + } +} + +/// Removes a relay member only if their current role matches `expected_role`. +/// +/// The delete and the role check are collapsed into a single +/// `DELETE … WHERE pubkey = $1 AND role = $2`, making the operation atomic — +/// no TOCTOU race between a prior read and this delete. +/// +/// Returns: +/// - `Removed` — row was deleted. +/// - `NotFound` — no member with that pubkey exists. +/// - `IsOwner` — member exists with role `"owner"` (cannot be removed). +/// - `RoleMismatch` — member exists but their role no longer matches +/// `expected_role` (e.g., they were promoted between the caller's read and +/// this delete). +pub async fn remove_relay_member_if_role( + pool: &PgPool, + pubkey: &str, + expected_role: &str, +) -> Result { + let result = sqlx::query("DELETE FROM relay_members WHERE pubkey = $1 AND role = $2") + .bind(pubkey) + .bind(expected_role) + .execute(pool) + .await?; + + if result.rows_affected() > 0 { + return Ok(RemoveResult::Removed); + } + + // rows_affected == 0: either not found or role changed. One cheap read to + // distinguish the cases so callers can return the right error message. + let row = sqlx::query("SELECT role FROM relay_members WHERE pubkey = $1") + .bind(pubkey) + .fetch_optional(pool) + .await?; + + match row { + None => Ok(RemoveResult::NotFound), + Some(r) => { + let role: String = r.try_get("role")?; + if role == "owner" { + Ok(RemoveResult::IsOwner) + } else { + // Role changed between the caller's check and this delete + // (e.g., target was promoted to admin). Signal that the + // caller no longer has authority to remove this target. + Ok(RemoveResult::RoleMismatch) + } + } + } +} + +/// Updates the role of an existing relay member. Returns `true` if updated. +pub async fn update_relay_member_role(pool: &PgPool, pubkey: &str, new_role: &str) -> Result { + let result = sqlx::query( + "UPDATE relay_members SET role = $1, updated_at = now() WHERE pubkey = $2 AND role <> 'owner'", + ) + .bind(new_role) + .bind(pubkey) + .execute(pool) + .await?; + Ok(result.rows_affected() > 0) +} + +/// Ensures the configured owner pubkey holds the `"owner"` role, and demotes +/// any other owners to `"admin"`. This handles owner rotation: if +/// `RELAY_OWNER_PUBKEY` changes, the old owner is automatically demoted. +/// +/// Runs in a single transaction. Safe to call at every startup — idempotent. +pub async fn bootstrap_owner(pool: &PgPool, owner_pubkey: &str) -> Result<()> { + let pubkey = owner_pubkey.to_ascii_lowercase(); + let mut tx = pool.begin().await?; + + // 1. Upsert the configured owner. + sqlx::query( + "INSERT INTO relay_members (pubkey, role, added_by) \ + VALUES ($1, 'owner', NULL) \ + ON CONFLICT (pubkey) DO UPDATE SET role = 'owner', updated_at = now()", + ) + .bind(&pubkey) + .execute(&mut *tx) + .await?; + + // 2. Demote any other owners to admin. + sqlx::query( + "UPDATE relay_members SET role = 'admin', updated_at = now() \ + WHERE role = 'owner' AND pubkey <> $1", + ) + .bind(&pubkey) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(()) +} + +/// Migrates existing `pubkey_allowlist` entries into `relay_members`. +/// +/// Converts BYTEA pubkeys to lowercase hex text and inserts them as members. +/// Returns the number of rows inserted, or 0 if: +/// - the `pubkey_allowlist` table doesn't exist, or +/// - `relay_members` already has rows (migration ran in a prior startup). +/// +/// The empty-table guard prevents re-adding members that were intentionally +/// removed by an admin after the initial backfill. +pub async fn backfill_from_allowlist(pool: &PgPool) -> Result { + // Check if pubkey_allowlist table exists. + let exists: bool = sqlx::query_scalar( + "SELECT EXISTS (SELECT 1 FROM information_schema.tables \ + WHERE table_schema = 'public' AND table_name = 'pubkey_allowlist')", + ) + .fetch_one(pool) + .await?; + + if !exists { + return Ok(0); + } + + // Only backfill if relay_members is empty — once the table has rows + // (from a previous backfill or manual admin commands), we must not + // re-add members that were intentionally removed. + let has_members: bool = sqlx::query_scalar("SELECT EXISTS (SELECT 1 FROM relay_members)") + .fetch_one(pool) + .await?; + + if has_members { + return Ok(0); + } + + let result = sqlx::query( + "INSERT INTO relay_members (pubkey, role, added_by, created_at) \ + SELECT encode(pubkey, 'hex'), 'member', NULL, added_at \ + FROM pubkey_allowlist \ + ON CONFLICT (pubkey) DO NOTHING", + ) + .execute(pool) + .await?; + + Ok(result.rows_affected()) +} diff --git a/crates/sprout-db/src/workflow.rs b/crates/sprout-db/src/workflow.rs index 3725fbe272..5c0b408fb9 100644 --- a/crates/sprout-db/src/workflow.rs +++ b/crates/sprout-db/src/workflow.rs @@ -823,6 +823,32 @@ fn row_to_approval_record(row: sqlx::postgres::PgRow) -> Result }) } +/// Find a workflow by owner pubkey and name. Returns the first match (active or not). +pub async fn find_by_owner_and_name( + pool: &PgPool, + owner_pubkey: &[u8], + name: &str, +) -> Result> { + let row = sqlx::query( + r#" + SELECT id, name, owner_pubkey, channel_id, definition, definition_hash, + status::text AS status, enabled, created_at, updated_at + FROM workflows + WHERE owner_pubkey = $1 AND name = $2 + LIMIT 1 + "#, + ) + .bind(owner_pubkey) + .bind(name) + .fetch_optional(pool) + .await?; + + match row { + Some(r) => Ok(Some(row_to_workflow_record(r)?)), + None => Ok(None), + } +} + // -- Tests -------------------------------------------------------------------- #[cfg(test)] diff --git a/crates/sprout-dev-mcp/Cargo.toml b/crates/sprout-dev-mcp/Cargo.toml new file mode 100644 index 0000000000..538a579cf4 --- /dev/null +++ b/crates/sprout-dev-mcp/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "sprout-dev-mcp" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true + +[[bin]] +name = "sprout-dev-mcp" +path = "src/main.rs" + +[dependencies] +sprout-cli = { path = "../sprout-cli" } +tokio = { workspace = true } +tokio-util = { workspace = true } +serde = { workspace = true } +serde_json = { workspace = true } +rmcp = { workspace = true } +schemars = { workspace = true } +similar = "2" +tempfile = "3" +ignore = "0.4.25" +tracing = { workspace = true } +tracing-subscriber = { workspace = true } + +[target.'cfg(unix)'.dependencies] +nix = { version = "0.31", default-features = false, features = ["signal", "process"] } diff --git a/crates/sprout-dev-mcp/src/main.rs b/crates/sprout-dev-mcp/src/main.rs new file mode 100644 index 0000000000..3a14fdf583 --- /dev/null +++ b/crates/sprout-dev-mcp/src/main.rs @@ -0,0 +1,146 @@ +#![forbid(unsafe_code)] +use rmcp::{ + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::{CallToolResult, ServerCapabilities, ServerInfo}, + tool, tool_handler, tool_router, + transport::stdio, + ErrorData, ServerHandler, ServiceExt, +}; +use std::path::Path; +use std::sync::Arc; + +mod rg; +mod shell; +mod shim; +mod str_replace; +mod todo; +mod tree; + +#[derive(Clone)] +struct DevMcp { + state: Arc, + todos: Arc, + tool_router: ToolRouter, +} + +#[tool_router] +impl DevMcp { + fn new(state: Arc) -> Self { + Self { + state, + todos: Arc::new(todo::TodoState::new()), + tool_router: Self::tool_router(), + } + } + + #[tool( + name = "shell", + description = "Run a bash command. Ephemeral process per call. Output tail-truncated to ~8KB for the LLM; full output (first 10MB) saved to artifact file. timeout_ms capped at 600000. On PATH: rg (prefer over grep; flags: -n -i -l -g -C --files), tree (flags: -d ; shows line counts), and sprout (Sprout relay CLI — run sprout --help for commands)." + )] + async fn shell( + &self, + Parameters(p): Parameters, + context: rmcp::service::RequestContext, + ) -> Result { + shell::run(&self.state, p, context.ct).await + } + + #[tool( + name = "str_replace", + description = "Atomic find-and-replace in a file. old_str must occur exactly once. Returns a unified diff. Path resolved relative to workdir (defaults to server cwd). Prefer over sed/awk." + )] + async fn str_replace( + &self, + Parameters(p): Parameters, + ) -> Result { + str_replace::run(&self.state, p) + } + + #[tool( + name = "todo", + description = "Session task list. Omit `todos` to read current state. Provide a full replacement array to update. Items are {text, done}. Open items removed without being marked done will trigger a warning. If the operator enables hooks for this server, the agent's _Stop hook will advise against ending the turn while items are open." + )] + async fn todo( + &self, + Parameters(p): Parameters, + ) -> Result { + match self.todos.handle_todo(p) { + Ok(text) => todo::text_result(text), + Err(e) => todo::error_result(format!("Error: {e}")), + } + } + + /// Hook: called by the agent before honoring end_turn. Returns + /// non-empty objection text iff items remain open. + #[tool( + name = "_Stop", + description = "Returns open todo items if any exist. Used by the agent's _Stop lifecycle hook to advise against ending with incomplete work." + )] + async fn stop_hook( + &self, + Parameters(_): Parameters, + ) -> Result { + todo::text_result(self.todos.stop_objection()) + } + + /// Hook: called by the agent after context compaction/handoff so the + /// todo list survives history truncation. + #[tool( + name = "_PostCompact", + description = "Internal hook. Agent invokes after handoff; returns todo state for re-injection." + )] + async fn post_compact_hook( + &self, + Parameters(_): Parameters, + ) -> Result { + todo::text_result(self.todos.post_compact()) + } +} + +#[tool_handler] +impl ServerHandler for DevMcp { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(rmcp::model::Implementation::new( + "sprout-dev-mcp", + env!("CARGO_PKG_VERSION"), + )) + .with_instructions(self.state.bootstrap_instructions.clone()) + } +} + +#[tokio::main(flavor = "current_thread")] +async fn main() -> Result<(), Box> { + tracing_subscriber::fmt() + .with_writer(std::io::stderr) + .with_ansi(false) + .init(); + let argv0 = std::env::args().next().unwrap_or_default(); + let cmd = Path::new(&argv0) + .file_name() + .and_then(|n| n.to_str()) + .unwrap_or("") + .to_ascii_lowercase(); + + if cmd == "rg" { + let args: Vec = std::env::args().skip(1).collect(); + std::process::exit(rg::run(args)); + } + + if cmd == "tree" { + let args: Vec = std::env::args().skip(1).collect(); + std::process::exit(tree::run(args)); + } + + if cmd == "sprout" { + std::process::exit(sprout_cli::run_from_args(std::env::args()).await); + } + + let cwd = std::env::current_dir()?; + let shim = shim::Shim::install()?; + let state = Arc::new(shell::SharedState::new(cwd, shim)?); + + let service = DevMcp::new(state).serve(stdio()).await?; + service.waiting().await?; + Ok(()) +} diff --git a/crates/sprout-dev-mcp/src/rg.rs b/crates/sprout-dev-mcp/src/rg.rs new file mode 100644 index 0000000000..199b63432e --- /dev/null +++ b/crates/sprout-dev-mcp/src/rg.rs @@ -0,0 +1,492 @@ +use std::io::{BufRead, BufReader}; +use std::path::{Path, PathBuf}; +use std::process::Command; + +const MAX_LINE_BYTES: usize = 1024 * 1024; // 1MB per line — skip files with longer lines (likely binary) +const MAX_OUTPUT_BYTES: usize = 50 * 1024; +const MAX_OUTPUT_LINES: usize = 2000; +const MAX_CONTEXT: usize = 100; +const MAX_WALK_DEPTH: usize = 50; + +pub fn run(args: Vec) -> i32 { + if let Some(code) = try_system_rg(&args) { + return code; + } + fallback(args) +} + +fn try_system_rg(args: &[String]) -> Option { + let self_exe = std::env::current_exe().ok()?; + let self_canon = std::fs::canonicalize(&self_exe).ok()?; + let cleaned_path = clean_path(&self_canon); + let candidate = which_rg(&cleaned_path)?; + + let status = Command::new(&candidate) + .args(args) + .env("PATH", &cleaned_path) + .status() + .ok()?; + Some(status.code().unwrap_or(2)) +} + +fn clean_path(self_canon: &Path) -> String { + let original = std::env::var("PATH").unwrap_or_default(); + original + .split(':') + .filter(|dir| { + if dir.is_empty() { + return false; + } + let candidate = Path::new(dir).join("rg"); + match std::fs::canonicalize(&candidate) { + Ok(c) => c != *self_canon, + Err(_) => true, + } + }) + .collect::>() + .join(":") +} + +fn which_rg(path: &str) -> Option { + for dir in path.split(':') { + if dir.is_empty() { + continue; + } + let candidate = Path::new(dir).join("rg"); + if candidate.is_file() && is_executable(&candidate) { + return Some(candidate); + } + } + None +} + +#[cfg(unix)] +fn is_executable(p: &Path) -> bool { + use std::os::unix::fs::PermissionsExt; + std::fs::metadata(p) + .map(|m| m.permissions().mode() & 0o111 != 0) + .unwrap_or(false) +} + +#[cfg(not(unix))] +fn is_executable(_p: &Path) -> bool { + true +} + +#[cfg_attr(test, derive(Debug))] +struct RgArgs { + pattern: Option, + paths: Vec, + line_numbers: bool, + ignore_case: bool, + files_only: bool, + list_files_with_matches: bool, + context: usize, + glob: Option, +} + +fn parse(args: Vec) -> Result { + let mut out = RgArgs { + pattern: None, + paths: Vec::new(), + line_numbers: false, + ignore_case: false, + files_only: false, + list_files_with_matches: false, + context: 0, + glob: None, + }; + let mut iter = args.into_iter(); + let mut positional: Vec = Vec::new(); + while let Some(a) = iter.next() { + match a.as_str() { + "--files" => out.files_only = true, + "-n" | "--line-number" => out.line_numbers = true, + "-i" | "--ignore-case" => out.ignore_case = true, + "-l" | "--files-with-matches" => out.list_files_with_matches = true, + "-C" | "--context" => { + let n = iter.next().ok_or("missing value for -C")?; + let parsed: usize = n.parse().map_err(|_| format!("bad -C value: {n}"))?; + out.context = parsed.min(MAX_CONTEXT); + } + "-g" | "--glob" => { + out.glob = Some(iter.next().ok_or("missing value for -g")?); + } + "--" => positional.extend(iter.by_ref()), + s if s.starts_with('-') && s.len() > 1 => { + return Err(format!("unsupported flag (fallback rg): {s}")); + } + _ => positional.push(a), + } + } + if out.files_only { + out.paths = positional.into_iter().map(PathBuf::from).collect(); + if out.paths.is_empty() { + out.paths.push(PathBuf::from(".")); + } + } else { + let mut it = positional.into_iter(); + out.pattern = it.next(); + out.paths = it.map(PathBuf::from).collect(); + if out.paths.is_empty() { + out.paths.push(PathBuf::from(".")); + } + } + Ok(out) +} + +struct CappedSink { + bytes: usize, + lines: usize, + capped: bool, +} + +impl CappedSink { + fn new() -> Self { + Self { + bytes: 0, + lines: 0, + capped: false, + } + } + + fn writeln(&mut self, s: &str) { + if self.capped { + return; + } + let next = self.bytes.saturating_add(s.len()).saturating_add(1); + if next > MAX_OUTPUT_BYTES || self.lines >= MAX_OUTPUT_LINES { + self.capped = true; + tracing::warn!("rg (fallback): output capped at {MAX_OUTPUT_BYTES} bytes / {MAX_OUTPUT_LINES} lines"); + return; + } + println!("{s}"); + self.bytes = next; + self.lines += 1; + } +} + +fn fallback(args: Vec) -> i32 { + let opts = match parse(args) { + Ok(o) => o, + Err(e) => { + tracing::error!("rg (fallback): {e}"); + return 2; + } + }; + let mut sink = CappedSink::new(); + let mut found = false; + let mut printed: std::collections::HashSet = std::collections::HashSet::new(); + + if opts.files_only { + for root in &opts.paths { + walk(root, &opts, &mut |p| { + sink.writeln(&p.display().to_string()); + found = true; + !sink.capped + }); + if sink.capped { + break; + } + } + return if found { 0 } else { 1 }; + } + + let pattern = match &opts.pattern { + Some(p) => p.clone(), + None => { + tracing::error!("rg (fallback): missing PATTERN"); + return 2; + } + }; + let needle = if opts.ignore_case { + pattern.to_lowercase() + } else { + pattern + }; + + for root in &opts.paths { + walk(root, &opts, &mut |path| { + if sink.capped { + return false; + } + if scan_file(path, &needle, &opts, &mut sink, &mut printed) { + found = true; + } + !sink.capped + }); + if sink.capped { + break; + } + } + if found { + 0 + } else { + 1 + } +} + +fn read_bounded_line(reader: &mut impl BufRead, max: usize) -> Option> { + let mut buf = Vec::new(); + loop { + let available = match reader.fill_buf() { + Ok([]) => { + if buf.is_empty() { + return None; + } + return match String::from_utf8(buf) { + Ok(s) => Some(Ok(s)), + Err(_) => Some(Err(())), + }; + } + Ok(b) => b, + Err(_) => return None, + }; + let take = available + .iter() + .position(|b| *b == b'\n') + .map_or(available.len(), |i| i + 1); + if buf.len() + take > max { + return Some(Err(())); + } + buf.extend_from_slice(&available[..take]); + reader.consume(take); + if buf.ends_with(b"\n") { + buf.pop(); + return match String::from_utf8(buf) { + Ok(s) => Some(Ok(s)), + Err(_) => Some(Err(())), + }; + } + } +} + +fn scan_file( + path: &Path, + needle: &str, + opts: &RgArgs, + sink: &mut CappedSink, + printed: &mut std::collections::HashSet, +) -> bool { + let file = match std::fs::File::open(path) { + Ok(f) => f, + Err(_) => return false, + }; + let cap = opts.context; + let mut ring: std::collections::VecDeque = + std::collections::VecDeque::with_capacity(cap); + let mut tail = 0usize; + let mut last: Option = None; + let mut found = false; + + let mut reader = BufReader::new(file); + let mut idx = 0usize; + loop { + let line = match read_bounded_line(&mut reader, MAX_LINE_BYTES) { + None => break, + Some(Err(())) => return found, + Some(Ok(l)) => l, + }; + if sink.capped { + return found; + } + let is_match = if opts.ignore_case { + line.to_lowercase().contains(needle) + } else { + line.contains(needle) + }; + + if is_match { + found = true; + if opts.list_files_with_matches { + if printed.insert(path.to_path_buf()) { + sink.writeln(&path.display().to_string()); + } + return found; + } + let ring_start = idx.saturating_sub(ring.len()); + for (offset, prev) in ring.iter().enumerate() { + let li = ring_start + offset; + if last.is_none_or(|l| li > l) { + emit_line(path, li, prev, opts, sink); + last = Some(li); + } + } + if last.is_none_or(|l| idx > l) { + emit_line(path, idx, &line, opts, sink); + last = Some(idx); + } + tail = cap; + } else if tail > 0 { + emit_line(path, idx, &line, opts, sink); + last = Some(idx); + tail -= 1; + } + + if cap > 0 { + if ring.len() == cap { + ring.pop_front(); + } + ring.push_back(line); + } + idx += 1; + } + found +} + +fn emit_line(path: &Path, line_idx: usize, line: &str, opts: &RgArgs, sink: &mut CappedSink) { + let prefix = if opts.line_numbers { + format!("{}:{}:", path.display(), line_idx + 1) + } else { + format!("{}:", path.display()) + }; + sink.writeln(&format!("{prefix}{line}")); +} + +fn walk(root: &Path, opts: &RgArgs, on_file: &mut dyn FnMut(&Path) -> bool) { + if root.is_file() { + if accept(root, opts) { + on_file(root); + } + return; + } + let mut stack: Vec<(PathBuf, usize)> = vec![(root.to_path_buf(), 0)]; + while let Some((dir, depth)) = stack.pop() { + let entries = match std::fs::read_dir(&dir) { + Ok(e) => e, + Err(_) => continue, + }; + for entry in entries.flatten() { + let path = entry.path(); + let name = match path.file_name().and_then(|n| n.to_str()) { + Some(n) => n, + None => continue, + }; + if name.starts_with('.') { + continue; + } + let ft = match entry.file_type() { + Ok(t) => t, + Err(_) => continue, + }; + if ft.is_symlink() { + continue; + } + if ft.is_dir() { + if matches!(name, "target" | "node_modules" | "dist" | "build") { + continue; + } + if depth < MAX_WALK_DEPTH { + stack.push((path, depth + 1)); + } + } else if ft.is_file() && accept(&path, opts) && !on_file(&path) { + return; + } + } + } +} + +fn accept(path: &Path, opts: &RgArgs) -> bool { + match &opts.glob { + None => true, + Some(g) => glob_match(g, path), + } +} + +fn glob_match(pattern: &str, path: &Path) -> bool { + let name = path.file_name().and_then(|n| n.to_str()).unwrap_or(""); + let full = path.to_string_lossy(); + simple_glob(pattern, name) || simple_glob(pattern, &full) +} + +fn simple_glob(pattern: &str, text: &str) -> bool { + let p: Vec = pattern.chars().collect(); + let t: Vec = text.chars().collect(); + + let (mut pi, mut ti) = (0usize, 0usize); + let (mut star_pi, mut star_ti) = (usize::MAX, 0usize); + + while ti < t.len() { + if pi < p.len() && (p[pi] == '?' || p[pi] == t[ti]) { + pi += 1; + ti += 1; + } else if pi < p.len() && p[pi] == '*' { + star_pi = pi; + star_ti = ti; + pi += 1; + } else if star_pi != usize::MAX { + pi = star_pi + 1; + star_ti += 1; + ti = star_ti; + } else { + return false; + } + } + + while pi < p.len() && p[pi] == '*' { + pi += 1; + } + + pi == p.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn parse_basic_pattern_and_path() { + let opts = parse(vec!["-n".into(), "needle".into(), "src".into()]).expect("parse"); + assert!(opts.line_numbers); + assert_eq!(opts.pattern.as_deref(), Some("needle")); + assert_eq!(opts.paths, vec![PathBuf::from("src")]); + } + + #[test] + fn parse_files_only() { + let opts = parse(vec!["--files".into(), ".".into()]).expect("parse"); + assert!(opts.files_only); + assert_eq!(opts.paths, vec![PathBuf::from(".")]); + } + + #[test] + fn parse_rejects_unknown_flag() { + let err = parse(vec!["-Z".into(), "x".into()]).unwrap_err(); + assert!(err.contains("unsupported flag")); + } + + #[test] + fn capped_sink_stops_at_byte_limit() { + let mut s = CappedSink::new(); + // Below cap: accepted. + s.bytes = MAX_OUTPUT_BYTES - 10; + s.writeln("12345"); + assert!(!s.capped); + // This pushes us over. + s.writeln("x".repeat(20).as_str()); + assert!(s.capped); + } + + #[test] + fn glob_matches_simple_patterns() { + assert!(simple_glob("*.rs", "main.rs")); + assert!(simple_glob("src/*.rs", "src/main.rs")); + assert!(!simple_glob("*.rs", "main.txt")); + assert!(simple_glob("a?c", "abc")); + } + + #[test] + fn fallback_finds_match_in_file() { + // End-to-end: create a file, run the fallback parser, scan it. + // We can't easily capture stdout here, so we verify scan_file + // returns true (match found). + let dir = tempfile::tempdir().expect("tempdir"); + let f = dir.path().join("a.txt"); + std::fs::write(&f, "alpha\nNEEDLE\nbeta\n").expect("write"); + let opts = parse(vec!["NEEDLE".into(), f.display().to_string()]).expect("parse"); + let mut sink = CappedSink::new(); + let mut printed: std::collections::HashSet = std::collections::HashSet::new(); + let found = scan_file(&f, "NEEDLE", &opts, &mut sink, &mut printed); + assert!(found, "expected NEEDLE to be found"); + } +} diff --git a/crates/sprout-dev-mcp/src/shell.rs b/crates/sprout-dev-mcp/src/shell.rs new file mode 100644 index 0000000000..ebe6e2db75 --- /dev/null +++ b/crates/sprout-dev-mcp/src/shell.rs @@ -0,0 +1,559 @@ +use crate::shim::Shim; +use rmcp::model::{CallToolResult, Content}; +use rmcp::ErrorData; +use schemars::JsonSchema; +use serde::Deserialize; +use std::collections::VecDeque; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use std::sync::Mutex; +use std::time::{Duration, Instant}; +use tempfile::TempDir; +use tokio::io::{AsyncRead, AsyncReadExt}; +use tokio::process::Command; +use tokio_util::sync::CancellationToken; + +const DEFAULT_TIMEOUT_MS: u64 = 120_000; +const MAX_TIMEOUT_MS: u64 = 600_000; +const MAX_COMMAND_BYTES: usize = 1_000_000; +const CAPTURE_CAP: usize = 10 * 1024 * 1024; +const MAX_BYTES: usize = 50 * 1024; +const MAX_LINES: usize = 2000; +const TAIL_BYTES: usize = 8 * 1024; +const ARTIFACT_RING_SIZE: usize = 8; +const READ_CHUNK: usize = 16 * 1024; + +pub struct SharedState { + pub cwd: PathBuf, + pub shim: Shim, + pub session_dir: TempDir, + pub bootstrap_instructions: String, + pub artifacts: Mutex>, + next_call_id: Mutex, +} + +impl SharedState { + pub fn new(cwd: PathBuf, shim: Shim) -> std::io::Result { + let session_dir = tempfile::Builder::new() + .prefix("sprout-dev-mcp-session-") + .tempdir()?; + let bootstrap_instructions = build_bootstrap(&cwd); + Ok(Self { + cwd, + shim, + session_dir, + bootstrap_instructions, + artifacts: Mutex::new(VecDeque::with_capacity(ARTIFACT_RING_SIZE)), + next_call_id: Mutex::new(0), + }) + } + + fn next_id(&self) -> u64 { + let mut g = match self.next_call_id.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + *g += 1; + *g + } +} + +fn build_bootstrap(cwd: &Path) -> String { + let stack = detect_stack(cwd); + let sprout_hint = if std::env::var("SPROUT_RELAY_URL").is_ok() + && std::env::var("SPROUT_PRIVATE_KEY").is_ok() + { + "\nSprout relay configured. Run `sprout --help` to see available commands.\n" + } else { + "" + }; + format!( + "Working directory: {}\n\ + Detected stack: {}\n\ + Pass `workdir` per call rather than `cd`.\n\ + {sprout_hint}", + cwd.display(), + stack, + ) +} + +fn detect_stack(cwd: &Path) -> String { + let markers = [ + ("Cargo.toml", "rust (cargo)"), + ("package.json", "node"), + ("go.mod", "go"), + ("pyproject.toml", "python (pyproject)"), + ("requirements.txt", "python"), + ("Gemfile", "ruby"), + ("pom.xml", "java (maven)"), + ("build.gradle", "java (gradle)"), + ("build.gradle.kts", "kotlin (gradle)"), + ]; + let mut found: Vec<&str> = markers + .iter() + .filter(|(f, _)| cwd.join(f).exists()) + .map(|(_, name)| *name) + .collect(); + if found.is_empty() { + "unknown".into() + } else { + found.sort(); + found.join(", ") + } +} + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct ShellParams { + pub command: String, + #[serde(default)] + pub workdir: Option, + #[serde(default)] + pub timeout_ms: Option, +} + +pub async fn run( + state: &SharedState, + p: ShellParams, + ct: CancellationToken, +) -> Result { + if p.command.len() > MAX_COMMAND_BYTES { + return Err(ErrorData::invalid_params( + format!("command exceeds {MAX_COMMAND_BYTES} byte limit"), + None, + )); + } + let timeout_ms = p + .timeout_ms + .unwrap_or(DEFAULT_TIMEOUT_MS) + .min(MAX_TIMEOUT_MS); + let workdir: PathBuf = p + .workdir + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| state.cwd.clone()); + + if !workdir.is_dir() { + return Err(ErrorData::invalid_params( + format!( + "workdir does not exist or is not a directory: {}", + workdir.display() + ), + None, + )); + } + + let mut cmd = Command::new("bash"); + cmd.arg("-c").arg(&p.command); + cmd.current_dir(&workdir); + cmd.env("PATH", &state.shim.path_env); + cmd.stdin(Stdio::null()); + cmd.stdout(Stdio::piped()); + cmd.stderr(Stdio::piped()); + cmd.kill_on_drop(true); + set_process_group(&mut cmd); + + let started = Instant::now(); + let mut child = match cmd.spawn() { + Ok(c) => c, + Err(e) => { + return Ok(CallToolResult::error(vec![Content::text(format!( + "failed to spawn bash: {e}" + ))])); + } + }; + + let pid = child.id(); + + struct PgidGuard(Option); + impl Drop for PgidGuard { + fn drop(&mut self) { + if let Some(pid) = self.0 { + kill_process_group_immediate(pid as i32); + } + } + } + let mut pgid_guard = PgidGuard(pid); + + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let mut stdout_handle = tokio::spawn(async move { + match stdout_pipe { + Some(p) => read_capped(p).await, + None => CapturedStream::default(), + } + }); + let mut stderr_handle = tokio::spawn(async move { + match stderr_pipe { + Some(p) => read_capped(p).await, + None => CapturedStream::default(), + } + }); + + let timeout_dur = Duration::from_millis(timeout_ms); + let mut notes: Vec = Vec::new(); + let (status, timed_out) = tokio::select! { + biased; + _ = ct.cancelled() => { + // Kill process group, reap child, abort reader tasks. + if let Some(pid) = pid { + kill_process_group_immediate(pid as i32); + } + // Bounded reap so we don't leak zombies. If reap times out, + // PgidGuard drop will SIGKILL again as a last resort. + match tokio::time::timeout(Duration::from_secs(1), child.wait()).await { + Ok(Ok(_)) => { pgid_guard.0 = None; } // reaped; disarm guard + Ok(Err(e)) => { + tracing::debug!("cancel: child wait error: {e}"); + // Leave pgid_guard armed for drop-kill. + } + Err(_) => { + tracing::debug!("cancel: child reap timed out; guard will SIGKILL on drop"); + } + } + stdout_handle.abort(); + stderr_handle.abort(); + return Ok(CallToolResult::error(vec![Content::text("cancelled")])); + } + r = tokio::time::timeout(timeout_dur, child.wait()) => match r { + Ok(Ok(s)) => (Some(s), false), + Ok(Err(err)) => { + notes.push(format!("child wait failed: {err}")); + (None, false) + } + Err(_) => { + // Kill process group — this closes the pipes, causing reads to EOF. + if let Some(pid) = pid { + kill_process_group_graceful(pid as i32).await; + } + // Reap the child so it doesn't become a zombie. + let deadline = Instant::now() + Duration::from_secs(2); + loop { + match child.try_wait() { + Ok(Some(_)) => break, + Ok(None) if Instant::now() >= deadline => { + if let Err(e) = child.start_kill() { + notes.push(format!("force-kill failed: {e}")); + } + if let Err(e) = child.wait().await { + notes.push(format!("post-kill wait: {e}")); + } + break; + } + Ok(None) => { + tokio::time::sleep(Duration::from_millis(20)).await; + } + Err(err) => { + notes.push(format!("try_wait failed: {err}")); + break; + } + } + } + (None, true) + } + } + }; + + if !timed_out { + if let Some(pid) = pid { + kill_process_group_graceful(pid as i32).await; + } + } + + let stdout_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stdout_handle).await { + Ok(Ok(cap)) => cap, + _ => { + stdout_handle.abort(); + notes.push("stdout reader did not complete".into()); + CapturedStream::default() + } + }; + let stderr_cap = match tokio::time::timeout(Duration::from_secs(5), &mut stderr_handle).await { + Ok(Ok(cap)) => cap, + _ => { + stderr_handle.abort(); + notes.push("stderr reader did not complete".into()); + CapturedStream::default() + } + }; + + let duration_ms = started.elapsed().as_millis() as u64; + let exit_code = status + .as_ref() + .and_then(|s| s.code()) + .unwrap_or(if timed_out { 124 } else { -1 }); + + let id = state.next_id(); + let (stdout_text, stdout_truncated, stdout_artifact) = + finalize_stream(state, id, "stdout", stdout_cap, &mut notes); + let (stderr_text, stderr_truncated, stderr_artifact) = + finalize_stream(state, id, "stderr", stderr_cap, &mut notes); + + let body = serde_json::json!({ + "exit_code": exit_code, + "stdout": stdout_text, + "stderr": stderr_text, + "timed_out": timed_out, + "duration_ms": duration_ms, + "stdout_truncated": stdout_truncated, + "stderr_truncated": stderr_truncated, + "stdout_artifact": stdout_artifact, + "stderr_artifact": stderr_artifact, + "notes": notes, + }); + let text = serde_json::to_string_pretty(&body).unwrap_or_else(|_| "{}".into()); + pgid_guard.0 = None; + Ok(CallToolResult::success(vec![Content::text(text)])) +} + +#[cfg(unix)] +fn set_process_group(cmd: &mut Command) { + cmd.process_group(0); +} + +#[cfg(not(unix))] +fn set_process_group(_cmd: &mut Command) {} + +/// Immediate SIGKILL of the process group. Sync; safe to call from Drop. +/// No grace period — used when the parent task is being torn down. +#[cfg(unix)] +fn kill_process_group_immediate(pid: i32) { + use nix::sys::signal::{killpg, Signal}; + use nix::unistd::Pid; + let _ = killpg(Pid::from_raw(pid), Signal::SIGKILL); +} + +#[cfg(not(unix))] +fn kill_process_group_immediate(_pid: i32) {} + +/// Graceful SIGTERM → 200ms async sleep → SIGKILL. Async; never blocks the runtime. +#[cfg(unix)] +async fn kill_process_group_graceful(pid: i32) { + use nix::sys::signal::{killpg, Signal}; + use nix::unistd::Pid; + let pgid = Pid::from_raw(pid); + let _ = killpg(pgid, Signal::SIGTERM); + tokio::time::sleep(Duration::from_millis(200)).await; + let _ = killpg(pgid, Signal::SIGKILL); +} + +#[cfg(not(unix))] +async fn kill_process_group_graceful(_pid: i32) {} + +#[derive(Default)] +struct CapturedStream { + bytes: Vec, + /// Total bytes the process produced (may exceed bytes.len() if capped). + total_bytes: usize, + capped: bool, +} + +async fn read_capped(mut r: R) -> CapturedStream { + let mut out = CapturedStream::default(); + let mut chunk = vec![0u8; READ_CHUNK]; + loop { + match r.read(&mut chunk).await { + Ok(0) => break, + Ok(n) => { + out.total_bytes = out.total_bytes.saturating_add(n); + if !out.capped { + let remaining = CAPTURE_CAP.saturating_sub(out.bytes.len()); + if remaining == 0 { + out.capped = true; + } else { + let take = n.min(remaining); + out.bytes.extend_from_slice(&chunk[..take]); + if out.bytes.len() >= CAPTURE_CAP { + out.capped = true; + } + } + } + } + Err(_) => break, + } + } + out +} + +fn finalize_stream( + state: &SharedState, + call_id: u64, + label: &str, + cap: CapturedStream, + notes: &mut Vec, +) -> (String, bool, Option) { + let CapturedStream { + bytes: buf, + total_bytes, + capped, + } = cap; + let captured_len = buf.len(); + let line_count = buf.iter().filter(|b| **b == b'\n').count(); + let needs_truncate = capped || captured_len > MAX_BYTES || line_count > MAX_LINES; + + if !needs_truncate { + return (lossy(buf), false, None); + } + + let artifact_path = crate::shim::artifact_dir(state.session_dir.path()) + .join(format!("{call_id:06}.{label}.txt")); + let artifact_str = match std::fs::write(&artifact_path, &buf) { + Ok(()) => { + rotate_artifacts(state, artifact_path.clone()); + Some(artifact_path.to_string_lossy().into_owned()) + } + Err(e) => { + notes.push(format!( + "{label}: artifact write failed ({}): {e}", + artifact_path.display() + )); + None + } + }; + + let tail_start = captured_len.saturating_sub(TAIL_BYTES); + let tail_aligned = align_to_char_boundary(&buf, tail_start); + let tail = lossy(buf[tail_aligned..].to_vec()); + + let cap_note = if capped { + format!( + " (capture capped at {} bytes; further output discarded)", + CAPTURE_CAP + ) + } else { + String::new() + }; + let artifact_suffix = match &artifact_str { + Some(p) => format!("; captured output (first 10MB) at {p}"), + None => "; artifact unavailable".into(), + }; + let notice = format!( + "[truncated: showing last {} bytes; {} bytes captured / {} lines / {} bytes total{cap_note}{artifact_suffix}]\n", + tail.len(), + captured_len, + line_count, + total_bytes, + ); + let mut out = String::with_capacity(notice.len() + tail.len()); + out.push_str(¬ice); + out.push_str(&tail); + (out, true, artifact_str) +} + +fn align_to_char_boundary(buf: &[u8], start: usize) -> usize { + let mut i = start.min(buf.len()); + while i < buf.len() && (buf[i] & 0xC0) == 0x80 { + i += 1; + } + i +} + +fn lossy(buf: Vec) -> String { + String::from_utf8(buf).unwrap_or_else(|e| String::from_utf8_lossy(e.as_bytes()).into_owned()) +} + +fn rotate_artifacts(state: &SharedState, new_path: PathBuf) { + let mut ring = match state.artifacts.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + ring.push_back(new_path); + while ring.len() > ARTIFACT_RING_SIZE { + if let Some(old) = ring.pop_front() { + let _ = std::fs::remove_file(old); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::shim::Shim; + use serde_json::Value; + use tempfile::tempdir; + + fn make_state(cwd: &std::path::Path) -> SharedState { + let shim = Shim::install().expect("shim install"); + SharedState::new(cwd.to_path_buf(), shim).expect("state new") + } + + /// Pull the JSON body out of a CallToolResult so tests can assert on fields. + fn body(r: rmcp::model::CallToolResult) -> Value { + let text = match r.content.first().and_then(|c| c.as_text()) { + Some(t) => t.text.clone(), + None => panic!("no text content"), + }; + serde_json::from_str(&text).expect("json") + } + + #[tokio::test(flavor = "current_thread")] + async fn basic_echo() { + let dir = tempdir().expect("tempdir"); + let state = make_state(dir.path()); + let r = run( + &state, + ShellParams { + command: "echo hello".into(), + workdir: None, + timeout_ms: Some(5_000), + }, + CancellationToken::new(), + ) + .await + .expect("ok"); + let v = body(r); + assert_eq!(v["exit_code"], 0); + assert_eq!(v["stdout"], "hello\n"); + assert_eq!(v["timed_out"], false); + } + + #[tokio::test(flavor = "current_thread")] + async fn timeout_fires() { + let dir = tempdir().expect("tempdir"); + let state = make_state(dir.path()); + let r = run( + &state, + ShellParams { + command: "sleep 999".into(), + workdir: None, + timeout_ms: Some(150), + }, + CancellationToken::new(), + ) + .await + .expect("ok"); + let v = body(r); + assert_eq!(v["timed_out"], true); + assert_eq!(v["exit_code"], 124); + } + + #[tokio::test(flavor = "current_thread")] + async fn workdir_is_honored() { + let dir = tempdir().expect("tempdir"); + let sub = dir.path().join("sub"); + std::fs::create_dir(&sub).expect("mkdir sub"); + let state = make_state(dir.path()); + let r = run( + &state, + ShellParams { + command: "pwd".into(), + workdir: Some(sub.display().to_string()), + timeout_ms: Some(5_000), + }, + CancellationToken::new(), + ) + .await + .expect("ok"); + let v = body(r); + let stdout = v["stdout"].as_str().unwrap_or(""); + // Compare canonicalized paths (macOS /tmp -> /private/tmp, etc.). + let sub_canon = std::fs::canonicalize(&sub).expect("canon"); + assert!( + stdout + .trim() + .ends_with(sub_canon.to_string_lossy().as_ref()) + || stdout.contains(sub.file_name().unwrap().to_str().unwrap()), + "stdout: {stdout}" + ); + } +} diff --git a/crates/sprout-dev-mcp/src/shim.rs b/crates/sprout-dev-mcp/src/shim.rs new file mode 100644 index 0000000000..cc7ff72eee --- /dev/null +++ b/crates/sprout-dev-mcp/src/shim.rs @@ -0,0 +1,68 @@ +use std::path::{Path, PathBuf}; +use tempfile::TempDir; + +pub struct Shim { + _dir: TempDir, + pub path_env: String, +} + +impl Shim { + pub fn install() -> std::io::Result { + let dir = tempfile::Builder::new() + .prefix("sprout-dev-mcp-") + .tempdir()?; + set_owner_only(dir.path())?; + + let self_exe = std::env::current_exe()?; + let rg_link = dir.path().join("rg"); + symlink(&self_exe, &rg_link)?; + + let tree_link = dir.path().join("tree"); + symlink(&self_exe, &tree_link)?; + + let sprout_link = dir.path().join("sprout"); + symlink(&self_exe, &sprout_link)?; + + let original = std::env::var_os("PATH").unwrap_or_default(); + let mut new_path = std::ffi::OsString::from(dir.path()); + if !original.is_empty() { + new_path.push(":"); + new_path.push(&original); + } + let path_env = new_path.to_string_lossy().into_owned(); + + Ok(Self { + _dir: dir, + path_env, + }) + } +} + +#[cfg(unix)] +fn set_owner_only(path: &Path) -> std::io::Result<()> { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path)?.permissions(); + perms.set_mode(0o700); + std::fs::set_permissions(path, perms) +} + +#[cfg(not(unix))] +fn set_owner_only(_: &Path) -> std::io::Result<()> { + Ok(()) +} + +#[cfg(unix)] +fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + std::os::unix::fs::symlink(src, dst) +} + +#[cfg(not(unix))] +fn symlink(src: &Path, dst: &Path) -> std::io::Result<()> { + std::fs::copy(src, dst).map(|_| ()) +} + +pub fn artifact_dir(session_root: &Path) -> PathBuf { + let p = session_root.join("artifacts"); + let _ = std::fs::create_dir_all(&p); + p +} diff --git a/crates/sprout-dev-mcp/src/str_replace.rs b/crates/sprout-dev-mcp/src/str_replace.rs new file mode 100644 index 0000000000..0a788bd0e9 --- /dev/null +++ b/crates/sprout-dev-mcp/src/str_replace.rs @@ -0,0 +1,386 @@ +use crate::shell::SharedState; +use rmcp::ErrorData; +use schemars::JsonSchema; +use serde::Deserialize; +use similar::{DiffTag, TextDiff}; +use std::io::Write; +use std::path::{Path, PathBuf}; + +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; +const MAX_INPUT_BYTES: usize = 1024 * 1024; +const HINT_SCAN_LINE_LIMIT: usize = 200; + +#[derive(Debug, Deserialize, JsonSchema)] +pub struct StrReplaceParams { + pub path: String, + pub old_str: String, + pub new_str: String, + #[serde(default)] + pub workdir: Option, +} + +pub fn run(state: &SharedState, p: StrReplaceParams) -> Result { + if p.old_str.is_empty() { + return Err(ErrorData::invalid_params( + "old_str must not be empty".to_string(), + None, + )); + } + if p.old_str.len() > MAX_INPUT_BYTES || p.new_str.len() > MAX_INPUT_BYTES { + return Err(ErrorData::invalid_params( + format!("old_str/new_str exceeds {} byte limit", MAX_INPUT_BYTES), + None, + )); + } + + let workspace_root = match p.workdir.as_deref() { + Some(w) => PathBuf::from(w), + None => state.cwd.clone(), + }; + let target = match resolve_within(&workspace_root, &p.path) { + Ok(t) => t, + Err(e) => return Err(ErrorData::invalid_params(e, None)), + }; + + let meta = match std::fs::metadata(&target) { + Ok(m) => m, + Err(e) => { + return Err(ErrorData::internal_error( + format!("cannot stat {}: {e}", target.display()), + None, + )); + } + }; + if !meta.is_file() { + return Err(ErrorData::invalid_params( + format!("not a regular file: {}", target.display()), + None, + )); + } + if meta.len() > MAX_FILE_BYTES { + return Err(ErrorData::invalid_params( + format!( + "file too large: {} is {} bytes (limit {} bytes)", + target.display(), + meta.len(), + MAX_FILE_BYTES + ), + None, + )); + } + + let file = match std::fs::File::open(&target) { + Ok(f) => f, + Err(e) => { + return Err(ErrorData::internal_error( + format!("cannot open {}: {e}", target.display()), + None, + )); + } + }; + let mut buf = Vec::with_capacity(meta.len() as usize); + use std::io::Read; + match file.take(MAX_FILE_BYTES + 1).read_to_end(&mut buf) { + Ok(n) if n as u64 > MAX_FILE_BYTES => { + return Err(ErrorData::invalid_params( + format!("file grew past {} bytes during read", MAX_FILE_BYTES), + None, + )); + } + Ok(_) => {} + Err(e) => { + return Err(ErrorData::internal_error( + format!("cannot read {}: {e}", target.display()), + None, + )); + } + } + let content = match String::from_utf8(buf) { + Ok(s) => s, + Err(e) => { + return Err(ErrorData::internal_error( + format!("not valid UTF-8: {}: {e}", target.display()), + None, + )); + } + }; + + let count = count_occurrences_capped(&content, &p.old_str); + match count { + 0 => { + let hint = nearest_line_hint(&content, &p.old_str) + .map(|h| format!("\n{h}")) + .unwrap_or_default(); + Err(ErrorData::invalid_params( + format!( + "old_str not found in {}.\nold_str (truncated): {:?}{hint}", + target.display(), + truncate(&p.old_str, 80) + ), + None, + )) + } + 1 => { + let new_content = content.replacen(&p.old_str, &p.new_str, 1); + if new_content.len() as u64 > MAX_FILE_BYTES { + return Err(ErrorData::invalid_params( + format!( + "result would exceed {} byte limit ({} bytes)", + MAX_FILE_BYTES, + new_content.len() + ), + None, + )); + } + if let Err(e) = atomic_write(&target, &new_content) { + return Err(ErrorData::internal_error( + format!("failed to write {}: {e}", target.display()), + None, + )); + } + let diff = unified_diff(&content, &new_content, &target); + Ok(format!( + "Replaced 1 occurrence in {}.\n\n{diff}", + target.display() + )) + } + _ => Err(ErrorData::invalid_params( + format!( + "old_str matched multiple locations in {}; provide more surrounding context to make the match unique.", + target.display() + ), + None, + )), + } +} + +pub(crate) fn resolve_within(root: &Path, path: &str) -> Result { + let raw = Path::new(path); + let candidate: PathBuf = if raw.is_absolute() { + raw.to_path_buf() + } else { + root.join(raw) + }; + + let root_canon = std::fs::canonicalize(root) + .map_err(|e| format!("workdir not accessible: {} ({e})", root.display()))?; + + let resolved = std::fs::canonicalize(&candidate) + .map_err(|e| format!("path not accessible: {} ({e})", candidate.display()))?; + + if !resolved.starts_with(&root_canon) { + return Err(format!( + "path escapes workspace: {} not within {}", + resolved.display(), + root_canon.display() + )); + } + Ok(resolved) +} + +pub(crate) fn count_occurrences_capped(text: &str, pattern: &str) -> usize { + if pattern.is_empty() { + return 0; + } + let mut count = 0; + let mut start = 0; + while let Some(pos) = text[start..].find(pattern) { + count += 1; + if count >= 2 { + return count; + } + start += pos + pattern.len(); + } + count +} + +fn atomic_write(target: &Path, content: &str) -> std::io::Result<()> { + let parent = target.parent().unwrap_or_else(|| Path::new(".")); + // Preserve original permissions so the atomic rename doesn't drop the file's mode. + let original_perms = std::fs::metadata(target).ok().map(|m| m.permissions()); + + let mut tmp = tempfile::NamedTempFile::new_in(parent)?; + tmp.write_all(content.as_bytes())?; + tmp.flush()?; + tmp.persist(target).map_err(|e| e.error)?; + + if let Some(perms) = original_perms { + let _ = std::fs::set_permissions(target, perms); + } + Ok(()) +} + +const MAX_DIFF_BYTES: usize = 64 * 1024; + +fn unified_diff(old: &str, new: &str, path: &Path) -> String { + let diff = TextDiff::from_lines(old, new); + let display = path.display(); + let mut out = format!("--- a/{display}\n+++ b/{display}\n"); + for hunk in diff.unified_diff().context_radius(3).iter_hunks() { + let h = hunk.to_string(); + if out.len() + h.len() > MAX_DIFF_BYTES { + out.push_str("\n[diff truncated]\n"); + break; + } + out.push_str(&h); + } + out +} + +fn truncate(s: &str, max_chars: usize) -> String { + if s.chars().count() <= max_chars { + s.to_string() + } else { + let head: String = s.chars().take(max_chars).collect(); + format!("{head}…") + } +} + +fn truncate_str(s: &str, max: usize) -> &str { + if s.len() <= max { + return s; + } + let mut cut = max; + while cut > 0 && !s.is_char_boundary(cut) { + cut -= 1; + } + &s[..cut] +} + +fn similarity(a: &str, b: &str) -> f64 { + if a == b { + return 1.0; + } + if a.is_empty() || b.is_empty() { + return 0.0; + } + const MAX: usize = 512; + let a = truncate_str(a, MAX); + let b = truncate_str(b, MAX); + let matched: usize = TextDiff::from_chars(a, b) + .ops() + .iter() + .filter(|op| matches!(op.tag(), DiffTag::Equal)) + .map(|op| op.new_range().len()) + .sum(); + matched as f64 / a.len().max(b.len()) as f64 +} + +fn nearest_line_hint(content: &str, pattern: &str) -> Option { + let first = pattern.lines().next()?.trim(); + if first.is_empty() { + return None; + } + let best = content + .lines() + .take(HINT_SCAN_LINE_LIMIT) + .enumerate() + .map(|(i, line)| (i, similarity(line.trim(), first), line)) + .filter(|(_, s, _)| *s > 0.6) + .max_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal))?; + Some(format!( + "Hint: nearest match around line {} (similarity {:.2}):\n found: {:?}\n expected: {:?}", + best.0 + 1, + best.1, + best.2.trim(), + first + )) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::fs; + use tempfile::tempdir; + + #[test] + fn count_occurrences_capped_returns_0_1_2() { + assert_eq!(count_occurrences_capped("hello world", "x"), 0); + assert_eq!(count_occurrences_capped("hello world", "hello"), 1); + assert_eq!(count_occurrences_capped("a a a a a", "a"), 2); // capped at 2 + assert_eq!(count_occurrences_capped("abc", ""), 0); + } + + #[test] + fn resolve_within_rejects_escape() { + let dir = tempdir().expect("tempdir"); + let inside = dir.path().join("file.txt"); + fs::write(&inside, b"x").expect("write"); + // Symlink targeting outside the dir should be rejected. + #[cfg(unix)] + { + let outside = std::env::temp_dir().join("sprout-mcp-escape-target"); + let _ = fs::remove_file(&outside); + fs::write(&outside, b"y").expect("write outside"); + let link = dir.path().join("link.txt"); + std::os::unix::fs::symlink(&outside, &link).expect("symlink"); + let err = resolve_within(dir.path(), "link.txt").unwrap_err(); + assert!(err.contains("escapes workspace"), "got: {err}"); + let _ = fs::remove_file(&outside); + } + // Resolves a normal path inside. + let p = resolve_within(dir.path(), "file.txt").expect("resolve"); + assert!(p.ends_with("file.txt")); + } + + fn make_state(cwd: &std::path::Path) -> SharedState { + let shim = crate::shim::Shim::install().expect("shim install"); + SharedState::new(cwd.to_path_buf(), shim).expect("state new") + } + + #[test] + fn run_basic_replace_emits_diff() { + let dir = tempdir().expect("tempdir"); + let f = dir.path().join("a.txt"); + fs::write(&f, "alpha\nbeta\ngamma\n").expect("write"); + let state = make_state(dir.path()); + let p = StrReplaceParams { + path: "a.txt".into(), + old_str: "beta".into(), + new_str: "BETA".into(), + workdir: Some(dir.path().display().to_string()), + }; + let out = run(&state, p).expect("ok"); + assert!(out.contains("Replaced 1 occurrence"), "out: {out}"); + assert!(out.contains("-beta"), "out: {out}"); + assert!(out.contains("+BETA"), "out: {out}"); + let contents = fs::read_to_string(&f).expect("read"); + assert_eq!(contents, "alpha\nBETA\ngamma\n"); + } + + #[test] + fn run_rejects_path_outside_workspace() { + let dir = tempdir().expect("tempdir"); + let state = make_state(dir.path()); + let p = StrReplaceParams { + path: "/etc/hosts".into(), + old_str: "x".into(), + new_str: "y".into(), + workdir: Some(dir.path().display().to_string()), + }; + let err = run(&state, p).unwrap_err(); + let msg = format!("{err:?}"); + assert!( + msg.contains("escapes workspace") || msg.contains("not accessible"), + "msg: {msg}" + ); + } + + #[test] + fn run_rejects_file_too_large() { + let dir = tempdir().expect("tempdir"); + let f = dir.path().join("big.bin"); + let big = vec![b'a'; (MAX_FILE_BYTES as usize) + 1024]; + fs::write(&f, &big).expect("write"); + let state = make_state(dir.path()); + let p = StrReplaceParams { + path: "big.bin".into(), + old_str: "a".into(), + new_str: "b".into(), + workdir: Some(dir.path().display().to_string()), + }; + let err = run(&state, p).unwrap_err(); + let msg = format!("{err:?}"); + assert!(msg.contains("too large"), "msg: {msg}"); + } +} diff --git a/crates/sprout-dev-mcp/src/todo.rs b/crates/sprout-dev-mcp/src/todo.rs new file mode 100644 index 0000000000..de218356f2 --- /dev/null +++ b/crates/sprout-dev-mcp/src/todo.rs @@ -0,0 +1,558 @@ +//! In-memory session task list with `_Stop` and `_PostCompact` hooks. +//! +//! - `todo`: read or full-list-replace the task list. Empty args reads. +//! - `_Stop`: hook called by the agent before honoring end_turn. Returns +//! objection text if any items are open, empty otherwise. +//! - `_PostCompact`: hook called after context compaction/handoff. +//! Returns the full list state so the agent can re-inject it. +//! +//! State is per-process (Vec behind a Mutex). Items are +//! `{text, done}` — no ids; the LLM provides a full replacement list. +//! On replacement, open items that disappear without being marked done +//! trigger a soft warning appended to the tool response. + +use rmcp::model::{CallToolResult, Content}; +use rmcp::ErrorData; +use schemars::JsonSchema; +use serde::Deserialize; +use std::sync::Mutex; + +const MAX_ITEMS: usize = 50; +const MAX_TEXT_CHARS: usize = 200; + +#[derive(Debug, Clone, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct Item { + #[schemars(length(min = 1, max = 200))] + pub text: String, + #[serde(default)] + pub done: bool, +} + +#[derive(Debug, Deserialize, JsonSchema)] +#[serde(deny_unknown_fields)] +pub struct TodoParams { + /// Full replacement list (max 50 items). Omit to read. + // Both omitted and explicit null mean "read current state". + #[serde(default)] + #[schemars(length(max = 50))] + pub todos: Option>, +} + +/// Empty params struct for the hook tools. Hooks take no arguments but +/// rmcp requires Parameters for the macro. +#[derive(Debug, Deserialize, JsonSchema, Default)] +#[serde(deny_unknown_fields)] +pub struct HookParams {} + +#[derive(Debug, Default)] +pub struct TodoState { + items: Mutex>, +} + +impl TodoState { + pub fn new() -> Self { + Self::default() + } + + fn with_items(&self, f: impl FnOnce(&mut Vec) -> R) -> R { + let mut g = match self.items.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + f(&mut g) + } + + /// Replace-or-read. Returns the rendered list, with a warning + /// appended if open items were silently removed. Mutation, warning + /// computation, and rendering all occur under a single lock hold so + /// that the rendered output reflects exactly the list that was just + /// written (no interleaving with concurrent calls). + pub fn handle_todo(&self, params: TodoParams) -> Result { + if let Some(mut new_items) = params.todos { + validate(&new_items)?; + // Normalize: trim text on storage so duplicate detection + // and silent-removal diff operate on the same canonical + // form. Validation already rejected empty-after-trim text, + // duplicates after trim, and invalid characters. + for it in &mut new_items { + it.text = it.text.trim().to_owned(); + } + return Ok(self.with_items(|items| { + let warning = silent_removal_warning(items, &new_items); + *items = new_items; + let mut out = render_items(items); + if !warning.is_empty() { + out.push('\n'); + out.push_str(&warning); + } + out + })); + } + Ok(self.render()) + } + + pub fn render(&self) -> String { + self.with_items(|items| render_items(items)) + } + + /// Objection text if open items exist, empty string otherwise. + pub fn stop_objection(&self) -> String { + self.with_items(|items| { + if items.iter().any(|i| !i.done) { + format!( + "You have open todo items. Keep working.\n\n{}", + render_items(items) + ) + } else { + String::new() + } + }) + } + + /// Re-injection block for after a handoff. Empty if no items. + pub fn post_compact(&self) -> String { + self.with_items(|items| { + if items.is_empty() { + String::new() + } else { + format!("# Todo List\n{}", render_items(items)) + } + }) + } +} + +/// Reject characters that aren't safe for single-line display. ASCII +/// space is the only whitespace allowed; everything else (control +/// chars, exotic whitespace, bidi/format chars, zero-width joiners, +/// BOM) could be used to spoof the rendered todo output. +fn invalid_text_char(c: char) -> bool { + if c.is_control() { + return true; + } + if c.is_whitespace() && c != ' ' { + // Catches \t, \n, \r, NBSP (\u{00A0}), line/paragraph + // separators (\u{2028}, \u{2029}), and other exotic spaces. + return true; + } + matches!(c, + '\u{200B}'..='\u{200F}' // zero-width spaces, LRM/RLM + | '\u{202A}'..='\u{202E}' // bidi embedding/override + | '\u{2060}'..='\u{206F}' // word joiner, invisible operators + | '\u{FEFF}' // BOM / zero-width no-break space + ) +} + +fn validate(items: &[Item]) -> Result<(), String> { + if items.len() > MAX_ITEMS { + return Err(format!("too many items (max {MAX_ITEMS})")); + } + for (i, it) in items.iter().enumerate() { + if it.text.trim().is_empty() { + return Err(format!("item {}: text is empty", i + 1)); + } + if it.text.trim().chars().count() > MAX_TEXT_CHARS { + return Err(format!( + "item {}: text exceeds {MAX_TEXT_CHARS} characters", + i + 1 + )); + } + // Reject control characters and Unicode trickery that could + // spoof the rendered output: line/paragraph separators, bidi + // overrides, zero-width joiners, BOM, etc. See + // `invalid_text_char` for the full list. + if it.text.chars().any(invalid_text_char) { + return Err(format!("item {}: text contains invalid characters", i + 1)); + } + } + // Reject duplicates (after trim). Without ids, duplicates are + // ambiguous: silent-removal detection can't tell which copy was + // dropped, and they have no semantic meaning anyway. + for (i, a) in items.iter().enumerate() { + let a_trim = a.text.trim(); + for (j, b) in items.iter().enumerate().skip(i + 1) { + if a_trim == b.text.trim() { + return Err(format!( + "item {}: duplicate text (matches item {})", + j + 1, + i + 1 + )); + } + } + } + Ok(()) +} + +/// Diff old vs new. Any open (`!done`) item in `old` whose `text` is +/// absent from `new` (any position, any done-state) is "silently +/// removed". Returns the warning string, or empty if none. +/// +/// `validate()` rejects duplicate text in `new`, so a `HashSet` lookup +/// is unambiguous here. +fn silent_removal_warning(old: &[Item], new: &[Item]) -> String { + let new_texts: std::collections::HashSet<&str> = new.iter().map(|i| i.text.as_str()).collect(); + let removed: Vec<&str> = old + .iter() + .filter(|i| !i.done && !new_texts.contains(i.text.as_str())) + .map(|i| i.text.as_str()) + .collect(); + if removed.is_empty() { + return String::new(); + } + let mut out = format!( + "⚠️ {} open item{} {} removed from the list:\n", + removed.len(), + if removed.len() == 1 { "" } else { "s" }, + if removed.len() == 1 { "was" } else { "were" }, + ); + for t in &removed { + out.push_str(" - \""); + out.push_str(t); + out.push_str("\"\n"); + } + out.push_str( + "If accidental, re-add them. If they are complete, prefer marking items done before removing.", + ); + out +} + +fn render_items(items: &[Item]) -> String { + if items.is_empty() { + return "(todo list is empty)".into(); + } + let next = items.iter().position(|i| !i.done); + let mut out = String::with_capacity(64 * items.len()); + for (i, it) in items.iter().enumerate() { + let box_ = if it.done { "[x]" } else { "[ ]" }; + out.push_str(box_); + out.push(' '); + out.push_str(&(i + 1).to_string()); + out.push_str(". "); + out.push_str(&it.text); + if Some(i) == next { + out.push_str(" ← next"); + } + out.push('\n'); + } + out +} + +/// Wrap a string result as an MCP CallToolResult with text content. +pub fn text_result(s: String) -> Result { + Ok(CallToolResult::success(vec![Content::text(s)])) +} + +/// Wrap an error string as an MCP CallToolResult with isError=true. +pub fn error_result(s: String) -> Result { + Ok(CallToolResult::error(vec![Content::text(s)])) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn mk(items: &[(&str, bool)]) -> Vec { + items + .iter() + .map(|(text, done)| Item { + text: (*text).to_owned(), + done: *done, + }) + .collect() + } + + fn write(s: &TodoState, items: &[(&str, bool)]) -> String { + s.handle_todo(TodoParams { + todos: Some(mk(items)), + }) + .unwrap() + } + + #[test] + fn empty_read_returns_placeholder() { + let s = TodoState::new(); + let out = s.handle_todo(TodoParams { todos: None }).unwrap(); + assert!(out.contains("empty")); + } + + #[test] + fn rejects_empty_text() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[(" ", false)])), + }) + .unwrap_err(); + assert!(err.contains("text is empty")); + } + + #[test] + fn rejects_too_many_items() { + let s = TodoState::new(); + let many: Vec = (0..=MAX_ITEMS) + .map(|i| Item { + text: format!("item {i}"), + done: false, + }) + .collect(); + let err = s.handle_todo(TodoParams { todos: Some(many) }).unwrap_err(); + assert!(err.contains("too many items")); + } + + #[test] + fn rejects_duplicate_text() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[("same", false), ("same", true)])), + }) + .unwrap_err(); + assert!(err.contains("duplicate"), "got: {err}"); + } + + #[test] + fn rejects_duplicate_text_after_trim() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[("foo", false), (" foo ", false)])), + }) + .unwrap_err(); + assert!(err.contains("duplicate"), "got: {err}"); + } + + #[test] + fn rejects_control_characters() { + let s = TodoState::new(); + let err_n = s + .handle_todo(TodoParams { + todos: Some(mk(&[("line1\nline2", false)])), + }) + .unwrap_err(); + assert!(err_n.contains("invalid characters"), "got: {err_n}"); + let err_t = s + .handle_todo(TodoParams { + todos: Some(mk(&[("col1\tcol2", false)])), + }) + .unwrap_err(); + assert!(err_t.contains("invalid characters"), "got: {err_t}"); + } + + #[test] + fn rejects_unicode_line_separator() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[("line1\u{2028}line2", false)])), + }) + .unwrap_err(); + assert!(err.contains("invalid characters"), "got: {err}"); + } + + #[test] + fn rejects_bidi_override() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[("text\u{202E}reversed", false)])), + }) + .unwrap_err(); + assert!(err.contains("invalid characters"), "got: {err}"); + } + + #[test] + fn rejects_zero_width() { + let s = TodoState::new(); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[("zero\u{200B}width", false)])), + }) + .unwrap_err(); + assert!(err.contains("invalid characters"), "got: {err}"); + } + + #[test] + fn leading_trailing_spaces_trimmed_on_store() { + let s = TodoState::new(); + // Send with surrounding spaces. + let out = write(&s, &[(" task ", false)]); + // Render should have the trimmed form; no leading/trailing + // spaces in the displayed text. + assert!(out.contains("[ ] 1. task")); + assert!(!out.contains(" task ")); + // A subsequent write of the trimmed form should not falsely + // warn that " task " was silently removed: stored text is + // already "task", so the diff sees no removal. + let out2 = write(&s, &[("task", false)]); + assert!(!out2.contains("⚠️"), "unexpected warning: {out2}"); + } + + #[test] + fn hook_params_rejects_unknown_fields() { + let res: Result = serde_json::from_str(r#"{"x":1}"#); + assert!(res.is_err(), "expected error, got: {res:?}"); + // Sanity: empty object parses. + let ok: Result = serde_json::from_str("{}"); + assert!(ok.is_ok(), "expected ok, got: {ok:?}"); + } + + #[test] + fn explicit_null_is_read() { + let s = TodoState::new(); + write(&s, &[("alpha", false)]); + // {"todos": null} should behave the same as omitting the field + // — return the current list, don't clear it. + let params: TodoParams = serde_json::from_str(r#"{"todos":null}"#).unwrap(); + let out = s.handle_todo(params).unwrap(); + assert!(out.contains("[ ] 1. alpha"), "got: {out}"); + // Verify state was not mutated. + let again = s.render(); + assert!(again.contains("[ ] 1. alpha")); + } + + #[test] + fn rejects_unknown_fields() { + // Unknown field on Item: `complete` instead of `done`. + let bad_item = r#"{"todos":[{"text":"x","complete":true}]}"#; + let res: Result = serde_json::from_str(bad_item); + assert!(res.is_err(), "expected error, got: {res:?}"); + // Unknown field on TodoParams. + let bad_root = r#"{"todos":[{"text":"x"}],"extra":1}"#; + let res: Result = serde_json::from_str(bad_root); + assert!(res.is_err(), "expected error, got: {res:?}"); + // Sanity: the correct shape parses. + let good = r#"{"todos":[{"text":"x","done":true}]}"#; + let res: Result = serde_json::from_str(good); + assert!(res.is_ok(), "expected ok, got: {res:?}"); + } + + #[test] + fn text_at_boundary_200_chars() { + let s = TodoState::new(); + let exactly_200 = "a".repeat(200); + let out = s + .handle_todo(TodoParams { + todos: Some(mk(&[(exactly_200.as_str(), false)])), + }) + .unwrap(); + assert!(out.contains(&exactly_200)); + let too_long = "b".repeat(201); + let err = s + .handle_todo(TodoParams { + todos: Some(mk(&[(too_long.as_str(), false)])), + }) + .unwrap_err(); + assert!(err.contains("exceeds"), "got: {err}"); + } + + #[test] + fn write_and_render_atomic() { + // The string returned from a write must reflect the list that + // was just written, not stale state. With the atomic + // implementation the response IS computed under the same lock + // hold as the mutation, so this is guaranteed structurally; we + // assert the contract. + let s = TodoState::new(); + let out = write(&s, &[("alpha", false), ("beta", true)]); + assert!(out.contains("[ ] 1. alpha")); + assert!(out.contains("[x] 2. beta")); + // And a subsequent render returns the same content. + let again = s.render(); + assert!(again.contains("[ ] 1. alpha")); + assert!(again.contains("[x] 2. beta")); + } + + #[test] + fn render_uses_position_numbers() { + let s = TodoState::new(); + let out = write(&s, &[("first", true), ("second", false), ("third", false)]); + assert!(out.contains("[x] 1. first")); + assert!(out.contains("[ ] 2. second")); + assert!(out.contains("[ ] 3. third")); + // ← next on first open item + assert!(out.contains("[ ] 2. second ← next")); + assert!(!out.contains("[ ] 3. third ← next")); + } + + #[test] + fn stop_returns_objection_when_open_items_exist() { + let s = TodoState::new(); + write(&s, &[("a", false), ("b", false)]); + let obj = s.stop_objection(); + assert!(!obj.is_empty(), "expected non-empty objection"); + assert!(obj.contains("open todo items")); + assert!(obj.contains("a")); + } + + #[test] + fn stop_returns_empty_when_all_done() { + let s = TodoState::new(); + write(&s, &[("a", true), ("b", true)]); + assert_eq!(s.stop_objection(), ""); + } + + #[test] + fn stop_returns_empty_when_list_is_empty() { + assert_eq!(TodoState::new().stop_objection(), ""); + } + + #[test] + fn post_compact_renders_when_populated() { + let s = TodoState::new(); + write(&s, &[("a", false)]); + let block = s.post_compact(); + assert!(block.starts_with("# Todo List\n")); + } + + #[test] + fn post_compact_empty_when_no_items() { + assert_eq!(TodoState::new().post_compact(), ""); + } + + #[test] + fn silent_removal_warns() { + let s = TodoState::new(); + write( + &s, + &[ + ("fix the flaky test", false), + ("write unit tests", false), + ("ship it", false), + ], + ); + let out = write(&s, &[("ship it", false)]); + assert!(out.contains("⚠️"), "expected warning, got: {out}"); + assert!(out.contains("2 open items")); + assert!(out.contains("\"fix the flaky test\"")); + assert!(out.contains("\"write unit tests\"")); + } + + #[test] + fn removal_of_done_item_no_warning() { + let s = TodoState::new(); + write(&s, &[("done thing", true), ("open thing", false)]); + let out = write(&s, &[("open thing", false)]); + assert!(!out.contains("⚠️"), "unexpected warning: {out}"); + } + + #[test] + fn rename_triggers_warning() { + let s = TodoState::new(); + write(&s, &[("write tests", false)]); + let out = write(&s, &[("write unit tests", false)]); + assert!(out.contains("⚠️"), "expected warning, got: {out}"); + assert!(out.contains("\"write tests\"")); + } + + #[test] + fn marking_done_then_removing_no_warning() { + let s = TodoState::new(); + write(&s, &[("task", false)]); + // Mark done. + let out1 = write(&s, &[("task", true)]); + assert!(!out1.contains("⚠️")); + // Now remove (it's done in the prior list). + let out2 = write(&s, &[]); + assert!(!out2.contains("⚠️"), "unexpected warning: {out2}"); + } +} diff --git a/crates/sprout-dev-mcp/src/tree.rs b/crates/sprout-dev-mcp/src/tree.rs new file mode 100644 index 0000000000..cfa114de7b --- /dev/null +++ b/crates/sprout-dev-mcp/src/tree.rs @@ -0,0 +1,184 @@ +use std::io::Write; +use std::path::{Path, PathBuf}; + +use ignore::WalkBuilder; + +const MAX_OUTPUT_BYTES: usize = 50 * 1024; +const MAX_OUTPUT_LINES: usize = 2000; +const MAX_WALK_DEPTH: usize = 50; +const MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; + +struct Frame { + depth: usize, + out_idx: usize, + total: usize, + leaf: bool, +} + +pub fn run(args: Vec) -> i32 { + let (root, max_depth) = match parse(args) { + Ok(v) => v, + Err(e) => { + tracing::error!("tree: {e}"); + return 2; + } + }; + if !root.is_dir() { + tracing::error!("tree: not a directory: {}", root.display()); + return 2; + } + + let mut out: Vec = Vec::new(); + let mut stack: Vec = vec![Frame { + depth: 0, + out_idx: 0, + total: 0, + leaf: false, + }]; + let line_budget = MAX_OUTPUT_LINES.saturating_sub(1); + let mut truncated = false; + + let mut builder = WalkBuilder::new(&root); + builder.git_ignore(true); + builder.git_exclude(true); + builder.git_global(true); + builder.require_git(false); + builder.ignore(true); + builder.hidden(true); + builder.max_depth(Some(max_depth)); + builder.sort_by_file_name(|a, b| a.cmp(b)); + + for entry in builder.build().flatten() { + if entry.depth() == 0 { + continue; + } + let depth = entry.depth(); + let is_dir = entry.file_type().is_some_and(|t| t.is_dir()); + let is_file = entry.file_type().is_some_and(|t| t.is_file()); + + while stack.last().is_some_and(|f| f.depth >= depth) { + let Some(frame) = stack.pop() else { break }; + if let Some(parent) = stack.last_mut() { + parent.total = parent.total.saturating_add(frame.total); + } + if frame.depth > 0 && !frame.leaf { + let placeholder = &mut out[frame.out_idx]; + *placeholder = format!("{} [{}]", placeholder, frame.total); + } + } + + if out.len() >= line_budget { + truncated = true; + break; + } + + let prefix = " ".repeat(depth - 1); + let name = entry.file_name().to_string_lossy(); + + if is_dir { + let idx = out.len(); + out.push(format!("{prefix}{name}/")); + stack.push(Frame { + depth, + out_idx: idx, + total: 0, + leaf: depth == max_depth, + }); + } else if is_file { + let lc = line_count(entry.path()); + if let Some(parent) = stack.last_mut() { + parent.total = parent.total.saturating_add(lc); + } + out.push(format!("{prefix}{name} [{lc}]")); + } + } + + let mut grand_total = 0usize; + while let Some(frame) = stack.pop() { + if let Some(parent) = stack.last_mut() { + parent.total = parent.total.saturating_add(frame.total); + } + if frame.depth == 0 { + grand_total = frame.total; + } else if !frame.leaf { + let placeholder = &mut out[frame.out_idx]; + *placeholder = format!("{} [{}]", placeholder, frame.total); + } + } + + let root_name = root + .file_name() + .unwrap_or(root.as_os_str()) + .to_string_lossy(); + + let stdout = std::io::stdout(); + let mut w = stdout.lock(); + if writeln!(w, "{root_name}/ [{grand_total}]").is_err() { + return 0; + } + + let mut bytes = 0usize; + for line in &out { + if bytes + line.len() + 1 > MAX_OUTPUT_BYTES { + let _ = writeln!(w, "[truncated]"); + return 0; + } + if writeln!(w, "{line}").is_err() { + return 0; + } + bytes += line.len() + 1; + } + if truncated { + let _ = writeln!(w, "[truncated]"); + } + 0 +} + +fn parse(args: Vec) -> Result<(PathBuf, usize), String> { + let mut depth = MAX_WALK_DEPTH; + let mut path = PathBuf::from("."); + let mut path_set = false; + let mut iter = args.into_iter(); + while let Some(a) = iter.next() { + match a.as_str() { + "--" => { + if let Some(p) = iter.next() { + if path_set { + return Err("multiple paths not supported".to_string()); + } + path = PathBuf::from(p); + } + break; + } + "-d" | "--depth" => { + let n = iter.next().ok_or("missing value for --depth")?; + depth = n.parse::().map_err(|_| format!("bad depth: {n}"))?; + } + s if s.starts_with('-') => return Err(format!("unknown flag: {s}")), + _ => { + if path_set { + return Err("multiple paths not supported".to_string()); + } + path = PathBuf::from(a); + path_set = true; + } + } + } + Ok((path, depth.min(MAX_WALK_DEPTH))) +} + +fn line_count(path: &Path) -> usize { + std::fs::metadata(path) + .ok() + .filter(|m| m.is_file() && m.len() <= MAX_FILE_BYTES) + .and_then(|_| std::fs::read(path).ok()) + .map(|b| { + if b.is_empty() { + 0 + } else { + b.iter().filter(|&&c| c == b'\n').count() + + if b.last() != Some(&b'\n') { 1 } else { 0 } + } + }) + .unwrap_or(0) +} diff --git a/crates/sprout-mcp/Cargo.toml b/crates/sprout-mcp/Cargo.toml index 35938d9e12..9db50f8ac5 100644 --- a/crates/sprout-mcp/Cargo.toml +++ b/crates/sprout-mcp/Cargo.toml @@ -38,6 +38,12 @@ reqwest = { workspace = true } # TLS crypto provider (required for wss:// connections) rustls = { version = "0.23", default-features = false, features = ["ring", "std"] } +# Crypto +sha2 = { workspace = true } +hex = { workspace = true } +base64 = "0.22" +infer = "0.19" + # Utilities uuid = { workspace = true } tracing = { workspace = true } diff --git a/crates/sprout-mcp/src/lib.rs b/crates/sprout-mcp/src/lib.rs index d0824a0756..2efa713a45 100644 --- a/crates/sprout-mcp/src/lib.rs +++ b/crates/sprout-mcp/src/lib.rs @@ -9,13 +9,13 @@ //! //! `sprout-mcp` runs as a stdio MCP server. An agent host (e.g. Claude Desktop, Goose) //! launches it as a subprocess and communicates over JSON-RPC on stdin/stdout. The server -//! maintains a persistent, authenticated WebSocket connection to a Sprout relay and a shared -//! HTTP client for REST API calls. +//! maintains a persistent, authenticated WebSocket connection to a Sprout relay. All reads +//! use Nostr REQ/EOSE queries; all writes publish signed Nostr events. //! //! ```text //! ┌─────────────┐ JSON-RPC (stdio) ┌──────────────┐ NIP-42 WebSocket ┌───────────────┐ //! │ Agent Host │ ◄─────────────────► │ sprout-mcp │ ◄─────────────────► │ Sprout Relay │ -//! └─────────────┘ └──────────────┘ REST (reqwest) └───────────────┘ +//! └─────────────┘ └──────────────┘ HTTP (media only) └───────────────┘ //! ``` //! //! ## Connecting to the Relay @@ -26,7 +26,7 @@ //! |----------------------|--------------------------|--------------------------------------------------| //! | `SPROUT_RELAY_URL` | `ws://localhost:3000` | WebSocket URL of the Sprout relay | //! | `SPROUT_PRIVATE_KEY` | *(generated)* | `nsec…` Nostr private key for the agent identity | -//! | `SPROUT_API_TOKEN` | *(none)* | Bearer token for REST auth (production mode) | +//! | `SPROUT_API_TOKEN` | *(none)* | Auth token embedded in NIP-42 handshake | //! //! If `SPROUT_PRIVATE_KEY` is absent a fresh ephemeral keypair is generated and its public key //! is printed to stderr. In production you should supply a stable key so the agent has a @@ -81,10 +81,11 @@ //! - **`join_channel`** / **`leave_channel`** — Membership management. //! - **`update_channel`** / **`set_channel_topic`** / **`set_channel_purpose`** — Metadata. //! - **`open_dm`** — Open a direct-message channel. +//! - **`list_channel_members`** — List members of a channel. //! //! ### Channel Admin (`channel_admin` toolset) //! - **`create_channel`** / **`archive_channel`** / **`unarchive_channel`** -//! - **`add_channel_member`** / **`remove_channel_member`** / **`list_channel_members`** +//! - **`add_channel_member`** / **`remove_channel_member`** //! //! ### Canvas (`canvas` toolset) //! - **`get_canvas`** — Retrieve the shared canvas document for a channel. @@ -125,3 +126,5 @@ pub mod relay_client; pub mod server; /// Toolset definitions and configuration for organizing MCP tools. pub mod toolsets; +/// File upload to the Sprout relay (Blossom protocol). +pub mod upload; diff --git a/crates/sprout-mcp/src/main.rs b/crates/sprout-mcp/src/main.rs index a8729cd091..c9b01bb6cb 100644 --- a/crates/sprout-mcp/src/main.rs +++ b/crates/sprout-mcp/src/main.rs @@ -1,5 +1,5 @@ use anyhow::Result; -use nostr::Keys; +use nostr::{Keys, Tag}; use rmcp::{transport::stdio, ServiceExt}; use tracing_subscriber::EnvFilter; @@ -7,6 +7,42 @@ use sprout_mcp::relay_client::RelayClient; use sprout_mcp::server::SproutMcpServer; use sprout_mcp::toolsets::ToolsetConfig; +/// Parse and validate the NIP-OA auth tag from the environment. +/// +/// Returns `Ok(Some(tag))` if a valid auth tag is configured, +/// `Ok(None)` if no auth tag is set (or empty), +/// `Err` if the tag is present but malformed, invalid, or non-Unicode. +fn resolve_auth_tag( + env_value: Result, + agent_pubkey: &nostr::PublicKey, +) -> anyhow::Result> { + let tag_json = match env_value { + Ok(s) if !s.is_empty() => s, + Ok(_) => return Ok(None), // empty string — treat as not set + Err(std::env::VarError::NotPresent) => return Ok(None), + Err(std::env::VarError::NotUnicode(_)) => { + anyhow::bail!("SPROUT_AUTH_TAG contains non-Unicode data"); + } + }; + + let tag = sprout_sdk::nip_oa::parse_auth_tag(&tag_json) + .map_err(|e| anyhow::anyhow!("SPROUT_AUTH_TAG is malformed: {e}"))?; + + sprout_sdk::nip_oa::verify_auth_tag(&tag_json, agent_pubkey).map_err(|e| { + anyhow::anyhow!( + "SPROUT_AUTH_TAG signature verification failed for agent pubkey {}: {e}", + agent_pubkey.to_hex() + ) + })?; + + eprintln!( + "sprout-mcp: NIP-OA auth tag configured (owner: {})", + tag.as_slice().get(1).map(|s| &s[..8]).unwrap_or("?") + ); + + Ok(Some(tag)) +} + #[tokio::main] async fn main() -> Result<()> { // Install the ring crypto provider for rustls (required for wss:// connections). @@ -36,11 +72,17 @@ async fn main() -> Result<()> { } }; + let auth_tag = resolve_auth_tag(std::env::var("SPROUT_AUTH_TAG"), &keys.public_key())?; + + if auth_tag.is_some() { + eprintln!("sprout-mcp: NIP-OA auth tag verified ✓"); + } + let toolset_config = ToolsetConfig::from_env(); eprintln!("sprout-mcp: toolsets: {:?}", toolset_config); eprintln!("sprout-mcp: connecting to relay at {relay_url}..."); - let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref()).await?; + let client = RelayClient::connect(&relay_url, &keys, api_token.as_deref(), auth_tag).await?; eprintln!("sprout-mcp: connected and authenticated."); let tools_to_remove = toolset_config.tools_to_remove(); @@ -50,3 +92,70 @@ async fn main() -> Result<()> { Ok(()) } + +#[cfg(test)] +mod tests { + use super::*; + + fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { + let owner_keys = nostr::Keys::generate(); + sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_keys.public_key(), "").unwrap() + } + + #[test] + fn resolve_auth_tag_valid() { + let agent_keys = nostr::Keys::generate(); + let json = make_valid_auth_tag(&agent_keys); + let result = resolve_auth_tag(Ok(json), &agent_keys.public_key()); + assert!(result.is_ok()); + assert!(result.unwrap().is_some()); + } + + #[test] + fn resolve_auth_tag_not_present() { + let keys = nostr::Keys::generate(); + let result = resolve_auth_tag(Err(std::env::VarError::NotPresent), &keys.public_key()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn resolve_auth_tag_empty_string() { + let keys = nostr::Keys::generate(); + let result = resolve_auth_tag(Ok(String::new()), &keys.public_key()); + assert!(result.unwrap().is_none()); + } + + #[test] + fn resolve_auth_tag_malformed_json() { + let keys = nostr::Keys::generate(); + let result = resolve_auth_tag(Ok("not json".into()), &keys.public_key()); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("malformed")); + } + + #[test] + fn resolve_auth_tag_bad_signature() { + let agent_keys = nostr::Keys::generate(); + // Structurally valid (4 elements, correct hex lengths) but cryptographically wrong. + let fake_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); + let result = resolve_auth_tag(Ok(fake_json), &agent_keys.public_key()); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("verification failed")); + } + + #[test] + fn resolve_auth_tag_non_unicode() { + let keys = nostr::Keys::generate(); + let result = resolve_auth_tag( + Err(std::env::VarError::NotUnicode(std::ffi::OsString::from( + "bad", + ))), + &keys.public_key(), + ); + assert!(result.is_err()); + assert!(result.unwrap_err().to_string().contains("non-Unicode")); + } +} diff --git a/crates/sprout-mcp/src/relay_client.rs b/crates/sprout-mcp/src/relay_client.rs index 943f5f75ec..bc7a08843f 100644 --- a/crates/sprout-mcp/src/relay_client.rs +++ b/crates/sprout-mcp/src/relay_client.rs @@ -221,6 +221,7 @@ async fn do_connect( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result { let parsed = relay_url .parse::() @@ -236,7 +237,7 @@ async fn do_connect( // Wait for AUTH challenge (5s timeout). let challenge = wait_for_auth_challenge(&mut ws, Duration::from_secs(5)).await?; - let auth_event = build_auth_event(&challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(&challenge, relay_url, keys, api_token, auth_tag)?; let event_id = auth_event.id.to_hex(); debug!("sending AUTH event {event_id}"); let auth_msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; @@ -318,18 +319,22 @@ async fn wait_for_ok( } /// Build a NIP-42 AUTH event for the given challenge. +/// +/// If `auth_tag` is provided (NIP-OA owner attestation), it is included in the +/// AUTH event so the relay can use it for membership delegation fallback. #[allow(clippy::result_large_err)] fn build_auth_event( challenge: &str, relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> Result { let relay_nostr_url: Url = relay_url .parse() .map_err(|e: url::ParseError| RelayClientError::Url(e.to_string()))?; if let Some(token) = api_token { - let tags = vec![ + let mut tags = vec![ Tag::parse(&["relay", relay_url]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, Tag::parse(&["challenge", challenge]) @@ -337,6 +342,19 @@ fn build_auth_event( Tag::parse(&["auth_token", token]) .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, ]; + if let Some(t) = auth_tag { + tags.push(t.clone()); + } + Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) + } else if let Some(t) = auth_tag { + // Cannot use EventBuilder::auth() shortcut — it doesn't accept extra tags. + let tags = vec![ + Tag::parse(&["relay", relay_url]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + Tag::parse(&["challenge", challenge]) + .map_err(|e| RelayClientError::EventBuilder(e.to_string()))?, + t.clone(), + ]; Ok(EventBuilder::new(Kind::Authentication, "", tags).sign_with_keys(keys)?) } else { Ok(EventBuilder::auth(challenge, relay_nostr_url).sign_with_keys(keys)?) @@ -353,9 +371,10 @@ async fn send_auth_response( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) { let result: Result<(), RelayClientError> = async { - let auth_event = build_auth_event(challenge, relay_url, keys, api_token)?; + let auth_event = build_auth_event(challenge, relay_url, keys, api_token, auth_tag)?; let msg = serde_json::to_string(&json!(["AUTH", auth_event]))?; ws.send(Message::Text(msg.into())).await?; debug!("sent AUTH response for mid-session challenge"); @@ -377,6 +396,7 @@ async fn handle_ws_message( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> bool { match msg { Message::Text(text) => { @@ -429,7 +449,7 @@ async fn handle_ws_message( } RelayMessage::Auth { challenge } => { debug!("received mid-session AUTH challenge — re-authenticating"); - send_auth_response(ws, &challenge, relay_url, keys, api_token).await; + send_auth_response(ws, &challenge, relay_url, keys, api_token, auth_tag).await; } } true @@ -463,13 +483,14 @@ async fn do_reconnect( keys: &Keys, relay_url: &str, api_token: Option<&str>, + auth_tag: Option<&Tag>, ) -> bool { warn!("relay connection lost — reconnecting…"); state.cancel_pending(); let mut delay = Duration::from_secs(1); loop { - match do_connect(relay_url, keys, api_token).await { + match do_connect(relay_url, keys, api_token, auth_tag).await { Ok(new_ws) => { tracing::info!("reconnected to relay at {relay_url}"); *ws = new_ws; @@ -544,6 +565,7 @@ async fn run_background_task( keys: Keys, relay_url: String, api_token: Option, + auth_tag: Option, ) { let mut state = BgState::new(); // Ticker for expiring timed-out pending operations (~1s granularity). @@ -557,14 +579,14 @@ async fn run_background_task( let needs_reconnect = match raw { Some(Ok(msg)) => { !handle_ws_message( - msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), + msg, &mut ws, &mut state, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref(), ).await } Some(Err(e)) => { warn!("WebSocket error: {e}"); true } None => { debug!("WebSocket stream ended"); true } }; if needs_reconnect - && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await + && !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; // Shutdown received during reconnect } @@ -581,7 +603,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -611,7 +633,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(text.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -636,7 +658,7 @@ async fn run_background_task( }; if let Err(e) = ws.send(Message::Text(msg.into())).await { let _ = reply.send(Err(RelayClientError::WebSocket(e))); - if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref()).await { + if !do_reconnect(&mut ws, &mut state, &mut cmd_rx, &keys, &relay_url, api_token.as_deref(), auth_tag.as_ref()).await { return; } continue; @@ -695,10 +717,10 @@ pub struct RelayClient { keys: Keys, /// WebSocket URL of the relay (e.g. "ws://localhost:3000"). relay_url: String, - /// Shared reqwest client for REST API calls. + /// Shared reqwest client for HTTP calls (media upload only). http: reqwest::Client, - /// Optional API token for Bearer auth on REST endpoints. - api_token: Option, + /// Optional NIP-OA auth tag injected into every signed event. + auth_tag: Option, } impl RelayClient { @@ -706,40 +728,76 @@ impl RelayClient { /// /// Performs the initial NIP-42 handshake synchronously so startup failures /// are surfaced immediately. After that, reconnection is automatic. + /// + /// `auth_tag` is an optional NIP-OA tag that will be injected into every + /// event signed via [`sign_event`](Self::sign_event). pub async fn connect( relay_url: &str, keys: &Keys, api_token: Option<&str>, + auth_tag: Option, ) -> Result { - let ws = do_connect(relay_url, keys, api_token).await?; + let ws = do_connect(relay_url, keys, api_token, auth_tag.as_ref()).await?; let (cmd_tx, cmd_rx) = mpsc::channel(CMD_CHANNEL_CAPACITY); let bg_keys = keys.clone(); let bg_relay_url = relay_url.to_string(); let bg_api_token = api_token.map(|t| t.to_string()); + let bg_auth_tag = auth_tag.clone(); let handle = tokio::spawn(async move { - run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token).await; + run_background_task(ws, cmd_rx, bg_keys, bg_relay_url, bg_api_token, bg_auth_tag).await; }); Ok(Self { bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }), keys: keys.clone(), relay_url: relay_url.to_string(), - // Default builder with only timeout config — infallible in practice. http: reqwest::Client::builder() - .timeout(std::time::Duration::from_secs(10)) + .timeout(std::time::Duration::from_secs(120)) .connect_timeout(std::time::Duration::from_secs(5)) .build() .map_err(|e| RelayClientError::Url(format!("HTTP client build failed: {e}")))?, - api_token: api_token.map(|t| t.to_string()), + auth_tag, }) } - /// Returns the Nostr keypair used for signing and authentication. - pub fn keys(&self) -> &Keys { - &self.keys + /// Sign an event builder, injecting the NIP-OA auth tag if configured. + /// + /// This is the canonical signing path in the MCP server. All event creation + /// should go through this method to ensure consistent auth tag injection. + /// + /// **Callers MUST NOT add `auth` tags to the builder before calling this + /// method.** The only `auth` tag that may appear in the signed event is the + /// one injected by this method. Any pre-existing `auth` tag — whether + /// `self.auth_tag` is configured or not — is rejected immediately. + pub fn sign_event(&self, builder: EventBuilder) -> Result { + let builder = if let Some(ref tag) = self.auth_tag { + builder.add_tags([tag.clone()]) + } else { + builder + }; + let event = builder + .sign_with_keys(&self.keys) + .map_err(RelayClientError::from)?; + + // Enforce: auth tags may only come from self.auth_tag injection. + // - If auth_tag is Some: exactly 1 auth tag must exist (the one we injected) + // - If auth_tag is None: zero auth tags must exist (no caller bypass) + let auth_count = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .count(); + let expected = if self.auth_tag.is_some() { 1 } else { 0 }; + if auth_count != expected { + return Err(RelayClientError::EventBuilder(format!( + "event has {auth_count} auth tags — expected {expected}; callers must not add auth tags manually" + ))); + } + + Ok(event) } /// Returns the WebSocket URL the client connected to. @@ -753,104 +811,110 @@ impl RelayClient { relay_ws_to_http(&self.relay_url) } - fn pubkey_hex(&self) -> String { + pub(crate) fn pubkey_hex(&self) -> String { self.keys.public_key().to_hex() } - /// Returns the appropriate auth header for REST requests. + /// Returns a reference to the shared reqwest HTTP client. + pub fn http_client(&self) -> &reqwest::Client { + &self.http + } + + /// Returns a reference to the Nostr signing keys. + pub fn keys(&self) -> &nostr::Keys { + &self.keys + } + + /// Returns the NIP-OA auth tag JSON string for use in HTTP `x-auth-tag` headers. /// - /// - If an API token is present: `Authorization: Bearer ` (production mode). - /// - Otherwise: `X-Pubkey: ` (dev mode, relay has `require_auth_token=false`). - fn apply_auth(&self, builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder { - if let Some(ref token) = self.api_token { - builder.header("Authorization", format!("Bearer {}", token)) - } else { - builder.header("X-Pubkey", self.pubkey_hex()) + /// Returns `None` if no auth tag is configured (direct-member agents). + pub fn auth_tag_json(&self) -> Option { + self.auth_tag + .as_ref() + .and_then(|t| serde_json::to_string(t.as_slice()).ok()) + } + + /// Returns the relay's server authority (host or host:port) for BUD-11 server tags. + /// + /// Uses the same logic as the desktop client's `extract_server_authority`: + /// default ports (80/443) are omitted, non-default ports are included. + /// Returns `None` for localhost (no server tag in dev mode). + pub fn server_domain(&self) -> Option { + // Convert ws:// → http://, wss:// → https:// for url::Url parsing. + let http_url = self + .relay_url + .replace("wss://", "https://") + .replace("ws://", "http://"); + let parsed = url::Url::parse(&http_url).ok()?; + let host = parsed.host_str()?; + if host.is_empty() || host == "localhost" { + return None; + } + match parsed.port() { + Some(port) => Some(format!("{host}:{port}")), + None => Some(host.to_string()), } } - /// Authenticated GET to the relay's REST API. Returns the response body. - pub async fn get(&self, path: &str) -> anyhow::Result { - let url = format!("{}{}", self.relay_http_url(), path); - let resp = self.apply_auth(self.http.get(&url)).send().await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); - } - Ok(resp.text().await?) - } - - /// Authenticated POST (JSON body) to the relay's REST API. - pub async fn post(&self, path: &str, body: &serde_json::Value) -> anyhow::Result { - let url = format!("{}{}", self.relay_http_url(), path); - let resp = self - .apply_auth(self.http.post(&url)) - .json(body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); - } - Ok(resp.text().await?) - } - - /// Authenticated PUT (JSON body) to the relay's REST API. - pub async fn put(&self, path: &str, body: &serde_json::Value) -> anyhow::Result { - let url = format!("{}{}", self.relay_http_url(), path); - let resp = self - .apply_auth(self.http.put(&url)) - .json(body) - .send() - .await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); - } - Ok(resp.text().await?) - } - - /// Authenticated DELETE to the relay's REST API. - pub async fn delete(&self, path: &str) -> anyhow::Result { - let url = format!("{}{}", self.relay_http_url(), path); - let resp = self.apply_auth(self.http.delete(&url)).send().await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); - } - Ok(resp.text().await?) - } - - /// Get the canvas content for a channel via REST. - pub async fn get_canvas(&self, channel_id: &str) -> anyhow::Result { - self.get(&format!("/api/channels/{}/canvas", channel_id)) - .await - } - - /// Set the canvas content for a channel via REST. - pub async fn set_canvas(&self, channel_id: &str, content: &str) -> anyhow::Result { - let body = serde_json::json!({ "content": content }); - self.put(&format!("/api/channels/{}/canvas", channel_id), &body) - .await - } - - /// Authenticated GET to a full URL (for feed tools that build the URL themselves). - pub async fn get_api(&self, url: &str) -> anyhow::Result { - let resp = self.apply_auth(self.http.get(url)).send().await?; - if !resp.status().is_success() { - let status = resp.status(); - let body = resp.text().await.unwrap_or_default(); - return Err(anyhow::anyhow!("{} {}: {}", status, url, body)); - } - Ok(resp.text().await?) + /// One-shot query: send REQ with auto-generated sub_id, collect events until EOSE. + /// + /// This is the primary read path for the MCP server. Equivalent to calling + /// `subscribe()` with a random sub_id. + pub async fn query(&self, filters: Vec) -> Result, RelayClientError> { + let sub_id = format!("q-{}", uuid::Uuid::new_v4().simple()); + self.subscribe(&sub_id, filters).await } /// Publish a signed Nostr event to the relay and wait for the `OK` acknowledgement. + /// + /// Defense-in-depth: validates that the event carries the expected number of + /// `auth` tags before publishing. This catches any code path that bypasses + /// [`sign_event`](Self::sign_event). pub async fn send_event(&self, event: Event) -> Result { + // Verify the event was authored by this client's keypair. + if event.pubkey != self.keys.public_key() { + return Err(RelayClientError::EventBuilder(format!( + "send_event rejected: event pubkey {} does not match client pubkey {}", + event.pubkey.to_hex(), + self.keys.public_key().to_hex() + ))); + } + + // Defense-in-depth: validate auth tags match configuration exactly. + let auth_tags: Vec<&nostr::Tag> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + + match (&self.auth_tag, auth_tags.as_slice()) { + // Configured: exactly 1 auth tag that matches our configured tag byte-for-byte + (Some(expected), [actual]) => { + if actual.as_slice() != expected.as_slice() { + return Err(RelayClientError::EventBuilder( + "send_event rejected: auth tag does not match configured attestation" + .into(), + )); + } + } + // Configured but wrong count + (Some(_), tags) => { + return Err(RelayClientError::EventBuilder(format!( + "send_event rejected: expected 1 auth tag, found {}", + tags.len() + ))); + } + // Unconfigured: no auth tags allowed + (None, tags) if !tags.is_empty() => { + return Err(RelayClientError::EventBuilder(format!( + "send_event rejected: auth tags not allowed when unconfigured, found {}", + tags.len() + ))); + } + // Unconfigured, no auth tags: OK + (None, _) => {} + } + let (reply_tx, reply_rx) = oneshot::channel(); self.bg .cmd_tx @@ -1213,6 +1277,167 @@ mod tests { assert!(result.is_err()); } + // ── sign_event auth tag injection ──────────────────────────────────────── + + /// Build a minimal `RelayClient` without a live relay connection. + /// + /// Only `keys` and `auth_tag` matter for `sign_event`; the other fields + /// are inert stubs (the background task immediately exits, the HTTP + /// client is never used). + fn make_client(keys: Keys, auth_tag: Option) -> RelayClient { + let (cmd_tx, _cmd_rx) = mpsc::channel(1); + let handle = tokio::runtime::Handle::current().spawn(async {}); + RelayClient { + bg: std::sync::Arc::new(BgTaskHandle { cmd_tx, handle }), + keys, + relay_url: "ws://127.0.0.1:1".to_string(), + http: reqwest::Client::new(), + auth_tag, + } + } + + #[tokio::test] + async fn test_sign_event_injects_auth_tag() { + let keys = Keys::generate(); + // Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "", "<128-char-hex-sig>"] + let owner_pubkey = "a".repeat(64); + let conditions = ""; + let signature = "b".repeat(128); + let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap(); + + // With auth_tag: the signed event must contain it. + let client = make_client(keys.clone(), Some(auth_tag.clone())); + let event = client + .sign_event(EventBuilder::new(Kind::TextNote, "hello", [])) + .expect("sign_event should succeed"); + + let tag_values: Vec> = event + .tags + .iter() + .map(|t| t.as_slice().iter().map(|s| s.to_string()).collect()) + .collect(); + assert!( + tag_values + .iter() + .any(|t| t.first().map(|s| s.as_str()) == Some("auth") + && t.get(1).map(|s| s.as_str()) == Some(owner_pubkey.as_str()) + && t.get(3).map(|s| s.as_str()) == Some(signature.as_str())), + "expected NIP-OA auth tag in event; got: {tag_values:?}" + ); + + // Without auth_tag: the signed event must NOT contain an auth tag. + let client_no_auth = make_client(keys, None); + let event_no_auth = client_no_auth + .sign_event(EventBuilder::new(Kind::TextNote, "hello", [])) + .expect("sign_event should succeed"); + + let has_auth_tag = event_no_auth + .tags + .iter() + .any(|t| t.as_slice().first().map(|s| s.as_str()).unwrap_or("") == "auth"); + assert!(!has_auth_tag, "expected no auth tag when auth_tag is None"); + } + + #[tokio::test] + async fn test_sign_event_rejects_duplicate_auth_tag() { + let keys = Keys::generate(); + // Real NIP-OA tag format: ["auth", "<64-char-hex-pubkey>", "", "<128-char-hex-sig>"] + let owner_pubkey = "c".repeat(64); + let conditions = ""; + let signature = "d".repeat(128); + let auth_tag = nostr::Tag::parse(&["auth", &owner_pubkey, conditions, &signature]).unwrap(); + + // Case 1: client has auth_tag configured, caller also pre-adds one → duplicate → reject. + let client = make_client(keys.clone(), Some(auth_tag.clone())); + let builder = EventBuilder::new(Kind::TextNote, "oops", [auth_tag.clone()]); + let result = client.sign_event(builder); + assert!( + result.is_err(), + "sign_event should return an error when the event would have duplicate auth tags" + ); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("auth tags"), + "error message should mention auth tags; got: {err_msg}" + ); + + // Case 2: client has NO auth_tag configured, but caller manually adds one → bypass → reject. + let client_no_auth = make_client(keys, None); + let builder_with_manual = EventBuilder::new(Kind::TextNote, "bypass", [auth_tag]); + let result2 = client_no_auth.sign_event(builder_with_manual); + assert!( + result2.is_err(), + "sign_event should reject a manually added auth tag even when auth_tag is None" + ); + let err_msg2 = result2.unwrap_err().to_string(); + assert!( + err_msg2.contains("auth tags"), + "error message should mention auth tags; got: {err_msg2}" + ); + } + + // ── send_event auth tag validation ─────────────────────────────────────── + + #[tokio::test] + async fn test_send_event_rejects_forged_auth_tag() { + let keys = Keys::generate(); + let real_tag = nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap(); + let forged_tag = + nostr::Tag::parse(&["auth", &"c".repeat(64), "", &"d".repeat(128)]).unwrap(); + + let client = make_client(keys.clone(), Some(real_tag)); + + // Build event with forged auth tag, bypassing sign_event + let event = EventBuilder::new(Kind::TextNote, "forged", [forged_tag]) + .sign_with_keys(&keys) + .unwrap(); + + let result = client.send_event(event).await; + assert!(result.is_err(), "send_event should reject forged auth tag"); + let err_msg = result.unwrap_err().to_string(); + assert!( + err_msg.contains("does not match"), + "error should mention mismatch: {err_msg}" + ); + } + + #[tokio::test] + async fn test_send_event_rejects_auth_tag_when_unconfigured() { + let keys = Keys::generate(); + let sneaky_tag = + nostr::Tag::parse(&["auth", &"a".repeat(64), "", &"b".repeat(128)]).unwrap(); + + let client = make_client(keys.clone(), None); + + let event = EventBuilder::new(Kind::TextNote, "sneaky", [sneaky_tag]) + .sign_with_keys(&keys) + .unwrap(); + + let result = client.send_event(event).await; + assert!( + result.is_err(), + "send_event should reject auth tags when unconfigured" + ); + } + + #[tokio::test] + async fn test_send_event_rejects_wrong_pubkey() { + let client_keys = Keys::generate(); + let other_keys = Keys::generate(); + let client = make_client(client_keys, None); + + // Event signed by a different keypair + let event = EventBuilder::new(Kind::TextNote, "wrong author", []) + .sign_with_keys(&other_keys) + .unwrap(); + + let result = client.send_event(event).await; + assert!( + result.is_err(), + "send_event should reject events from wrong pubkey" + ); + } + // ── Integration tests: mini relay ───────────────────────────────────────── // // Each test spins up a lightweight in-process WebSocket server that performs @@ -1296,7 +1521,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); // Give the background task a moment to process the Ping. tokio::time::sleep(Duration::from_millis(200)).await; let _ = client.close().await; @@ -1337,7 +1562,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); tokio::time::sleep(Duration::from_millis(200)).await; let _ = client.close().await; } @@ -1366,7 +1591,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); let event = EventBuilder::new(Kind::Custom(9), "test", []) .sign_with_keys(&keys) @@ -1418,7 +1643,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); let events = client .subscribe("sub-1", vec![Filter::new()]) @@ -1444,7 +1669,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); let event = EventBuilder::new(Kind::Custom(9), "timeout-test", []) .sign_with_keys(&keys) @@ -1517,7 +1742,7 @@ mod tests { .await; let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); // Subscribe (EOSE comes back immediately). client @@ -1634,7 +1859,7 @@ mod tests { }); let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); // Wait for the second relay to complete auth (background task reconnected). tokio::time::timeout(Duration::from_secs(5), ok_rx) @@ -1697,7 +1922,7 @@ mod tests { }); let keys = Keys::generate(); - let client = RelayClient::connect(&url, &keys, None).await.unwrap(); + let client = RelayClient::connect(&url, &keys, None, None).await.unwrap(); // Give the background task time to notice the close and enter // the reconnect backoff loop. diff --git a/crates/sprout-mcp/src/server.rs b/crates/sprout-mcp/src/server.rs index 2959d18dbd..454d6eab5d 100644 --- a/crates/sprout-mcp/src/server.rs +++ b/crates/sprout-mcp/src/server.rs @@ -1,16 +1,38 @@ -use nostr::EventId; +use nostr::{Alphabet, EventBuilder, EventId, Filter, JsonUtil, Kind, SingleLetterTag, Tag}; use rmcp::{ handler::server::{router::tool::ToolRouter, wrapper::Parameters}, model::{ServerCapabilities, ServerInfo}, schemars, tool, tool_handler, tool_router, ServerHandler, }; use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; use crate::relay_client::RelayClient; +use sprout_core::kind; use sprout_core::PresenceStatus; +/// Helper to create a lowercase single-letter tag for Nostr filter custom_tag. +fn tag_h() -> SingleLetterTag { + SingleLetterTag::lowercase(Alphabet::H) +} +fn tag_d() -> SingleLetterTag { + SingleLetterTag::lowercase(Alphabet::D) +} +fn tag_e() -> SingleLetterTag { + SingleLetterTag::lowercase(Alphabet::E) +} +fn tag_p() -> SingleLetterTag { + SingleLetterTag::lowercase(Alphabet::P) +} + +/// Convert a sprout-core kind constant (u32) to a nostr Kind. +fn k(kind_num: u32) -> Kind { + Kind::Custom(kind_num as u16) +} + /// Percent-encode a string for safe inclusion in a URL query parameter value. /// Encodes all characters except unreserved ones (A-Z a-z 0-9 - _ . ~). +#[cfg(test)] fn percent_encode(s: &str) -> String { let mut out = String::with_capacity(s.len()); for byte in s.bytes() { @@ -137,23 +159,53 @@ async fn resolve_content_mentions( if names.is_empty() { return vec![]; } - let body = client - .get(&format!("/api/channels/{channel_id}/members")) - .await - .unwrap_or_default(); - let parsed: serde_json::Value = serde_json::from_str(&body).unwrap_or_default(); - let Some(members) = parsed["members"].as_array() else { + // Query membership list (kind:39002) for this channel. + let filter = Filter::new() + .kind(k(kind::KIND_NIP29_GROUP_MEMBERS)) + .custom_tag(tag_d(), [channel_id]) + .limit(1); + let events = match client.query(vec![filter]).await { + Ok(e) => e, + Err(_) => return vec![], + }; + let Some(event) = events.first() else { return vec![]; }; + // Members are in p-tags. We need profiles to match display names. + let member_pubkeys: Vec<&str> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p")) + .filter_map(|t| t.as_slice().get(1).map(|v| v.as_str())) + .collect(); + if member_pubkeys.is_empty() { + return vec![]; + } + // Fetch profiles for members. + let authors: Vec = member_pubkeys + .iter() + .filter_map(|pk| nostr::PublicKey::from_hex(pk).ok()) + .collect(); + let profile_filter = Filter::new() + .kind(k(kind::KIND_PROFILE)) + .authors(authors) + .limit(member_pubkeys.len()); + let profiles = match client.query(vec![profile_filter]).await { + Ok(e) => e, + Err(_) => return vec![], + }; let mut pubkeys = Vec::new(); - for m in members { - let Some(dn) = m["display_name"].as_str() else { + for profile in &profiles { + let Ok(content) = serde_json::from_str::(&profile.content) else { continue; }; - if names.iter().any(|n| n.eq_ignore_ascii_case(dn)) { - if let Some(pk) = m["pubkey"].as_str() { - pubkeys.push(pk.to_ascii_lowercase()); - } + let display_name = content + .get("display_name") + .or_else(|| content.get("name")) + .and_then(|v| v.as_str()) + .unwrap_or(""); + if names.iter().any(|n| n.eq_ignore_ascii_case(display_name)) { + pubkeys.push(profile.pubkey.to_hex()); } } pubkeys @@ -178,6 +230,10 @@ pub struct SendMessageParams { /// Pubkeys to @mention in the message. #[serde(default)] pub mention_pubkeys: Option>, + /// Optional file paths to upload and attach as media. Each file is uploaded + /// to the relay and included as an imeta tag + markdown image in the message. + #[serde(default)] + pub file_paths: Option>, } fn default_kind() -> Option { Some(sprout_core::kind::KIND_STREAM_MESSAGE as u16) @@ -805,6 +861,13 @@ fn infer_language(file_path: &str) -> Option { Some(lang.to_string()) } +/// Parameters for the `upload_file` tool. +#[derive(Debug, Serialize, Deserialize, schemars::JsonSchema)] +pub struct UploadFileParams { + /// Local filesystem path to the file to upload. + pub file_path: String, +} + /// The MCP server that exposes Sprout relay functionality as tools. #[derive(Clone)] pub struct SproutMcpServer { @@ -844,16 +907,21 @@ impl SproutMcpServer { parent_event_id: &str, parent_eid: EventId, ) -> Result { - let resp = self + let filter = Filter::new().id(parent_eid).limit(1); + let events = self .client - .get(&format!("/api/events/{}", parent_event_id)) + .query(vec![filter]) .await .map_err(|e| format!("failed to fetch parent event: {e}"))?; - let event_json: serde_json::Value = serde_json::from_str(&resp) - .map_err(|e| format!("failed to parse parent event: {e}"))?; + let parent_event = events + .first() + .ok_or_else(|| "parent event not found".to_string())?; - let root_eid = match find_root_from_tags(&event_json["tags"]) { + let tags_json = serde_json::to_value(&parent_event.tags) + .map_err(|e| format!("failed to serialize tags: {e}"))?; + + let root_eid = match find_root_from_tags(&tags_json) { Some(root_hex) if root_hex != parent_event_id => EventId::from_hex(&root_hex) .map_err(|e| format!("failed to parse root event id: {e}"))?, _ => parent_eid, @@ -938,11 +1006,53 @@ Default kind is 9 (stream message)." let mention_refs: Vec<&str> = mentions.iter().map(String::as_str).collect(); let broadcast = p.broadcast_to_channel.unwrap_or(false); + // Upload files and build media tags + let mut media_tags: Vec> = Vec::new(); + let mut media_content = String::new(); + if let Some(ref paths) = p.file_paths { + for path in paths { + match crate::upload::upload_file( + self.client.http_client(), + self.client.keys(), + &self.client.relay_http_url(), + self.client.server_domain().as_deref(), + path, + self.client.auth_tag_json().as_deref(), + ) + .await + { + Ok(desc) => { + media_tags.push(crate::upload::build_imeta_tag(&desc)); + if desc.mime_type.starts_with("video/") { + media_content.push_str("\n![video]("); + } else { + media_content.push_str("\n![image]("); + } + media_content.push_str(&desc.url); + media_content.push(')'); + } + Err(e) => { + return format!("Error uploading {}: {e}", path); + } + } + } + } + let final_content = if media_content.is_empty() { + p.content.clone() + } else { + format!("{}{}", p.content, media_content) + }; + // Build the event builder via SDK, routing by kind. let builder = match kind_num as u32 { sprout_core::kind::KIND_FORUM_POST => { // kind 45001: forum post (no thread ref, no broadcast) - match sprout_sdk::build_forum_post(channel_uuid, &p.content, &mention_refs, &[]) { + match sprout_sdk::build_forum_post( + channel_uuid, + &final_content, + &mention_refs, + &media_tags, + ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), } @@ -964,10 +1074,10 @@ Default kind is 9 (stream message)." }; match sprout_sdk::build_forum_comment( channel_uuid, - &p.content, + &final_content, &thread_ref, &mention_refs, - &[], + &media_tags, ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), @@ -989,11 +1099,11 @@ Default kind is 9 (stream message)." }; match sprout_sdk::build_message( channel_uuid, - &p.content, + &final_content, thread_ref.as_ref(), &mention_refs, broadcast && p.parent_event_id.is_some(), - &[], + &media_tags, ) { Ok(b) => b, Err(e) => return format!("Error: {e}"), @@ -1001,7 +1111,7 @@ Default kind is 9 (stream message)." } }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign message event: {e}"), }; @@ -1115,7 +1225,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign diff event: {e}"), }; @@ -1166,7 +1276,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign edit event: {e}"), }; @@ -1199,28 +1309,21 @@ The diff is rendered with GitHub-quality visualization in the desktop client." }; // Fetch the event to extract its channel_id (h-tag) — required by build_delete_message. - let resp = match self - .client - .get(&format!("/api/events/{}", p.event_id)) - .await - { - Ok(r) => r, + let filter = Filter::new().id(target_eid).limit(1); + let fetched = match self.client.query(vec![filter]).await { + Ok(e) => e, Err(e) => return format!("Error: failed to fetch event: {e}"), }; - let event_json: serde_json::Value = match serde_json::from_str(&resp) { - Ok(v) => v, - Err(e) => return format!("Error: failed to parse event: {e}"), + let fetched_event = match fetched.first() { + Some(e) => e, + None => return format!("Error: event '{}' not found", p.event_id), }; - let channel_id_str = match event_json["tags"].as_array().and_then(|tags| { - tags.iter().find_map(|t| { - let parts = t.as_array()?; - if parts.first()?.as_str() == Some("h") { - parts.get(1)?.as_str().map(|s| s.to_string()) - } else { - None - } - }) - }) { + let channel_id_str = match fetched_event + .tags + .iter() + .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("h")) + .and_then(|t| t.as_slice().get(1).map(|v| v.to_string())) + { Some(id) => id, None => return "Error: could not find channel_id (h-tag) on event".to_string(), }; @@ -1233,7 +1336,7 @@ The diff is rendered with GitHub-quality visualization in the desktop client." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign delete event: {e}"), }; @@ -1263,33 +1366,56 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi return format!("Error: {e}"); } - const MAX_HISTORY_LIMIT: u32 = 200; - let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT); + const MAX_HISTORY_LIMIT: usize = 200; + let limit = p.limit.unwrap_or(50).min(MAX_HISTORY_LIMIT as u32) as usize; + + // Build filter for channel messages (stream messages, edits, diffs, forum posts/comments). + let message_kinds: Vec = if let Some(ref kinds_str) = p.kinds { + kinds_str + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .map(Kind::from) + .collect() + } else { + vec![ + k(kind::KIND_STREAM_MESSAGE), + k(kind::KIND_STREAM_MESSAGE_V2), + k(kind::KIND_STREAM_MESSAGE_DIFF), + k(kind::KIND_FORUM_POST), + k(kind::KIND_FORUM_COMMENT), + ] + }; + + let mut filter = Filter::new() + .kinds(message_kinds) + .custom_tag(tag_h(), [&p.channel_id]) + .limit(limit); - // Use the REST endpoint so callers get the canonical history payload. - // Note: with_threads is legacy — summaries are always included server-side. - let with_threads = p.with_threads.unwrap_or(false); - let mut query_parts: Vec = Vec::new(); - if with_threads { - query_parts.push("with_threads=true".to_string()); - } - query_parts.push(format!("limit={limit}")); if let Some(before) = p.before { - query_parts.push(format!("before={before}")); + filter = filter.until(nostr::Timestamp::from(before as u64)); } if let Some(since) = p.since { - query_parts.push(format!("since={since}")); + filter = filter.since(nostr::Timestamp::from(since as u64)); } - if let Some(ref kinds) = p.kinds { - query_parts.push(format!("kinds={}", percent_encode(kinds))); - } - let path = format!( - "/api/channels/{}/messages?{}", - p.channel_id, - query_parts.join("&") - ); - match self.client.get(&path).await { - Ok(body) => body, + + match self.client.query(vec![filter]).await { + Ok(mut events) => { + events.sort_by_key(|e| e.created_at); + let result: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_hex(), + "pubkey": e.pubkey.to_hex(), + "kind": e.kind.as_u16(), + "content": e.content, + "created_at": e.created_at.as_u64(), + "tags": e.tags.iter().map(|t| t.as_slice()).collect::>(), + }) + }) + .collect(); + serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -1300,16 +1426,49 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi description = "List Sprout channels accessible to this agent" )] pub async fn list_channels(&self, Parameters(p): Parameters) -> String { - // Use the REST endpoint — faster and simpler than a WebSocket subscription. - let path = if let Some(ref vis) = p.visibility { - // percent-encode the visibility value to prevent query-string injection - let encoded = percent_encode(vis); - format!("/api/channels?visibility={encoded}") - } else { - "/api/channels".to_string() - }; - match self.client.get(&path).await { - Ok(body) => body, + // Query channel metadata events (kind:41 = NIP-29 group metadata). + let filter = Filter::new() + .kind(k(kind::KIND_CHANNEL_METADATA)) + .limit(500); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let channels: Vec = events + .iter() + .filter(|e| { + if let Some(ref vis) = p.visibility { + // Filter by visibility tag if specified + e.tags + .iter() + .any(|t| { + let s = t.as_slice(); + s.first().map(|v| v.as_str()) == Some("visibility") + && s.get(1).map(|v| v.as_str()) == Some(vis.as_str()) + }) + } else { + true + } + }) + .map(|e| { + let content: serde_json::Value = + serde_json::from_str(&e.content).unwrap_or(serde_json::json!({})); + let channel_id = e + .tags + .iter() + .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d")) + .and_then(|t| t.as_slice().get(1)) + .map(|s| s.to_string()) + .unwrap_or_default(); + serde_json::json!({ + "channel_id": channel_id, + "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": e.created_at.as_u64(), + }) + }) + .collect(); + serde_json::to_string(&channels).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -1349,7 +1508,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign create_channel event: {e}"), }; @@ -1374,18 +1533,17 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - match self.client.get_canvas(&p.channel_id).await { - Ok(body) => { - // Parse REST JSON and return just the content string. - if let Ok(v) = serde_json::from_str::(&body) { - match v.get("content").and_then(|c| c.as_str()) { - Some(content) => content.to_string(), - None => "No canvas set for this channel.".to_string(), - } - } else { - body - } - } + // Canvas is kind:40100 with #h tag = channel_id + let filter = Filter::new() + .kind(k(kind::KIND_CANVAS)) + .custom_tag(tag_h(), [&p.channel_id]) + .limit(1); + + match self.client.query(vec![filter]).await { + Ok(events) => match events.first() { + Some(event) => event.content.clone(), + None => "No canvas set for this channel.".to_string(), + }, Err(e) => format!("Error: {e}"), } } @@ -1407,7 +1565,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign set_canvas event: {e}"), }; @@ -1433,12 +1591,34 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.channel_id).is_err() { return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id); } - match self - .client - .get(&format!("/api/channels/{}/workflows", p.channel_id)) - .await - { - Ok(body) => body, + // Workflows are kind:30620 (param-replaceable) with #h tag = channel_id + let filter = Filter::new() + .kind(k(kind::KIND_WORKFLOW_DEF)) + .custom_tag(tag_h(), [&p.channel_id]) + .limit(100); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let workflows: Vec = events + .iter() + .map(|e| { + let d_tag = e + .tags + .iter() + .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d")) + .and_then(|t| t.as_slice().get(1)) + .map(|s| s.to_string()) + .unwrap_or_default(); + serde_json::json!({ + "workflow_id": d_tag, + "content": e.content, + "created_at": e.created_at.as_u64(), + "pubkey": e.pubkey.to_hex(), + }) + }) + .collect(); + serde_json::to_string(&workflows).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -1452,13 +1632,26 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.channel_id).is_err() { return format!("Error: channel_id '{}' is not a valid UUID", p.channel_id); } - let body = serde_json::json!({ "yaml_definition": p.yaml_definition }); - match self - .client - .post(&format!("/api/channels/{}/workflows", p.channel_id), &body) - .await - { - Ok(b) => b, + // Workflow definition is a kind:30620 (param-replaceable) event. + // d-tag = workflow UUID, h-tag = channel_id, content = YAML. + let workflow_id = uuid::Uuid::new_v4().to_string(); + let tags = vec![ + Tag::parse(&["d", &workflow_id]).unwrap(), + Tag::parse(&["h", &p.channel_id]).unwrap(), + ]; + let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition, tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign workflow event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "workflow_id": workflow_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -1472,13 +1665,20 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.workflow_id).is_err() { return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id); } - let body = serde_json::json!({ "yaml_definition": p.yaml_definition }); - match self - .client - .put(&format!("/api/workflows/{}", p.workflow_id), &body) - .await - { - Ok(b) => b, + // Publish a new kind:30620 event with the same d-tag to replace the existing one. + let tags = vec![Tag::parse(&["d", &p.workflow_id]).unwrap()]; + let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_DEF), &p.yaml_definition, tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign workflow event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -1489,12 +1689,22 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.workflow_id).is_err() { return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id); } - match self - .client - .delete(&format!("/api/workflows/{}", p.workflow_id)) - .await - { - Ok(_) => "Workflow deleted.".to_string(), + // Delete via kind:5 event referencing the workflow's d-tag coordinate. + let coordinate = format!( + "{}:{}:{}", + kind::KIND_WORKFLOW_DEF, + self.client.pubkey_hex(), + p.workflow_id + ); + let tags = vec![Tag::parse(&["a", &coordinate]).unwrap()]; + let builder = EventBuilder::new(k(kind::KIND_DELETION), "", tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign deletion event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) if ok.accepted => "Workflow deleted.".to_string(), + Ok(ok) => format!("Error: relay rejected deletion: {}", ok.message), Err(e) => format!("Error: {e}"), } } @@ -1511,15 +1721,23 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.workflow_id).is_err() { return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id); } - let body = serde_json::json!({ - "inputs": p.inputs.unwrap_or(serde_json::Value::Object(Default::default())) - }); - match self - .client - .post(&format!("/api/workflows/{}/trigger", p.workflow_id), &body) - .await - { - Ok(b) => b, + let inputs = p + .inputs + .unwrap_or(serde_json::Value::Object(Default::default())); + let content = serde_json::to_string(&inputs).unwrap_or_default(); + let tags = vec![Tag::parse(&["d", &p.workflow_id]).unwrap()]; + let builder = EventBuilder::new(k(kind::KIND_WORKFLOW_TRIGGER), &content, tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign trigger event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -1536,16 +1754,33 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if uuid::Uuid::parse_str(&p.workflow_id).is_err() { return format!("Error: workflow_id '{}' is not a valid UUID", p.workflow_id); } - let limit = p.limit.unwrap_or(20).min(100); - match self - .client - .get(&format!( - "/api/workflows/{}/runs?limit={}", - p.workflow_id, limit - )) - .await - { - Ok(b) => b, + let limit = p.limit.unwrap_or(20).min(100) as usize; + // Query workflow execution events (kind:46001–46010) referencing this workflow. + let filter = Filter::new() + .kinds(vec![ + k(kind::KIND_WORKFLOW_TRIGGERED), + k(kind::KIND_WORKFLOW_TRIGGERED + 1), // completed + k(kind::KIND_WORKFLOW_TRIGGERED + 2), // failed + ]) + .custom_tag(tag_d(), [&p.workflow_id]) + .limit(limit); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let runs: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "event_id": e.id.to_hex(), + "kind": e.kind.as_u16(), + "content": e.content, + "created_at": e.created_at.as_u64(), + "tags": e.tags.iter().map(|t| t.as_slice()).collect::>(), + }) + }) + .collect(); + serde_json::to_string(&runs).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -1562,14 +1797,27 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi p.approval_token ); } - let route = if p.approved { - format!("/api/approvals/{}/grant", p.approval_token) + let kind_num = if p.approved { + kind::KIND_APPROVAL_GRANT } else { - format!("/api/approvals/{}/deny", p.approval_token) + kind::KIND_APPROVAL_DENY }; - let body = serde_json::json!({ "note": p.note }); - match self.client.post(&route, &body).await { - Ok(b) => b, + let content = p.note.as_deref().unwrap_or(""); + // The relay expects d-tag = hex(SHA256(token)), not the raw token UUID. + let token_hash = hex::encode(Sha256::digest(p.approval_token.as_bytes())); + let tags = vec![Tag::parse(&["d", &token_hash]).unwrap()]; + let builder = EventBuilder::new(k(kind_num), content, tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign approval event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -1584,26 +1832,38 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Equivalent to what a human sees on the Home tab in the desktop app." )] pub async fn get_feed(&self, Parameters(p): Parameters) -> String { - const MAX_FEED_LIMIT: u32 = 50; - let base = format!("{}/api/feed", self.client.relay_http_url()); - let mut query_parts: Vec = Vec::new(); + const MAX_FEED_LIMIT: usize = 50; + let limit = p + .limit + .map(|l| l.min(MAX_FEED_LIMIT as u32) as usize) + .unwrap_or(MAX_FEED_LIMIT); + + // Query events that mention this agent (p-tag) or are in channels we're in. + let my_pubkey = self.client.pubkey_hex(); + let mut filter = Filter::new().custom_tag(tag_p(), [&my_pubkey]).limit(limit); + if let Some(since) = p.since { - query_parts.push(format!("since={since}")); + filter = filter.since(nostr::Timestamp::from(since as u64)); } - if let Some(limit) = p.limit { - query_parts.push(format!("limit={}", limit.min(MAX_FEED_LIMIT))); - } - if let Some(types) = &p.types { - // percent-encode to prevent query-string injection (e.g. values containing & or ?) - query_parts.push(format!("types={}", percent_encode(types))); - } - let url = if query_parts.is_empty() { - base - } else { - format!("{base}?{}", query_parts.join("&")) - }; - match self.client.get_api(&url).await { - Ok(body) => body, + + match self.client.query(vec![filter]).await { + Ok(mut events) => { + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + let feed: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_hex(), + "pubkey": e.pubkey.to_hex(), + "kind": e.kind.as_u16(), + "content": e.content, + "created_at": e.created_at.as_u64(), + "tags": e.tags.iter().map(|t| t.as_slice()).collect::>(), + }) + }) + .collect(); + serde_json::to_string(&feed).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error fetching feed: {e}"), } } @@ -1643,7 +1903,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign add_member event: {e}"), }; @@ -1678,7 +1938,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign remove_member event: {e}"), }; @@ -1705,12 +1965,32 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - match self - .client - .get(&format!("/api/channels/{}/members", p.channel_id)) - .await - { - Ok(b) => b, + // Query membership list event (kind:39002 = NIP-29 group members) for this channel. + let filter = Filter::new() + .kind(k(kind::KIND_NIP29_GROUP_MEMBERS)) + .custom_tag(tag_d(), [&p.channel_id]) + .limit(1); + + match self.client.query(vec![filter]).await { + Ok(events) => match events.first() { + Some(event) => { + // Members are in p-tags: ["p", "", ""] + let members: Vec = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p")) + .map(|t| { + let s = t.as_slice(); + serde_json::json!({ + "pubkey": s.get(1).map(|v| v.as_str()).unwrap_or(""), + "role": s.get(2).map(|v| v.as_str()).unwrap_or("member"), + }) + }) + .collect(); + serde_json::to_string(&members).unwrap_or_else(|e| format!("Error: {e}")) + } + None => "[]".to_string(), + }, Err(e) => format!("Error: {e}"), } } @@ -1732,7 +2012,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign join event: {e}"), }; @@ -1764,7 +2044,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign leave event: {e}"), }; @@ -1788,12 +2068,28 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - match self - .client - .get(&format!("/api/channels/{}", p.channel_id)) - .await - { - Ok(b) => b, + // Query channel metadata (kind:41) with d-tag = channel_id. + let filter = Filter::new() + .kind(k(kind::KIND_CHANNEL_METADATA)) + .custom_tag(tag_d(), [&p.channel_id]) + .limit(1); + + match self.client.query(vec![filter]).await { + Ok(events) => match events.first() { + Some(event) => { + let content: serde_json::Value = + serde_json::from_str(&event.content).unwrap_or(serde_json::json!({})); + serde_json::json!({ + "channel_id": p.channel_id, + "name": content.get("name").and_then(|v| v.as_str()).unwrap_or(""), + "description": content.get("about").and_then(|v| v.as_str()).unwrap_or(""), + "created_at": event.created_at.as_u64(), + "pubkey": event.pubkey.to_hex(), + }) + .to_string() + } + None => format!("Error: channel '{}' not found", p.channel_id), + }, Err(e) => format!("Error: {e}"), } } @@ -1821,7 +2117,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign update_channel event: {e}"), }; @@ -1856,7 +2152,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign set_topic event: {e}"), }; @@ -1891,7 +2187,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign set_purpose event: {e}"), }; @@ -1923,7 +2219,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign archive event: {e}"), }; @@ -1958,7 +2254,7 @@ are not returned — use `get_thread` to fetch the full reply tree for a specifi Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign unarchive event: {e}"), }; @@ -1987,31 +2283,39 @@ with kind:45003 comments)." return format!("Error: {e}"); } - let mut query_parts: Vec = Vec::new(); - if let Some(depth) = p.depth_limit { - query_parts.push(format!("depth_limit={depth}")); - } - if let Some(limit) = p.limit { - query_parts.push(format!("limit={}", limit.min(200))); - } + let limit = p.limit.map(|l| l.min(200) as usize).unwrap_or(100); - let encoded_event_id = percent_encode(&p.event_id); - let path = if query_parts.is_empty() { - format!( - "/api/channels/{}/threads/{}", - p.channel_id, encoded_event_id - ) - } else { - format!( - "/api/channels/{}/threads/{}?{}", - p.channel_id, - encoded_event_id, - query_parts.join("&") - ) + // Query events that reference the root event via e-tag. + let filter = Filter::new() + .custom_tag(tag_e(), [&p.event_id]) + .custom_tag(tag_h(), [&p.channel_id]) + .limit(limit); + + // Also fetch the root event itself. + let root_eid = match EventId::from_hex(&p.event_id) { + Ok(id) => id, + Err(e) => return format!("Error: invalid event_id: {e}"), }; + let root_filter = Filter::new().id(root_eid).limit(1); - match self.client.get(&path).await { - Ok(b) => b, + match self.client.query(vec![filter, root_filter]).await { + Ok(mut events) => { + events.sort_by_key(|e| e.created_at); + let result: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_hex(), + "pubkey": e.pubkey.to_hex(), + "kind": e.kind.as_u16(), + "content": e.content, + "created_at": e.created_at.as_u64(), + "tags": e.tags.iter().map(|t| t.as_slice()).collect::>(), + }) + }) + .collect(); + serde_json::to_string(&result).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -2034,9 +2338,29 @@ with kind:45003 comments)." p.pubkeys.len() ); } - let body = serde_json::json!({ "pubkeys": p.pubkeys }); - match self.client.post("/api/dms", &body).await { - Ok(b) => b, + // Command event kind:41010 with p-tags for each participant. + let mut tags: Vec = p + .pubkeys + .iter() + .filter_map(|pk| Tag::parse(&["p", pk]).ok()) + .collect(); + // Add a unique d-tag so the relay can deduplicate. + let dm_id = uuid::Uuid::new_v4().to_string(); + tags.push(Tag::parse(&["d", &dm_id]).unwrap()); + + let builder = EventBuilder::new(k(kind::KIND_DM_OPEN), "", tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign open_dm event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "dm_id": dm_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -2050,13 +2374,23 @@ with kind:45003 comments)." if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - let body = serde_json::json!({ "pubkeys": [p.pubkey] }); - match self - .client - .post(&format!("/api/dms/{}/members", p.channel_id), &body) - .await - { - Ok(b) => b, + // Command event kind:41011 with h-tag = DM channel, p-tag = new member. + let tags = vec![ + Tag::parse(&["h", &p.channel_id]).unwrap(), + Tag::parse(&["p", &p.pubkey]).unwrap(), + ]; + let builder = EventBuilder::new(k(kind::KIND_DM_ADD_MEMBER), "", tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign add_dm_member event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "event_id": ok.event_id, + "accepted": ok.accepted, + "message": ok.message, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -2067,8 +2401,40 @@ with kind:45003 comments)." description = "List all direct message channels the agent is a participant in." )] pub async fn list_dms(&self) -> String { - match self.client.get("/api/dms").await { - Ok(b) => b, + // Query DM-created events (kind:41001) where we are a participant (p-tag). + let my_pubkey = self.client.pubkey_hex(); + let filter = Filter::new() + .kind(k(kind::KIND_DM_CREATED)) + .custom_tag(tag_p(), [&my_pubkey]) + .limit(100); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let dms: Vec = events + .iter() + .map(|e| { + let participants: Vec<&str> = e + .tags + .iter() + .filter(|t| t.as_slice().first().map(|v| v.as_str()) == Some("p")) + .filter_map(|t| t.as_slice().get(1).map(|v| v.as_str())) + .collect(); + let dm_id = e + .tags + .iter() + .find(|t| t.as_slice().first().map(|v| v.as_str()) == Some("d")) + .and_then(|t| t.as_slice().get(1)) + .map(|s| s.to_string()) + .unwrap_or_default(); + serde_json::json!({ + "dm_id": dm_id, + "participants": participants, + "created_at": e.created_at.as_u64(), + }) + }) + .collect(); + serde_json::to_string(&dms).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -2082,21 +2448,16 @@ with kind:45003 comments)." if let Err(e) = validate_uuid(&p.channel_id) { return format!("Error: {e}"); } - match self - .client - .post( - &format!("/api/dms/{}/hide", p.channel_id), - &serde_json::json!({}), - ) - .await - { - Ok(b) => { - if b.is_empty() { - "DM hidden successfully.".to_string() - } else { - b - } - } + // Command event kind:41012 with h-tag = DM channel. + let tags = vec![Tag::parse(&["h", &p.channel_id]).unwrap()]; + let builder = EventBuilder::new(k(kind::KIND_DM_HIDE), "", tags); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign hide_dm event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) if ok.accepted => "DM hidden successfully.".to_string(), + Ok(ok) => format!("Error: relay rejected: {}", ok.message), Err(e) => format!("Error: {e}"), } } @@ -2117,7 +2478,7 @@ with kind:45003 comments)." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign reaction event: {e}"), }; @@ -2138,55 +2499,32 @@ with kind:45003 comments)." description = "Remove an emoji reaction from a Sprout message." )] pub async fn remove_reaction(&self, Parameters(p): Parameters) -> String { - // Fetch the reactions list to find the current user's reaction event ID for this emoji. - let encoded_event_id = percent_encode(&p.event_id); - let my_pubkey = self.client.keys().public_key().to_hex(); - let reactions_resp = match self - .client - .get(&format!("/api/messages/{}/reactions", encoded_event_id)) - .await - { - Ok(r) => r, + // Validate event_id format. + if EventId::from_hex(&p.event_id).is_err() { + return format!("Error: invalid event_id: {}", p.event_id); + } + let my_pubkey_parsed = self.client.keys().public_key(); + let filter = Filter::new() + .kind(k(kind::KIND_REACTION)) + .author(my_pubkey_parsed) + .custom_tag(tag_e(), [&p.event_id]) + .limit(50); + + let events = match self.client.query(vec![filter]).await { + Ok(e) => e, Err(e) => return format!("Error: failed to fetch reactions: {e}"), }; - let reactions: serde_json::Value = match serde_json::from_str(&reactions_resp) { - Ok(v) => v, - Err(e) => return format!("Error: failed to parse reactions: {e}"), - }; - // Parse the grouped response: { "reactions": [ { "emoji": "...", "users": [ { "pubkey": "...", "reaction_event_id": "..." } ] } ] } - let reaction_event_id_hex = reactions - .get("reactions") - .and_then(|r| r.as_array()) - .and_then(|groups| { - groups.iter().find_map(|group| { - let group_emoji = group.get("emoji")?.as_str()?; - if group_emoji != p.emoji { - return None; - } - group.get("users")?.as_array()?.iter().find_map(|user| { - let pubkey = user.get("pubkey")?.as_str()?; - if pubkey != my_pubkey { - return None; - } - user.get("reaction_event_id")? - .as_str() - .map(|s| s.to_string()) - }) - }) - }); + // Find the reaction with matching emoji content. + let reaction_event = events.iter().find(|e| e.content == p.emoji); - match reaction_event_id_hex { - Some(hex) => { - let reaction_eid = match EventId::from_hex(&hex) { - Ok(id) => id, - Err(e) => return format!("Error: invalid reaction event_id: {e}"), - }; - let builder = match sprout_sdk::build_remove_reaction(reaction_eid) { + match reaction_event { + Some(re) => { + let builder = match sprout_sdk::build_remove_reaction(re.id) { Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign remove_reaction event: {e}"), }; @@ -2200,8 +2538,8 @@ with kind:45003 comments)." Err(e) => format!("Error: {e}"), } } - None => "Error: could not find your reaction event ID for this emoji. \ - The reaction may not exist or the relay has not recorded the event ID." + None => "Error: could not find your reaction event for this emoji. \ + The reaction may not exist." .to_string(), } } @@ -2212,13 +2550,36 @@ with kind:45003 comments)." description = "Get all emoji reactions for a Sprout message." )] pub async fn get_reactions(&self, Parameters(p): Parameters) -> String { - let encoded_event_id = percent_encode(&p.event_id); - match self - .client - .get(&format!("/api/messages/{}/reactions", encoded_event_id)) - .await - { - Ok(b) => b, + // Query kind:7 reactions referencing this event via e-tag. + let filter = Filter::new() + .kind(k(kind::KIND_REACTION)) + .custom_tag(tag_e(), [&p.event_id]) + .limit(200); + + match self.client.query(vec![filter]).await { + Ok(events) => { + // Group reactions by emoji content. + let mut grouped: std::collections::HashMap> = + std::collections::HashMap::new(); + for e in &events { + grouped + .entry(e.content.clone()) + .or_default() + .push(e.pubkey.to_hex()); + } + let reactions: Vec = grouped + .into_iter() + .map(|(emoji, pubkeys)| { + serde_json::json!({ + "emoji": emoji, + "count": pubkeys.len(), + "pubkeys": pubkeys, + }) + }) + .collect(); + serde_json::to_string(&serde_json::json!({ "reactions": reactions })) + .unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -2232,12 +2593,18 @@ with kind:45003 comments)." )] pub async fn set_profile(&self, Parameters(p): Parameters) -> String { // Read-merge-write: fetch current profile, merge desired changes, sign kind:0. - let current_profile: serde_json::Value = - match self.client.get("/api/users/me/profile").await { - Ok(body) => serde_json::from_str(&body) - .unwrap_or(serde_json::Value::Object(Default::default())), - Err(_) => serde_json::Value::Object(Default::default()), - }; + let my_pubkey = self.client.keys().public_key(); + let filter = Filter::new() + .kind(k(kind::KIND_PROFILE)) + .author(my_pubkey) + .limit(1); + let current_profile: serde_json::Value = match self.client.query(vec![filter]).await { + Ok(events) => events + .first() + .and_then(|e| serde_json::from_str(&e.content).ok()) + .unwrap_or(serde_json::Value::Object(Default::default())), + Err(_) => serde_json::Value::Object(Default::default()), + }; // Resolve each field: use new value if provided, else keep existing. let display_name = p @@ -2262,7 +2629,7 @@ with kind:45003 comments)." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign profile event: {e}"), }; @@ -2295,25 +2662,42 @@ with kind:45003 comments)." ); } } - match pubkeys.len() { - 0 => match self.client.get("/api/users/me/profile").await { - Ok(body) => body, - Err(e) => format!("Error fetching profile: {e}"), - }, - 1 => { - let path = format!("/api/users/{}/profile", percent_encode(&pubkeys[0])); - match self.client.get(&path).await { - Ok(body) => body, - Err(e) => format!("Error fetching profile: {e}"), - } - } - _ => { - let body = serde_json::json!({ "pubkeys": pubkeys }); - match self.client.post("/api/users/batch", &body).await { - Ok(resp) => resp, - Err(e) => format!("Error fetching profiles: {e}"), + + // If no pubkeys specified, fetch our own profile. + let authors: Vec = if pubkeys.is_empty() { + vec![self.client.keys().public_key()] + } else { + pubkeys + .iter() + .filter_map(|pk| nostr::PublicKey::from_hex(pk).ok()) + .collect() + }; + + let filter = Filter::new() + .kind(k(kind::KIND_PROFILE)) + .authors(authors) + .limit(pubkeys.len().max(1)); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let profiles: Vec = events + .iter() + .map(|e| { + let mut profile: serde_json::Value = + serde_json::from_str(&e.content).unwrap_or(serde_json::json!({})); + if let Some(obj) = profile.as_object_mut() { + obj.insert("pubkey".to_string(), serde_json::json!(e.pubkey.to_hex())); + } + profile + }) + .collect(); + if profiles.len() == 1 { + serde_json::to_string(&profiles[0]).unwrap_or_else(|e| format!("Error: {e}")) + } else { + serde_json::to_string(&profiles).unwrap_or_else(|e| format!("Error: {e}")) } } + Err(e) => format!("Error fetching profile: {e}"), } } @@ -2323,10 +2707,36 @@ with kind:45003 comments)." description = "Full-text search across messages in accessible channels. Returns matching messages with channel context. Powered by Typesense." )] pub async fn search(&self, Parameters(p): Parameters) -> String { - let limit = p.limit.unwrap_or(20).min(100); - let path = format!("/api/search?q={}&limit={}", percent_encode(&p.q), limit); - match self.client.get(&path).await { - Ok(body) => body, + let limit = p.limit.unwrap_or(20).min(100) as usize; + // Use NIP-50 search filter if the relay supports it. + // The `search` field in a filter is a NIP-50 extension. + let filter = Filter::new() + .kinds(vec![ + k(kind::KIND_STREAM_MESSAGE), + k(kind::KIND_STREAM_MESSAGE_V2), + k(kind::KIND_FORUM_POST), + k(kind::KIND_FORUM_COMMENT), + ]) + .search(&p.q) + .limit(limit); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let results: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_hex(), + "pubkey": e.pubkey.to_hex(), + "kind": e.kind.as_u16(), + "content": e.content, + "created_at": e.created_at.as_u64(), + "tags": e.tags.iter().map(|t| t.as_slice()).collect::>(), + }) + }) + .collect(); + serde_json::to_string(&results).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error searching: {e}"), } } @@ -2337,9 +2747,37 @@ with kind:45003 comments)." description = "Get presence status (online/away/offline) for one or more users by pubkey. Pass comma-separated hex pubkeys." )] pub async fn get_presence(&self, Parameters(p): Parameters) -> String { - let path = format!("/api/presence?pubkeys={}", percent_encode(&p.pubkeys)); - match self.client.get(&path).await { - Ok(body) => body, + // Query ephemeral presence events (kind:20001) for the given pubkeys. + let authors: Vec = p + .pubkeys + .split(',') + .filter_map(|pk| nostr::PublicKey::from_hex(pk.trim()).ok()) + .collect(); + + if authors.is_empty() { + return "Error: no valid pubkeys provided".to_string(); + } + + // Presence snapshots are kind:40902 (relay-generated, latest state). + let filter = Filter::new() + .kind(k(kind::KIND_PRESENCE_SNAPSHOT)) + .authors(authors) + .limit(200); + + match self.client.query(vec![filter]).await { + Ok(events) => { + let presence: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "pubkey": e.pubkey.to_hex(), + "status": e.content, + "updated_at": e.created_at.as_u64(), + }) + }) + .collect(); + serde_json::to_string(&presence).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error fetching presence: {e}"), } } @@ -2350,9 +2788,19 @@ with kind:45003 comments)." description = "Set the agent's presence status. Valid values: 'online', 'away', 'offline'. Presence auto-expires after 90 seconds — call periodically to stay online." )] pub async fn set_presence(&self, Parameters(p): Parameters) -> String { - let body = serde_json::json!({ "status": p.status }); - match self.client.put("/api/presence", &body).await { - Ok(b) => b, + // Validate status value. + // Publish ephemeral presence event (kind:20001). + let builder = EventBuilder::new(k(kind::KIND_PRESENCE_UPDATE), p.status.as_str(), []); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign presence event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "status": p.status, + "accepted": ok.accepted, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -2372,13 +2820,19 @@ with kind:45003 comments)." 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, + // Store as a kind:10100 (agent profile) replaceable event with the policy in content. + let content = serde_json::json!({ "channel_add_policy": p.policy }).to_string(); + let builder = EventBuilder::new(k(kind::KIND_AGENT_PROFILE), &content, []); + let event = match self.client.sign_event(builder) { + Ok(e) => e, + Err(e) => return format!("Error: failed to sign agent profile event: {e}"), + }; + match self.client.send_event(event).await { + Ok(ok) => serde_json::json!({ + "policy": p.policy, + "accepted": ok.accepted, + }) + .to_string(), Err(e) => format!("Error: {e}"), } } @@ -2415,7 +2869,7 @@ with kind:45003 comments)." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign vote event: {e}"), }; @@ -2447,7 +2901,7 @@ with kind:45003 comments)." Ok(b) => b, Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign delete_channel event: {e}"), }; @@ -2490,7 +2944,7 @@ with kind:45003 comments)." Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign event: {e}"), }; @@ -2532,7 +2986,7 @@ with kind:45003 comments)." Err(e) => return format!("Error: {e}"), }; - let event = match builder.sign_with_keys(self.client.keys()) { + let event = match self.client.sign_event(builder) { Ok(e) => e, Err(e) => return format!("Error: failed to sign event: {e}"), }; @@ -2557,9 +3011,16 @@ with kind:45003 comments)." if let Err(e) = validate_hex64(&p.event_id, "event_id") { return e; } - let path = format!("/api/events/{}", p.event_id); - match self.client.get(&path).await { - Ok(body) => body, + let eid = match EventId::from_hex(&p.event_id) { + Ok(id) => id, + Err(e) => return format!("Error: invalid event_id: {e}"), + }; + let filter = Filter::new().id(eid).limit(1); + match self.client.query(vec![filter]).await { + Ok(events) => match events.first() { + Some(event) => event.as_json(), + None => format!("Error: event '{}' not found", p.event_id), + }, Err(e) => format!("Error: {e}"), } } @@ -2573,31 +3034,35 @@ with kind:45003 comments)." if let Err(e) = validate_hex64(&p.pubkey, "pubkey") { return e; } - if let Some(ref bid) = p.before_id { - if let Err(e) = validate_hex64(bid, "before_id") { - return e; - } - } - if p.before_id.is_some() && p.before.is_none() { - return "Error: before_id requires before".to_string(); - } - let mut url = format!("/api/users/{}/notes", p.pubkey); - let mut query_parts = vec![]; - if let Some(limit) = p.limit { - query_parts.push(format!("limit={limit}")); - } + let author = match nostr::PublicKey::from_hex(&p.pubkey) { + Ok(pk) => pk, + Err(e) => return format!("Error: invalid pubkey: {e}"), + }; + let limit = p.limit.unwrap_or(20).min(200) as usize; + let mut filter = Filter::new() + .kind(k(kind::KIND_TEXT_NOTE)) + .author(author) + .limit(limit); + if let Some(before) = p.before { - query_parts.push(format!("before={before}")); + filter = filter.until(nostr::Timestamp::from(before as u64)); } - if let Some(ref before_id) = p.before_id { - query_parts.push(format!("before_id={before_id}")); - } - if !query_parts.is_empty() { - url.push('?'); - url.push_str(&query_parts.join("&")); - } - match self.client.get(&url).await { - Ok(body) => body, + + match self.client.query(vec![filter]).await { + Ok(events) => { + let notes: Vec = events + .iter() + .map(|e| { + serde_json::json!({ + "id": e.id.to_hex(), + "pubkey": e.pubkey.to_hex(), + "content": e.content, + "created_at": e.created_at.as_u64(), + }) + }) + .collect(); + serde_json::to_string(¬es).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } @@ -2614,9 +3079,48 @@ with kind:45003 comments)." if let Err(e) = validate_hex64(&p.pubkey, "pubkey") { return e; } - let path = format!("/api/users/{}/contact-list", p.pubkey); - match self.client.get(&path).await { - Ok(body) => body, + let author = match nostr::PublicKey::from_hex(&p.pubkey) { + Ok(pk) => pk, + Err(e) => return format!("Error: invalid pubkey: {e}"), + }; + // Kind:3 is replaceable — query latest. + let filter = Filter::new() + .kind(k(kind::KIND_CONTACT_LIST)) + .author(author) + .limit(1); + + match self.client.query(vec![filter]).await { + Ok(events) => match events.first() { + Some(event) => event.as_json(), + None => format!("Error: no contact list found for {}", p.pubkey), + }, + Err(e) => format!("Error: {e}"), + } + } + + /// Upload a local file to the Sprout relay. + #[tool( + name = "upload_file", + description = "Upload a local file (image or video) to the Sprout relay. \ +Returns a BlobDescriptor with the URL, hash, dimensions, and other metadata. \ +Supported types: JPEG, PNG, GIF, WebP, MP4. \ +The returned URL can be included in messages, or use the file_paths parameter \ +on send_message to upload and attach in one step." + )] + pub async fn upload_file(&self, Parameters(p): Parameters) -> String { + match crate::upload::upload_file( + self.client.http_client(), + self.client.keys(), + &self.client.relay_http_url(), + self.client.server_domain().as_deref(), + &p.file_path, + self.client.auth_tag_json().as_deref(), + ) + .await + { + Ok(desc) => { + serde_json::to_string_pretty(&desc).unwrap_or_else(|e| format!("Error: {e}")) + } Err(e) => format!("Error: {e}"), } } diff --git a/crates/sprout-mcp/src/toolsets.rs b/crates/sprout-mcp/src/toolsets.rs index ed3b067d8c..a8ac37b41c 100644 --- a/crates/sprout-mcp/src/toolsets.rs +++ b/crates/sprout-mcp/src/toolsets.rs @@ -19,8 +19,8 @@ //! //! | Name | Tools | //! |-----------------|-------| -//! | `default` | 25 | -//! | `channel_admin` | 6 | +//! | `default` | 26 | +//! | `channel_admin` | 5 | //! | `dms` | 3 | //! | `canvas` | 2 | //! | `workflow_admin`| 5 | @@ -69,13 +69,13 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("set_presence", "default", false), ("trigger_workflow", "default", false), ("approve_step", "default", false), + ("list_channel_members", "default", true), // ── channel_admin ──────────────────────────────────────────────────────── ("create_channel", "channel_admin", false), ("archive_channel", "channel_admin", false), ("unarchive_channel", "channel_admin", false), ("add_channel_member", "channel_admin", false), ("remove_channel_member", "channel_admin", false), - ("list_channel_members", "channel_admin", true), // ── dms ────────────────────────────────────────────────────────────────── ("add_dm_member", "dms", false), ("hide_dm", "dms", false), @@ -103,13 +103,13 @@ pub const ALL_TOOLS: &[(&str, &str, bool)] = &[ ("get_event", "social", true), ("get_user_notes", "social", true), ("get_contact_list", "social", true), - // Deferred tools (not yet implemented): upload_file, subscribe, unsubscribe + // ── media ──────────────────────────────────────────────────────────────── + ("upload_file", "media", false), ]; /// Tools planned but not yet implemented. These will be added to ALL_TOOLS /// when their #[tool] handlers are created in server.rs. pub const DEFERRED_TOOLS: &[(&str, &str, bool)] = &[ - ("upload_file", "media", false), ("subscribe", "realtime", true), ("unsubscribe", "realtime", false), ]; @@ -317,9 +317,9 @@ mod tests { } #[test] - fn default_includes_25_tools() { + fn default_includes_26_tools() { let tools = enabled_tools("default"); - assert_eq!(tools.len(), 25); + assert_eq!(tools.len(), 26); assert!(tools.contains("send_message")); assert!(tools.contains("approve_step")); assert!(!tools.contains("create_channel")); @@ -356,7 +356,7 @@ mod tests { assert!(!remove.contains("send_message")); // create_channel is channel_admin+write → should be removed (ro) assert!(remove.contains("create_channel")); - // list_channel_members is channel_admin+read → should be kept (ro allows reads) + // list_channel_members is default+read → should be kept (rw) assert!(!remove.contains("list_channel_members")); } @@ -364,7 +364,7 @@ mod tests { fn unknown_toolset_is_skipped_gracefully() { // Should not panic; unknown toolset is silently ignored let tools = enabled_tools("default,nonexistent_toolset"); - assert_eq!(tools.len(), 25); // only default + assert_eq!(tools.len(), 26); // only default } #[test] @@ -383,13 +383,13 @@ mod tests { } #[test] - fn all_tools_count_is_48() { - assert_eq!(ALL_TOOLS.len(), 48); + fn all_tools_count_is_49() { + assert_eq!(ALL_TOOLS.len(), 49); } #[test] - fn deferred_tools_count_is_3() { - assert_eq!(DEFERRED_TOOLS.len(), 3); + fn deferred_tools_count_is_2() { + assert_eq!(DEFERRED_TOOLS.len(), 2); } #[test] @@ -408,15 +408,16 @@ mod tests { #[test] fn all_toolsets_returns_correct_count() { - // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social - // (media and realtime have no implemented tools yet) + // ALL_TOOLS covers: default, channel_admin, dms, canvas, workflow_admin, identity, forums, social, media + // (realtime has no implemented tools yet) let defs = all_toolsets(); - assert_eq!(defs.len(), 8); + assert_eq!(defs.len(), 9); let names: Vec<_> = defs.iter().map(|d| d.name).collect(); assert!(names.contains(&"default")); assert!(names.contains(&"canvas")); assert!(names.contains(&"forums")); assert!(names.contains(&"social")); + assert!(names.contains(&"media")); } // ── Cross-check: ALL_TOOLS integrity ──────────────────────────────────── diff --git a/crates/sprout-mcp/src/upload.rs b/crates/sprout-mcp/src/upload.rs new file mode 100644 index 0000000000..9ac75c2c0d --- /dev/null +++ b/crates/sprout-mcp/src/upload.rs @@ -0,0 +1,420 @@ +//! File upload pipeline for Sprout media (Blossom protocol). +//! +//! Reads a local file, validates it against size/type constraints, computes a SHA-256 +//! hash, signs a kind:24242 Blossom auth event, PUTs the file to the relay's +//! `/media/upload` endpoint, and returns a [`BlobDescriptor`]. + +use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _}; +use nostr::util::hex as nostr_hex; +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp}; +use sha2::{Digest, Sha256}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Maximum file size for image uploads (50 MB). +pub const MAX_IMAGE_BYTES: u64 = 50 * 1024 * 1024; + +/// Maximum file size for video uploads (500 MB). +pub const MAX_VIDEO_BYTES: u64 = 500 * 1024 * 1024; + +/// MIME types we accept for upload. +const ALLOWED_MIMES: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", +]; + +// ── Types ───────────────────────────────────────────────────────────────────── + +/// Descriptor returned by the relay after a successful upload. +#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)] +pub struct BlobDescriptor { + /// Public URL of the uploaded blob. + pub url: String, + /// Hex-encoded SHA-256 of the file content. + pub sha256: String, + /// File size in bytes. + pub size: u64, + /// MIME type (e.g. `image/jpeg`). + #[serde(rename = "type")] + pub mime_type: String, + /// Unix timestamp when the file was uploaded. + pub uploaded: i64, + /// Image dimensions as `x` (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub dim: Option, + /// Blurhash placeholder string (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub blurhash: Option, + /// Thumbnail URL (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub thumb: Option, + /// Duration in seconds for video/audio (optional). + #[serde(skip_serializing_if = "Option::is_none")] + pub duration: Option, +} + +/// Errors that can occur during the upload pipeline. +#[derive(Debug, thiserror::Error)] +pub enum UploadError { + /// The path does not exist on disk. + #[error("file not found: {0}")] + FileNotFound(String), + /// The path exists but is not a regular file. + #[error("not a file: {0}")] + NotAFile(String), + /// File exceeds the size limit for its type. + #[error("file too large: {size} bytes (max {max})")] + FileTooLarge { + /// Actual file size. + size: u64, + /// Maximum allowed size. + max: u64, + }, + /// MIME type is not in the allowlist. + #[error("unsupported file type: {0}")] + UnsupportedFileType(String), + /// Read bytes don't match metadata length. + #[error("size mismatch: metadata says {expected}, read {actual}")] + SizeMismatch { + /// Size reported by filesystem metadata. + expected: u64, + /// Actual number of bytes read. + actual: u64, + }, + /// Filesystem I/O error. + #[error("IO error: {0}")] + Io(#[from] std::io::Error), + /// Nostr event signing failed. + #[error("signing failed: {0}")] + SigningFailed(String), + /// Server returned a non-success status. + #[error("upload rejected ({status}): {body}")] + ServerRejected { + /// HTTP status code. + status: u16, + /// Response body text. + body: String, + }, + /// HTTP transport error. + #[error("HTTP error: {0}")] + Http(#[from] reqwest::Error), + /// Could not parse the server's response as a BlobDescriptor. + #[error("invalid response: {0}")] + InvalidResponse(String), +} + +// ── Upload pipeline ─────────────────────────────────────────────────────────── + +/// Upload a local file to the Sprout relay's media endpoint. +/// +/// Performs validation, SHA-256 hashing, Blossom auth signing, and the HTTP PUT. +/// Returns the relay's [`BlobDescriptor`] on success. +/// +/// `auth_tag_json` is an optional NIP-OA auth tag (JSON-array string) sent as +/// the `x-auth-tag` header for relay membership delegation. +pub async fn upload_file( + http: &reqwest::Client, + keys: &Keys, + relay_http_url: &str, + server_domain: Option<&str>, + file_path: &str, + auth_tag_json: Option<&str>, +) -> Result { + // 1. Validate path exists + let metadata = std::fs::metadata(file_path).map_err(|e| { + if e.kind() == std::io::ErrorKind::NotFound { + UploadError::FileNotFound(file_path.to_string()) + } else { + UploadError::Io(e) + } + })?; + + if !metadata.is_file() { + return Err(UploadError::NotAFile(file_path.to_string())); + } + + let expected_size = metadata.len(); + + // Early rejection: no supported file type exceeds MAX_VIDEO_BYTES. + // This prevents buffering hundreds of MB into RAM before MIME detection. + if expected_size > MAX_VIDEO_BYTES { + return Err(UploadError::FileTooLarge { + size: expected_size, + max: MAX_VIDEO_BYTES, + }); + } + + // 2. Read file into memory + let bytes = std::fs::read(file_path)?; + let actual_size = bytes.len() as u64; + + // Post-read size check + if actual_size != expected_size { + return Err(UploadError::SizeMismatch { + expected: expected_size, + actual: actual_size, + }); + } + + // 3. Detect MIME via magic bytes + let mime = infer::get(&bytes) + .map(|t| t.mime_type()) + .unwrap_or("application/octet-stream"); + + if !ALLOWED_MIMES.contains(&mime) { + return Err(UploadError::UnsupportedFileType(mime.to_string())); + } + + // 4. Pre-check file size against type-specific limits + let max_size = if mime.starts_with("video/") { + MAX_VIDEO_BYTES + } else { + MAX_IMAGE_BYTES + }; + + if actual_size > max_size { + return Err(UploadError::FileTooLarge { + size: actual_size, + max: max_size, + }); + } + + // 5. Compute SHA-256 + let sha256 = nostr_hex::encode(Sha256::digest(&bytes)); + + // 6. Sign Blossom auth event (kind:24242) + let now = Timestamp::now().as_u64(); + let expiry = if mime.starts_with("video/") { + 3600 + } else { + 600 + }; + let exp_str = (now + expiry).to_string(); + + let mut tags = vec![ + Tag::parse(&["t", "upload"]).map_err(|e| UploadError::SigningFailed(e.to_string()))?, + Tag::parse(&["x", &sha256]).map_err(|e| UploadError::SigningFailed(e.to_string()))?, + Tag::parse(&["expiration", &exp_str]) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?, + ]; + if let Some(domain) = server_domain { + tags.push( + Tag::parse(&["server", domain]) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?, + ); + } + + let auth_event = EventBuilder::new(Kind::from(24242), "Upload file", tags) + .sign_with_keys(keys) + .map_err(|e| UploadError::SigningFailed(e.to_string()))?; + + // 7. Base64url encode the auth event + let auth_header = format!( + "Nostr {}", + URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) + ); + + // 8. HTTP PUT — with a generous per-request timeout. + // The shared reqwest client has a 10s timeout suitable for REST API calls, + // but uploads can take minutes for large files. Override per-request. + let upload_timeout = if mime.starts_with("video/") { + std::time::Duration::from_secs(600) // 10 min for video (up to 500 MB) + } else { + std::time::Duration::from_secs(120) // 2 min for images (up to 50 MB) + }; + + let url = format!("{}/media/upload", relay_http_url.trim_end_matches('/')); + let mut req = http + .put(&url) + .timeout(upload_timeout) + .header("Authorization", &auth_header) + .header("Content-Type", mime) + .header("X-SHA-256", &sha256); + if let Some(tag) = auth_tag_json { + req = req.header("x-auth-tag", tag); + } + let resp = req.body(bytes).send().await?; + + // 9. Handle response + let status = resp.status(); + if !status.is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(UploadError::ServerRejected { + status: status.as_u16(), + body, + }); + } + + let body = resp.text().await?; + serde_json::from_str::(&body) + .map_err(|e| UploadError::InvalidResponse(format!("{e}: {body}"))) +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/// Build a NIP-92 `imeta` tag from a [`BlobDescriptor`]. +/// +/// The returned `Vec` is suitable for passing to `Tag::parse`. +pub fn build_imeta_tag(d: &BlobDescriptor) -> Vec { + let mut tag = vec![ + "imeta".to_string(), + format!("url {}", d.url), + format!("m {}", d.mime_type), + format!("x {}", d.sha256), + format!("size {}", d.size), + ]; + if let Some(ref dim) = d.dim { + tag.push(format!("dim {dim}")); + } + if let Some(ref bh) = d.blurhash { + tag.push(format!("blurhash {bh}")); + } + if let Some(ref th) = d.thumb { + tag.push(format!("thumb {th}")); + } + if let Some(dur) = d.duration { + tag.push(format!("duration {dur}")); + } + tag +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn image_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/abc123.jpg".to_string(), + sha256: "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855".to_string(), + size: 12345, + mime_type: "image/jpeg".to_string(), + uploaded: 1700000000, + dim: Some("1920x1080".to_string()), + blurhash: Some("LEHV6nWB2yk8pyo0adR*.7kCMdnj".to_string()), + thumb: Some("https://relay.example.com/media/abc123_thumb.jpg".to_string()), + duration: None, + } + } + + fn video_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/vid456.mp4".to_string(), + sha256: "abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890".to_string(), + size: 5_000_000, + mime_type: "video/mp4".to_string(), + uploaded: 1700000000, + dim: Some("1280x720".to_string()), + blurhash: None, + thumb: None, + duration: Some(42.5), + } + } + + fn minimal_descriptor() -> BlobDescriptor { + BlobDescriptor { + url: "https://relay.example.com/media/min.png".to_string(), + sha256: "0000000000000000000000000000000000000000000000000000000000000000".to_string(), + size: 100, + mime_type: "image/png".to_string(), + uploaded: 1700000000, + dim: None, + blurhash: None, + thumb: None, + duration: None, + } + } + + #[test] + fn test_build_imeta_tag_image() { + let d = image_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/abc123.jpg"); + assert_eq!(tag[2], "m image/jpeg"); + assert_eq!( + tag[3], + "x e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!(tag[4], "size 12345"); + assert_eq!(tag[5], "dim 1920x1080"); + assert_eq!(tag[6], "blurhash LEHV6nWB2yk8pyo0adR*.7kCMdnj"); + assert_eq!( + tag[7], + "thumb https://relay.example.com/media/abc123_thumb.jpg" + ); + // No duration for images + assert_eq!(tag.len(), 8); + } + + #[test] + fn test_build_imeta_tag_video() { + let d = video_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/vid456.mp4"); + assert_eq!(tag[2], "m video/mp4"); + assert_eq!( + tag[3], + "x abcdef1234567890abcdef1234567890abcdef1234567890abcdef1234567890" + ); + assert_eq!(tag[4], "size 5000000"); + assert_eq!(tag[5], "dim 1280x720"); + // No blurhash or thumb + assert_eq!(tag[6], "duration 42.5"); + assert_eq!(tag.len(), 7); + } + + #[test] + fn test_build_imeta_tag_minimal() { + let d = minimal_descriptor(); + let tag = build_imeta_tag(&d); + + assert_eq!(tag.len(), 5); + assert_eq!(tag[0], "imeta"); + assert_eq!(tag[1], "url https://relay.example.com/media/min.png"); + assert_eq!(tag[2], "m image/png"); + assert_eq!( + tag[3], + "x 0000000000000000000000000000000000000000000000000000000000000000" + ); + assert_eq!(tag[4], "size 100"); + } + + #[test] + fn test_mime_allowlist() { + // Allowed types + assert!(ALLOWED_MIMES.contains(&"image/jpeg")); + assert!(ALLOWED_MIMES.contains(&"image/png")); + assert!(ALLOWED_MIMES.contains(&"image/gif")); + assert!(ALLOWED_MIMES.contains(&"image/webp")); + assert!(ALLOWED_MIMES.contains(&"video/mp4")); + + // Rejected types + assert!(!ALLOWED_MIMES.contains(&"application/pdf")); + assert!(!ALLOWED_MIMES.contains(&"text/plain")); + assert!(!ALLOWED_MIMES.contains(&"image/svg+xml")); + assert!(!ALLOWED_MIMES.contains(&"video/webm")); + assert!(!ALLOWED_MIMES.contains(&"application/octet-stream")); + } + + #[test] + fn test_file_size_limits() { + // Image limit: 50 MB + assert_eq!(MAX_IMAGE_BYTES, 50 * 1024 * 1024); + assert_eq!(MAX_IMAGE_BYTES, 52_428_800); + + // Video limit: 500 MB + assert_eq!(MAX_VIDEO_BYTES, 500 * 1024 * 1024); + assert_eq!(MAX_VIDEO_BYTES, 524_288_000); + + // Video limit is 10x image limit + assert_eq!(MAX_VIDEO_BYTES, MAX_IMAGE_BYTES * 10); + } +} diff --git a/crates/sprout-media/src/error.rs b/crates/sprout-media/src/error.rs index 7460db0447..764e163906 100644 --- a/crates/sprout-media/src/error.rs +++ b/crates/sprout-media/src/error.rs @@ -50,6 +50,8 @@ pub enum MediaError { Unauthorized, #[error("insufficient scope")] InsufficientScope, + #[error("relay membership required")] + RelayMembershipRequired, #[error("token revoked")] TokenRevoked, #[error("pubkey mismatch")] @@ -130,6 +132,7 @@ impl IntoResponse for MediaError { ) } Self::InsufficientScope => (StatusCode::FORBIDDEN, self.to_string()), + Self::RelayMembershipRequired => (StatusCode::FORBIDDEN, self.to_string()), Self::UnsupportedContainer => (StatusCode::UNSUPPORTED_MEDIA_TYPE, self.to_string()), Self::WrongCodec | Self::DurationTooLong diff --git a/crates/sprout-pair-relay/Cargo.toml b/crates/sprout-pair-relay/Cargo.toml new file mode 100644 index 0000000000..a4b727895d --- /dev/null +++ b/crates/sprout-pair-relay/Cargo.toml @@ -0,0 +1,36 @@ +[package] +name = "sprout-pair-relay" +description = "Ephemeral sidecar relay for NIP-AB device pairing handshakes" +version.workspace = true +edition.workspace = true +rust-version.workspace = true +license.workspace = true +repository.workspace = true + +[lib] +name = "sprout_pair_relay" +path = "src/lib.rs" + +[[bin]] +name = "sprout-pair-relay" +path = "src/main.rs" + +[dependencies] +tokio = { workspace = true, features = ["rt-multi-thread", "macros", "net", "time", "sync"] } +tokio-tungstenite = { workspace = true } +serde_json = { workspace = true } +futures-util = { workspace = true } +tokio-util = { workspace = true } +parking_lot = "0.12" +hyper = { version = "1", features = ["server", "http1"] } +hyper-util = { version = "0.1", features = ["tokio"] } +http-body-util = "0.1" +secp256k1 = { version = "0.29", features = ["global-context"] } +sha2 = "0.10" + +[dev-dependencies] +tokio = { workspace = true, features = ["test-util"] } +tokio-tungstenite = { workspace = true } +secp256k1 = { version = "0.29", features = ["global-context", "rand-std"] } +sha2 = "0.10" +rand = "0.8" diff --git a/crates/sprout-pair-relay/src/lib.rs b/crates/sprout-pair-relay/src/lib.rs new file mode 100644 index 0000000000..09d44f26cb --- /dev/null +++ b/crates/sprout-pair-relay/src/lib.rs @@ -0,0 +1,1044 @@ +//! Ephemeral sidecar relay for NIP-AB device pairing handshakes. +//! +//! Accepts WebSocket connections, matches incoming kind:24134 events against +//! live `#p`-filtered subscriptions, and forwards matches to the subscriber. +//! No persistence. No auth. No history. +//! +//! # Deployment +//! +//! This binary binds **loopback only** and MUST run behind a reverse proxy +//! (nginx, caddy, etc.) that: +//! - Routes only `/pair` to this sidecar +//! - Enforces HTTP read timeouts (mitigates slowloris at the TCP layer) +//! - Terminates TLS +//! +//! The relay does not enforce path restrictions or pre-upgrade connection +//! limits — those are the reverse proxy's responsibility. +//! +//! # Security Model +//! +//! - **Signature verification** — Schnorr signatures are verified against the +//! NIP-01 event ID hash. Events with invalid signatures are rejected. +//! - **No persistence** — events exist only in-flight between matched pub/sub. +//! - **Bounded resources** — 128 max WS connections, 4 KiB max frame, 120s TTL. +//! - **Session cap** — at most 6 accepted EVENTs per connection. +//! - **Freshness** — `created_at` must be within ±120 s of relay wall-clock. +//! - **Deduplication** — duplicate event IDs are rejected; dedup entries expire after 300 s. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU32, AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use http_body_util::Full; +use hyper::body::{Bytes, Incoming}; +use hyper::header::{ + HeaderValue, CONNECTION, SEC_WEBSOCKET_ACCEPT, SEC_WEBSOCKET_KEY, SEC_WEBSOCKET_VERSION, + UPGRADE, +}; +use hyper::server::conn::http1; +use hyper::service::service_fn; +use hyper::upgrade::Upgraded; +use hyper::{Method, Request, Response, StatusCode, Version}; +use hyper_util::rt::TokioIo; +use parking_lot::Mutex; +use secp256k1::schnorr::Signature as SchnorrSig; +use secp256k1::{Message as SecpMsg, XOnlyPublicKey}; +use serde_json::Value; +use sha2::{Digest, Sha256}; +use tokio::net::TcpListener; +use tokio::sync::mpsc; +use tokio::time::timeout; +use tokio_tungstenite::tungstenite::handshake::derive_accept_key; +use tokio_tungstenite::tungstenite::protocol::{Message, Role, WebSocketConfig}; +use tokio_tungstenite::WebSocketStream; +use tokio_util::sync::CancellationToken; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// Hard per-connection lifetime. `pub(crate)` for test access. +pub(crate) const CONN_TIMEOUT: Duration = Duration::from_secs(120); + +const MAX_CONNS: u32 = 128; +const CHANNEL_CAP: usize = 4; +const KIND_PAIR: u64 = 24134; +/// Max WebSocket frame/message size. NIP-AB handshake payloads are small +/// (ephemeral pubkeys + encrypted session data), well under 4 KiB. +const MAX_FRAME: usize = 4096; +const RATE_WINDOW: Duration = Duration::from_secs(10); +const RATE_MSG_MAX: u32 = 20; +const RATE_EVENT_MAX: u32 = 10; +const SUB_ID_MAX: usize = 64; + +/// Hard session cap: at most this many attempted EVENTs (post-sig-check) per connection. +const MAX_EVENTS_PER_CONN: u32 = 6; + +/// Per-#p delivery budget: enough for one full pairing from each direction. +const MAX_DELIVERED_PER_P: u32 = 12; + +/// Dedup vec rejects new events when still at capacity after TTL eviction (fail closed). +const DEDUP_CAP: usize = 1024; + +/// Delivered map rejects new #p keys when still at capacity after TTL eviction (fail closed). +const DELIVERED_MAP_CAP: usize = 4096; + +/// TTL for entries in `seen_ids` and `delivered`. Entries older than this are +/// evicted on the next access, keeping both structures bounded over time. +const ENTRY_TTL: Duration = Duration::from_secs(300); + +/// Freshness window in seconds (±). +const FRESHNESS_SECS: i64 = 120; + +// ── Core types ──────────────────────────────────────────────────────────────── + +enum OutMsg { + Text(String), + Pong(Vec), + Close, +} + +struct Sub { + conn_id: u64, + sub_id: String, + p_value: [u8; 32], + writer_tx: mpsc::Sender, +} + +pub struct Relay { + subs: Mutex>, + conn_count: AtomicU32, + next_conn_id: AtomicU64, + /// Global dedup vec — entries expire after ENTRY_TTL; rejects at DEDUP_CAP after eviction. + seen_ids: Mutex>, + /// Per-#p delivery counter — entries expire after ENTRY_TTL; rejects at DELIVERED_MAP_CAP after eviction. + delivered: Mutex>, +} + +impl Default for Relay { + fn default() -> Self { + Self::new() + } +} + +impl Relay { + pub fn new() -> Self { + Self { + subs: Mutex::new(Vec::new()), + conn_count: AtomicU32::new(0), + next_conn_id: AtomicU64::new(0), + seen_ids: Mutex::new(Vec::new()), + delivered: Mutex::new(HashMap::new()), + } + } + + /// Atomically check-and-reserve an event ID. Evicts expired entries first. + /// Returns `Ok(true)` if duplicate (already seen), `Ok(false)` if new + /// (reserved — caller MUST call `unreserve_id` if delivery fails), + /// `Err(())` if at capacity after eviction (fail closed). + fn reserve_id(&self, id: &[u8; 32]) -> Result { + let mut vec = self.seen_ids.lock(); + vec.retain(|(_, ts)| ts.elapsed() < ENTRY_TTL); + if vec.iter().any(|(eid, _)| eid == id) { + return Ok(true); // duplicate + } + if vec.len() >= DEDUP_CAP { + return Err(()); // at capacity after eviction — fail closed + } + // Optimistically reserve the slot. + vec.push((*id, tokio::time::Instant::now())); + Ok(false) + } + + /// Remove a previously reserved ID (called when delivery fails). + fn unreserve_id(&self, id: &[u8; 32]) { + let mut vec = self.seen_ids.lock(); + if let Some(pos) = vec.iter().position(|(eid, _)| eid == id) { + vec.swap_remove(pos); + } + } + + /// Atomically check for exactly one subscriber and deliver. + /// Returns `Ok(true)` if delivered, `Ok(false)` if `try_send` failed, + /// `Err(reason)` if wrong subscriber count or budget exceeded. + fn deliver_single(&self, p_value: &[u8; 32], event: &Value) -> Result { + // Acquire subs lock once for the entire operation. + let subs = self.subs.lock(); + + let matching: Vec<&Sub> = subs.iter().filter(|s| &s.p_value == p_value).collect(); + + match matching.len() { + 0 => return Err("no live subscriber"), + 1 => {} // exactly one — proceed + _ => return Err("ambiguous recipient"), + } + + let sub = matching[0]; + + // Hold delivered lock for the entire check+increment (atomic budget). + let mut delivered = self.delivered.lock(); + + // Evict entries older than ENTRY_TTL before checking capacity. + delivered.retain(|_, (_, ts)| ts.elapsed() < ENTRY_TTL); + + let count = delivered.get(p_value).map(|(c, _)| *c).unwrap_or(0); + if count >= MAX_DELIVERED_PER_P { + return Err("recipient session budget exhausted"); + } + // Fail closed if still at capacity after eviction and key is new. + if delivered.len() >= DELIVERED_MAP_CAP && !delivered.contains_key(p_value) { + return Err("relay at capacity"); + } + + // Build the EVENT message. + let msg = Value::Array(vec![ + Value::String("EVENT".into()), + Value::String(sub.sub_id.clone()), + event.clone(), + ]); + let text = match serde_json::to_string(&msg) { + Ok(s) => s, + Err(_) => return Ok(false), + }; + + // Attempt delivery. + if sub.writer_tx.try_send(OutMsg::Text(text)).is_ok() { + // Increment counter atomically (lock already held), refreshing the timestamp. + let entry = delivered + .entry(*p_value) + .or_insert((0, tokio::time::Instant::now())); + entry.0 += 1; + entry.1 = tokio::time::Instant::now(); + Ok(true) + } else { + Ok(false) + } + } + + fn remove_sub(&self, conn_id: u64) { + self.subs.lock().retain(|s| s.conn_id != conn_id); + } +} + +// ── RAII connection guard ───────────────────────────────────────────────────── + +struct ConnGuard { + relay: Arc, + conn_id: u64, +} + +impl Drop for ConnGuard { + fn drop(&mut self) { + self.relay.remove_sub(self.conn_id); + self.relay.conn_count.fetch_sub(1, Ordering::Relaxed); + eprintln!( + "conn closed conn_id={} active={}", + self.conn_id, + self.relay.conn_count.load(Ordering::Relaxed) + ); + } +} + +// ── Rate limiter ────────────────────────────────────────────────────────────── + +struct RateWindow { + count: u32, + window_start: tokio::time::Instant, +} + +impl RateWindow { + fn new() -> Self { + Self { + count: 0, + window_start: tokio::time::Instant::now(), + } + } + + fn tick(&mut self) -> u32 { + if self.window_start.elapsed() >= RATE_WINDOW { + self.count = 0; + self.window_start = tokio::time::Instant::now(); + } + self.count += 1; + self.count + } +} + +// ── JSON helpers ────────────────────────────────────────────────────────────── + +fn jarr(v: Vec) -> String { + Value::Array(v).to_string() +} + +fn make_ok(id: &str, ok: bool, msg: &str) -> String { + jarr(vec![ + Value::String("OK".into()), + Value::String(id.into()), + Value::Bool(ok), + Value::String(msg.into()), + ]) +} + +fn make_closed(sub_id: &str, msg: &str) -> String { + jarr(vec![ + Value::String("CLOSED".into()), + Value::String(sub_id.into()), + Value::String(msg.into()), + ]) +} + +fn make_eose(sub_id: &str) -> String { + jarr(vec![ + Value::String("EOSE".into()), + Value::String(sub_id.into()), + ]) +} + +fn make_notice(msg: &str) -> String { + jarr(vec![ + Value::String("NOTICE".into()), + Value::String(msg.into()), + ]) +} + +// ── Validation ──────────────────────────────────────────────────────────────── + +fn is_lower_hex(s: &str, len: usize) -> bool { + s.len() == len && s.bytes().all(|b| matches!(b, b'0'..=b'9' | b'a'..=b'f')) +} + +fn decode_hex32(s: &str) -> Option<[u8; 32]> { + if !is_lower_hex(s, 64) { + return None; + } + let mut out = [0u8; 32]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = (chunk[0] as char).to_digit(16)?; + let lo = (chunk[1] as char).to_digit(16)?; + out[i] = (hi * 16 + lo) as u8; + } + Some(out) +} + +/// Validate a REQ filter. Returns `Ok(p_value)` or `Err(reason)`. +fn validate_filter(filter: &Value) -> Result<[u8; 32], &'static str> { + let obj = filter.as_object().ok_or("filter must be an object")?; + for key in obj.keys() { + match key.as_str() { + "kinds" | "#p" => {} + _ => return Err("unsupported filter field"), + } + } + if let Some(kinds) = obj.get("kinds") { + let arr = kinds.as_array().ok_or("kinds must be an array")?; + if arr.len() != 1 || arr[0].as_u64() != Some(KIND_PAIR) { + return Err("kinds must be [24134]"); + } + } + let p_arr = obj + .get("#p") + .and_then(|v| v.as_array()) + .ok_or("#p filter required")?; + if p_arr.len() != 1 { + return Err("#p must have exactly one value"); + } + let p_str = p_arr[0].as_str().ok_or("#p value must be a string")?; + decode_hex32(p_str).ok_or("#p value must be 64 lowercase hex chars") +} + +/// Validate NIP-44 content structure (no external crate — manual base64 check). +/// +/// Checks: +/// - Standard base64 alphabet only (A-Z, a-z, 0-9, +, /, =) +/// - Decoded length ≥ 99 bytes (1 version + 32 nonce + 32 min ciphertext + 32 MAC + 2 padding) +/// - First decoded byte is 0x02 (NIP-44 version 2) +fn validate_nip44_content(content: &str) -> Result<(), &'static str> { + if content.is_empty() { + return Err("content must not be empty"); + } + + // Validate base64 alphabet and compute decoded length. + let bytes = content.as_bytes(); + let len = bytes.len(); + + // base64 strings must have length that is a multiple of 4 (with padding). + if !len.is_multiple_of(4) { + return Err("content is not valid base64"); + } + + // Check alphabet and count padding. + let mut pad_count = 0usize; + for (i, &b) in bytes.iter().enumerate() { + match b { + b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'+' | b'/' => { + if pad_count > 0 { + // Non-pad after pad is invalid. + return Err("content is not valid base64"); + } + } + b'=' => { + // Padding only allowed in last two positions. + if i < len - 2 { + return Err("content is not valid base64"); + } + pad_count += 1; + if pad_count > 2 { + return Err("content is not valid base64"); + } + } + _ => return Err("content is not valid base64"), + } + } + + // Decoded byte length = (len / 4) * 3 - pad_count. + let decoded_len = (len / 4) * 3 - pad_count; + if decoded_len < 99 { + return Err("content too short for NIP-44 v2"); + } + + // Decode only the first byte to check the version prefix. + // First base64 char encodes bits 7-2 of byte 0; second encodes bits 1-0 of + // byte 0 (high) and bits 5-2 of byte 1 (low). We only need byte 0. + let b64_val = |c: u8| -> Option { + match c { + b'A'..=b'Z' => Some(c - b'A'), + b'a'..=b'z' => Some(c - b'a' + 26), + b'0'..=b'9' => Some(c - b'0' + 52), + b'+' => Some(62), + b'/' => Some(63), + _ => None, + } + }; + let v0 = b64_val(bytes[0]).ok_or("content is not valid base64")?; + let v1 = b64_val(bytes[1]).ok_or("content is not valid base64")?; + let first_byte = (v0 << 2) | (v1 >> 4); + + if first_byte != 0x02 { + return Err("content is not NIP-44 v2 (expected 0x02 prefix)"); + } + + Ok(()) +} + +/// Validate an EVENT object. Returns `Ok((event_id_str, event_id_bytes, p_value))` or `Err(reason)`. +/// +/// Tightenings applied here: +/// - Exactly 7 top-level keys (tightening #4) +/// - Strict tag shape: exactly `[["p", "<64-hex>"]]` (tightening #3) +/// - NIP-44 content validation (tightening #5) +/// - Freshness window on `created_at` (tightening #6) +fn validate_event(ev: &Value) -> Result<(String, [u8; 32], [u8; 32]), &'static str> { + let obj = ev.as_object().ok_or("event must be an object")?; + + // Tightening #4: reject extra top-level fields. + const ALLOWED_KEYS: &[&str] = &[ + "id", + "pubkey", + "created_at", + "kind", + "tags", + "content", + "sig", + ]; + for key in obj.keys() { + if !ALLOWED_KEYS.contains(&key.as_str()) { + return Err("unknown top-level field"); + } + } + + let id = obj.get("id").and_then(|v| v.as_str()).ok_or("missing id")?; + if !is_lower_hex(id, 64) { + return Err("id must be 64 lowercase hex chars"); + } + let id_bytes = decode_hex32(id).ok_or("id must be 64 lowercase hex chars")?; + + let pubkey = obj + .get("pubkey") + .and_then(|v| v.as_str()) + .ok_or("missing pubkey")?; + if !is_lower_hex(pubkey, 64) { + return Err("pubkey must be 64 lowercase hex chars"); + } + if obj.get("kind").and_then(|v| v.as_u64()) != Some(KIND_PAIR) { + return Err("kind must be 24134"); + } + + // Tightening #6: freshness window. + let created_at = obj + .get("created_at") + .and_then(|v| v.as_i64()) + .ok_or("missing created_at")?; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (created_at as i128 - now as i128).unsigned_abs() > FRESHNESS_SECS as u128 { + return Err("created_at outside freshness window"); + } + + let content = obj + .get("content") + .and_then(|v| v.as_str()) + .ok_or("missing content")?; + + // Tightening #5: NIP-44 content validation. + validate_nip44_content(content)?; + + let sig = obj + .get("sig") + .and_then(|v| v.as_str()) + .ok_or("missing sig")?; + if !is_lower_hex(sig, 128) { + return Err("sig must be 128 lowercase hex chars"); + } + + let tags = obj + .get("tags") + .and_then(|v| v.as_array()) + .ok_or("missing tags")?; + + // Tightening #3: exactly one tag, exactly ["p", "<64-hex>"]. + if tags.len() != 1 { + return Err("event must have exactly one p tag"); + } + let tag = tags[0].as_array().ok_or("tag must be an array")?; + if tag.len() != 2 { + return Err("p tag must have exactly 2 elements"); + } + if tag[0].as_str() != Some("p") { + return Err("event must have exactly one p tag"); + } + let p_str = tag[1].as_str().ok_or("p tag value must be a string")?; + let p_bytes = decode_hex32(p_str).ok_or("p tag value must be 64 lowercase hex chars")?; + + Ok((id.to_string(), id_bytes, p_bytes)) +} + +fn safe_event_id(ev: &Value) -> String { + ev.get("id") + .and_then(|v| v.as_str()) + .filter(|s| is_lower_hex(s, 64)) + .unwrap_or("") + .to_string() +} + +/// Verify the Schnorr signature and event ID for a NIP-01 event. +/// +/// Steps: +/// 1. Serialize the commitment array `[0, pubkey, created_at, kind, tags, content]` +/// as compact JSON (no spaces). +/// 2. SHA-256 hash the serialization. +/// 3. Verify the hash matches the claimed `id` field. +/// 4. Verify the Schnorr signature over the hash using the `pubkey` field. +fn verify_event_sig(ev: &Value) -> Result<(), &'static str> { + let obj = ev.as_object().ok_or("event must be an object")?; + + let pubkey_str = obj + .get("pubkey") + .and_then(|v| v.as_str()) + .ok_or("missing pubkey")?; + let created_at = obj + .get("created_at") + .and_then(|v| v.as_i64()) + .ok_or("missing created_at")?; + let kind = obj + .get("kind") + .and_then(|v| v.as_u64()) + .ok_or("missing kind")?; + let tags = obj.get("tags").ok_or("missing tags")?; + let content = obj + .get("content") + .and_then(|v| v.as_str()) + .ok_or("missing content")?; + let id_str = obj.get("id").and_then(|v| v.as_str()).ok_or("missing id")?; + let sig_str = obj + .get("sig") + .and_then(|v| v.as_str()) + .ok_or("missing sig")?; + + // Step 1: build the NIP-01 commitment and hash it. + let commitment = Value::Array(vec![ + Value::Number(0.into()), + Value::String(pubkey_str.to_string()), + Value::Number(created_at.into()), + Value::Number(kind.into()), + tags.clone(), + Value::String(content.to_string()), + ]); + let commitment_json = + serde_json::to_string(&commitment).map_err(|_| "failed to serialize commitment")?; + let hash: [u8; 32] = Sha256::digest(commitment_json.as_bytes()).into(); + + // Step 2: verify hash matches claimed id. + let id_bytes = decode_hex32(id_str).ok_or("id must be 64 lowercase hex chars")?; + if hash != id_bytes { + return Err("invalid: event id mismatch"); + } + + // Step 3: parse pubkey as x-only. + let pubkey_bytes = decode_hex32(pubkey_str).ok_or("pubkey must be 64 lowercase hex chars")?; + let xonly_pk = XOnlyPublicKey::from_slice(&pubkey_bytes).map_err(|_| "invalid: bad pubkey")?; + + // Step 4: parse sig. + let sig_bytes = decode_hex64(sig_str).ok_or("sig must be 128 lowercase hex chars")?; + let schnorr_sig = + SchnorrSig::from_slice(&sig_bytes).map_err(|_| "invalid: bad signature encoding")?; + + // Step 5: verify. + let secp = secp256k1::Secp256k1::verification_only(); + let msg = SecpMsg::from_digest(hash); + secp.verify_schnorr(&schnorr_sig, &msg, &xonly_pk) + .map_err(|_| "invalid: signature verification failed") +} + +/// Decode a 128-char lowercase hex string into 64 bytes. +fn decode_hex64(s: &str) -> Option<[u8; 64]> { + if !is_lower_hex(s, 128) { + return None; + } + let mut out = [0u8; 64]; + for (i, chunk) in s.as_bytes().chunks(2).enumerate() { + let hi = (chunk[0] as char).to_digit(16)?; + let lo = (chunk[1] as char).to_digit(16)?; + out[i] = (hi * 16 + lo) as u8; + } + Some(out) +} + +// ── Writer task ─────────────────────────────────────────────────────────────── + +type WsSink = futures_util::stream::SplitSink>, Message>; + +async fn writer_task(mut sink: WsSink, mut rx: mpsc::Receiver, cancel: CancellationToken) { + loop { + let msg = tokio::select! { + _ = cancel.cancelled() => break, + m = rx.recv() => match m { Some(m) => m, None => break }, + }; + let ws_msg = match msg { + OutMsg::Text(s) => Message::Text(s.into()), + OutMsg::Pong(d) => Message::Pong(d.into()), + OutMsg::Close => Message::Close(None), + }; + let result = tokio::select! { + _ = cancel.cancelled() => break, + r = timeout(Duration::from_secs(5), sink.send(ws_msg)) => r, + }; + match result { + Err(_) => break, // timeout + Ok(Err(_)) => break, // send error + Ok(Ok(())) => {} // success + } + } +} + +// ── Connection handler ──────────────────────────────────────────────────────── + +async fn handle_conn(relay: Arc, conn_id: u64, stream: WebSocketStream>) { + let _guard = ConnGuard { + relay: Arc::clone(&relay), + conn_id, + }; + let (sink, mut source) = stream.split(); + let (tx, rx) = mpsc::channel::(CHANNEL_CAP); + let cancel = CancellationToken::new(); + let writer_handle = tokio::spawn(writer_task(sink, rx, cancel.clone())); + tokio::pin!(writer_handle); + + let mut msg_rate = RateWindow::new(); + let mut event_rate = RateWindow::new(); + let mut sub_id: Option = None; + // Tightening #1: hard session cap — counts all valid+sig-verified EVENT attempts. + let mut events_attempted: u32 = 0; + let deadline = tokio::time::sleep(CONN_TIMEOUT); + tokio::pin!(deadline); + + 'conn: loop { + let frame = tokio::select! { + _ = &mut deadline => break 'conn, + _ = &mut writer_handle => break 'conn, // writer died → close + f = source.next() => match f { Some(f) => f, None => break 'conn }, + }; + + // All inbound frames count toward the message rate limit. + if msg_rate.tick() > RATE_MSG_MAX { + eprintln!("conn_id={} rate-limited (msg)", conn_id); + break 'conn; + } + + let frame = match frame { + Ok(f) => f, + Err(_) => break 'conn, + }; + + match frame { + Message::Binary(_) | Message::Frame(_) => break 'conn, + + Message::Ping(data) => { + if tx.try_send(OutMsg::Pong(data.to_vec())).is_err() { + break 'conn; + } + } + + Message::Pong(_) => {} + + Message::Close(_) => { + let _ = tx.try_send(OutMsg::Close); + break 'conn; + } + + Message::Text(text) => { + let arr: Vec = match serde_json::from_str::(text.as_str()).ok() { + Some(Value::Array(a)) if !a.is_empty() => a, + _ => { + let _ = tx.try_send(OutMsg::Text(make_notice("error: invalid message"))); + continue; + } + }; + + let verb = match arr[0].as_str() { + Some(v) => v.to_string(), + None => { + let _ = tx.try_send(OutMsg::Text(make_notice("error: invalid message"))); + continue; + } + }; + + match verb.as_str() { + "REQ" => { + // Structural validation first (before we have a valid sub_id). + if arr.len() < 3 || !arr[1].is_string() { + let _ = tx.try_send(OutMsg::Text(make_notice("error: invalid REQ"))); + continue; + } + // Now we know arr[1] is a string. + let client_sub_id = match arr[1].as_str() { + Some(s) if s.len() <= SUB_ID_MAX => s.to_string(), + Some(_) => { + let _ = tx.try_send(OutMsg::Text(make_closed( + "", + "error: sub_id too long", + ))); + continue; + } + None => { + continue; + } // arr[1].is_string() checked above + }; + if arr.len() > 3 { + let _ = tx.try_send(OutMsg::Text(make_closed( + &client_sub_id, + "error: multiple filters not supported", + ))); + continue; + } + if !arr[2].is_object() { + let _ = tx.try_send(OutMsg::Text(make_closed( + &client_sub_id, + "error: invalid filter", + ))); + continue; + } + if sub_id.is_some() { + let _ = tx.try_send(OutMsg::Text(make_closed( + &client_sub_id, + "error: already subscribed, send CLOSE first", + ))); + continue; + } + let p_value = match validate_filter(&arr[2]) { + Ok(p) => p, + Err(reason) => { + let _ = tx.try_send(OutMsg::Text(make_closed( + &client_sub_id, + &format!("error: {reason}"), + ))); + continue; + } + }; + // Atomically check #p uniqueness + register under one lock. + // EOSE try_send happens inside the lock to prevent a + // concurrent REQ from racing between check and push. + { + let mut subs = relay.subs.lock(); + if subs.iter().any(|s| s.p_value == p_value) { + let _ = tx.try_send(OutMsg::Text(make_closed( + &client_sub_id, + "error: #p already has a live subscriber", + ))); + continue; + } + // Send EOSE before registering (still under lock). + if tx + .try_send(OutMsg::Text(make_eose(&client_sub_id))) + .is_err() + { + break 'conn; + } + subs.push(Sub { + conn_id, + sub_id: client_sub_id.clone(), + p_value, + writer_tx: tx.clone(), + }); + } + sub_id = Some(client_sub_id); + } + + "EVENT" => { + if arr.len() != 2 { + let _ = tx.try_send(OutMsg::Text(make_notice("error: invalid EVENT"))); + continue; + } + // Rate check before validation (rate check takes priority). + if event_rate.tick() > RATE_EVENT_MAX { + let safe_id = safe_event_id(&arr[1]); + let _ = + tx.try_send(OutMsg::Text(make_ok(&safe_id, false, "rate-limited"))); + continue; + } + // Tightening #1: hard session cap check. + if events_attempted >= MAX_EVENTS_PER_CONN { + let safe_id = safe_event_id(&arr[1]); + let _ = tx.try_send(OutMsg::Text(make_ok( + &safe_id, + false, + "error: session event limit reached", + ))); + continue; + } + if !arr[1].is_object() { + let _ = tx.try_send(OutMsg::Text(make_ok( + "", + false, + "invalid: malformed event", + ))); + continue; + } + match validate_event(&arr[1]) { + Ok((event_id, id_bytes, p_value)) => { + // Tightening #8: Schnorr signature verification BEFORE dedup. + if let Err(reason) = verify_event_sig(&arr[1]) { + let _ = tx + .try_send(OutMsg::Text(make_ok(&event_id, false, reason))); + continue; + } + // Count all valid+sig-verified attempts toward the session cap. + events_attempted += 1; + // Tightening #7: atomic dedup reservation AFTER sig check. + match relay.reserve_id(&id_bytes) { + Ok(true) => { + let _ = tx.try_send(OutMsg::Text(make_ok( + &event_id, + false, + "duplicate: already seen", + ))); + continue; + } + Err(()) => { + let _ = tx.try_send(OutMsg::Text(make_ok( + &event_id, + false, + "relay at capacity", + ))); + continue; + } + Ok(false) => {} // reserved — proceed to delivery + } + // Tightening #2: atomically check subscriber and deliver. + match relay.deliver_single(&p_value, &arr[1]) { + Ok(true) => { + // ID stays reserved (already in dedup vec). + let _ = + tx.try_send(OutMsg::Text(make_ok(&event_id, true, ""))); + } + Ok(false) => { + // Delivery failed — unreserve so ID can be retried. + relay.unreserve_id(&id_bytes); + let _ = tx.try_send(OutMsg::Text(make_ok( + &event_id, + false, + "delivery failed", + ))); + } + Err(reason) => { + // Delivery rejected — unreserve so ID can be retried. + relay.unreserve_id(&id_bytes); + let _ = tx.try_send(OutMsg::Text(make_ok( + &event_id, false, reason, + ))); + } + } + } + Err(reason) => { + let safe_id = safe_event_id(&arr[1]); + let _ = tx.try_send(OutMsg::Text(make_ok( + &safe_id, + false, + &format!("invalid: {reason}"), + ))); + } + } + } + + "CLOSE" => { + if arr.len() != 2 { + let _ = tx.try_send(OutMsg::Text(make_notice("error: invalid CLOSE"))); + continue; + } + match arr[1].as_str() { + Some(sid) => { + if sub_id.as_deref() == Some(sid) { + relay.remove_sub(conn_id); + sub_id = None; + } + // Silently ignore unknown sub_id per NIP-01. + } + None => { + let _ = + tx.try_send(OutMsg::Text(make_notice("error: invalid CLOSE"))); + } + } + } + + _ => { + let _ = + tx.try_send(OutMsg::Text(make_notice("error: unsupported message"))); + } + } + } + } + } + + // Remove the subscription first so its cloned writer_tx is dropped. + // This allows the channel to close when we drop our local tx. + relay.remove_sub(conn_id); + + // Drop the sender so the writer can drain any queued messages (including + // Close frames), then cancel after a brief grace period. + drop(tx); + + // Only await the writer if it hasn't already completed (avoid double-poll panic). + if !writer_handle.is_finished() { + let _ = tokio::time::timeout(Duration::from_millis(100), &mut writer_handle).await; + } + cancel.cancel(); +} + +// ── HTTP upgrade ────────────────────────────────────────────────────────────── + +async fn http_service( + relay: Arc, + mut req: Request, +) -> Result>, hyper::Error> { + let headers = req.headers(); + let key = headers.get(SEC_WEBSOCKET_KEY).cloned(); + let is_ws = req.method() == Method::GET + && req.version() >= Version::HTTP_11 + && headers + .get(CONNECTION) + .and_then(|h| h.to_str().ok()) + .map(|h| { + h.split([' ', ',']) + .any(|p| p.eq_ignore_ascii_case("upgrade")) + }) + .unwrap_or(false) + && headers + .get(UPGRADE) + .and_then(|h| h.to_str().ok()) + .map(|h| h.eq_ignore_ascii_case("websocket")) + .unwrap_or(false) + && headers + .get(SEC_WEBSOCKET_VERSION) + .map(|h| h == "13") + .unwrap_or(false) + && key + .as_ref() + .map(|k| k.len() == 24 && k.as_bytes().iter().all(|&b| b.is_ascii())) + .unwrap_or(false); + + if !is_ws { + let mut r = Response::new(Full::default()); + *r.status_mut() = StatusCode::BAD_REQUEST; + return Ok(r); + } + + // Reserve slot before upgrading. + if relay.conn_count.fetch_add(1, Ordering::Relaxed) >= MAX_CONNS { + relay.conn_count.fetch_sub(1, Ordering::Relaxed); + let mut r = Response::new(Full::default()); + *r.status_mut() = StatusCode::SERVICE_UNAVAILABLE; + return Ok(r); + } + + let conn_id = relay.next_conn_id.fetch_add(1, Ordering::Relaxed); + eprintln!( + "conn opened conn_id={} active={}", + conn_id, + relay.conn_count.load(Ordering::Relaxed) + ); + + let accept = derive_accept_key(key.as_ref().map(|k| k.as_bytes()).unwrap_or(b"")); + let relay_clone = Arc::clone(&relay); + + tokio::spawn(async move { + match hyper::upgrade::on(&mut req).await { + Ok(upgraded) => { + let io = TokioIo::new(upgraded); + let mut ws_config = WebSocketConfig::default(); + ws_config.max_frame_size = Some(MAX_FRAME); + ws_config.max_message_size = Some(MAX_FRAME); + let stream = + WebSocketStream::from_raw_socket(io, Role::Server, Some(ws_config)).await; + handle_conn(relay_clone, conn_id, stream).await; + } + Err(e) => { + eprintln!("upgrade error: {e}"); + relay_clone.conn_count.fetch_sub(1, Ordering::Relaxed); + } + } + }); + + let mut resp = Response::new(Full::default()); + *resp.status_mut() = StatusCode::SWITCHING_PROTOCOLS; + resp.headers_mut() + .insert(CONNECTION, HeaderValue::from_static("Upgrade")); + resp.headers_mut() + .insert(UPGRADE, HeaderValue::from_static("websocket")); + if let Ok(val) = HeaderValue::from_str(&accept) { + resp.headers_mut().insert(SEC_WEBSOCKET_ACCEPT, val); + } + Ok(resp) +} + +// ── Server loop (extracted for testability) ─────────────────────────────────── + +/// Run the relay accept loop on the given listener. +/// Public for integration tests that bind to `:0`. +pub async fn run_server(listener: TcpListener, relay: Arc) { + let addr = listener.local_addr().ok(); + if let Some(a) = addr { + eprintln!("sprout-pair-relay listening on {a}"); + } + loop { + let (tcp, _peer) = match listener.accept().await { + Ok(pair) => pair, + Err(e) => { + eprintln!("accept error: {e}"); + continue; + } + }; + let relay = Arc::clone(&relay); + tokio::spawn(async move { + let io = TokioIo::new(tcp); + let svc = service_fn(move |req| http_service(Arc::clone(&relay), req)); + if let Err(e) = http1::Builder::new() + .serve_connection(io, svc) + .with_upgrades() + .await + { + eprintln!("http error: {e}"); + } + }); + } +} diff --git a/crates/sprout-pair-relay/src/main.rs b/crates/sprout-pair-relay/src/main.rs new file mode 100644 index 0000000000..f64c041edf --- /dev/null +++ b/crates/sprout-pair-relay/src/main.rs @@ -0,0 +1,19 @@ +use std::net::SocketAddr; +use std::sync::Arc; + +use sprout_pair_relay::{run_server, Relay}; +use tokio::net::TcpListener; + +#[tokio::main] +async fn main() { + let addr: SocketAddr = ([127, 0, 0, 1], 5000).into(); + let listener = match TcpListener::bind(addr).await { + Ok(l) => l, + Err(e) => { + eprintln!("fatal: failed to bind {addr}: {e}"); + std::process::exit(1); + } + }; + let relay = Arc::new(Relay::new()); + run_server(listener, relay).await; +} diff --git a/crates/sprout-pair-relay/tests/integration.rs b/crates/sprout-pair-relay/tests/integration.rs new file mode 100644 index 0000000000..ee09093b9a --- /dev/null +++ b/crates/sprout-pair-relay/tests/integration.rs @@ -0,0 +1,1400 @@ +//! Integration tests for sprout-pair-relay. +//! +//! Each test spins up a relay on a random port (`:0`), connects one or more +//! WebSocket clients, and exercises the observable protocol surface. + +use std::sync::Arc; +use std::time::Duration; + +use futures_util::{SinkExt, StreamExt}; +use serde_json::{json, Value}; +use tokio::net::TcpListener; +use tokio_tungstenite::{connect_async, tungstenite::Message, MaybeTlsStream, WebSocketStream}; + +use sprout_pair_relay::{run_server, Relay}; + +// ── Crypto imports (for real event signing) ─────────────────────────────────── + +use secp256k1::{Keypair, Secp256k1, SecretKey}; +use sha2::{Digest, Sha256}; + +// ── Constants ───────────────────────────────────────────────────────────────── + +/// A valid 64-char lowercase hex string (all 'a's). +const P_A: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; +/// A different valid 64-char lowercase hex string (all 'b's). +const P_B: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; +/// A valid event id (all 'c's) — used only in tests that are rejected before +/// ID/sig verification (kind check, shape check, etc.). +const EV_ID: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc"; +/// A valid pubkey (all 'd's) — used only in pre-sig-check rejection tests. +const PUBKEY: &str = "dddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddddd"; +/// A valid sig (128 'e's) — used only in pre-sig-check rejection tests. +const SIG: &str = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee"; + +// ── Test infrastructure ─────────────────────────────────────────────────────── + +type WS = WebSocketStream>; + +/// Start a relay on a random port, return the WebSocket URL. +async fn start_relay() -> String { + let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); + let addr = listener.local_addr().unwrap(); + let relay = Arc::new(Relay::new()); + tokio::spawn(run_server(listener, relay)); + format!("ws://127.0.0.1:{}", addr.port()) +} + +/// Connect a WebSocket client to the relay. +async fn connect(url: &str) -> WS { + let (ws, _) = connect_async(url).await.unwrap(); + ws +} + +/// Send a JSON value as a text frame. +async fn send(ws: &mut WS, msg: &Value) { + ws.send(Message::Text(msg.to_string().into())) + .await + .unwrap(); +} + +/// Receive the next text frame and parse it as JSON. +/// Panics if no message arrives within 2 seconds. +async fn recv(ws: &mut WS) -> Value { + let msg = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .expect("timed out waiting for message") + .expect("stream ended") + .expect("WebSocket error"); + match msg { + Message::Text(t) => serde_json::from_str(t.as_str()).expect("invalid JSON"), + other => panic!("expected Text frame, got {:?}", other), + } +} + +/// Try to receive the next frame; return None if nothing arrives within 500 ms. +async fn try_recv(ws: &mut WS) -> Option { + tokio::time::timeout(Duration::from_millis(500), ws.next()) + .await + .ok()? + .and_then(|r| r.ok()) +} + +/// Assert the connection is closed (stream ends) within 2 seconds. +async fn assert_closed(ws: &mut WS) { + let result = tokio::time::timeout(Duration::from_secs(2), ws.next()).await; + match result { + Err(_) => panic!("connection did not close within 2 s"), + Ok(None) => {} // clean EOF + Ok(Some(Ok(Message::Close(_)))) => {} // close frame + Ok(Some(Err(_))) => {} // protocol error / reset + Ok(Some(Ok(other))) => panic!("expected close, got {:?}", other), + } +} + +// ── Crypto helpers ──────────────────────────────────────────────────────────── + +/// Generate a random keypair; returns `(SecretKey, pubkey_hex)`. +fn gen_keypair() -> (SecretKey, String) { + let secp = Secp256k1::new(); + let (sk, pk) = secp.generate_keypair(&mut secp256k1::rand::rngs::OsRng); + let xonly = pk.x_only_public_key().0; + let pubkey_hex = xonly + .serialize() + .iter() + .map(|b| format!("{b:02x}")) + .collect(); + (sk, pubkey_hex) +} + +/// Standard base64 encoder (no external dep). +fn base64_encode(data: &[u8]) -> String { + const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(data.len().div_ceil(3) * 4); + for chunk in data.chunks(3) { + let b0 = chunk[0] as u32; + let b1 = if chunk.len() > 1 { chunk[1] as u32 } else { 0 }; + let b2 = if chunk.len() > 2 { chunk[2] as u32 } else { 0 }; + let triple = (b0 << 16) | (b1 << 8) | b2; + out.push(ALPHA[((triple >> 18) & 0x3F) as usize] as char); + out.push(ALPHA[((triple >> 12) & 0x3F) as usize] as char); + if chunk.len() > 1 { + out.push(ALPHA[((triple >> 6) & 0x3F) as usize] as char); + } else { + out.push('='); + } + if chunk.len() > 2 { + out.push(ALPHA[(triple & 0x3F) as usize] as char); + } else { + out.push('='); + } + } + out +} + +/// Build a minimal valid NIP-44 v2 fake ciphertext and base64-encode it. +/// Layout: 0x02 (version) | 32-byte nonce | 48-byte ciphertext | 32-byte MAC = 113 bytes. +/// 113 bytes ≥ 99 minimum; first decoded byte is 0x02. +fn make_nip44_content() -> String { + let mut blob = vec![0x02u8]; // version + blob.extend_from_slice(&[0xAA; 32]); // nonce + blob.extend_from_slice(&[0xBB; 48]); // ciphertext + blob.extend_from_slice(&[0xCC; 32]); // MAC + // 113 bytes total + base64_encode(&blob) +} + +/// Build a properly signed kind:24134 event targeting `p_hex`. +/// `nonce` is mixed into the content so callers can produce unique IDs from +/// the same keypair without sleeping. +fn make_signed_event(sk: &SecretKey, pubkey_hex: &str, p_hex: &str, nonce: u64) -> Value { + let secp = Secp256k1::new(); + let created_at = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64; + + // Vary content per nonce so each call produces a unique event ID. + let mut blob = vec![0x02u8]; + blob.extend_from_slice(&nonce.to_le_bytes()); // 8 bytes of nonce + blob.extend_from_slice(&[0xAA; 24]); // pad to 32-byte "nonce" field + blob.extend_from_slice(&[0xBB; 48]); + blob.extend_from_slice(&[0xCC; 32]); + let content = base64_encode(&blob); + + let tags = json!([["p", p_hex]]); + let kind = 24134u64; + + // NIP-01 commitment: [0, pubkey, created_at, kind, tags, content] + let commitment = json!([0, pubkey_hex, created_at, kind, tags, content]); + let commitment_str = serde_json::to_string(&commitment).unwrap(); + let hash: [u8; 32] = Sha256::digest(commitment_str.as_bytes()).into(); + let id_hex: String = hash.iter().map(|b| format!("{b:02x}")).collect(); + + let msg = secp256k1::Message::from_digest(hash); + let keypair = Keypair::from_secret_key(&secp, sk); + let sig = secp.sign_schnorr_no_aux_rand(&msg, &keypair); + let sig_hex: String = sig.serialize().iter().map(|b| format!("{b:02x}")).collect(); + + json!({ + "id": id_hex, + "pubkey": pubkey_hex, + "kind": 24134, + "created_at": created_at, + "content": content, + "sig": sig_hex, + "tags": [["p", p_hex]] + }) +} + +/// Return the current Unix timestamp as i64. +fn now_ts() -> i64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap() + .as_secs() as i64 +} + +/// Send a REQ for the given sub_id and p_hex, then consume the EOSE. +async fn subscribe(ws: &mut WS, sub_id: &str, p_hex: &str) { + send(ws, &json!(["REQ", sub_id, {"#p": [p_hex]}])).await; + let eose = recv(ws).await; + assert_eq!(eose[0], "EOSE", "expected EOSE, got {eose}"); + assert_eq!(eose[1], sub_id); +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// 1. No replay: events published before a subscription are not delivered. +/// With tightening #2, publishing with no live subscriber is rejected +/// ("no live subscriber"), so the publisher gets OK false. +#[tokio::test] +async fn test_no_replay() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Publish first — no subscriber yet, so relay rejects with "no live subscriber". + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + assert_eq!(ok[2], false, "expected rejection with no live subscriber"); + assert!( + ok[3].as_str().unwrap_or("").contains("no live subscriber"), + "unexpected message: {}", + ok[3] + ); + + // Subscribe — should only get EOSE, no EVENT. + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + let first = recv(&mut sub_ws).await; + assert_eq!(first[0], "EOSE", "expected EOSE, got {first}"); + assert!( + try_recv(&mut sub_ws).await.is_none(), + "received unexpected message after EOSE" + ); +} + +/// 2. Live delivery: events published after subscription are delivered; events +/// for a different p-tag are not. +#[tokio::test] +async fn test_live_delivery() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + let mut sub_ws = connect(&url).await; + subscribe(&mut sub_ws, "s1", P_A).await; + + // Publish matching event from a second connection. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + assert!(ok[2].as_bool().unwrap(), "event rejected: {}", ok[3]); + + // Subscriber should receive the event. + let ev_msg = recv(&mut sub_ws).await; + assert_eq!(ev_msg[0], "EVENT"); + assert_eq!(ev_msg[1], "s1"); + + // Publish to a different p-tag — no subscriber for P_B, so OK false. + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_B, 1)]), + ) + .await; + let ok2 = recv(&mut pub_ws).await; + assert_eq!(ok2[0], "OK"); + assert_eq!(ok2[2], false, "expected rejection for unsubscribed p-tag"); + + assert!( + try_recv(&mut sub_ws).await.is_none(), + "subscriber received event for wrong p-tag" + ); +} + +/// 3. Kind rejection: events with kind != 24134 are rejected. +#[tokio::test] +async fn test_kind_rejection() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let bad_event = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 1, + "created_at": 1_700_000_000i64, + "content": "hello", + "sig": SIG, + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", bad_event])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!( + resp[3] + .as_str() + .unwrap_or("") + .contains("kind must be 24134"), + "unexpected message: {}", + resp[3] + ); +} + +/// 4. REQ without #p filter is rejected. +#[tokio::test] +async fn test_no_p_filter() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", "s1", {}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("#p filter required"), + "unexpected message: {}", + resp[2] + ); +} + +/// 5. REQ with multiple #p values is rejected. +#[tokio::test] +async fn test_multi_value_p() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", "s1", {"#p": [P_A, P_B]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("#p must have exactly one value"), + "unexpected message: {}", + resp[2] + ); +} + +/// 6. REQ with unsupported filter field is rejected. +#[tokio::test] +async fn test_unsupported_filter_field() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send( + &mut ws, + &json!(["REQ", "s1", {"#p": [P_A], "authors": [PUBKEY]}]), + ) + .await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("unsupported filter field"), + "unexpected message: {}", + resp[2] + ); +} + +/// 7. Second subscription with a different sub_id is rejected; first still works. +#[tokio::test] +async fn test_second_sub_different_id() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + let mut ws = connect(&url).await; + + subscribe(&mut ws, "s1", P_A).await; + + // Second REQ with a different sub_id. + send(&mut ws, &json!(["REQ", "s2", {"#p": [P_B]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s2"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("already subscribed"), + "unexpected message: {}", + resp[2] + ); + + // First subscription still works. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ev_msg = recv(&mut ws).await; + assert_eq!(ev_msg[0], "EVENT"); + assert_eq!(ev_msg[1], "s1"); +} + +/// 8. Second subscription with the same sub_id is rejected; first still works. +#[tokio::test] +async fn test_second_sub_same_id() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + let mut ws = connect(&url).await; + + subscribe(&mut ws, "s1", P_A).await; + + // Same sub_id again. + send(&mut ws, &json!(["REQ", "s1", {"#p": [P_B]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("already subscribed"), + "unexpected message: {}", + resp[2] + ); + + // First subscription still works. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ev_msg = recv(&mut ws).await; + assert_eq!(ev_msg[0], "EVENT"); + assert_eq!(ev_msg[1], "s1"); +} + +/// 9. Connection closes after 120 s (virtual time). +#[tokio::test(start_paused = true)] +async fn test_120s_timeout() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Advance virtual time past the connection timeout. + tokio::time::advance(Duration::from_secs(121)).await; + // Yield to let the relay task run its deadline branch. + tokio::task::yield_now().await; + + assert_closed(&mut ws).await; +} + +/// 10. Backpressure unit test: bounded mpsc channel rejects when full. +#[tokio::test] +async fn test_backpressure_unit() { + let (tx, _rx) = tokio::sync::mpsc::channel::(4); + for i in 0..4 { + tx.try_send(format!("msg {i}")) + .expect("send should succeed"); + } + // Channel is now full; 5th send must fail. + assert!( + tx.try_send("overflow".to_string()).is_err(), + "expected channel to be full" + ); +} + +/// 11. Frame larger than 4096 bytes closes the connection. +#[tokio::test] +async fn test_max_frame_size() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let big = "x".repeat(4097); + // Ignore send errors — the server may close mid-send. + let _ = ws.send(Message::Text(big.into())).await; + + assert_closed(&mut ws).await; +} + +/// 12. EVENT with missing `id` field is rejected. +#[tokio::test] +async fn test_event_shape_missing_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let bad = json!({ + "pubkey": PUBKEY, + "kind": 24134, + "created_at": 1_700_000_000i64, + "content": "x", + "sig": SIG, + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", bad])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); +} + +/// 13. EVENT with no `p` tag is rejected. +#[tokio::test] +async fn test_no_p_tag() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let bad = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": 1_700_000_000i64, + "content": "x", + "sig": SIG, + "tags": [] + }); + send(&mut ws, &json!(["EVENT", bad])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); +} + +/// 14. EVENT with two `p` tags is rejected. +#[tokio::test] +async fn test_multiple_p_tags() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let bad = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": now_ts(), + "content": make_nip44_content(), + "sig": SIG, + "tags": [["p", P_A], ["p", P_B]] + }); + send(&mut ws, &json!(["EVENT", bad])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!( + resp[3].as_str().unwrap_or("").contains("exactly one p tag"), + "unexpected message: {}", + resp[3] + ); +} + +/// 15. REQ with non-hex #p value is rejected. +#[tokio::test] +async fn test_invalid_hex_in_p_filter() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", "s1", {"#p": ["not-hex-64-chars"]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); +} + +/// 16. EVENT with non-hex `id` is rejected. +#[tokio::test] +async fn test_invalid_hex_in_event_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let bad = json!({ + "id": "ZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZZ", + "pubkey": PUBKEY, + "kind": 24134, + "created_at": 1_700_000_000i64, + "content": "x", + "sig": SIG, + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", bad])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); +} + +/// 17. Global connection cap: 128 connections succeed; 129th is rejected. +/// After closing one, a new connection succeeds. +#[tokio::test] +async fn test_global_conn_cap() { + let url = start_relay().await; + + let mut conns: Vec = Vec::with_capacity(128); + for _ in 0..128 { + conns.push(connect(&url).await); + } + + // 129th should fail (server returns 503). + let result = connect_async(&url).await; + assert!(result.is_err(), "expected 129th connection to fail"); + + // Close one connection. + let mut dropped = conns.pop().unwrap(); + dropped.close(None).await.unwrap(); + // Give the relay a moment to decrement its counter. + tokio::time::sleep(Duration::from_millis(100)).await; + + // Now a new connection should succeed. + let _new = connect(&url).await; +} + +/// 18. Session event cap: 6 EVENTs are accepted; 7th is rejected with +/// "session event limit reached". (The relay's hard cap is 6 per +/// connection, which is tighter than the per-window rate limit of 10.) +#[tokio::test] +async fn test_event_rate_limit() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Subscribe so the relay has exactly one live recipient for P_A. + let mut sub_ws = connect(&url).await; + subscribe(&mut sub_ws, "s1", P_A).await; + + let mut pub_ws = connect(&url).await; + + // First 6 events must be accepted (session cap = 6). + for i in 0..6u64 { + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, i)]), + ) + .await; + let resp = recv(&mut pub_ws).await; + assert_eq!(resp[0], "OK", "event {i}: {resp}"); + assert!( + resp[2].as_bool().unwrap(), + "event {i} rejected: {}", + resp[3] + ); + // Drain the forwarded event from the subscriber so the channel stays open. + let _ = recv(&mut sub_ws).await; + } + + // 7th must be rejected by the session cap. + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 6)]), + ) + .await; + let resp = recv(&mut pub_ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!( + resp[3] + .as_str() + .unwrap_or("") + .contains("session event limit reached"), + "unexpected message: {}", + resp[3] + ); +} + +/// 19. Message rate limit: 20 messages succeed; 21st closes the connection. +#[tokio::test] +async fn test_message_rate_limit() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Send 20 messages (CLOSE with unknown sub_id — no response generated). + for _ in 0..20 { + send(&mut ws, &json!(["CLOSE", "nonexistent"])).await; + } + // Small delay to let the server process all messages. + tokio::time::sleep(Duration::from_millis(50)).await; + + // 21st message should trigger the rate limit and close the connection. + send(&mut ws, &json!(["CLOSE", "nonexistent"])).await; + assert_closed(&mut ws).await; +} + +/// 20. REQ with multiple filters is rejected. +#[tokio::test] +async fn test_multiple_filters() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", "s1", {"#p": [P_A]}, {"#p": [P_B]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2].as_str().unwrap_or("").contains("multiple filters"), + "unexpected message: {}", + resp[2] + ); +} + +/// 21. Unknown message type receives a NOTICE. +#[tokio::test] +async fn test_unknown_message() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["AUTH", {}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "NOTICE"); + assert!( + resp[1] + .as_str() + .unwrap_or("") + .contains("unsupported message"), + "unexpected message: {}", + resp[1] + ); +} + +/// 22. Sub_id containing JSON special characters does not cause injection. +#[tokio::test] +async fn test_json_injection_sub_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let evil_id = r#""],\"evil\":["#; + send(&mut ws, &json!(["REQ", evil_id, {}])).await; + let resp = recv(&mut ws).await; + // Whatever the server responds, it must be valid JSON (recv() already + // parses it). The sub_id in the response must equal the literal string. + assert!(resp.is_array(), "response is not a JSON array: {resp}"); + // The response should be CLOSED with the literal sub_id echoed back safely. + if resp[0] == "CLOSED" { + let echoed = resp[1].as_str().unwrap_or(""); + assert_eq!(echoed, evil_id, "sub_id was not echoed verbatim"); + } +} + +/// 23. CLOSE removes the subscription; subsequent events are not delivered. +#[tokio::test] +async fn test_close_removes_sub() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + let mut ws = connect(&url).await; + + subscribe(&mut ws, "s1", P_A).await; + + send(&mut ws, &json!(["CLOSE", "s1"])).await; + // Give the relay a moment to process the CLOSE. + tokio::time::sleep(Duration::from_millis(50)).await; + + // Publish a matching event — no subscriber, so OK false. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + // No subscriber after CLOSE, so event is rejected. + assert_eq!(ok[2], false); + + // Original subscriber must not receive anything. + assert!( + try_recv(&mut ws).await.is_none(), + "received event after CLOSE" + ); +} + +/// 24. CLOSE does not close the WebSocket connection. +#[tokio::test] +async fn test_close_keeps_connection() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + subscribe(&mut ws, "s1", P_A).await; + send(&mut ws, &json!(["CLOSE", "s1"])).await; + + // Connection must still be open — send another message and get a response. + send(&mut ws, &json!(["REQ", "s2", {"#p": [P_B]}])).await; + let eose = recv(&mut ws).await; + assert_eq!(eose[0], "EOSE"); + assert_eq!(eose[1], "s2"); +} + +/// 25. After CLOSE, a new REQ works and receives future events. +#[tokio::test] +async fn test_req_after_close() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + let mut ws = connect(&url).await; + + subscribe(&mut ws, "s1", P_A).await; + send(&mut ws, &json!(["CLOSE", "s1"])).await; + + // Re-subscribe with a new sub_id. + subscribe(&mut ws, "s2", P_A).await; + + // Publish a matching event. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + assert!(ok[2].as_bool().unwrap(), "event rejected: {}", ok[3]); + + // New subscription must receive the event. + let ev_msg = recv(&mut ws).await; + assert_eq!(ev_msg[0], "EVENT"); + assert_eq!(ev_msg[1], "s2"); +} + +/// 26. CLOSE with an unknown sub_id is silently ignored. +#[tokio::test] +async fn test_close_unknown_sub_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["CLOSE", "nonexistent"])).await; + + // No error response; connection stays open. + assert!( + try_recv(&mut ws).await.is_none(), + "received unexpected response to CLOSE of unknown sub_id" + ); + + // Connection is still usable. + send(&mut ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + let eose = recv(&mut ws).await; + assert_eq!(eose[0], "EOSE"); +} + +/// 27. No events delivered after CLOSE (explicit duplicate of test 23). +#[tokio::test] +async fn test_no_events_after_close() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + let mut ws = connect(&url).await; + + subscribe(&mut ws, "sub", P_A).await; + send(&mut ws, &json!(["CLOSE", "sub"])).await; + tokio::time::sleep(Duration::from_millis(50)).await; + + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + recv(&mut pub_ws).await; // consume OK (will be false — no subscriber) + + assert!( + try_recv(&mut ws).await.is_none(), + "received event after CLOSE" + ); +} + +/// 28. Binary WebSocket frame closes the connection. +#[tokio::test] +async fn test_binary_frame() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let _ = ws.send(Message::Binary(b"hello".to_vec().into())).await; + + assert_closed(&mut ws).await; +} + +/// 29. REQ with too few elements receives a NOTICE. +#[tokio::test] +async fn test_malformed_req_too_few() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ"])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "NOTICE"); + assert!( + resp[1].as_str().unwrap_or("").contains("invalid REQ"), + "unexpected message: {}", + resp[1] + ); +} + +/// 30. REQ with a non-string sub_id receives a NOTICE. +#[tokio::test] +async fn test_malformed_req_non_string_sub_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", 123, {}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "NOTICE"); + assert!( + resp[1].as_str().unwrap_or("").contains("invalid REQ"), + "unexpected message: {}", + resp[1] + ); +} + +/// 31. REQ with a non-object filter receives a CLOSED. +#[tokio::test] +async fn test_malformed_req_non_object_filter() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + send(&mut ws, &json!(["REQ", "s1", "bad"])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], "s1"); + assert!( + resp[2].as_str().unwrap_or("").contains("invalid filter"), + "unexpected message: {}", + resp[2] + ); +} + +/// 32. Write timeout / slow reader: subscriber that doesn't read eventually +/// gets disconnected when the server's write buffer fills up. +/// +/// We flood the subscriber's p-tag with events from a second connection. +/// Because the subscriber never reads, the relay's bounded channel fills +/// and subsequent fan-out drops are silent. The subscriber connection +/// itself stays open (fan-out drops don't close). This test verifies the +/// observable behavior: the publisher keeps getting OK responses and the +/// subscriber connection is still alive after the flood. +#[tokio::test] +async fn test_write_timeout() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Subscribe but never read. + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + // Don't call recv — leave the EOSE unread. + + // Flood from a publisher — stay within the 6-event session cap. + let mut pub_ws = connect(&url).await; + for i in 0..6u64 { + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, i)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + } + + // Publisher connection is still healthy. + send(&mut pub_ws, &json!(["REQ", "check", {"#p": [P_B]}])).await; + let eose = recv(&mut pub_ws).await; + assert_eq!(eose[0], "EOSE"); +} + +/// 33. Ping frame receives a Pong with the same payload. +#[tokio::test] +async fn test_ping_pong() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let payload = b"hello".to_vec(); + ws.send(Message::Ping(payload.clone().into())) + .await + .unwrap(); + + let msg = tokio::time::timeout(Duration::from_secs(2), ws.next()) + .await + .expect("timed out") + .expect("stream ended") + .expect("WebSocket error"); + + match msg { + Message::Pong(data) => assert_eq!(data.as_ref(), payload.as_slice()), + other => panic!("expected Pong, got {:?}", other), + } +} + +/// 34. Client-initiated Close frame receives a Close reply. +#[tokio::test] +async fn test_close_handshake() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + ws.send(Message::Close(None)).await.unwrap(); + + // The stream should end (server sends its own Close and closes). + assert_closed(&mut ws).await; +} + +/// 35. Connection counter does not leak: open/close 5 connections, then open +/// 128 more — all should succeed. +#[tokio::test] +async fn test_conn_counter_no_leak() { + let url = start_relay().await; + + // Open and close 5 connections. + for _ in 0..5 { + let mut ws = connect(&url).await; + ws.close(None).await.unwrap(); + } + // Give the relay time to decrement counters. + tokio::time::sleep(Duration::from_millis(200)).await; + + // Now open 128 connections — all should succeed. + let mut conns: Vec = Vec::with_capacity(128); + for _ in 0..128 { + conns.push(connect(&url).await); + } + assert_eq!(conns.len(), 128); +} + +/// 36. Fan-out drops do not close the subscriber connection. +#[tokio::test] +async fn test_control_msg_backpressure() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + // Leave EOSE unread to fill the channel quickly. + + // Flood within the 6-event session cap. + let mut pub_ws = connect(&url).await; + for i in 0..6u64 { + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, i)]), + ) + .await; + let _ = recv(&mut pub_ws).await; + } + + // Subscriber connection must still be alive — verify by closing it cleanly. + let close_result = tokio::time::timeout(Duration::from_secs(2), sub_ws.close(None)).await; + assert!( + close_result.is_ok(), + "subscriber connection died after flood" + ); +} + +/// 37. Various malformed inputs do not crash the relay (no panic). +#[tokio::test] +async fn test_no_client_data_in_logs() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // A grab-bag of weird inputs — none should panic the relay. + let inputs: &[Value] = &[ + json!(null), + json!(42), + json!("just a string"), + json!({}), + json!([]), + json!(["UNKNOWN"]), + json!(["EVENT"]), + json!(["EVENT", null]), + json!(["REQ", null]), + json!(["CLOSE"]), + json!(["CLOSE", null]), + ]; + + for input in inputs { + // Ignore send errors (server may close on some inputs). + let _ = ws.send(Message::Text(input.to_string().into())).await; + // Small pause to let the relay process. + tokio::time::sleep(Duration::from_millis(10)).await; + } + + // If we reach here without a panic, the test passes. + // The connection may or may not still be open. +} + +/// 38. EOSE arrives before any EVENT in normal flow. +/// Publishing with no live subscriber is rejected (tightening #2). +#[tokio::test] +async fn test_eose_try_send_failure() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Publisher sends an event before the subscriber connects — rejected. + let mut pub_ws = connect(&url).await; + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, 0)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + assert_eq!(ok[0], "OK"); + assert_eq!(ok[2], false, "expected rejection with no live subscriber"); + + // Subscriber connects after the event — should see EOSE first (no EVENT, + // since there is no persistence). + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + let first = recv(&mut sub_ws).await; + assert_eq!(first[0], "EOSE", "first message must be EOSE, got {first}"); + + // No further messages (no stored events). + assert!( + try_recv(&mut sub_ws).await.is_none(), + "received unexpected message after EOSE" + ); +} + +/// 39. Ping frames count toward the per-connection message rate limit. +/// We use CLOSE messages (no response generated) to burn through the rate +/// limit without buffered responses interfering with the close assertion. +#[tokio::test] +async fn test_ping_counts_toward_rate_limit() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Send 20 CLOSE messages for a non-existent sub_id. + // Each counts toward the 20-message rate limit but generates no response. + for _ in 0..20 { + send(&mut ws, &json!(["CLOSE", "nope"])).await; + } + + // Small yield to let the server process all 20. + tokio::time::sleep(Duration::from_millis(50)).await; + + // 21st message (a Ping this time) should trigger the rate limit. + let _ = ws.send(Message::Ping(vec![].into())).await; + + assert_closed(&mut ws).await; +} + +/// 40. Fan-out drops do not close the subscriber connection. +#[tokio::test] +async fn test_fan_out_drop_doesnt_close() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Subscribe but don't read (leave EOSE buffered). + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + + // Flood from a publisher — stay within the 6-event session cap. + let mut pub_ws = connect(&url).await; + for i in 0..6u64 { + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, i)]), + ) + .await; + let _ = recv(&mut pub_ws).await; + } + + // Subscriber connection must still be alive — verify by closing it cleanly. + let result = tokio::time::timeout(Duration::from_secs(2), sub_ws.close(None)).await; + assert!( + result.is_ok(), + "subscriber connection was unexpectedly dead" + ); +} + +/// 41. Reader backpressure: publisher can keep running even when the subscriber +/// never reads. +#[tokio::test] +async fn test_reader_backpressure_closes() { + let url = start_relay().await; + let (sk, pk) = gen_keypair(); + + // Slow subscriber — never reads. + let mut sub_ws = connect(&url).await; + send(&mut sub_ws, &json!(["REQ", "s1", {"#p": [P_A]}])).await; + + // Publisher floods events up to the session cap. + let mut pub_ws = connect(&url).await; + for i in 0..6u64 { + send( + &mut pub_ws, + &json!(["EVENT", make_signed_event(&sk, &pk, P_A, i)]), + ) + .await; + let ok = recv(&mut pub_ws).await; + // Publisher must always get OK (fan-out drops are silent to publisher). + assert_eq!(ok[0], "OK"); + } +} + +/// 42. Connection closes promptly after 120 s (virtual time). +/// Explicit duplicate of test 9 with a slightly different assertion style. +#[tokio::test(start_paused = true)] +async fn test_cancellation_immediate() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + tokio::time::advance(Duration::from_secs(121)).await; + tokio::task::yield_now().await; + + // The connection must be closed — not just slow. + assert_closed(&mut ws).await; +} + +/// 43. Client-initiated graceful close receives a Close reply. +/// Explicit duplicate of test 34 with a different connection state. +#[tokio::test] +async fn test_graceful_close() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Subscribe first, then close gracefully. + subscribe(&mut ws, "s1", P_A).await; + + ws.send(Message::Close(None)).await.unwrap(); + assert_closed(&mut ws).await; +} + +/// 44. Multiple subscribers on the same #p value: event is rejected with +/// "ambiguous recipient" (tightening #2 — exactly one subscriber required). +#[tokio::test] +async fn test_multiple_subscribers_same_p() { + let url = start_relay().await; + + // First subscriber on #p succeeds. + let mut sub1 = connect(&url).await; + subscribe(&mut sub1, "s1", P_A).await; + + // Second subscriber on the SAME #p is rejected at REQ time. + let mut sub2 = connect(&url).await; + send(&mut sub2, &json!(["REQ", "s2", {"#p": [P_A]}])).await; + let resp = recv(&mut sub2).await; + assert_eq!(resp[0], "CLOSED"); + assert!( + resp[2] + .as_str() + .unwrap_or("") + .contains("already has a live subscriber"), + "unexpected message: {}", + resp[2] + ); +} + +/// 45. Uppercase hex in #p filter value is rejected. +#[tokio::test] +async fn test_uppercase_hex_in_p_filter() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Uppercase hex — must be rejected. + let upper_p = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; + send(&mut ws, &json!(["REQ", "s1", {"#p": [upper_p]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert!( + resp[2].as_str().unwrap_or("").contains("64 lowercase hex"), + "unexpected: {}", + resp[2] + ); +} + +/// 46. Uppercase hex in event id is rejected. +#[tokio::test] +async fn test_uppercase_hex_in_event_fields() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Build an event with a valid shape but uppercase id — rejected at hex check. + let ev = json!({ + "id": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA", + "pubkey": PUBKEY, + "kind": 24134, + "created_at": 1_700_000_000i64, + "content": make_nip44_content(), + "sig": SIG, + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", ev])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!(resp[3].as_str().unwrap_or("").contains("64 lowercase hex")); +} + +/// 47. Overlong sub_id (> 64 bytes) is rejected with CLOSED "". +#[tokio::test] +async fn test_overlong_sub_id() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let long_sub_id = "x".repeat(65); + send(&mut ws, &json!(["REQ", long_sub_id, {"#p": [P_A]}])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "CLOSED"); + assert_eq!(resp[1], ""); // sub_id too long → use "" + assert!(resp[2].as_str().unwrap_or("").contains("sub_id too long")); +} + +/// 48. Negative created_at is rejected by the freshness window check. +/// (Tightening #6: created_at must be within ±120 s of relay wall-clock.) +#[tokio::test] +async fn test_negative_created_at() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // A stale event with created_at = -1 is far outside the ±120 s window. + let ev = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": -1i64, + "content": make_nip44_content(), + "sig": SIG, + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", ev])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false, "expected rejection for stale created_at"); + assert!( + resp[3].as_str().unwrap_or("").contains("freshness window"), + "unexpected message: {}", + resp[3] + ); +} + +/// 49. Event with missing sig field is rejected. +#[tokio::test] +async fn test_event_missing_sig() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + let ev = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": now_ts(), + "content": make_nip44_content(), + "tags": [["p", P_A]] + }); + send(&mut ws, &json!(["EVENT", ev])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!(resp[3].as_str().unwrap_or("").contains("missing sig")); +} + +/// 50. EVENT with extra tags (not exactly one `["p", ...]`) is rejected. +/// (Tightening #3: tags must be exactly `[["p", "<64-hex>"]]`.) +#[tokio::test] +async fn test_event_too_many_tags() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Two tags — violates the "exactly one p tag" rule. + let ev = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": now_ts(), + "content": make_nip44_content(), + "sig": SIG, + "tags": [["p", P_A], ["x", "extra"]] + }); + send(&mut ws, &json!(["EVENT", ev])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + assert!( + resp[3].as_str().unwrap_or("").contains("exactly one p tag"), + "unexpected message: {}", + resp[3] + ); +} + +/// 51. EVENT with extra tags (not exactly one `["p", ...]`) is rejected. +/// (Tightening #3: tags must be exactly `[["p", "<64-hex>"]]`.) +/// Variant: two tags where one has a long string value. +#[tokio::test] +async fn test_event_tag_string_too_long() { + let url = start_relay().await; + let mut ws = connect(&url).await; + + // Two tags — violates the "exactly one p tag" rule before any length check. + let long_val = "x".repeat(129); + let ev = json!({ + "id": EV_ID, + "pubkey": PUBKEY, + "kind": 24134, + "created_at": now_ts(), + "content": make_nip44_content(), + "sig": SIG, + "tags": [["p", P_A], ["x", long_val]] + }); + send(&mut ws, &json!(["EVENT", ev])).await; + let resp = recv(&mut ws).await; + assert_eq!(resp[0], "OK"); + assert_eq!(resp[2], false); + // Strict tag check fires first: exactly one tag required. + assert!( + resp[3].as_str().unwrap_or("").contains("exactly one p tag"), + "unexpected message: {}", + resp[3] + ); +} diff --git a/crates/sprout-persona/src/resolve.rs b/crates/sprout-persona/src/resolve.rs index f3a92b4d5b..86f296a773 100644 --- a/crates/sprout-persona/src/resolve.rs +++ b/crates/sprout-persona/src/resolve.rs @@ -313,7 +313,7 @@ fn merge_mcp_servers( // Return in deterministic order (sorted by name) let mut servers: Vec<_> = by_name.into_values().collect(); - servers.sort_by(|a, b| a.name.cmp(&b.name)); + servers.sort_by_key(|s| s.name.clone()); servers } diff --git a/crates/sprout-relay/Cargo.toml b/crates/sprout-relay/Cargo.toml index ab56a601c3..6c9305ce4c 100644 --- a/crates/sprout-relay/Cargo.toml +++ b/crates/sprout-relay/Cargo.toml @@ -38,12 +38,16 @@ deadpool-redis = { workspace = true } redis = { workspace = true } sqlx = { workspace = true } base64 = "0.22" +sprout-sdk = { workspace = true } sprout-workflow = { workspace = true, features = ["reqwest"] } sprout-media = { workspace = true } bytes = "1" infer = "0.19" serde_yaml = { workspace = true } sha2 = { workspace = true } +hmac = { workspace = true } +subtle = { workspace = true } +rand = { workspace = true } hex = { workspace = true } url = { workspace = true } moka = { workspace = true } diff --git a/crates/sprout-relay/src/api/agents.rs b/crates/sprout-relay/src/api/agents.rs deleted file mode 100644 index c55d9c4bf3..0000000000 --- a/crates/sprout-relay/src/api/agents.rs +++ /dev/null @@ -1,141 +0,0 @@ -//! GET /api/agents — list bot/agent members with presence status. - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::State, - http::{HeaderMap, StatusCode}, - response::Json, -}; - -use nostr::util::hex as nostr_hex; - -use crate::state::AppState; - -use super::{constrain_accessible_channels, extract_auth_context, internal_error}; - -/// Returns all bot/agent members visible to the authenticated user, with presence status. -/// -/// Filters channel visibility to only channels the requester can access. -pub async fn agents_handler( - State(state): State>, - headers: HeaderMap, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - // Get requester's accessible channels to filter bot channel visibility. - let accessible_channels = constrain_accessible_channels( - state - .db - .get_accessible_channels(&pubkey_bytes, None, None) - .await - .map_err(|e| { - tracing::error!("agents: failed to load accessible channels: {e}"); - internal_error("presence lookup failed") - })?, - ctx.channel_ids.as_deref(), - ); - let accessible_ids: std::collections::HashSet = accessible_channels - .iter() - .map(|ac| ac.channel.id.to_string()) - .collect(); - - let bots = state - .db - .get_bot_members() - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let mut pubkeys_for_presence: Vec = Vec::new(); - let mut bot_pubkey_hexes: Vec = Vec::new(); - - for bot in &bots { - let hex = nostr_hex::encode(&bot.pubkey); - bot_pubkey_hexes.push(hex); - if let Ok(pk) = nostr::PublicKey::from_slice(&bot.pubkey) { - pubkeys_for_presence.push(pk); - } - } - - // Bulk presence lookup (non-critical — degrade gracefully on failure). - let presence_map = state - .pubsub - .get_presence_bulk(&pubkeys_for_presence) - .await - .unwrap_or_else(|e| { - tracing::warn!("agents: presence lookup failed, returning empty map: {e}"); - Default::default() - }); - - let user_records = state - .db - .get_users_bulk(&bots.iter().map(|b| b.pubkey.clone()).collect::>()) - .await - .map_err(|e| { - tracing::error!("agents: failed to load user records: {e}"); - internal_error("presence lookup failed") - })?; - - let user_name_map: HashMap = user_records - .into_iter() - .filter_map(|u| { - let hex = nostr_hex::encode(&u.pubkey); - u.display_name.map(|name| (hex, name)) - }) - .collect(); - - let mut result = Vec::with_capacity(bots.len()); - - for (bot, hex) in bots.iter().zip(bot_pubkey_hexes.iter()) { - let name = user_name_map - .get(hex.as_str()) - .cloned() - .or_else(|| bot.display_name.clone()) - .unwrap_or_else(|| { - let end = hex.len().min(8); - format!("agent-{}", &hex[..end]) - }); - - // Filter by accessible channel IDs — each entry has a paired name+UUID. - let visible: Vec<&sprout_db::channel::BotChannelEntry> = bot - .channels - .iter() - .filter(|entry| accessible_ids.contains(&entry.id)) - .collect(); - let channels: Vec<&str> = visible.iter().map(|e| e.name.as_str()).collect(); - let channel_ids: Vec<&str> = visible.iter().map(|e| e.id.as_str()).collect(); - - let capabilities: Vec = bot - .capabilities - .as_ref() - .and_then(|v| v.as_array()) - .map(|arr| { - arr.iter() - .filter_map(|v| v.as_str().map(|s| s.to_string())) - .collect() - }) - .unwrap_or_default(); - - let status = presence_map - .get(hex.as_str()) - .map(|s| s.as_str()) - .unwrap_or("offline") - .to_string(); - - result.push(serde_json::json!({ - "pubkey": hex, - "name": name, - "agent_type": bot.agent_type.clone().unwrap_or_default(), - "channels": channels, - "channel_ids": channel_ids, - "capabilities": capabilities, - "status": status, - })); - } - - Ok(Json(serde_json::json!(result))) -} diff --git a/crates/sprout-relay/src/api/approvals.rs b/crates/sprout-relay/src/api/approvals.rs deleted file mode 100644 index 46eda8809e..0000000000 --- a/crates/sprout-relay/src/api/approvals.rs +++ /dev/null @@ -1,524 +0,0 @@ -//! Approval grant/deny endpoints. -//! -//! Endpoints: -//! POST /api/approvals/:token/grant — grant a pending approval -//! POST /api/approvals/:token/deny — deny a pending approval - -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use chrono::Utc; -use serde::Deserialize; - -use crate::state::AppState; - -use super::{api_error, extract_auth_context, forbidden, internal_error, not_found, scope_error}; - -// ── Request body ────────────────────────────────────────────────────────────── - -/// Request body for approval grant/deny endpoints. -#[derive(Debug, Deserialize)] -pub struct ApprovalBody { - /// Optional human-readable note explaining the approval decision. - pub note: Option, -} - -// ── Shared approver-spec enforcement ───────────────────────────────────────── - -/// Enforce the approver_spec field against the requesting pubkey. -/// -/// Accepted specs: -/// - `""` or `"any"` — any authenticated user may approve. -/// - 64-char lowercase hex string — only that exact pubkey may approve. -/// -/// All other formats (role strings such as `@release-manager`, group specs, etc.) -/// are **rejected** (fail-closed). They are not yet implemented; allowing them -/// silently would let any user approve a gate the workflow author intended to restrict. -fn check_approver_spec( - approver_spec: &str, - requester_hex: &str, -) -> Result<(), (StatusCode, Json)> { - let spec = approver_spec.trim(); - - // Empty or "any" — anyone may approve. - if spec.is_empty() || spec == "any" { - return Ok(()); - } - - // Exact pubkey match (64-char hex, case-insensitive). - if spec.len() == 64 && spec.chars().all(|c| c.is_ascii_hexdigit()) { - if requester_hex.to_lowercase() == spec.to_lowercase() { - return Ok(()); - } - return Err(forbidden( - "you are not the designated approver for this request", - )); - } - - // Role-based specs (e.g., "@release-manager") and any other unrecognised format: - // fail closed until role resolution is implemented. - Err(forbidden(&format!( - "approver spec '{}' is not yet supported — only 'any' or a specific pubkey hex are currently accepted", - spec - ))) -} - -// ── Resume workflow after approval ─────────────────────────────────────────── - -/// Resume a suspended workflow run after an approval gate has been granted. -/// -/// Extracted from `grant_approval` to keep the handler lean and allow independent testing. -async fn resume_workflow_after_approval( - engine: Arc, - db: sprout_db::Db, - run_id: uuid::Uuid, - workflow_id: uuid::Uuid, - resume_index: usize, -) { - let run = match db.get_workflow_run(run_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!("grant_approval: failed to fetch run {run_id}: {e}"); - return; - } - }; - - // Guard: only resume runs that are actually waiting for approval. - // A stale approval token could otherwise resurrect a cancelled/failed/completed run. - if run.status != sprout_db::workflow::RunStatus::WaitingApproval { - tracing::warn!( - "grant_approval: run {run_id} has status '{}', expected 'waiting_approval' — ignoring stale approval", - run.status - ); - return; - } - - let workflow = match db.get_workflow(workflow_id).await { - Ok(w) => w, - Err(e) => { - tracing::error!("grant_approval: failed to fetch workflow {workflow_id}: {e}"); - return; - } - }; - - let def: sprout_workflow::WorkflowDef = - match serde_json::from_value(workflow.definition.clone()) { - Ok(d) => d, - Err(e) => { - tracing::error!("grant_approval: failed to parse workflow definition: {e}"); - if let Err(db_err) = db - .update_workflow_run( - run_id, - sprout_db::workflow::RunStatus::Failed, - run.current_step, - &run.execution_trace, - Some(&format!("definition parse error: {e}")), - ) - .await - { - tracing::error!( - "grant_approval: failed to set Failed status for run {run_id}: {db_err}" - ); - } - return; - } - }; - - // Reconstruct step_outputs from the execution trace so that steps after - // the resume point can reference {{steps.PREV_STEP.output.X}}. - let mut initial_outputs: std::collections::HashMap = - std::collections::HashMap::new(); - if let Some(trace_arr) = run.execution_trace.as_array() { - for entry in trace_arr { - if let (Some(step_id), Some(output)) = ( - entry.get("step_id").and_then(|v| v.as_str()), - entry.get("output"), - ) { - initial_outputs.insert(step_id.to_string(), output.clone()); - } - } - } - - // Restore the original trigger context so that {{trigger.*}} templates - // in post-approval steps resolve correctly. Fall back to default (empty) - // for runs created before the trigger_context column was added. - let trigger_ctx: sprout_workflow::executor::TriggerContext = run - .trigger_context - .as_ref() - .and_then(|v| serde_json::from_value(v.clone()).ok()) - .unwrap_or_default(); - - // Execute remaining steps and finalize the run. - // Pass existing trace so finalize_run merges pre-approval + post-approval entries. - let existing_trace = run.execution_trace.as_array().cloned(); - let result = sprout_workflow::executor::execute_from_step( - &engine, - run_id, - &def, - &trigger_ctx, - resume_index, - Some(initial_outputs), - ) - .await; - engine.finalize_run(run_id, result, existing_trace).await; -} - -// ── Shared approval validation ─────────────────────────────────────────────── - -/// Validate that an approval is pending and not expired, and that the requester -/// is allowed by the approver spec. -fn validate_approval( - approval: &sprout_db::workflow::ApprovalRecord, - requester_hex: &str, -) -> Result<(), (StatusCode, Json)> { - if approval.status != sprout_db::workflow::ApprovalStatus::Pending { - return Err(api_error( - StatusCode::CONFLICT, - &format!("approval already {}", approval.status), - )); - } - if Utc::now() > approval.expires_at { - return Err(api_error(StatusCode::GONE, "approval token has expired")); - } - check_approver_spec(&approval.approver_spec, requester_hex)?; - Ok(()) -} - -/// Execute the grant: update DB, spawn workflow resume, return response. -async fn execute_grant( - state: &Arc, - approval: &sprout_db::workflow::ApprovalRecord, - token_hash: &[u8], - pubkey_bytes: &[u8], - note: Option<&str>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let updated = state - .db - .update_approval_by_stored_hash( - token_hash, - sprout_db::workflow::ApprovalStatus::Granted, - Some(pubkey_bytes), - note, - ) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if !updated { - return Err(api_error(StatusCode::CONFLICT, "approval already acted on")); - } - - let run_id = approval.run_id; - let workflow_id = approval.workflow_id; - let resume_index = approval.step_index as usize + 1; - let engine = Arc::clone(&state.workflow_engine); - let db = state.db.clone(); - - tokio::spawn(async move { - resume_workflow_after_approval(engine, db, run_id, workflow_id, resume_index).await; - }); - - Ok(( - StatusCode::ACCEPTED, - Json(serde_json::json!({ - "status": "granted", - "run_id": approval.run_id.to_string(), - "workflow_id": approval.workflow_id.to_string(), - })), - )) -} - -/// Execute the deny: update DB, spawn workflow cancellation, return response. -async fn execute_deny( - state: &Arc, - approval: &sprout_db::workflow::ApprovalRecord, - token_hash: &[u8], - pubkey_bytes: &[u8], - pubkey_hex: String, - note: Option<&str>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let updated = state - .db - .update_approval_by_stored_hash( - token_hash, - sprout_db::workflow::ApprovalStatus::Denied, - Some(pubkey_bytes), - note, - ) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if !updated { - return Err(api_error(StatusCode::CONFLICT, "approval already acted on")); - } - - let run_id = approval.run_id; - let db = state.db.clone(); - tokio::spawn(async move { - let run = match db.get_workflow_run(run_id).await { - Ok(r) => r, - Err(e) => { - tracing::error!("deny_approval: failed to fetch run {run_id}: {e}"); - return; - } - }; - - if run.status != sprout_db::workflow::RunStatus::WaitingApproval { - tracing::warn!( - "deny_approval: run {run_id} has status '{}', expected 'waiting_approval' — skipping cancellation", - run.status - ); - return; - } - - let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}"); - if let Err(e) = db - .update_workflow_run( - run_id, - sprout_db::workflow::RunStatus::Cancelled, - run.current_step, - &run.execution_trace, - Some(&cancel_msg), - ) - .await - { - tracing::error!("deny_approval: failed to set Cancelled status for run {run_id}: {e}"); - } - }); - - Ok(( - StatusCode::ACCEPTED, - Json(serde_json::json!({ - "status": "denied", - "run_id": approval.run_id.to_string(), - "workflow_id": approval.workflow_id.to_string(), - })), - )) -} - -// ── POST /api/approvals/:token/grant ───────────────────────────────────────── - -/// Grant a pending approval using the raw (plaintext) token. -/// -/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races. -pub async fn grant_approval( - State(state): State>, - headers: HeaderMap, - Path(token): Path, - body: Option>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - - let approval = state - .db - .get_approval(&token) - .await - .map_err(|_| not_found("approval not found"))?; - - validate_approval(&approval, &ctx.pubkey.to_hex())?; - let note = body.as_ref().and_then(|b| b.note.as_deref()); - execute_grant( - &state, - &approval, - &approval.token.clone(), - &ctx.pubkey_bytes, - note, - ) - .await -} - -// ── POST /api/approvals/:token/deny ────────────────────────────────────────── - -/// Deny a pending approval using the raw (plaintext) token. -/// -/// Uses `AND status = 'pending'` in the DB update to prevent TOCTOU races. -pub async fn deny_approval( - State(state): State>, - headers: HeaderMap, - Path(token): Path, - body: Option>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - - let approval = state - .db - .get_approval(&token) - .await - .map_err(|_| not_found("approval not found"))?; - - validate_approval(&approval, &ctx.pubkey.to_hex())?; - let note = body.as_ref().and_then(|b| b.note.as_deref()); - execute_deny( - &state, - &approval, - &approval.token.clone(), - &ctx.pubkey_bytes, - ctx.pubkey.to_hex(), - note, - ) - .await -} - -// ── POST /api/approvals/by-hash/:hash/grant ───────────────────────────────── - -/// Grant a pending approval using the DB-stored token hash. -/// -/// This endpoint is used by the UI, which receives the hash from the -/// run-approvals listing. The hash is used directly without re-hashing. -pub async fn grant_approval_by_hash( - State(state): State>, - headers: HeaderMap, - Path(hash_hex): Path, - body: Option>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - - let token_hash = hex::decode(&hash_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid hex token hash"))?; - - let approval = state - .db - .get_approval_by_stored_hash(&token_hash) - .await - .map_err(|_| not_found("approval not found"))?; - - validate_approval(&approval, &ctx.pubkey.to_hex())?; - let note = body.as_ref().and_then(|b| b.note.as_deref()); - execute_grant(&state, &approval, &token_hash, &ctx.pubkey_bytes, note).await -} - -// ── POST /api/approvals/by-hash/:hash/deny ────────────────────────────────── - -/// Deny a pending approval using the DB-stored token hash. -/// -/// This endpoint is used by the UI, which receives the hash from the -/// run-approvals listing. The hash is used directly without re-hashing. -pub async fn deny_approval_by_hash( - State(state): State>, - headers: HeaderMap, - Path(hash_hex): Path, - body: Option>, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - - let token_hash = hex::decode(&hash_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid hex token hash"))?; - - let approval = state - .db - .get_approval_by_stored_hash(&token_hash) - .await - .map_err(|_| not_found("approval not found"))?; - - validate_approval(&approval, &ctx.pubkey.to_hex())?; - let note = body.as_ref().and_then(|b| b.note.as_deref()); - execute_deny( - &state, - &approval, - &token_hash, - &ctx.pubkey_bytes, - ctx.pubkey.to_hex(), - note, - ) - .await -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - - // A valid 64-char lowercase hex pubkey for testing. - const ALICE_HEX: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; - const BOB_HEX: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; - - // ── Empty / "any" spec ──────────────────────────────────────────────────── - - #[test] - fn empty_spec_allows_any_requester() { - assert!(check_approver_spec("", ALICE_HEX).is_ok()); - assert!(check_approver_spec("", BOB_HEX).is_ok()); - } - - #[test] - fn any_spec_allows_any_requester() { - assert!(check_approver_spec("any", ALICE_HEX).is_ok()); - assert!(check_approver_spec("any", BOB_HEX).is_ok()); - } - - #[test] - fn any_spec_with_surrounding_whitespace_allows_any_requester() { - assert!(check_approver_spec(" any ", ALICE_HEX).is_ok()); - } - - // ── Exact pubkey spec ───────────────────────────────────────────────────── - - #[test] - fn exact_pubkey_spec_allows_matching_requester() { - assert!(check_approver_spec(ALICE_HEX, ALICE_HEX).is_ok()); - } - - #[test] - fn exact_pubkey_spec_rejects_non_matching_requester() { - let result = check_approver_spec(ALICE_HEX, BOB_HEX); - assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[test] - fn exact_pubkey_spec_rejects_empty_requester() { - let result = check_approver_spec(ALICE_HEX, ""); - assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - // ── Role-based / unrecognised spec ──────────────────────────────────────── - - #[test] - fn role_spec_is_rejected_fail_closed() { - // Role strings are not yet implemented — must fail closed regardless of requester. - let result = check_approver_spec("@release-manager", ALICE_HEX); - assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[test] - fn group_spec_is_rejected_fail_closed() { - let result = check_approver_spec("group:security-team", BOB_HEX); - assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[test] - fn short_hex_spec_is_rejected_as_unrecognised() { - // A hex string shorter than 64 chars is not a valid pubkey spec — fail closed. - let result = check_approver_spec("deadbeef", ALICE_HEX); - assert!(result.is_err()); - let (status, _) = result.unwrap_err(); - assert_eq!(status, StatusCode::FORBIDDEN); - } - - #[test] - fn uppercase_hex_spec_is_accepted_case_insensitive() { - // Uppercase hex spec should now succeed — comparison is case-insensitive. - let upper = ALICE_HEX.to_uppercase(); - let result = check_approver_spec(&upper, &upper.to_lowercase()); - assert!(result.is_ok()); - } -} diff --git a/crates/sprout-relay/src/api/bridge.rs b/crates/sprout-relay/src/api/bridge.rs new file mode 100644 index 0000000000..2c2a429f74 --- /dev/null +++ b/crates/sprout-relay/src/api/bridge.rs @@ -0,0 +1,737 @@ +//! Nostr HTTP bridge — POST /events, /query, /count with NIP-98 auth. +//! +//! These endpoints provide HTTP access to the relay's Nostr protocol, +//! authenticated via NIP-98 signed events. + +use std::sync::Arc; + +use axum::{ + extract::{Path, Query, State}, + http::{HeaderMap, StatusCode}, + response::Json, +}; +use base64::Engine; +use serde_json::Value; + +use crate::handlers::ingest::{IngestAuth, IngestError}; +use crate::state::AppState; + +use super::{api_error, internal_error, not_found}; + +// ── NIP-98 verification ────────────────────────────────────────────────────── + +/// Verify bridge auth: NIP-98 (production) or X-Pubkey (dev mode). +/// +/// Returns the authenticated public key and an event ID for replay detection. +/// For X-Pubkey dev mode, the event ID is a zero hash (no replay concern). +fn verify_bridge_auth( + headers: &HeaderMap, + method: &str, + url: &str, + body: Option<&[u8]>, + require_auth_token: bool, +) -> Result<(nostr::PublicKey, [u8; 32]), (StatusCode, Json)> { + // Try NIP-98 first (Authorization: Nostr ) + if let Some(auth_str) = headers + .get("authorization") + .and_then(|v| v.to_str().ok()) + .and_then(|s| s.strip_prefix("Nostr ")) + { + let event_json = { + use base64::engine::general_purpose::STANDARD as BASE64; + let bytes = BASE64 + .decode(auth_str) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid base64 in Nostr auth"))?; + String::from_utf8(bytes) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid UTF-8 in Nostr auth"))? + }; + + let event: nostr::Event = serde_json::from_str(&event_json) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid NIP-98 event JSON"))?; + let event_id_bytes = event.id.to_bytes(); + + let pubkey = sprout_auth::verify_nip98_event(&event_json, url, method, body) + .map_err(|e| api_error(StatusCode::UNAUTHORIZED, &format!("NIP-98: {e}")))?; + + return Ok((pubkey, event_id_bytes)); + } + + // Dev-mode fallback: X-Pubkey header (only when require_auth_token is false) + if !require_auth_token { + if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) { + let pubkey = nostr::PublicKey::from_hex(hex_val) + .map_err(|_| api_error(StatusCode::UNAUTHORIZED, "invalid X-Pubkey hex"))?; + // Zero event ID — no replay detection needed for dev mode + return Ok((pubkey, [0u8; 32])); + } + } + + Err(api_error(StatusCode::UNAUTHORIZED, "missing Nostr auth")) +} + +/// Check NIP-98 replay and record the event ID atomically. +/// +/// Uses moka's `entry` API for atomic insert-if-absent — no race window +/// between "check if seen" and "mark as seen". +fn check_nip98_replay( + state: &AppState, + event_id_bytes: [u8; 32], +) -> Result<(), (StatusCode, Json)> { + // Skip replay detection for dev-mode X-Pubkey auth (zero hash). + if event_id_bytes == [0u8; 32] { + return Ok(()); + } + // Atomic: get_with inserts the value if absent and returns it. + // If the entry already existed, this is a replay. + let entry = state.nip98_seen.entry(event_id_bytes); + let result = entry.or_insert(()); + if !result.is_fresh() { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "NIP-98: replay detected", + )); + } + Ok(()) +} + +/// Reconstruct the canonical URL for NIP-98 verification from the relay config. +fn canonical_url(relay_url: &str, path: &str) -> String { + let base = relay_url + .trim() + .trim_end_matches('/') + .replace("wss://", "https://") + .replace("ws://", "http://"); + format!("{base}{path}") +} + +// ── Channel access helpers ─────────────────────────────────────────────────── + +/// Extract a channel UUID from a single filter's `#h` tag. +fn extract_channel_from_filter(filter: &nostr::Filter) -> Option { + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + filter.generic_tags.get(&h_tag).and_then(|vs| { + if vs.len() == 1 { + vs.iter().next()?.parse::().ok() + } else { + None + } + }) +} + +// ── POST /events ───────────────────────────────────────────────────────────── + +/// Submit a signed Nostr event via HTTP bridge (NIP-98 auth). +pub async fn submit_event( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let url = canonical_url(&state.config.relay_url, "/events"); + let (pubkey, event_id_bytes) = verify_bridge_auth( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token, + )?; + check_nip98_replay(&state, event_id_bytes)?; + let pubkey_bytes = pubkey.serialize().to_vec(); + + // Enforce relay membership (with NIP-OA fallback via x-auth-tag header). + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; + + let event: nostr::Event = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; + + let auth = IngestAuth::Http { + pubkey, + scopes: sprout_auth::Scope::all_known(), // Pure Nostr: full scopes, channel access via membership + auth_method: crate::handlers::ingest::HttpAuthMethod::Nip98, + }; + + match crate::handlers::ingest::ingest_event(&state, event, auth).await { + Ok(result) => Ok(Json(serde_json::json!({ + "event_id": result.event_id, + "accepted": result.accepted, + "message": result.message, + }))), + Err(e) => match e { + IngestError::Rejected(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), + IngestError::AuthFailed(msg) => Err(api_error(StatusCode::FORBIDDEN, &msg)), + IngestError::Internal(msg) => Err(internal_error(&msg)), + }, + } +} + +// ── POST /query ────────────────────────────────────────────────────────────── + +/// Query events via HTTP bridge (NIP-98 auth). Returns JSON array of events. +/// +/// Enforces channel access: results are filtered to channels the user can access. +pub async fn query_events( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let url = canonical_url(&state.config.relay_url, "/query"); + let (pubkey, event_id_bytes) = verify_bridge_auth( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token, + )?; + check_nip98_replay(&state, event_id_bytes)?; + let pubkey_bytes = pubkey.serialize().to_vec(); + + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; + + let filters: Vec = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + + // P-gated kinds (gift wraps, member notifications, observer frames) require + // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + let authed_pubkey_hex = pubkey.to_hex(); + if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: p-gated kinds require #p tag matching your pubkey", + )); + } + + // Get channels this user can access — same enforcement as WS REQ handler. + let accessible_channels = state + .get_accessible_channel_ids_cached(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + + // ── NIP-50 search: route to Typesense if any filter has a `search` field ── + if filters.iter().any(|f| f.search.is_some()) { + return handle_bridge_search(&state, &filters, &accessible_channels).await; + } + + // ── Presence: synthesize kind:20001 from Redis (ephemeral, never in DB) ── + if let Some(presence_events) = synthesize_presence(&state, &filters).await { + return Ok(Json(Value::Array(presence_events))); + } + + // Execute each filter and collect results, enforcing channel access. + let mut events: Vec = Vec::new(); + for filter in &filters { + // If filter targets a specific channel, verify access. + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + continue; // Skip filters targeting inaccessible channels. + } + } + + let query = + crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) + .await; + match state.db.query_events(&query).await { + Ok(stored_events) => { + for se in stored_events { + // Post-filter: only return events from accessible channels. + if let Some(ch_id) = se.channel_id { + if !accessible_channels.contains(&ch_id) { + continue; + } + } + // Post-filter: verify event matches the full filter (generic tags, etc.). + // The DB query may not push down all constraints (e.g. #e, #a tags). + if !sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) { + continue; + } + if let Ok(v) = serde_json::to_value(&se.event) { + events.push(v); + } + } + } + Err(e) => { + return Err(internal_error(&format!("query error: {e}"))); + } + } + } + + Ok(Json(Value::Array(events))) +} + +// ── POST /count ────────────────────────────────────────────────────────────── + +/// Count events via HTTP bridge (NIP-98 auth). Returns `{"count": N}`. +/// +/// Enforces channel access: only counts events in channels the user can access. +/// For filters without a `#h` tag, falls back to per-event counting with access checks. +pub async fn count_events( + State(state): State>, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result, (StatusCode, Json)> { + let url = canonical_url(&state.config.relay_url, "/count"); + let (pubkey, event_id_bytes) = verify_bridge_auth( + &headers, + "POST", + &url, + Some(&body), + state.config.require_auth_token, + )?; + check_nip98_replay(&state, event_id_bytes)?; + let pubkey_bytes = pubkey.serialize().to_vec(); + + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + super::relay_members::enforce_relay_membership(&state, &pubkey_bytes, auth_tag).await?; + + let filters: Vec = serde_json::from_slice(&body) + .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid filters: {e}")))?; + + // P-gated kinds enforcement — same as WS REQ and /query. + let authed_pubkey_hex = pubkey.to_hex(); + if !crate::handlers::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + return Err(api_error( + StatusCode::FORBIDDEN, + "restricted: p-gated kinds require #p tag matching your pubkey", + )); + } + + // Get channels this user can access. + let accessible_channels = state + .get_accessible_channel_ids_cached(&pubkey_bytes) + .await + .map_err(|e| internal_error(&format!("channel access lookup: {e}")))?; + + let mut total: u64 = 0; + for filter in &filters { + // If filter targets a specific channel, verify access. + if let Some(ch_id) = extract_channel_from_filter(filter) { + if !accessible_channels.contains(&ch_id) { + continue; // Skip filters targeting inaccessible channels. + } + // Channel is accessible — count with pushability check. + let query = + crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) + .await; + if crate::handlers::req::filter_fully_pushable(filter) { + match state.db.count_events(&query).await { + Ok(n) => total += n as u64, + Err(e) => { + return Err(internal_error(&format!("count error: {e}"))); + } + } + } else { + // Fallback: query + post-filter for non-pushable constraints. + let mut q = query; + q.limit = Some(100_000); + q.max_limit = Some(100_000); + match state.db.query_events(&q).await { + Ok(stored_events) => { + for se in stored_events { + if sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) + { + total += 1; + } + } + } + Err(e) => { + return Err(internal_error(&format!("count error: {e}"))); + } + } + } + } else { + // No channel filter — use SQL-level channel_ids pushdown to count + // only events in accessible channels (+ global events). + let mut query = + crate::handlers::req::build_event_query_from_filter(filter, &pubkey_bytes, &state) + .await; + query.channel_ids = Some(accessible_channels.to_vec()); + + if crate::handlers::req::filter_fully_pushable(filter) { + query.limit = None; + match state.db.count_events(&query).await { + Ok(n) => total += n as u64, + Err(e) => { + return Err(internal_error(&format!("count error: {e}"))); + } + } + } else { + // Fallback: query with high limit + post-filter for correctness. + query.limit = Some(100_000); + query.max_limit = Some(100_000); + match state.db.query_events(&query).await { + Ok(stored_events) => { + for se in stored_events { + if sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) + { + total += 1; + } + } + } + Err(e) => { + return Err(internal_error(&format!("count error: {e}"))); + } + } + } + } + } + + Ok(Json(serde_json::json!({ "count": total }))) +} + +// ── NIP-50 search via HTTP bridge ──────────────────────────────────────────── + +/// Handle search filters by routing to Typesense, then fetching full events from DB. +/// Returns first page of results (no pagination for bridge MVP). +async fn handle_bridge_search( + state: &AppState, + filters: &[nostr::Filter], + accessible_channels: &[uuid::Uuid], +) -> Result, (StatusCode, Json)> { + // Bridge always includes global (non-channel) events — same as WS with full scopes. + let channel_scope = match crate::handlers::req::build_search_channel_scope_filter( + accessible_channels, + true, // include_global + ) { + Some(f) => f, + None => return Ok(Json(Value::Array(Vec::new()))), + }; + + let mut events: Vec = Vec::new(); + let mut seen_ids: std::collections::HashSet<[u8; 32]> = std::collections::HashSet::new(); + + for filter in filters { + let search_text = match &filter.search { + Some(s) if !s.is_empty() => s.clone(), + _ => continue, + }; + + let limit = filter.limit.unwrap_or(100).min(500) as u32; + if limit == 0 { + continue; + } + + // Build Typesense filter — push channel scope + NIP-01 constraints. + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + let filter_channel_scope = + if let Some(vs) = filter.generic_tags.get(&h_tag).filter(|vs| !vs.is_empty()) { + let valid: Vec = vs + .iter() + .filter_map(|v| v.parse::().ok()) + .filter(|id| accessible_channels.contains(id)) + .map(|id| id.to_string()) + .collect(); + if valid.is_empty() { + continue; // All #h values inaccessible — skip filter. + } + format!("channel_id:=[{}]", valid.join(",")) + } else { + channel_scope.clone() + }; + + let mut filter_parts = vec![filter_channel_scope]; + if let Some(ref kinds) = filter.kinds { + if !kinds.is_empty() { + let kind_vals: Vec = kinds.iter().map(|k| k.as_u16().to_string()).collect(); + filter_parts.push(format!("kind:=[{}]", kind_vals.join(","))); + } + } + if let Some(ref authors) = filter.authors { + if !authors.is_empty() { + let author_vals: Vec = authors.iter().map(|a| a.to_hex()).collect(); + filter_parts.push(format!("pubkey:=[{}]", author_vals.join(","))); + } + } + if let Some(since) = filter.since { + filter_parts.push(format!("created_at:>={}", since.as_u64())); + } + if let Some(until) = filter.until { + filter_parts.push(format!("created_at:<={}", until.as_u64())); + } + + let filter_by = filter_parts.join(" && "); + + let search_query = sprout_search::SearchQuery { + q: search_text, + filter_by: Some(filter_by), + sort_by: None, // Typesense default = relevance + page: 1, + per_page: limit, + }; + + let search_result = state + .search + .search(&search_query) + .await + .map_err(|e| internal_error(&format!("search error: {e}")))?; + + // Fetch full events from DB by ID. + let hit_ids: Vec> = search_result + .hits + .into_iter() + .filter_map(|h| hex::decode(&h.event_id).ok()) + .filter(|bytes| bytes.len() == 32) + .collect(); + + if hit_ids.is_empty() { + continue; + } + + let id_refs: Vec<&[u8]> = hit_ids.iter().map(|b| b.as_slice()).collect(); + let stored_events = state + .db + .get_events_by_ids(&id_refs) + .await + .map_err(|e| internal_error(&format!("search fetch error: {e}")))?; + + // Build lookup map to preserve Typesense relevance ordering. + let event_map: std::collections::HashMap<[u8; 32], &sprout_core::StoredEvent> = + stored_events + .iter() + .map(|ev| (ev.event.id.to_bytes(), ev)) + .collect(); + + for hit_id in &hit_ids { + let id_array: [u8; 32] = match hit_id.as_slice().try_into() { + Ok(a) => a, + Err(_) => continue, + }; + let stored = match event_map.get(&id_array) { + Some(ev) => ev, + None => continue, + }; + // Channel access post-filter. + if let Some(ch_id) = stored.channel_id { + if !accessible_channels.contains(&ch_id) { + continue; + } + } + // Dedup across filters. + if !seen_ids.insert(id_array) { + continue; + } + if let Ok(v) = serde_json::to_value(&stored.event) { + events.push(v); + } + } + } + + Ok(Json(Value::Array(events))) +} + +// ── POST /hooks/{id} — Webhook trigger ─────────────────────────────────────── + +/// Query parameters for the webhook trigger endpoint. +#[derive(serde::Deserialize)] +pub struct WebhookQuery { + /// Webhook secret for authentication. Prefer the `X-Webhook-Secret` header instead. + pub secret: Option, +} + +/// Webhook trigger endpoint. No user auth — the webhook secret authenticates the caller. +/// +/// Prefers `X-Webhook-Secret` header over `?secret=` query param (headers aren't logged +/// by most proxies). Returns 202 Accepted; execution is async. +pub async fn workflow_webhook( + State(state): State>, + Path(id_str): Path, + Query(query): Query, + headers: HeaderMap, + body: axum::body::Bytes, +) -> Result<(StatusCode, Json), (StatusCode, Json)> { + let id = uuid::Uuid::parse_str(&id_str) + .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; + + let workflow = state + .db + .get_workflow(id) + .await + .map_err(|_| not_found("workflow not found"))?; + + let def: sprout_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) + .map_err(|e| super::internal_error(&format!("corrupt workflow definition: {e}")))?; + + if !matches!(def.trigger, sprout_workflow::TriggerDef::Webhook) { + return Err(api_error( + StatusCode::BAD_REQUEST, + "workflow does not have a webhook trigger", + )); + } + + // Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. + let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); + let provided_secret = headers + .get("x-webhook-secret") + .and_then(|v| v.to_str().ok()) + .map(|s| s.to_string()) + .or_else(|| query.secret.clone()) + .unwrap_or_default(); + + match &stored_secret { + Some(secret) => { + if !crate::webhook_secret::verify_secret(&provided_secret, secret) { + tracing::warn!("webhook: invalid secret for workflow {id}"); + return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); + } + } + None => { + return Err(api_error( + StatusCode::UNAUTHORIZED, + "webhook secret required but not configured — re-save the workflow to generate one", + )); + } + } + + // Parse optional JSON body as trigger context. + let body_json: Option = + if body.is_empty() { + None + } else { + Some(serde_json::from_slice(&body).map_err(|e| { + api_error(StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}")) + })?) + }; + + // Build trigger context from webhook body fields. + let mut trigger_ctx = sprout_workflow::executor::TriggerContext { + channel_id: workflow + .channel_id + .map(|ch| ch.to_string()) + .unwrap_or_default(), + ..Default::default() + }; + if let Some(Value::Object(ref map)) = body_json { + for (k, v) in map { + let val_str = match v { + Value::String(s) => s.clone(), + other => other.to_string(), + }; + trigger_ctx.webhook_fields.insert(k.clone(), val_str); + } + } + let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); + + let run_id = state + .db + .create_workflow_run(id, None, trigger_ctx_json.as_ref()) + .await + .map_err(|e| super::internal_error(&format!("db error: {e}")))?; + + // Spawn workflow execution asynchronously. + let engine = Arc::clone(&state.workflow_engine); + let db = state.db.clone(); + let def_value = workflow.definition.clone(); + let trigger_ctx_clone = trigger_ctx.clone(); + tokio::spawn(async move { + let def: sprout_workflow::WorkflowDef = match serde_json::from_value(def_value) { + Ok(d) => d, + Err(e) => { + tracing::error!("webhook: failed to parse definition: {e}"); + if let Err(db_err) = db + .update_workflow_run( + run_id, + sprout_db::workflow::RunStatus::Failed, + 0, + &serde_json::json!([]), + Some(&format!("definition parse error: {e}")), + ) + .await + { + tracing::error!("webhook: failed to mark run as failed: {db_err}"); + } + return; + } + }; + + let result = sprout_workflow::executor::execute_from_step( + &engine, + run_id, + &def, + &trigger_ctx_clone, + 0, + None, + ) + .await; + engine.finalize_run(run_id, result, None).await; + }); + + Ok(( + StatusCode::ACCEPTED, + Json(serde_json::json!({ + "run_id": run_id.to_string(), + "workflow_id": id.to_string(), + "status": "pending", + })), + )) +} + +// ── Presence synthesis from Redis ──────────────────────────────────────────── + +/// If all filters target kind:20001 or kind:40902 with authors, synthesize +/// presence from Redis instead of querying the DB (ephemeral events are never +/// stored, and kind:40902 snapshots are relay-generated on demand). +/// +/// Returns `Some(events)` if handled, `None` to fall through to normal query. +async fn synthesize_presence(state: &AppState, filters: &[nostr::Filter]) -> Option> { + use sprout_core::kind::{KIND_PRESENCE_SNAPSHOT, KIND_PRESENCE_UPDATE}; + + // Only intercept if every filter targets kind:20001 or 40902 with authors. + let mut all_pubkeys: Vec = Vec::new(); + for filter in filters { + let kinds = filter.kinds.as_ref()?; + let only_kind = kinds.iter().next()?; + let k = only_kind.as_u16() as u32; + if kinds.len() != 1 || (k != KIND_PRESENCE_UPDATE && k != KIND_PRESENCE_SNAPSHOT) { + return None; + } + let authors = filter.authors.as_ref()?; + if authors.is_empty() { + return None; + } + all_pubkeys.extend(authors.iter().copied()); + } + + if all_pubkeys.is_empty() { + return Some(Vec::new()); + } + + // Dedup pubkeys. + all_pubkeys.sort_by_key(|pk| pk.to_hex()); + all_pubkeys.dedup(); + + // Look up Redis. + let presence_map = state + .pubsub + .get_presence_bulk(&all_pubkeys) + .await + .unwrap_or_default(); + + if presence_map.is_empty() { + return Some(Vec::new()); + } + + // Synthesize kind:20001 events signed by the relay. + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let mut events = Vec::with_capacity(presence_map.len()); + for (pubkey_hex, status) in &presence_map { + // Build a synthetic event: relay-signed, content = status, p-tag = subject. + let tags = vec![nostr::Tag::parse(&["p", pubkey_hex]).ok()?]; + let event = nostr::EventBuilder::new( + nostr::Kind::Custom(KIND_PRESENCE_UPDATE as u16), + status, + tags, + ) + .custom_created_at(nostr::Timestamp::from(now)) + .sign_with_keys(&state.relay_keypair) + .ok()?; + + if let Ok(v) = serde_json::to_value(&event) { + events.push(v); + } + } + + Some(events) +} diff --git a/crates/sprout-relay/src/api/canvas.rs b/crates/sprout-relay/src/api/canvas.rs deleted file mode 100644 index ed72573a48..0000000000 --- a/crates/sprout-relay/src/api/canvas.rs +++ /dev/null @@ -1,82 +0,0 @@ -//! Canvas REST API. -//! -//! Endpoints: -//! GET /api/channels/:channel_id/canvas — fetch the most recent canvas for a channel - -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use sprout_core::kind::KIND_CANVAS; -use sprout_db::event::EventQuery; - -use crate::state::AppState; - -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, - internal_error, -}; - -// ── GET /api/channels/:channel_id/canvas ───────────────────────────────────── - -/// Fetch the most recent canvas for a channel. -/// -/// Returns the canvas content, author pubkey (hex), and updated_at timestamp -/// (Unix seconds) if a canvas exists, or `{"content": null}` if none has been set. -pub async fn get_canvas( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; - - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - // Query the events table for the most recent KIND_CANVAS event scoped to - // this channel. `channel_id` is stored as a column on the events row, so - // we can filter directly without scanning tags. - let q = EventQuery { - channel_id: Some(channel_id), - kinds: Some(vec![KIND_CANVAS as i32]), - limit: Some(1), - ..Default::default() - }; - - let events = state - .db - .query_events(&q) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - match events.into_iter().next() { - Some(stored) => { - // Canvas events are relay-signed; the real author is in the first `p` tag. - let author_hex = stored - .event - .tags - .find(nostr::TagKind::SingleLetter( - nostr::SingleLetterTag::lowercase(nostr::Alphabet::P), - )) - .and_then(|t| t.content().map(|s| s.to_string())) - .unwrap_or_else(|| nostr_hex::encode(stored.event.pubkey.serialize())); - let updated_at = stored.event.created_at.as_u64() as i64; - Ok(Json(serde_json::json!({ - "content": stored.event.content, - "updated_at": updated_at, - "author": author_hex, - }))) - } - None => Ok(Json(serde_json::json!({ "content": null }))), - } -} diff --git a/crates/sprout-relay/src/api/channels.rs b/crates/sprout-relay/src/api/channels.rs deleted file mode 100644 index 2047c20ef5..0000000000 --- a/crates/sprout-relay/src/api/channels.rs +++ /dev/null @@ -1,186 +0,0 @@ -//! Channel REST API. -//! -//! Endpoints: -//! GET /api/channels — list accessible channels for the authenticated user - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use serde::Deserialize; -use sprout_db::channel::ChannelRecord; - -use crate::state::AppState; - -use super::{constrain_accessible_channels, extract_auth_context, internal_error}; - -/// Query parameters for `GET /api/channels`. -#[derive(Debug, Deserialize)] -pub struct ListChannelsParams { - /// Optional visibility filter: `"open"` or `"private"`. - pub visibility: Option, - /// When `true`, return only channels the user is a member of. - pub member: Option, -} - -/// Returns all channels accessible to the authenticated user. -/// -/// For DM channels, resolves participant display names and pubkeys. -pub async fn channels_handler( - State(state): State>, - headers: HeaderMap, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channels = constrain_accessible_channels( - state - .db - .get_accessible_channels(&pubkey_bytes, params.visibility.as_deref(), params.member) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?, - ctx.channel_ids.as_deref(), - ); - - // Bulk-fetch member counts and last-message timestamps in two queries - // instead of 2N queries (one per channel per metric). - let channel_ids: Vec = channels.iter().map(|ac| ac.channel.id).collect(); - let member_counts = state - .db - .get_member_counts_bulk(&channel_ids) - .await - .unwrap_or_default(); - let last_messages = state - .db - .get_last_message_at_bulk(&channel_ids) - .await - .unwrap_or_default(); - - // ── Batch DM participant resolution (2 queries total, not 2×N_DMs) ── - let dm_channel_ids: Vec = channels - .iter() - .filter(|ac| ac.channel.channel_type == "dm") - .map(|ac| ac.channel.id) - .collect(); - - // 1. One query: all members for all DM channels. - let all_dm_members = state - .db - .get_members_bulk(&dm_channel_ids) - .await - .unwrap_or_else(|e| { - tracing::error!("channels: failed to bulk-load DM members: {e}"); - vec![] - }); - - // 2. Collect unique pubkeys across all DM members. - let unique_pubkeys: Vec> = { - let mut seen = std::collections::HashSet::new(); - all_dm_members - .iter() - .filter(|m| seen.insert(m.pubkey.clone())) - .map(|m| m.pubkey.clone()) - .collect() - }; - - // 3. One query: resolve display names for all unique pubkeys. - let user_records = state - .db - .get_users_bulk(&unique_pubkeys) - .await - .unwrap_or_else(|e| { - tracing::error!("channels: failed to bulk-load DM participant profiles: {e}"); - vec![] - }); - let user_map: HashMap = user_records - .into_iter() - .filter_map(|u| { - let hex = nostr_hex::encode(&u.pubkey); - u.display_name.map(|name| (hex, name)) - }) - .collect(); - - // 4. Group members by channel_id for O(1) lookup. - let mut members_by_channel: HashMap> = - HashMap::new(); - for m in &all_dm_members { - members_by_channel.entry(m.channel_id).or_default().push(m); - } - - let mut result = Vec::with_capacity(channels.len()); - - for ac in &channels { - let ch = &ac.channel; - let (participants, participant_pubkeys) = if ch.channel_type == "dm" { - let members = members_by_channel.get(&ch.id); - let mut names = Vec::new(); - let mut pk_hexes = Vec::new(); - if let Some(members) = members { - for m in members { - let hex = nostr_hex::encode(&m.pubkey); - let name = user_map - .get(&hex) - .cloned() - .unwrap_or_else(|| hex[..8.min(hex.len())].to_string()); - names.push(name); - pk_hexes.push(hex); - } - } - (names, pk_hexes) - } else { - (vec![], vec![]) - }; - - let member_count = member_counts.get(&ch.id).copied().unwrap_or(0); - let last_message_at = last_messages.get(&ch.id).copied(); - - result.push(channel_record_to_json( - ch, - participants, - participant_pubkeys, - member_count, - last_message_at, - ac.is_member, - )); - } - - Ok(Json(serde_json::json!(result))) -} - -fn channel_record_to_json( - channel: &ChannelRecord, - participants: Vec, - participant_pubkeys: Vec, - member_count: i64, - last_message_at: Option>, - is_member: bool, -) -> serde_json::Value { - serde_json::json!({ - "id": channel.id.to_string(), - "name": &channel.name, - "channel_type": &channel.channel_type, - "visibility": &channel.visibility, - "description": channel.description.clone().unwrap_or_default(), - "topic": channel.topic, - "purpose": channel.purpose, - "created_by": nostr_hex::encode(&channel.created_by), - "created_at": channel.created_at.to_rfc3339(), - "updated_at": channel.updated_at.to_rfc3339(), - "archived_at": channel.archived_at.map(|t| t.to_rfc3339()), - "member_count": member_count, - "last_message_at": last_message_at.map(|t| t.to_rfc3339()), - "participants": participants, - "participant_pubkeys": participant_pubkeys, - "is_member": is_member, - "ttl_seconds": channel.ttl_seconds, - "ttl_deadline": channel.ttl_deadline.map(|t| t.to_rfc3339()), - }) -} diff --git a/crates/sprout-relay/src/api/channels_metadata.rs b/crates/sprout-relay/src/api/channels_metadata.rs deleted file mode 100644 index eac3753727..0000000000 --- a/crates/sprout-relay/src/api/channels_metadata.rs +++ /dev/null @@ -1,93 +0,0 @@ -//! Channel metadata REST API handlers. -//! -//! Endpoints: -//! GET /api/channels/{channel_id} — Get channel details - -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use sprout_db::channel::ChannelRecord; - -use crate::state::AppState; - -use super::{ - check_channel_access, check_token_channel_access, extract_auth_context, internal_error, - not_found, scope_error, -}; - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Parse a channel_id path parameter as a UUID. -fn parse_channel_id(raw: &str) -> Result)> { - uuid::Uuid::parse_str(raw) - .map_err(|_| super::api_error(StatusCode::BAD_REQUEST, "invalid channel_id")) -} - -/// Serialize a `ChannelRecord` to JSON, including topic, purpose, and member_count. -fn channel_detail_to_json(record: &ChannelRecord, member_count: i64) -> serde_json::Value { - serde_json::json!({ - "id": record.id.to_string(), - "name": record.name, - "channel_type": record.channel_type, - "visibility": record.visibility, - "description": record.description, - "topic": record.topic, - "topic_set_by": record.topic_set_by.as_deref().map(nostr_hex::encode), - "topic_set_at": record.topic_set_at.map(|t| t.to_rfc3339()), - "purpose": record.purpose, - "purpose_set_by": record.purpose_set_by.as_deref().map(nostr_hex::encode), - "purpose_set_at": record.purpose_set_at.map(|t| t.to_rfc3339()), - "created_by": nostr_hex::encode(&record.created_by), - "created_at": record.created_at.to_rfc3339(), - "updated_at": record.updated_at.to_rfc3339(), - "archived_at": record.archived_at.map(|t| t.to_rfc3339()), - "member_count": member_count, - "topic_required": record.topic_required, - "max_members": record.max_members, - "nip29_group_id": record.nip29_group_id, - "ttl_seconds": record.ttl_seconds, - "ttl_deadline": record.ttl_deadline.map(|t| t.to_rfc3339()), - }) -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -/// GET /api/channels/{channel_id} — Get full channel details. -/// -/// Requires the caller to be a member or the channel to be open. -pub async fn get_channel_handler( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - let channel_id = parse_channel_id(&channel_id_str)?; - check_token_channel_access(&ctx, &channel_id)?; - - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let record = state - .db - .get_channel(channel_id) - .await - .map_err(|e| match e { - sprout_db::error::DbError::ChannelNotFound(_) => not_found("channel not found"), - other => internal_error(&format!("db error: {other}")), - })?; - - let member_count = state - .db - .get_member_count(channel_id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - Ok(Json(channel_detail_to_json(&record, member_count))) -} diff --git a/crates/sprout-relay/src/api/dms.rs b/crates/sprout-relay/src/api/dms.rs deleted file mode 100644 index b1ab18191c..0000000000 --- a/crates/sprout-relay/src/api/dms.rs +++ /dev/null @@ -1,463 +0,0 @@ -//! Direct Message REST API. -//! -//! Endpoints: -//! POST /api/dms — Open or create a DM (idempotent) -//! POST /api/dms/{channel_id}/members — Add member to group DM (creates new DM) -//! POST /api/dms/{channel_id}/hide — Hide a DM from the user's sidebar -//! GET /api/dms — List user's DM conversations - -use std::sync::Arc; - -use axum::{ - extract::{Json as ExtractJson, Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use serde::Deserialize; -use uuid::Uuid; - -use sprout_core::kind::KIND_MEMBER_ADDED_NOTIFICATION; - -use crate::handlers::side_effects::{ - emit_group_discovery_events, emit_membership_notification, emit_system_message, -}; -use crate::state::AppState; - -use super::{api_error, extract_auth_context, internal_error}; - -// ── Request / query types ───────────────────────────────────────────────────── - -/// Request body for opening a DM. -#[derive(Debug, Deserialize)] -pub struct OpenDmBody { - /// Hex-encoded pubkeys of the OTHER participants (self is added automatically). - /// Must contain 1–8 entries (self brings the total to 2–9). - pub pubkeys: Vec, -} - -/// Request body for adding a member to a group DM. -#[derive(Debug, Deserialize)] -pub struct AddDmMemberBody { - /// Hex-encoded pubkeys of the new participants to add. - pub pubkeys: Vec, -} - -/// Query parameters for listing DMs. -#[derive(Debug, Deserialize)] -pub struct ListDmsQuery { - /// Pagination cursor (channel_id of the last item from the previous page). - pub cursor: Option, - /// Maximum number of results to return (default 50, max 200). - pub limit: Option, -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -/// `POST /api/dms` — Open or create a DM conversation. -/// -/// The caller is automatically added as a participant. The operation is -/// idempotent: the same participant set always returns the same channel. -pub async fn open_dm_handler( - State(state): State>, - headers: HeaderMap, - ExtractJson(body): ExtractJson, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) - .map_err(super::scope_error)?; - let self_bytes = ctx.pubkey_bytes.clone(); - - if body.pubkeys.is_empty() { - return Err(api_error( - StatusCode::BAD_REQUEST, - "pubkeys must contain at least 1 other participant", - )); - } - if body.pubkeys.len() > 8 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "pubkeys may contain at most 8 other participants (9 total including self)", - )); - } - - // Decode all provided pubkeys. - let mut other_bytes: Vec> = Vec::with_capacity(body.pubkeys.len()); - for hex in &body.pubkeys { - let bytes = hex::decode(hex).map_err(|_| { - api_error( - StatusCode::BAD_REQUEST, - &format!("invalid pubkey hex: {hex}"), - ) - })?; - if bytes.len() != 32 { - return Err(api_error( - StatusCode::BAD_REQUEST, - &format!("pubkey must be 32 bytes (64 hex chars): {hex}"), - )); - } - other_bytes.push(bytes); - } - - // Build the full participant slice (self + others). - let mut all_bytes: Vec> = vec![self_bytes.clone()]; - for ob in &other_bytes { - if !all_bytes.iter().any(|b| b == ob) { - all_bytes.push(ob.clone()); - } - } - - let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); - - let (channel, was_created) = state - .db - .open_dm(&all_refs, &self_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if was_created { - // Invalidate membership + accessible-channels caches for all participants - // so REQ, /api/feed, and /api/search immediately include the new DM. - // Note: DM hide/unhide does NOT need cache invalidation because - // get_accessible_channel_ids() does not filter on hidden_at. - for pk in &all_bytes { - state.invalidate_membership(channel.id, pk); - } - - let actor_hex = nostr_hex::encode(&self_bytes); - let participant_hexes: Vec = all_bytes.iter().map(nostr_hex::encode).collect(); - if let Err(e) = emit_system_message( - &state, - channel.id, - serde_json::json!({ - "type": "dm_created", - "actor": actor_hex, - "participants": participant_hexes, - }), - ) - .await - { - tracing::warn!("Failed to emit system message: {e}"); - } - - // Emit NIP-29 group discovery events so Nostr clients can find this DM. - if let Err(e) = emit_group_discovery_events(&state, channel.id).await { - tracing::warn!(channel = %channel.id, "DM discovery emission failed: {e}"); - } - - // Notify each participant so their Nostr client learns about the new DM. - for participant in &all_bytes { - if let Err(e) = emit_membership_notification( - &state, - channel.id, - participant, - &self_bytes, - KIND_MEMBER_ADDED_NOTIFICATION, - ) - .await - { - tracing::warn!("DM membership notification failed: {e}"); - } - } - } - - // Resolve participant display names. - let participants = resolve_participants(&state, channel.id).await; - - let status = if was_created { - StatusCode::CREATED - } else { - StatusCode::OK - }; - - Ok(( - status, - Json(serde_json::json!({ - "channel_id": channel.id.to_string(), - "created": was_created, - "participants": participants, - })), - )) -} - -/// `POST /api/dms/{channel_id}/members` — Add a member to a group DM. -/// -/// Because DM participant sets are immutable, this creates a NEW DM with the -/// expanded participant set. The original DM is not modified. -pub async fn add_dm_member_handler( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, - ExtractJson(body): ExtractJson, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) - .map_err(super::scope_error)?; - let self_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?; - - if body.pubkeys.is_empty() { - return Err(api_error( - StatusCode::BAD_REQUEST, - "pubkeys must contain at least 1 new participant", - )); - } - - // Verify caller is a member of the existing DM. - let is_member = state - .is_member_cached(channel_id, &self_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - if !is_member { - return Err(super::forbidden("not a member of this DM")); - } - - // Verify the channel is actually a DM. - let existing_channel = state - .db - .get_channel(channel_id) - .await - .map_err(|_| super::not_found("DM not found"))?; - if existing_channel.channel_type != "dm" { - return Err(api_error(StatusCode::BAD_REQUEST, "channel is not a DM")); - } - - // Get existing participants. - let existing_members = state - .db - .get_members(channel_id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let mut all_bytes: Vec> = existing_members.into_iter().map(|m| m.pubkey).collect(); - - // Decode and merge new pubkeys. - for hex in &body.pubkeys { - let bytes = hex::decode(hex).map_err(|_| { - api_error( - StatusCode::BAD_REQUEST, - &format!("invalid pubkey hex: {hex}"), - ) - })?; - if bytes.len() != 32 { - return Err(api_error( - StatusCode::BAD_REQUEST, - &format!("pubkey must be 32 bytes (64 hex chars): {hex}"), - )); - } - if !all_bytes.iter().any(|b| b == &bytes) { - all_bytes.push(bytes); - } - } - - // Enforce max 9 participants. - if all_bytes.len() > 9 { - return Err(api_error( - StatusCode::UNPROCESSABLE_ENTITY, - "DM supports at most 9 participants", - )); - } - - let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); - - let (new_channel, was_created) = state - .db - .open_dm(&all_refs, &self_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if was_created { - // Invalidate membership + accessible-channels caches for all participants - // so REQ, /api/feed, and /api/search immediately include the new DM. - for pk in &all_bytes { - state.invalidate_membership(new_channel.id, pk); - } - - // Emit NIP-29 group discovery events for the new expanded DM. - if let Err(e) = emit_group_discovery_events(&state, new_channel.id).await { - tracing::warn!(channel = %new_channel.id, "DM discovery emission failed: {e}"); - } - - // Notify each participant about the new DM. - for participant_bytes in &all_bytes { - if let Err(e) = emit_membership_notification( - &state, - new_channel.id, - participant_bytes, - &self_bytes, - KIND_MEMBER_ADDED_NOTIFICATION, - ) - .await - { - tracing::warn!("DM membership notification failed: {e}"); - } - } - } - - let participants = resolve_participants(&state, new_channel.id).await; - - let status = if was_created { - StatusCode::CREATED - } else { - StatusCode::OK - }; - - Ok(( - status, - Json(serde_json::json!({ - "channel_id": new_channel.id.to_string(), - "created": was_created, - "participants": participants, - "note": "A new DM was created with the expanded participant set. The original DM is unchanged.", - })), - )) -} - -/// `GET /api/dms` — List the authenticated user's DM conversations. -/// -/// Returns DMs ordered by most recent activity (updated_at DESC). -/// Supports cursor-based pagination. -pub async fn list_dms_handler( - State(state): State>, - headers: HeaderMap, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let self_bytes = ctx.pubkey_bytes.clone(); - - let limit = params.limit.unwrap_or(50).min(200); - - let cursor = params - .cursor - .as_deref() - .map(Uuid::parse_str) - .transpose() - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid cursor format"))?; - - let dms = state - .db - .list_dms_for_user(&self_bytes, limit, cursor) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let next_cursor = dms.last().map(|d| d.channel_id.to_string()); - - let dm_json: Vec = dms - .iter() - .map(|dm| { - let participants: Vec = dm - .participants - .iter() - .map(|p| { - serde_json::json!({ - "pubkey": nostr_hex::encode(&p.pubkey), - "display_name": p.display_name, - "role": p.role, - }) - }) - .collect(); - - serde_json::json!({ - "channel_id": dm.channel_id.to_string(), - "participants": participants, - "last_message_at": dm.last_message_at.map(|t| t.to_rfc3339()), - "created_at": dm.created_at.to_rfc3339(), - }) - }) - .collect(); - - Ok(Json(serde_json::json!({ - "dms": dm_json, - "next_cursor": next_cursor, - }))) -} - -/// `POST /api/dms/{channel_id}/hide` — Hide a DM from the caller's sidebar. -/// -/// The DM is not deleted — it can be restored by opening a new DM with the -/// same participants. Returns 204 No Content on success. -pub async fn hide_dm_handler( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, -) -> Result)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesWrite) - .map_err(super::scope_error)?; - - let channel_id: Uuid = channel_id_str - .parse() - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id format"))?; - - // Verify the channel exists and is a DM. - let channel = state - .db - .get_channel(channel_id) - .await - .map_err(|_| super::not_found("DM not found"))?; - - if channel.channel_type != "dm" { - return Err(api_error(StatusCode::BAD_REQUEST, "channel is not a DM")); - } - - // Verify caller is a member. - let is_member = state - .is_member_cached(channel_id, &ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if !is_member { - return Err(super::forbidden("not a member of this DM")); - } - - state - .db - .hide_dm(channel_id, &ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - Ok(StatusCode::NO_CONTENT) -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Fetch and format participant info for a DM channel. -async fn resolve_participants(state: &AppState, channel_id: Uuid) -> Vec { - let members = state.db.get_members(channel_id).await.unwrap_or_else(|e| { - tracing::error!("dms: failed to load members for channel {channel_id}: {e}"); - vec![] - }); - - let member_pubkeys: Vec> = members.iter().map(|m| m.pubkey.clone()).collect(); - - let user_records = state - .db - .get_users_bulk(&member_pubkeys) - .await - .unwrap_or_else(|e| { - tracing::error!("dms: failed to load user records for DM participants: {e}"); - vec![] - }); - - let user_map: std::collections::HashMap> = user_records - .into_iter() - .map(|u| (nostr_hex::encode(&u.pubkey), u.display_name)) - .collect(); - - members - .iter() - .map(|m| { - let hex = nostr_hex::encode(&m.pubkey); - let display_name = user_map.get(&hex).and_then(|n| n.clone()); - serde_json::json!({ - "pubkey": hex, - "display_name": display_name, - "role": m.role, - }) - }) - .collect() -} diff --git a/crates/sprout-relay/src/api/events.rs b/crates/sprout-relay/src/api/events.rs index 4f9ee1890c..59a595b433 100644 --- a/crates/sprout-relay/src/api/events.rs +++ b/crates/sprout-relay/src/api/events.rs @@ -1,277 +1,5 @@ -//! Event endpoints. +//! Event endpoints — now served via the Nostr HTTP bridge. //! -//! Endpoints: -//! GET /api/events/:id — fetch a single stored event by ID -//! POST /api/events — submit a signed Nostr event for ingestion +//! This module re-exports bridge handlers for backward compatibility with router.rs. -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; - -use crate::handlers::ingest::{HttpAuthMethod, IngestAuth, IngestError}; -use crate::state::AppState; - -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, - internal_error, not_found, RestAuthMethod, -}; - -use sprout_core::kind::{ - event_kind_u32, KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE, -}; - -/// Global event kinds that require `UsersRead` scope. -pub(crate) const GLOBAL_USER_DATA_KINDS: [u32; 2] = [KIND_PROFILE, KIND_CONTACT_LIST]; -/// Global event kinds that require `MessagesRead` scope. -pub(crate) const GLOBAL_MESSAGE_KINDS: [u32; 2] = [KIND_TEXT_NOTE, KIND_LONG_FORM]; - -/// Fetch a single stored event by its 64-char hex ID. -pub async fn get_event( - State(state): State>, - headers: HeaderMap, - Path(event_id): Path, -) -> Result, (StatusCode, Json)> { - // Step 1: authenticate (no scope check yet) - let ctx = extract_auth_context(&headers, &state).await?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - // Step 2: parse event ID - let id_bytes = hex::decode(&event_id) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event ID"))?; - if id_bytes.len() != 32 { - return Err(api_error(StatusCode::BAD_REQUEST, "invalid event ID")); - } - - // Step 3: load the event (no scope check yet) - let stored_event = state - .db - .get_event_by_id(&id_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))? - .ok_or_else(|| not_found("event not found"))?; - - // Step 4: scope check depends on whether this is a channel event or a global event - if let Some(channel_id) = stored_event.channel_id { - // Channel event: MessagesRead + membership check. - // All failures return 404 (not 403) to avoid leaking event existence. - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(|_| not_found("event not found"))?; - check_token_channel_access(&ctx, &channel_id).map_err(|_| not_found("event not found"))?; - check_channel_access(&state, channel_id, &pubkey_bytes) - .await - .map_err(|_| not_found("event not found"))?; - } else { - // Global event — scope-aware allowlist. - let event_kind = event_kind_u32(&stored_event.event); - - let scope_ok = if GLOBAL_USER_DATA_KINDS.contains(&event_kind) { - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).is_ok() - } else if GLOBAL_MESSAGE_KINDS.contains(&event_kind) { - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead).is_ok() - } else { - false - }; - - if !scope_ok { - // Return 404 (not 403) to avoid leaking event existence - // when the caller lacks the required scope. - return Err(not_found("event not found")); - } - } - - let tags = serde_json::to_value(&stored_event.event.tags) - .map_err(|e| internal_error(&format!("tag serialization error: {e}")))?; - - Ok(Json(serde_json::json!({ - "id": stored_event.event.id.to_hex(), - "pubkey": stored_event.event.pubkey.to_hex(), - "created_at": stored_event.event.created_at.as_u64(), - "kind": stored_event.event.kind.as_u16(), - "tags": tags, - "content": stored_event.event.content, - "sig": stored_event.event.sig.to_string(), - }))) -} - -// ── POST /api/events ───────────────────────────────────────────────────────── - -/// Submit a signed Nostr event for ingestion. -/// -/// Accepts the same 18 persistent kinds as the WebSocket `["EVENT", ...]` path. -/// WS-only kinds (1059 gift-wrap, 20001 presence) are rejected. -/// -/// Auth: API token, Okta JWT, or dev X-Pubkey — mapped to [`IngestAuth::Http`]. -pub async fn submit_event( - State(state): State>, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - - let event: nostr::Event = serde_json::from_slice(&body) - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &format!("invalid event JSON: {e}")))?; - - let auth = IngestAuth::Http { - pubkey: ctx.pubkey, - scopes: ctx.scopes, - auth_method: match ctx.auth_method { - RestAuthMethod::ApiToken => HttpAuthMethod::ApiToken, - RestAuthMethod::OktaJwt => HttpAuthMethod::OktaJwt, - RestAuthMethod::DevPubkey => HttpAuthMethod::DevPubkey, - RestAuthMethod::Nip98 => { - return Err(api_error( - StatusCode::BAD_REQUEST, - "NIP-98 auth is not supported for event submission", - )); - } - }, - token_id: ctx.token_id, - channel_ids: ctx.channel_ids, - }; - - match crate::handlers::ingest::ingest_event(&state, event, auth).await { - Ok(result) => Ok(Json(serde_json::json!({ - "event_id": result.event_id, - "accepted": result.accepted, - "message": result.message, - }))), - Err(e) => match e { - IngestError::Rejected(msg) => Err(api_error(StatusCode::BAD_REQUEST, &msg)), - IngestError::AuthFailed(msg) => Err(api_error(StatusCode::FORBIDDEN, &msg)), - IngestError::Internal(msg) => Err(internal_error(&msg)), - }, - } -} - -#[cfg(test)] -mod tests { - use sprout_core::kind::{KIND_CONTACT_LIST, KIND_LONG_FORM, KIND_PROFILE, KIND_TEXT_NOTE}; - - use super::{GLOBAL_MESSAGE_KINDS, GLOBAL_USER_DATA_KINDS}; - - /// Reproduce the scope-check routing logic from `get_event` so we can - /// unit-test it without standing up a full HTTP server. - fn scope_check_for_global_event(event_kind: u32, scopes: &[sprout_auth::Scope]) -> bool { - if GLOBAL_USER_DATA_KINDS.contains(&event_kind) { - sprout_auth::require_scope(scopes, sprout_auth::Scope::UsersRead).is_ok() - } else if GLOBAL_MESSAGE_KINDS.contains(&event_kind) { - sprout_auth::require_scope(scopes, sprout_auth::Scope::MessagesRead).is_ok() - } else { - false - } - } - - // ── Positive cases: correct scope grants access ────────────────────── - - #[test] - fn kind0_profile_allowed_with_users_read() { - assert!(scope_check_for_global_event( - KIND_PROFILE, - &[sprout_auth::Scope::UsersRead], - )); - } - - #[test] - fn kind3_contact_list_allowed_with_users_read() { - assert!(scope_check_for_global_event( - KIND_CONTACT_LIST, - &[sprout_auth::Scope::UsersRead], - )); - } - - #[test] - fn kind1_text_note_allowed_with_messages_read() { - assert!(scope_check_for_global_event( - KIND_TEXT_NOTE, - &[sprout_auth::Scope::MessagesRead], - )); - } - - #[test] - fn kind30023_long_form_allowed_with_messages_read() { - assert!(scope_check_for_global_event( - KIND_LONG_FORM, - &[sprout_auth::Scope::MessagesRead], - )); - } - - // ── Negative cases: wrong scope is denied ──────────────────────────── - - #[test] - fn kind0_profile_denied_with_only_messages_read() { - assert!(!scope_check_for_global_event( - KIND_PROFILE, - &[sprout_auth::Scope::MessagesRead], - )); - } - - #[test] - fn kind3_contact_list_denied_with_only_messages_read() { - assert!(!scope_check_for_global_event( - KIND_CONTACT_LIST, - &[sprout_auth::Scope::MessagesRead], - )); - } - - #[test] - fn kind1_text_note_denied_with_only_users_read() { - assert!(!scope_check_for_global_event( - KIND_TEXT_NOTE, - &[sprout_auth::Scope::UsersRead], - )); - } - - #[test] - fn kind30023_long_form_denied_with_only_users_read() { - assert!(!scope_check_for_global_event( - KIND_LONG_FORM, - &[sprout_auth::Scope::UsersRead], - )); - } - - // ── Closed-default: unknown kinds are always denied ────────────────── - - #[test] - fn unknown_kind_denied_even_with_all_scopes() { - let all_scopes = vec![ - sprout_auth::Scope::UsersRead, - sprout_auth::Scope::MessagesRead, - ]; - // kind:1059 (gift wrap), kind:5 (delete), kind:7 (reaction), kind:9 (stream msg) - for kind in [1059, 5, 7, 9, 9002, 40003, 45001] { - assert!( - !scope_check_for_global_event(kind, &all_scopes), - "kind:{kind} must be denied by the closed-default allowlist" - ); - } - } - - // ── Edge case: empty scopes deny everything ────────────────────────── - - #[test] - fn empty_scopes_deny_all_allowed_kinds() { - let no_scopes: &[sprout_auth::Scope] = &[]; - assert!(!scope_check_for_global_event(KIND_PROFILE, no_scopes)); - assert!(!scope_check_for_global_event(KIND_CONTACT_LIST, no_scopes)); - assert!(!scope_check_for_global_event(KIND_TEXT_NOTE, no_scopes)); - assert!(!scope_check_for_global_event(KIND_LONG_FORM, no_scopes)); - } - - // ── Both scopes together grant access to all allowed kinds ─────────── - - #[test] - fn both_scopes_grant_all_allowed_kinds() { - let both = vec![ - sprout_auth::Scope::UsersRead, - sprout_auth::Scope::MessagesRead, - ]; - assert!(scope_check_for_global_event(KIND_PROFILE, &both)); - assert!(scope_check_for_global_event(KIND_CONTACT_LIST, &both)); - assert!(scope_check_for_global_event(KIND_TEXT_NOTE, &both)); - assert!(scope_check_for_global_event(KIND_LONG_FORM, &both)); - } -} +pub use super::bridge::{count_events, query_events, submit_event}; diff --git a/crates/sprout-relay/src/api/feed.rs b/crates/sprout-relay/src/api/feed.rs deleted file mode 100644 index 633f7aa6dc..0000000000 --- a/crates/sprout-relay/src/api/feed.rs +++ /dev/null @@ -1,213 +0,0 @@ -//! GET /api/feed — personalized home feed. -//! -//! Returns a structured feed with four categories: -//! - `mentions` — messages that mention the authenticated user -//! - `needs_action` — items requiring the user's attention -//! - `activity` — recent channel activity -//! - `agent_activity` — agent/bot job events - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use chrono::{DateTime, Duration, Utc}; -use serde::Deserialize; - -use sprout_core::kind::{self, event_kind_u32}; - -use crate::state::AppState; - -use super::{constrain_channel_ids, extract_auth_context, internal_error}; - -/// Agent activity kind set — used to partition activity into agent vs channel activity. -const AGENT_KINDS: &[u32] = &[ - kind::KIND_JOB_REQUEST, - kind::KIND_JOB_ACCEPTED, - kind::KIND_JOB_PROGRESS, - kind::KIND_JOB_RESULT, - kind::KIND_JOB_CANCEL, - kind::KIND_JOB_ERROR, -]; - -/// Query parameters for the feed endpoint. -#[derive(Debug, Deserialize)] -pub struct FeedParams { - /// Unix timestamp — only return events after this time. Default: now - 7 days. - pub since: Option, - /// Max items per category. Default: 20. Max: 50. - pub limit: Option, - /// Comma-separated category filter: "mentions,needs_action,activity,agent_activity" - /// Default: all categories. - pub types: Option, -} - -/// Returns a personalized home feed for the authenticated user. -/// -/// Runs mention, needs-action, and activity queries in parallel. Partitions -/// activity into agent vs channel activity by event kind. -pub async fn feed_handler( - State(state): State>, - headers: HeaderMap, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let limit = params.limit.unwrap_or(20).min(50) as i64; - let since: DateTime = params - .since - .and_then(|ts| DateTime::from_timestamp(ts, 0)) - .unwrap_or_else(|| Utc::now() - Duration::days(7)); - - let type_filter: Option> = params - .types - .as_deref() - .map(|t| t.split(',').map(|s| s.trim()).collect()); - let wants = |cat: &str| -> bool { type_filter.as_ref().is_none_or(|f| f.contains(cat)) }; - - let accessible_ids = constrain_channel_ids( - state - .get_accessible_channel_ids_cached(&pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?, - ctx.channel_ids.as_deref(), - ); - - if accessible_ids.is_empty() { - let generated_at = Utc::now().timestamp(); - return Ok(Json(serde_json::json!({ - "feed": { - "mentions": [], - "needs_action": [], - "activity": [], - "agent_activity": [], - }, - "meta": { - "since": since.timestamp(), - "total": 0, - "generated_at": generated_at, - } - }))); - } - - let (mentions_res, needs_action_res, activity_res) = tokio::join!( - state - .db - .query_feed_mentions(&pubkey_bytes, &accessible_ids, Some(since), limit), - state - .db - .query_feed_needs_action(&pubkey_bytes, &accessible_ids, Some(since), limit), - state - .db - .query_feed_activity(&accessible_ids, Some(since), limit), - ); - - // I10: Return 500 for critical feed query failures instead of masking with empty. - let mentions = mentions_res.map_err(|e| internal_error(&format!("db error: {e}")))?; - let needs_action = needs_action_res.map_err(|e| internal_error(&format!("db error: {e}")))?; - let activity_all = activity_res.map_err(|e| internal_error(&format!("db error: {e}")))?; - - let (agent_activity, channel_activity): (Vec<_>, Vec<_>) = activity_all - .into_iter() - .partition(|e| AGENT_KINDS.contains(&event_kind_u32(&e.event))); - - let all_channels = state.db.list_channels(None).await.unwrap_or_else(|e| { - tracing::warn!("feed: failed to load channel names for enrichment: {e}"); - vec![] - }); - let channel_name_map: HashMap = - all_channels.into_iter().map(|c| (c.id, c.name)).collect(); - - let to_feed_item = |event: &sprout_core::StoredEvent, category: &str| -> serde_json::Value { - let channel_name = event - .channel_id - .and_then(|id| channel_name_map.get(&id)) - .cloned() - .unwrap_or_default(); - - let tags: Vec = event - .event - .tags - .iter() - .map(|t| { - let tag_vec: Vec = t.as_slice().iter().map(|s| s.to_string()).collect(); - serde_json::json!(tag_vec) - }) - .collect(); - - serde_json::json!({ - "id": event.event.id.to_hex(), - "kind": event_kind_u32(&event.event), - "pubkey": event.event.pubkey.to_hex(), - "content": event.event.content, - "created_at": event.event.created_at.as_u64(), - "channel_id": event.channel_id.map(|id| id.to_string()), - "channel_name": channel_name, - "tags": tags, - "category": category, - }) - }; - - let mentions_items: Vec = if wants("mentions") { - mentions - .iter() - .map(|e| to_feed_item(e, "mention")) - .collect() - } else { - vec![] - }; - - let needs_action_items: Vec = if wants("needs_action") { - needs_action - .iter() - .map(|e| to_feed_item(e, "needs_action")) - .collect() - } else { - vec![] - }; - - let activity_items: Vec = if wants("activity") { - channel_activity - .iter() - .map(|e| to_feed_item(e, "activity")) - .collect() - } else { - vec![] - }; - - let agent_activity_items: Vec = if wants("agent_activity") { - agent_activity - .iter() - .map(|e| to_feed_item(e, "agent_activity")) - .collect() - } else { - vec![] - }; - - let total = mentions_items.len() - + needs_action_items.len() - + activity_items.len() - + agent_activity_items.len(); - - let generated_at = Utc::now().timestamp(); - - Ok(Json(serde_json::json!({ - "feed": { - "mentions": mentions_items, - "needs_action": needs_action_items, - "activity": activity_items, - "agent_activity": agent_activity_items, - }, - "meta": { - "since": since.timestamp(), - "total": total, - "generated_at": generated_at, - } - }))) -} diff --git a/crates/sprout-relay/src/api/git/hook.rs b/crates/sprout-relay/src/api/git/hook.rs new file mode 100644 index 0000000000..2bc1133e60 --- /dev/null +++ b/crates/sprout-relay/src/api/git/hook.rs @@ -0,0 +1,176 @@ +//! Pre-receive hook script generation and injection. +//! +//! The hook is a shell script that: +//! 1. Reads `old_oid new_oid ref_name` lines from stdin +//! 2. For each non-create/non-delete, runs `git merge-base --is-ancestor` +//! (inheriting quarantine env vars) +//! 3. POSTs the payload to the relay's internal policy endpoint with HMAC +//! 4. Exits non-zero on ANY non-200 response (fail-closed) +//! +//! Security invariants: +//! - Fail-closed: curl failure, timeout, non-200 → exit 1 +//! - Quarantine vars inherited for ancestry checks +//! - HMAC binds callback to specific push operation + +use std::path::Path; + +use tokio::fs; +use tracing::{error, info}; + +/// The pre-receive hook script content. +/// +/// Environment variables set by the relay before spawning git receive-pack: +/// - `SPROUT_HOOK_URL` — internal policy endpoint (http://127.0.0.1:{port}/internal/git/policy) +/// - `SPROUT_HOOK_SECRET` — per-push HMAC secret +/// - `SPROUT_REPO_ID` — repo identifier (d-tag) +/// - `SPROUT_PUSHER_PUBKEY` — authenticated pusher's hex pubkey +/// +/// Git sets automatically (quarantine): +/// - `GIT_OBJECT_DIRECTORY` — quarantine object store +/// - `GIT_ALTERNATE_OBJECT_DIRECTORIES` — includes the real object store +const PRE_RECEIVE_HOOK: &str = r#"#!/usr/bin/env bash +# Sprout pre-receive hook — FAIL-CLOSED +# ANY error, timeout, or non-200 response → reject the push. +set -eo pipefail + +# Force C locale for deterministic sort order and byte-accurate string lengths. +# Rust uses byte-order comparison and byte lengths — locale-aware sort/strlen would mismatch. +export LC_ALL=C + +ZERO="0000000000000000000000000000000000000000" + +# Fail-closed: required env vars must be set by the relay. +: "${SPROUT_REPO_ID:?error: SPROUT_REPO_ID not set}" +: "${SPROUT_REPO_OWNER:?error: SPROUT_REPO_OWNER not set}" +: "${SPROUT_PUSHER_PUBKEY:?error: SPROUT_PUSHER_PUBKEY not set}" +: "${SPROUT_HOOK_URL:?error: SPROUT_HOOK_URL not set}" +: "${SPROUT_HOOK_SECRET:?error: SPROUT_HOOK_SECRET not set}" + +WORK_DIR=$(mktemp -d) || { echo "error: cannot create temp dir" >&2; exit 1; } +REFS_FILE="$WORK_DIR/refs" +HMAC_FILE="$WORK_DIR/hmac" +RESP_FILE="$WORK_DIR/resp" +trap 'rm -rf "$WORK_DIR"' EXIT + +# Phase 1: Read ref updates from stdin, classify each, build JSON + HMAC lines. +# We write two files in parallel: +# REFS_FILE: JSON entries (unsorted, for the request body) +# HMAC_FILE: "ref_name old_oid new_oid" lines (for sorting → HMAC input) +REFS="" +while read -r old_oid new_oid ref_name; do + # Ancestry check for FF detection. + # CRITICAL: GIT_OBJECT_DIRECTORY and GIT_ALTERNATE_OBJECT_DIRECTORIES are + # inherited from our environment (git sets them for quarantine). Any git + # subprocess we call sees the quarantined objects automatically. + IS_ANCESTOR="false" + if [ "$old_oid" != "$ZERO" ] && [ "$new_oid" != "$ZERO" ]; then + # Exit 0 = is ancestor (FF), exit 1 = not ancestor (NFF), + # exit 128 = error → treat as NFF (fail-closed). + if git merge-base --is-ancestor "$old_oid" "$new_oid" 2>/dev/null; then + IS_ANCESTOR="true" + fi + fi + + # JSON entry for request body. + # Escape any special JSON characters in ref_name (defense against injection). + # Git ref names can't contain most special chars, but belt-and-suspenders. + SAFE_REF=$(printf '%s' "$ref_name" | sed 's/\\/\\\\/g; s/"/\\"/g') + + if [ -n "$REFS" ]; then + REFS="${REFS}," + fi + REFS="${REFS}{\"old_oid\":\"${old_oid}\",\"new_oid\":\"${new_oid}\",\"ref_name\":\"${SAFE_REF}\",\"is_ancestor\":${IS_ANCESTOR}}" + + # HMAC line: ref_name first (for sorting), then oids + is_ancestor. + # is_ancestor as "1" or "0" to match Rust's b"1"/b"0". + if [ "$IS_ANCESTOR" = "true" ]; then + echo "${ref_name} ${old_oid} ${new_oid} 1" >> "$HMAC_FILE" + else + echo "${ref_name} ${old_oid} ${new_oid} 0" >> "$HMAC_FILE" + fi +done + +# Phase 2: Compute HMAC-SHA256 signature. +# Payload format MUST match relay's compute_hmac() in policy.rs: +# repo_id | repo_owner | pusher_pubkey | (old_oid + new_oid + ref_name + is_ancestor) per ref sorted by ref_name | timestamp +TIMESTAMP=$(date +%s) + +# Structurally unambiguous HMAC format (matches Rust's compute_hmac): +# len(repo_id):repo_id | repo_owner | pusher | (old_oid + new_oid + len(ref):ref + is_anc)* | timestamp +REPO_ID_LEN=${#SPROUT_REPO_ID} +HMAC_INPUT="${REPO_ID_LEN}:${SPROUT_REPO_ID}|${SPROUT_REPO_OWNER}|${SPROUT_PUSHER_PUBKEY}|" +# Sort by ref_name (field 1) — matches Rust's sort_by(|a, b| a.ref_name.cmp(&b.ref_name)) +if [ -f "$HMAC_FILE" ]; then + sort "$HMAC_FILE" | while IFS=' ' read ref_name old_oid new_oid is_anc; do + REF_LEN=${#ref_name} + printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc" + done > "$HMAC_FILE.concat" + HMAC_INPUT="${HMAC_INPUT}$(cat "$HMAC_FILE.concat")" + rm -f "$HMAC_FILE.concat" +fi +HMAC_INPUT="${HMAC_INPUT}|${TIMESTAMP}" + +SIGNATURE=$(printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //') +if [ -z "$SIGNATURE" ]; then + echo "error: failed to compute HMAC signature" >&2 + exit 1 +fi + +# Phase 3: POST to policy endpoint — FAIL-CLOSED. +# repo_id is free-form (user-chosen d-tag) — must be escaped for JSON safety. +# repo_owner and pusher_pubkey are validated 64-char lowercase hex — no escaping needed. +SAFE_REPO_ID=$(printf '%s' "$SPROUT_REPO_ID" | sed 's/\\/\\\\/g; s/"/\\"/g') +BODY="{\"repo_id\":\"${SAFE_REPO_ID}\",\"repo_owner\":\"${SPROUT_REPO_OWNER}\",\"pusher_pubkey\":\"${SPROUT_PUSHER_PUBKEY}\",\"ref_updates\":[${REFS}],\"timestamp\":${TIMESTAMP},\"signature\":\"${SIGNATURE}\"}" + +HTTP_CODE=$(curl --silent --max-time 10 \ + -o "$RESP_FILE" \ + -w "%{http_code}" \ + -X POST \ + -H "Content-Type: application/json" \ + -d "$BODY" \ + "$SPROUT_HOOK_URL" 2>/dev/null) || { + echo "error: push authorization failed (network error reaching policy service)" >&2 + exit 1 +} + +if [ "$HTTP_CODE" != "200" ]; then + echo "error: push denied by policy (HTTP $HTTP_CODE)" >&2 + cat "$RESP_FILE" >&2 2>/dev/null + exit 1 +fi + +exit 0 +"#; + +/// Install the pre-receive hook into a bare repository. +/// +/// Creates a `hooks/` directory and writes the hook script with execute permission. +/// Called during repo creation (kind:30617 handling) and can be called to +/// retrofit existing repos. +pub async fn install_hook(repo_path: &Path) -> anyhow::Result<()> { + let hooks_dir = repo_path.join("hooks"); + fs::create_dir_all(&hooks_dir).await.map_err(|e| { + error!(path = %hooks_dir.display(), error = %e, "failed to create hooks dir"); + anyhow::anyhow!("failed to create hooks directory: {e}") + })?; + + let hook_path = hooks_dir.join("pre-receive"); + fs::write(&hook_path, PRE_RECEIVE_HOOK).await.map_err(|e| { + error!(path = %hook_path.display(), error = %e, "failed to write hook"); + anyhow::anyhow!("failed to write pre-receive hook: {e}") + })?; + + // Make executable (Unix only). + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let perms = std::fs::Permissions::from_mode(0o755); + std::fs::set_permissions(&hook_path, perms).map_err(|e| { + error!(path = %hook_path.display(), error = %e, "failed to chmod hook"); + anyhow::anyhow!("failed to set hook permissions: {e}") + })?; + } + + info!(repo = %repo_path.display(), "pre-receive hook installed"); + Ok(()) +} diff --git a/crates/sprout-relay/src/api/git/mod.rs b/crates/sprout-relay/src/api/git/mod.rs new file mode 100644 index 0000000000..538122bc4d --- /dev/null +++ b/crates/sprout-relay/src/api/git/mod.rs @@ -0,0 +1,60 @@ +//! Git hosting — Smart HTTP transport, permission hooks, and policy engine. +//! +//! # Module structure +//! +//! - `transport` — Smart HTTP protocol (info/refs, upload-pack, receive-pack) +//! - `hook` — Pre-receive hook script and injection +//! - `policy` — Internal policy endpoint (HMAC-authenticated callback from hook) + +use std::net::SocketAddr; +use std::sync::Arc; + +use axum::{ + body::Body, + extract::ConnectInfo, + http::{Request, StatusCode}, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::post, + Router, +}; +use tower_http::limit::RequestBodyLimitLayer; + +use crate::state::AppState; + +pub mod hook; +pub mod policy; +pub mod transport; + +pub use transport::git_router; + +/// Middleware that rejects requests from non-loopback addresses. +/// +/// Defense-in-depth: the internal policy endpoint should only be reachable +/// from localhost (the pre-receive hook runs on the same host as the relay). +async fn require_localhost(req: Request, next: Next) -> Response { + let is_loopback = req + .extensions() + .get::>() + .map(|ci| ci.0.ip().is_loopback()) + .unwrap_or(false); + + if !is_loopback { + return (StatusCode::FORBIDDEN, "internal endpoint: localhost only").into_response(); + } + + next.run(req).await +} + +/// Build the internal git policy router. +/// +/// Mounted at `/internal/git/policy` — only accessible from localhost. +/// The pre-receive hook calls this to authorize pushes. +/// Body limit: 1 MB (500 refs × ~200 bytes each = ~100 KB typical; 1 MB is generous). +pub fn git_policy_router(state: Arc) -> Router { + Router::new() + .route("/internal/git/policy", post(policy::hook_policy_check)) + .layer(RequestBodyLimitLayer::new(1024 * 1024)) // 1 MB + .layer(middleware::from_fn(require_localhost)) + .with_state(state) +} diff --git a/crates/sprout-relay/src/api/git/policy.rs b/crates/sprout-relay/src/api/git/policy.rs new file mode 100644 index 0000000000..9109d0a2d3 --- /dev/null +++ b/crates/sprout-relay/src/api/git/policy.rs @@ -0,0 +1,726 @@ +//! Internal policy endpoint — pre-receive hook callback. +//! +//! The pre-receive hook POSTs here with HMAC-signed payload containing +//! the pusher's pubkey, repo ID, and ref updates. This endpoint: +//! +//! 1. Validates HMAC signature + 30s TTL (fail-closed) +//! 2. Resolves kind:30617 → protection rules +//! 3. Resolves pusher's channel role via sprout-channel binding +//! 4. Promotes Bot → Member (bots in a channel push as members) +//! 5. Calls `sprout_core::git_perms::evaluate_push()` +//! 6. Returns 200 (allow) or 403 (deny with reasons) +//! +//! # Bot Role Model +//! +//! Bots are intentionally added to channels by members/admins. For git push, +//! they're promoted to Member — protection rules still apply. Bot is a +//! designation (what it is), not a permission tier (what it can do). The +//! promotion is scoped to this module; the core `MemberRole::Bot` hierarchy +//! is unchanged. +//! +//! # Security invariants +//! +//! - Endpoint binds to 127.0.0.1 only (enforced at router level) +//! - HMAC binds callback to the specific push operation +//! - Fail-closed: any error → 403 + +use std::sync::Arc; +use std::time::{SystemTime, UNIX_EPOCH}; + +use axum::{ + extract::State, + http::StatusCode, + response::{IntoResponse, Response}, + Json, +}; +use hmac::{Hmac, KeyInit, Mac}; +use serde::{Deserialize, Serialize}; +use sha2::Sha256; +use tracing::{error, warn}; + +use uuid::Uuid; + +use sprout_core::channel::MemberRole; +use sprout_core::git_perms::{evaluate_push, parse_protection_tags, Denial, RefUpdate, UpdateKind}; +use sprout_db::EventQuery; + +use crate::state::AppState; + +// ── Types ──────────────────────────────────────────────────────────────────── + +/// Maximum age of a hook callback (seconds). Push is synchronous so 30s is generous. +const MAX_CALLBACK_AGE_SECS: u64 = 30; + +/// Request payload from the pre-receive hook. +#[derive(Debug, Clone, Deserialize)] +pub struct HookCallbackRequest { + /// Repo identifier (d-tag from kind:30617). + pub repo_id: String, + /// Hex-encoded repo owner pubkey (from URL path, verified against kind:30617). + pub repo_owner: String, + /// Hex-encoded pusher pubkey. + pub pusher_pubkey: String, + /// Ref updates from git stdin (old_oid, new_oid, ref_name, is_ancestor). + pub ref_updates: Vec, + /// Unix timestamp when the hook was invoked. + pub timestamp: u64, + /// HMAC-SHA256 signature over the canonical payload. + pub signature: String, +} + +/// A single ref update as reported by the pre-receive hook. +#[derive(Debug, Clone, Deserialize)] +pub struct HookRefUpdate { + /// Old object ID (40 hex chars, zero OID for creates). + pub old_oid: String, + /// New object ID (40 hex chars, zero OID for deletes). + pub new_oid: String, + /// Full ref name (e.g., "refs/heads/main"). + pub ref_name: String, + /// Result of `git merge-base --is-ancestor old new`. + /// For creates/deletes this is false (ignored by classifier). + pub is_ancestor: bool, +} + +/// Response to the hook — either allow or deny. +#[derive(Debug, Serialize)] +pub struct HookCallbackResponse { + /// Whether the push is allowed. + pub allowed: bool, + /// Denial reasons (empty if allowed). + #[serde(skip_serializing_if = "Vec::is_empty")] + pub denials: Vec, +} + +/// A single denial reason in the hook response. +#[derive(Debug, Serialize)] +pub struct DenialResponse { + /// The ref that was denied. + pub ref_name: String, + /// Human-readable reason for denial. + pub reason: String, +} + +impl From for DenialResponse { + fn from(d: Denial) -> Self { + Self { + ref_name: d.ref_name, + reason: d.reason, + } + } +} + +// ── HMAC Verification ──────────────────────────────────────────────────────── + +/// Compute the canonical HMAC payload. +/// +/// Format (length-prefixed, `|`-separated, structurally unambiguous): +/// ```text +/// len(repo_id):repo_id | repo_owner(64) | pusher(64) | sorted_refs | timestamp +/// ``` +/// where each ref is: `old_oid(40) + new_oid(40) + len(ref_name):ref_name + is_ancestor("1"/"0")` +/// +/// Fixed-length fields (OIDs=40, pubkeys=64) need no length prefix. +/// Variable-length fields (repo_id, ref_name) are length-prefixed to prevent concatenation ambiguity. +fn compute_hmac(secret: &[u8], req: &HookCallbackRequest) -> Vec { + let mut mac = Hmac::::new_from_slice(secret).expect("HMAC can take key of any size"); + + // Structurally unambiguous format: length-prefixed fields separated by |. + // This prevents field confusion attacks (e.g., repo_id="a|b" being parsed differently). + mac.update(req.repo_id.len().to_string().as_bytes()); + mac.update(b":"); + mac.update(req.repo_id.as_bytes()); + mac.update(b"|"); + mac.update(req.repo_owner.as_bytes()); // Fixed 64 chars, no ambiguity. + mac.update(b"|"); + mac.update(req.pusher_pubkey.as_bytes()); // Fixed 64 chars, no ambiguity. + mac.update(b"|"); + // Deterministic ref update representation: sorted by ref_name. + // Each ref is length-prefixed to prevent concatenation ambiguity. + let mut refs_sorted: Vec<&HookRefUpdate> = req.ref_updates.iter().collect(); + refs_sorted.sort_by_key(|r| r.ref_name.clone()); + for r in &refs_sorted { + mac.update(r.old_oid.as_bytes()); // Fixed 40 chars. + mac.update(r.new_oid.as_bytes()); // Fixed 40 chars. + mac.update(r.ref_name.len().to_string().as_bytes()); + mac.update(b":"); + mac.update(r.ref_name.as_bytes()); + mac.update(if r.is_ancestor { b"1" } else { b"0" }); + } + mac.update(b"|"); + mac.update(req.timestamp.to_string().as_bytes()); + + mac.finalize().into_bytes().to_vec() +} + +/// Verify the HMAC signature on a hook callback. +fn verify_hmac(secret: &[u8], req: &HookCallbackRequest) -> bool { + let expected = compute_hmac(secret, req); + let provided = match hex::decode(&req.signature) { + Ok(bytes) => bytes, + Err(_) => return false, + }; + // Constant-time comparison. + use subtle::ConstantTimeEq; + expected.ct_eq(&provided).into() +} + +// ── Handler ────────────────────────────────────────────────────────────────── + +/// `POST /internal/git/policy` — pre-receive hook callback. +/// +/// Fail-closed: ANY error returns 403. The hook script treats non-200 as deny. +pub async fn hook_policy_check( + State(state): State>, + Json(req): Json, +) -> Response { + // 1. Validate input fields (cheap structural checks before expensive HMAC). + // This prevents wasting CPU on malformed payloads. + if req.repo_id.is_empty() || req.repo_id.len() > 64 { + return (StatusCode::FORBIDDEN, "invalid repo_id").into_response(); + } + if req.repo_owner.len() != 64 + || !req + .repo_owner + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return (StatusCode::FORBIDDEN, "invalid repo_owner").into_response(); + } + if req.pusher_pubkey.len() != 64 + || !req + .pusher_pubkey + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return (StatusCode::FORBIDDEN, "invalid pusher_pubkey").into_response(); + } + if req.ref_updates.is_empty() || req.ref_updates.len() > 500 { + return (StatusCode::FORBIDDEN, "invalid ref_updates count").into_response(); + } + for r in &req.ref_updates { + if r.old_oid.len() != 40 || !r.old_oid.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::FORBIDDEN, "invalid old_oid").into_response(); + } + if r.new_oid.len() != 40 || !r.new_oid.chars().all(|c| c.is_ascii_hexdigit()) { + return (StatusCode::FORBIDDEN, "invalid new_oid").into_response(); + } + if r.ref_name.is_empty() + || r.ref_name.len() > 256 + || !r.ref_name.starts_with("refs/") + || r.ref_name.contains("..") + || r.ref_name.bytes().any(|b| b <= 0x20 || b == 0x7f) + { + return (StatusCode::FORBIDDEN, "invalid ref_name").into_response(); + } + } + + // 2. Verify HMAC signature (now that we know the payload is structurally valid). + let secret = state.config.git_hook_hmac_secret.as_bytes(); + if !verify_hmac(secret, &req) { + warn!(repo = %req.repo_id, "hook callback: HMAC verification failed"); + return (StatusCode::FORBIDDEN, "signature verification failed").into_response(); + } + + // 3. Validate timestamp (30s TTL, max 5s future tolerance). + let now = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + if now.saturating_sub(req.timestamp) > MAX_CALLBACK_AGE_SECS { + warn!(repo = %req.repo_id, age = now.saturating_sub(req.timestamp), "hook callback: expired"); + return (StatusCode::FORBIDDEN, "callback expired").into_response(); + } + if req.timestamp.saturating_sub(now) > 5 { + warn!(repo = %req.repo_id, "hook callback: timestamp too far in future"); + return (StatusCode::FORBIDDEN, "callback timestamp invalid").into_response(); + } + + // 4. Validate and resolve kind:30617 for this repo. + // Query by (kind=30617, pubkey=owner, d_tag=repo_id) to prevent spoofing. + let owner_bytes = match hex::decode(&req.repo_owner) { + Ok(b) if b.len() == 32 => b, + _ => { + return (StatusCode::FORBIDDEN, "invalid repo owner").into_response(); + } + }; + let query = EventQuery { + kinds: Some(vec![30617]), + pubkey: Some(owner_bytes), + d_tag: Some(req.repo_id.clone()), + global_only: true, + limit: Some(1), + ..Default::default() + }; + let repo_event = match state.db.query_events(&query).await { + Ok(mut events) => { + if let Some(event) = events.pop() { + event + } else { + warn!(repo = %req.repo_id, "hook callback: kind:30617 not found"); + return (StatusCode::FORBIDDEN, "repository not found").into_response(); + } + } + Err(e) => { + error!(repo = %req.repo_id, error = %e, "hook callback: DB error"); + return (StatusCode::FORBIDDEN, "internal error").into_response(); + } + }; + + // 5. Parse protection rules from kind:30617 tags. + let tags: Vec> = repo_event + .event + .tags + .iter() + .map(|t| t.as_slice().to_vec()) + .collect(); + + let rules = match parse_protection_tags(&tags) { + Ok(parsed) => { + // Log unknown rules as warnings (helps catch typos). + for unknown in &parsed.unknown_rules { + warn!(repo = %req.repo_id, rule = %unknown, "unknown sprout-protect rule (skipped)"); + } + parsed.rules + } + Err(e) => { + warn!(repo = %req.repo_id, error = %e, "hook callback: malformed protection tags"); + // Fail-closed: malformed rules = deny. + return (StatusCode::FORBIDDEN, "malformed protection rules").into_response(); + } + }; + + // 6. Resolve channel and check archived state (applies to ALL pushers including owner). + let channel_id = tags + .iter() + .find(|t| t.first().map(|s| s.as_str()) == Some("sprout-channel")) + .and_then(|t| t.get(1)) + .and_then(|id| Uuid::parse_str(id).ok()); + + if let Some(ch_id) = channel_id { + match state.db.get_channel(ch_id).await { + Ok(ch) if ch.archived_at.is_some() => { + return (StatusCode::FORBIDDEN, "channel is archived (read-only)").into_response(); + } + Err(e) => { + error!(error = %e, "hook callback: channel lookup failed"); + return (StatusCode::FORBIDDEN, "internal error").into_response(); + } + _ => {} // Channel exists and is not archived. + } + } + + // 7. Resolve pusher's role. + let repo_owner_hex = hex::encode(repo_event.event.pubkey.to_bytes()); + let role = if req.pusher_pubkey == repo_owner_hex { + MemberRole::Owner + } else { + match channel_id { + None => { + warn!(repo = %req.repo_id, "hook callback: no sprout-channel binding"); + return (StatusCode::FORBIDDEN, "no channel binding").into_response(); + } + Some(ch_id) => { + let pusher_bytes = match hex::decode(&req.pusher_pubkey) { + Ok(b) if b.len() == 32 => b, + _ => { + return (StatusCode::FORBIDDEN, "invalid pusher pubkey").into_response(); + } + }; + match state.db.get_member_role(ch_id, &pusher_bytes).await { + Ok(Some(role_str)) => match role_str.parse::() { + Ok(role) => role, + Err(_) => { + error!(role = %role_str, "hook callback: unknown role"); + return (StatusCode::FORBIDDEN, "internal error").into_response(); + } + }, + Ok(None) => { + return (StatusCode::FORBIDDEN, "not a channel member").into_response(); + } + Err(e) => { + error!(error = %e, "hook callback: role lookup failed"); + return (StatusCode::FORBIDDEN, "internal error").into_response(); + } + } + } + } + }; + + // 8. Effective git role: bots intentionally added to a channel push as members. + // Protection rules (push:admin, no-force-push, require-patch, etc.) still apply. + // Bot is a designation (what it is), not a permission tier (what it can do). + let git_role = match role { + MemberRole::Bot => MemberRole::Member, + other => other, + }; + + // 9. Classify ref updates and evaluate policy. + let updates: Vec = req + .ref_updates + .iter() + .map(|r| RefUpdate { + ref_name: r.ref_name.clone(), + kind: UpdateKind::classify(&r.old_oid, &r.new_oid, r.is_ancestor), + old_oid: r.old_oid.clone(), + new_oid: r.new_oid.clone(), + }) + .collect(); + + match evaluate_push(&updates, git_role, &rules) { + Ok(()) => Json(HookCallbackResponse { + allowed: true, + denials: vec![], + }) + .into_response(), + Err(denials) => { + let response = HookCallbackResponse { + allowed: false, + denials: denials.into_iter().map(DenialResponse::from).collect(), + }; + (StatusCode::FORBIDDEN, Json(response)).into_response() + } + } +} + +// ── HMAC Generation (for the relay to pass to the hook) ────────────────────── + +/// Generate the HMAC signature for a hook callback payload. +/// +/// Called by the relay when setting up the pre-receive hook environment. +pub fn generate_hook_hmac( + secret: &[u8], + repo_id: &str, + repo_owner: &str, + pusher_pubkey: &str, + ref_updates: &[HookRefUpdate], + timestamp: u64, +) -> String { + let req = HookCallbackRequest { + repo_id: repo_id.to_string(), + repo_owner: repo_owner.to_string(), + pusher_pubkey: pusher_pubkey.to_string(), + ref_updates: ref_updates.to_vec(), + timestamp, + signature: String::new(), // Not used in computation. + }; + let mac_bytes = compute_hmac(secret, &req); + hex::encode(mac_bytes) +} + +// ── Tests ──────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + fn make_request() -> HookCallbackRequest { + HookCallbackRequest { + repo_id: "test-repo".to_string(), + repo_owner: "a".repeat(64), + pusher_pubkey: "b".repeat(64), + ref_updates: vec![HookRefUpdate { + old_oid: "1".repeat(40), + new_oid: "2".repeat(40), + ref_name: "refs/heads/main".to_string(), + is_ancestor: true, + }], + timestamp: SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(), + signature: String::new(), + } + } + + fn sign_request(req: &mut HookCallbackRequest, secret: &[u8]) { + let mac = compute_hmac(secret, req); + req.signature = hex::encode(mac); + } + + #[test] + fn hmac_valid_signature_accepted() { + let secret = b"test-secret-key"; + let mut req = make_request(); + sign_request(&mut req, secret); + assert!(verify_hmac(secret, &req)); + } + + #[test] + fn hmac_wrong_secret_rejected() { + let mut req = make_request(); + sign_request(&mut req, b"correct-secret"); + assert!(!verify_hmac(b"wrong-secret", &req)); + } + + #[test] + fn hmac_tampered_repo_id_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.repo_id = "evil-repo".to_string(); + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_tampered_pusher_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.pusher_pubkey = "c".repeat(64); + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_tampered_ref_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.ref_updates[0].ref_name = "refs/heads/evil".to_string(); + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_tampered_is_ancestor_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.ref_updates[0].is_ancestor = false; // Flip FF → NFF + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_tampered_owner_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.repo_owner = "c".repeat(64); + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_tampered_timestamp_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + sign_request(&mut req, secret); + req.timestamp += 1; + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_invalid_hex_rejected() { + let secret = b"test-secret"; + let mut req = make_request(); + req.signature = "not-valid-hex!!!".to_string(); + assert!(!verify_hmac(secret, &req)); + } + + #[test] + fn hmac_deterministic_across_ref_order() { + let secret = b"test-secret"; + let mut req1 = make_request(); + req1.ref_updates.push(HookRefUpdate { + old_oid: "3".repeat(40), + new_oid: "4".repeat(40), + ref_name: "refs/heads/develop".to_string(), + is_ancestor: false, + }); + let mut req2 = req1.clone(); + // Reverse the ref order — HMAC should be the same (sorted internally). + req2.ref_updates.reverse(); + let mac1 = compute_hmac(secret, &req1); + let mac2 = compute_hmac(secret, &req2); + assert_eq!(mac1, mac2); + } + + #[test] + fn generate_hook_hmac_matches_verify() { + let secret = b"test-secret"; + let mut req = make_request(); + let sig = generate_hook_hmac( + secret, + &req.repo_id, + &req.repo_owner, + &req.pusher_pubkey, + &req.ref_updates, + req.timestamp, + ); + req.signature = sig; + assert!(verify_hmac(secret, &req)); + } + + /// Cross-boundary HMAC integration test. + /// + /// Runs the bash HMAC computation logic (extracted from the pre-receive hook) + /// and compares its output against Rust's `generate_hook_hmac`. This is the + /// most critical test — it verifies the bash/Rust format agreement that the + /// entire security model depends on. + #[test] + fn bash_hmac_matches_rust_hmac() { + let secret = "cross-boundary-test-secret-key-1234"; + let repo_id = "my-project"; + let repo_owner = "ab".repeat(32); // 64 hex chars + let pusher = "cd".repeat(32); // 64 hex chars + let timestamp: u64 = 1700000000; + + // Two refs, intentionally out of sorted order to test sorting. + let ref_updates = vec![ + HookRefUpdate { + old_oid: "b".repeat(40), + new_oid: "c".repeat(40), + ref_name: "refs/heads/main".to_string(), + is_ancestor: true, + }, + HookRefUpdate { + old_oid: "a".repeat(40), + new_oid: "d".repeat(40), + ref_name: "refs/heads/feature".to_string(), + is_ancestor: false, + }, + ]; + + // Compute Rust-side HMAC. + let rust_sig = generate_hook_hmac( + secret.as_bytes(), + repo_id, + &repo_owner, + &pusher, + &ref_updates, + timestamp, + ); + + // Bash script that replicates the hook's HMAC computation. + // This is the exact logic from hook.rs PRE_RECEIVE_HOOK, extracted into + // a standalone script with hardcoded values. + let bash_script = format!( + r#" +export LC_ALL=C +SPROUT_REPO_ID="{repo_id}" +SPROUT_REPO_OWNER="{repo_owner}" +SPROUT_PUSHER_PUBKEY="{pusher}" +SPROUT_HOOK_SECRET="{secret}" +TIMESTAMP="{timestamp}" + +# Simulate the HMAC_FILE with two refs (unsorted, like the hook writes them) +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT +HMAC_FILE="$WORK_DIR/hmac" + +# Write refs in the order they'd arrive (main first, feature second) +echo "refs/heads/main {old1} {new1} 1" >> "$HMAC_FILE" +echo "refs/heads/feature {old2} {new2} 0" >> "$HMAC_FILE" + +# Build HMAC input — exact logic from hook script +REPO_ID_LEN=${{#SPROUT_REPO_ID}} +HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|${{SPROUT_REPO_OWNER}}|${{SPROUT_PUSHER_PUBKEY}}|" +sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do + REF_LEN=${{#ref_name}} + printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc" +done > "$HMAC_FILE.concat" +HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|${{TIMESTAMP}}" + +# Compute HMAC-SHA256 +printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "$SPROUT_HOOK_SECRET" -hex 2>/dev/null | sed 's/.*= //' +"#, + repo_id = repo_id, + repo_owner = repo_owner, + pusher = pusher, + secret = secret, + timestamp = timestamp, + old1 = "b".repeat(40), + new1 = "c".repeat(40), + old2 = "a".repeat(40), + new2 = "d".repeat(40), + ); + + let output = std::process::Command::new("bash") + .arg("-c") + .arg(&bash_script) + .output() + .expect("failed to run bash"); + + assert!( + output.status.success(), + "bash script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string(); + + assert_eq!( + rust_sig, bash_sig, + "HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}\n\ + The pre-receive hook and policy endpoint disagree on the canonical format." + ); + } + + /// Cross-boundary test with a single ref (simpler case). + #[test] + fn bash_hmac_single_ref() { + let secret = "single-ref-secret"; + let repo_id = "test-repo"; + let repo_owner = "a".repeat(64); + let pusher = "b".repeat(64); + let timestamp: u64 = 1700000001; + + let ref_updates = vec![HookRefUpdate { + old_oid: "1".repeat(40), + new_oid: "2".repeat(40), + ref_name: "refs/heads/main".to_string(), + is_ancestor: true, + }]; + + let rust_sig = generate_hook_hmac( + secret.as_bytes(), + repo_id, + &repo_owner, + &pusher, + &ref_updates, + timestamp, + ); + + let bash_script = format!( + r#" +export LC_ALL=C +WORK_DIR=$(mktemp -d) +trap 'rm -rf "$WORK_DIR"' EXIT +HMAC_FILE="$WORK_DIR/hmac" +echo "refs/heads/main {old} {new} 1" >> "$HMAC_FILE" +SPROUT_REPO_ID="{repo_id}" +REPO_ID_LEN=${{#SPROUT_REPO_ID}} +HMAC_INPUT="${{REPO_ID_LEN}}:${{SPROUT_REPO_ID}}|{owner}|{pusher}|" +sort "$HMAC_FILE" | while IFS=' ' read -r ref_name old_oid new_oid is_anc; do + REF_LEN=${{#ref_name}} + printf '%s%s%s:%s%s' "$old_oid" "$new_oid" "$REF_LEN" "$ref_name" "$is_anc" +done > "$HMAC_FILE.concat" +HMAC_INPUT="${{HMAC_INPUT}}$(cat "$HMAC_FILE.concat")|{timestamp}" +printf '%s' "$HMAC_INPUT" | openssl dgst -sha256 -hmac "{secret}" -hex 2>/dev/null | sed 's/.*= //' +"#, + old = "1".repeat(40), + new = "2".repeat(40), + repo_id = repo_id, + owner = repo_owner, + pusher = pusher, + timestamp = timestamp, + secret = secret, + ); + + let output = std::process::Command::new("bash") + .arg("-c") + .arg(&bash_script) + .output() + .expect("failed to run bash"); + + assert!( + output.status.success(), + "bash script failed: {}", + String::from_utf8_lossy(&output.stderr) + ); + + let bash_sig = String::from_utf8_lossy(&output.stdout).trim().to_string(); + assert_eq!( + rust_sig, bash_sig, + "Single-ref HMAC mismatch!\n Rust: {rust_sig}\n Bash: {bash_sig}" + ); + } +} diff --git a/crates/sprout-relay/src/api/git/transport.rs b/crates/sprout-relay/src/api/git/transport.rs new file mode 100644 index 0000000000..7c00900c5c --- /dev/null +++ b/crates/sprout-relay/src/api/git/transport.rs @@ -0,0 +1,764 @@ +//! Smart HTTP git transport for Sprout. +//! +//! Three endpoints implement the git Smart HTTP protocol: +//! - `GET /git/{owner}/{repo}/info/refs?service={svc}` — ref advertisement +//! - `POST /git/{owner}/{repo}/git-upload-pack` — clone/fetch +//! - `POST /git/{owner}/{repo}/git-receive-pack` — push +//! +//! Auth: NIP-98 on all routes (clone + push). No public repos for v1. +//! Transport: shells out to `git --stateless-rpc` with `env_clear()`. + +use std::path::{Path, PathBuf}; +use std::sync::Arc; + +use axum::{ + body::Body, + extract::{Path as AxumPath, Query, State}, + http::{header, StatusCode}, + response::{IntoResponse, Response}, + routing::{get, post}, + Router, +}; +use base64::Engine; +use hex; +use serde::Deserialize; +use tokio::process::Command; +use tower_http::limit::RequestBodyLimitLayer; +use tracing::{error, info, warn}; + +use crate::state::AppState; + +// ── Timeouts ───────────────────────────────────────────────────────────────── + +/// Timeout for `info/refs` — ref advertisement is fast (essentially `git show-ref`). +const INFO_REFS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(120); +/// Timeout for pack operations (upload-pack, receive-pack) — large repos need time. +const PACK_OPS_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(300); + +// ── NIP-98 Auth Extractor ──────────────────────────────────────────────────── + +/// NIP-98 auth extractor for git routes. +/// +/// Validates the `Authorization: Nostr ` header before the request body +/// is read. Same pattern as `AuthenticatedUpload` in media.rs. +/// +/// Authorization model: any authenticated pubkey can clone; push authorization +/// is handled by the pre-receive hook (calls back to the internal policy endpoint +/// which checks channel role + protection rules from kind:30617). +pub struct GitAuth { + /// The authenticated user's public key, extracted from the NIP-98 event. + pub pubkey: nostr::PublicKey, +} + +impl axum::extract::FromRequestParts> for GitAuth { + type Rejection = Response; + + async fn from_request_parts( + parts: &mut axum::http::request::Parts, + state: &Arc, + ) -> Result { + let method = parts.method.as_str(); + + let auth_header = parts + .headers + .get(header::AUTHORIZATION) + .and_then(|v| v.to_str().ok()) + .ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"sprout\", method=\"{method}\""), + ) + .body(Body::from("missing Authorization header")) + .unwrap() + })?; + + let token = auth_header.strip_prefix("Nostr ").ok_or_else(|| { + Response::builder() + .status(StatusCode::UNAUTHORIZED) + .header( + "WWW-Authenticate", + format!("Nostr realm=\"sprout\", method=\"{method}\""), + ) + .body(Body::from("expected Authorization: Nostr ")) + .unwrap() + })?; + + let event_bytes = base64::engine::general_purpose::STANDARD + .decode(token) + .or_else(|_| base64::engine::general_purpose::URL_SAFE_NO_PAD.decode(token)) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid base64").into_response())?; + let event_json = String::from_utf8(event_bytes) + .map_err(|_| (StatusCode::UNAUTHORIZED, "invalid utf-8").into_response())?; + + // Use configured relay_url as canonical base (don't trust forwarded headers). + let relay_url = &state.config.relay_url; + let base_url = relay_url + .replace("ws://", "http://") + .replace("wss://", "https://"); + let base_url = base_url.trim_end_matches('/'); + let path_and_query = parts + .uri + .path_and_query() + .map(|pq| pq.as_str()) + .unwrap_or(parts.uri.path()); + + // Repo-root URL verification. + // + // The credential helper signs a NIP-98 token with: + // u = (e.g., http://host/git/{owner}/{repo}) + // + // Git's credential protocol does NOT pass query strings to helpers, so + // service-scoping (`?service=...`) cannot be implemented at the NIP-98 + // level without protocol changes. The token is repo-scoped, not service-scoped. + // + // Security is still provided by: + // - ±60s timestamp window (limits replay) + // - HTTPS in production (prevents token theft) + // - Pre-receive hook for push authorization (role + protection rules) + // - Endpoint routing (clone/push are different HTTP paths) + let repo_path = if let Some((prefix, _query)) = path_and_query.split_once("/info/refs") { + prefix + } else if let Some(prefix) = path_and_query.strip_suffix("/git-upload-pack") { + prefix + } else if let Some(prefix) = path_and_query.strip_suffix("/git-receive-pack") { + prefix + } else { + return Err((StatusCode::BAD_REQUEST, "unrecognized git endpoint").into_response()); + }; + let expected_url = format!("{base_url}{repo_path}"); + + // Skip HTTP method check for git routes. + // + // Git's credential helper signs with `method=GET` (the initial /info/refs request) + // then reuses the token for POST (pack data). Method binding can't work here. + // + // Security is provided by: service-binding in the URL (clone vs push scoped), + // ±60s timestamp, and the pre-receive hook for push authorization. + // We pass the method from the event itself so verify_nip98_event always accepts. + let event_method = serde_json::from_str::(&event_json) + .ok() + .and_then(|v| { + v["tags"] + .as_array()? + .iter() + .find(|t| t[0].as_str() == Some("method"))?[1] + .as_str() + .map(str::to_owned) + }) + .unwrap_or_else(|| method.to_owned()); + + // SECURITY: method intentionally not verified for git routes. The tautological + // check (event.method == event.method) is deliberate — see comment block above. + // Git's credential protocol signs once with GET and reuses for POST. The URL tag + // provides the real security boundary (±60s timestamp + URL lock + HTTPS). + + // body=None: can't buffer streaming pack data to verify payload hash. + // Token is time-bounded (±60s) and URL-locked — acceptable trade-off. + let pubkey = + sprout_auth::nip98::verify_nip98_event(&event_json, &expected_url, &event_method, None) + .map_err(|e| { + warn!(error = %e, "git NIP-98 auth failed"); + (StatusCode::UNAUTHORIZED, "NIP-98 auth failed").into_response() + })?; + + // NOTE: NIP-98 event-ID dedup intentionally NOT implemented here. + // Git's credential protocol reuses one signed token across multiple requests + // in a session (info_refs GET → upload-pack/receive-pack POST). Rejecting + // replayed event IDs would break normal clone/push operations. + // The ±60s timestamp window + URL scoping + HTTPS transport provide sufficient + // replay protection for v1. Per-request signing requires protocol changes. + + // Relay membership gate (NIP-43). + let auth_tag = parts + .headers + .get("x-auth-tag") + .and_then(|v| v.to_str().ok()); + if crate::api::relay_members::enforce_relay_membership(state, &pubkey.serialize(), auth_tag) + .await + .is_err() + { + warn!(pubkey = %pubkey.to_hex(), "git: relay membership denied"); + return Err((StatusCode::FORBIDDEN, "restricted: not a relay member").into_response()); + } + + Ok(GitAuth { pubkey }) + } +} + +// ── Path Validation ────────────────────────────────────────────────────────── + +struct ValidatedRepoPath { + repo_path: PathBuf, +} + +/// Validate and resolve a git repo path from URL parameters. +/// +/// Security: allowlist characters, canonicalize, verify under repo root. +#[allow(clippy::result_large_err)] // Response is the natural error type for axum handlers +fn validate_repo_path( + owner: &str, + repo: &str, + git_repo_root: &Path, +) -> Result { + // Owner must be exactly 64 lowercase hex chars. + if owner.len() != 64 + || !owner + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase()) + { + return Err((StatusCode::BAD_REQUEST, "invalid owner").into_response()); + } + + // Strip trailing .git if present. + let repo_name = repo.strip_suffix(".git").unwrap_or(repo); + + // Repo name: [a-zA-Z0-9._-]{1,64}, no leading dots, no "..". + if repo_name.is_empty() + || repo_name.len() > 64 + || repo_name.starts_with('.') + || repo_name.contains("..") + || !repo_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') + { + return Err((StatusCode::BAD_REQUEST, "invalid repo name").into_response()); + } + + let repo_path = git_repo_root.join(owner).join(format!("{repo_name}.git")); + + // Path canonicalization: verify resolved path is under repo root. + // Fail closed: if the repo root doesn't exist, reject — the service can't operate safely. + let canonical_root = git_repo_root.canonicalize().map_err(|_| { + error!("git_repo_path does not exist or cannot be canonicalized"); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "git service misconfigured", + ) + .into_response() + })?; + // Repo may not exist yet (404 handled later), but if it does, verify containment. + if let Ok(canonical_repo) = repo_path.canonicalize() { + if !canonical_repo.starts_with(&canonical_root) { + return Err((StatusCode::BAD_REQUEST, "path traversal detected").into_response()); + } + } + + Ok(ValidatedRepoPath { repo_path }) +} + +/// Apply hardened environment to a git subprocess command. +/// +/// Clears all inherited env vars, then sets only the minimum required: +/// - `PATH` — so git can find its own helpers +/// - `GIT_HTTP_EXPORT_ALL` — required for Smart HTTP +/// - `GIT_CONFIG_NOSYSTEM=1` — ignore system-wide gitconfig +/// - `GIT_CONFIG_GLOBAL=/dev/null` — prevent reading global gitconfig +/// - `HOME=/dev/null` — prevent reading ~/.gitconfig +fn harden_git_env(cmd: &mut Command) { + cmd.env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_HTTP_EXPORT_ALL", "1") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null"); +} + +// ── Route Handlers ─────────────────────────────────────────────────────────── + +#[derive(Deserialize)] +/// Query parameters for the `info/refs` endpoint. +pub struct InfoRefsQuery { + service: String, +} + +#[derive(Deserialize)] +/// Path parameters for git repo routes: `{owner}/{repo}`. +pub struct GitRepoParams { + owner: String, + repo: String, +} + +/// `GET /git/{owner}/{repo}/info/refs?service={service}` +/// +/// Advertises refs for clone (git-upload-pack) or push (git-receive-pack). +pub async fn info_refs( + State(state): State>, + _auth: GitAuth, + AxumPath(params): AxumPath, + Query(query): Query, +) -> Result { + // Validate service parameter: exact allowlist. + let service = match query.service.as_str() { + "git-upload-pack" | "git-receive-pack" => &query.service, + _ => return Err((StatusCode::BAD_REQUEST, "invalid service").into_response()), + }; + + let validated = validate_repo_path(¶ms.owner, ¶ms.repo, &state.config.git_repo_path)?; + if !validated.repo_path.exists() { + return Err((StatusCode::NOT_FOUND, "repository not found").into_response()); + } + + let _permit = state.git_semaphore.try_acquire().map_err(|_| { + Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Retry-After", "5") + .body(Body::from("git service busy")) + .unwrap() + })?; + + let mut cmd = Command::new("git"); + // Git's smart HTTP protocol uses service names like "git-upload-pack" and + // "git-receive-pack", but the actual git subcommands are "upload-pack" and + // "receive-pack" (without the "git-" prefix). + let git_subcmd = service.strip_prefix("git-").unwrap_or(service.as_str()); + cmd.arg(git_subcmd) + .arg("--stateless-rpc") + .arg("--advertise-refs") + .arg(&validated.repo_path) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + harden_git_env(&mut cmd); + + let child = cmd.spawn().map_err(|e| { + error!(error = %e, "git subprocess failed to spawn"); + (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() + })?; + + // kill_on_drop requires a Child handle — .output() doesn't expose one. + // Spawn first, then wait under a timeout; on timeout the Child is dropped + // and kill_on_drop terminates the subprocess. + let output = tokio::time::timeout(INFO_REFS_TIMEOUT, child.wait_with_output()) + .await + .map_err(|_| { + warn!( + "git info_refs subprocess timed out ({}s)", + INFO_REFS_TIMEOUT.as_secs() + ); + (StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response() + })? + .map_err(|e| { + error!(error = %e, "git subprocess failed"); + (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() + })?; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + error!(stderr = %stderr, "git --advertise-refs failed"); + return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()); + } + + // Build pkt-line response: service header + flush + git output. + let svc_line = format!("# service={service}\n"); + let svc_pkt = format!("{:04x}{svc_line}", svc_line.len() + 4); + let mut body = Vec::with_capacity(svc_pkt.len() + 4 + output.stdout.len()); + body.extend_from_slice(svc_pkt.as_bytes()); + body.extend_from_slice(b"0000"); // flush packet + body.extend_from_slice(&output.stdout); + + let content_type = format!("application/x-{service}-advertisement"); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from(body)) + .unwrap()) +} + +/// `POST /git/{owner}/{repo}/git-upload-pack` +/// +/// Handles clone/fetch — client sends wants/haves, server sends pack data. +pub async fn upload_pack( + State(state): State>, + _auth: GitAuth, + AxumPath(params): AxumPath, + body: Body, +) -> Result { + run_git_service(&state, ¶ms.owner, ¶ms.repo, "upload-pack", body).await +} + +/// `POST /git/{owner}/{repo}/git-receive-pack` +/// +/// Handles push — client sends ref updates + pack data. +/// Authorization: NIP-98 authenticates the pusher. The pre-receive hook +/// calls back to the internal policy endpoint for ref-level authorization +/// (channel role + protection rules). Any authenticated user can attempt a push; +/// the hook enforces the actual permissions. +pub async fn receive_pack( + State(state): State>, + auth: GitAuth, + AxumPath(params): AxumPath, + body: Body, +) -> Result { + let pusher_hex = hex::encode(auth.pubkey.serialize()); + + // Per-repo lock: prevent concurrent pushes to the same bare repo. + // git receive-pack is not safe for concurrent access. + let validated = validate_repo_path(¶ms.owner, ¶ms.repo, &state.config.git_repo_path)?; + let repo_lock = state + .git_repo_locks + .entry(validated.repo_path.clone()) + .or_insert_with(|| Arc::new(tokio::sync::Mutex::new(()))) + .clone(); + let _repo_guard = repo_lock.lock().await; + + // SECURITY: Verify pre-receive hook is a regular file, executable, and not a symlink. + // If the hook is missing, non-executable, or a symlink (potential tampering), + // deny the push rather than allowing it without permission checks. + let hook_path = validated.repo_path.join("hooks").join("pre-receive"); + { + let hook_ok = { + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + // Use symlink_metadata to detect symlinks (doesn't follow them). + std::fs::symlink_metadata(&hook_path) + .map(|m| { + m.file_type().is_file() // Regular file, not symlink + && m.permissions().mode() & 0o111 != 0 // Executable + }) + .unwrap_or(false) + } + #[cfg(not(unix))] + { + hook_path.is_file() + } + }; + if !hook_ok { + warn!( + repo = %params.repo, + hook = %hook_path.display(), + "push denied: pre-receive hook missing, not executable, or symlink" + ); + return Err(( + StatusCode::INTERNAL_SERVER_ERROR, + "push denied: repository permission hook not installed", + ) + .into_response()); + } + } + + // Resolve repo name (strip .git suffix if present). + let repo_name = params.repo.strip_suffix(".git").unwrap_or(¶ms.repo); + + // Build hook env vars for the pre-receive hook. + // The hook uses these to call back to the internal policy endpoint. + let hook_url = format!( + "http://127.0.0.1:{}/internal/git/policy", + state.config.bind_addr.port() + ); + // SECURITY: Force core.hooksPath via env to prevent repo-local config from + // overriding the hook directory. Without this, a malicious repo config could + // set core.hooksPath=/dev/null to bypass the pre-receive hook entirely. + let hooks_dir = validated.repo_path.join("hooks").display().to_string(); + let hook_env = vec![ + ("SPROUT_HOOK_URL", hook_url), + ( + "SPROUT_HOOK_SECRET", + state.config.git_hook_hmac_secret.clone(), + ), + ("SPROUT_REPO_ID", repo_name.to_string()), + ("SPROUT_REPO_OWNER", params.owner.clone()), + ("SPROUT_PUSHER_PUBKEY", pusher_hex.clone()), + // Override any repo-local core.hooksPath setting. + ("GIT_CONFIG_COUNT", "1".to_string()), + ("GIT_CONFIG_KEY_0", "core.hooksPath".to_string()), + ("GIT_CONFIG_VALUE_0", hooks_dir), + ]; + + // Snapshot refs before push — used to detect whether anything actually changed. + let refs_before = snapshot_refs(&validated.repo_path).await; + + let response = run_git_service_with_env( + &state, + ¶ms.owner, + ¶ms.repo, + "receive-pack", + body, + &hook_env, + ) + .await?; + + // Post-push: publish kind:30618 ref state only if refs actually changed. + // Git smart HTTP returns 200 even on denied pushes (in-band rejection), + // so we compare before/after refs to avoid publishing on no-ops. + let state_clone = state.clone(); + let owner = params.owner.clone(); + let repo = params.repo.clone(); + let pusher = auth.pubkey; + let repo_path = validated.repo_path.clone(); + tokio::spawn(async move { + let refs_after = snapshot_refs(&repo_path).await; + if refs_before == refs_after { + return; // Nothing changed — skip publish. + } + if let Err(e) = publish_ref_state(&state_clone, &owner, &repo, &pusher).await { + warn!(error = %e, owner = %owner, repo = %repo, "failed to publish kind:30618"); + } + }); + + Ok(response) +} + +/// Shared git service runner — spawns subprocess and streams I/O. +async fn run_git_service( + state: &Arc, + owner: &str, + repo: &str, + service: &str, + body: Body, +) -> Result { + run_git_service_with_env(state, owner, repo, service, body, &[]).await +} + +/// Shared git service runner with extra environment variables. +/// +/// The `extra_env` pairs are set AFTER `harden_git_env` clears the environment, +/// so they're available to the git subprocess and any hooks it spawns. +async fn run_git_service_with_env( + state: &Arc, + owner: &str, + repo: &str, + service: &str, + body: Body, + extra_env: &[(&str, String)], +) -> Result { + let validated = validate_repo_path(owner, repo, &state.config.git_repo_path)?; + if !validated.repo_path.exists() { + return Err((StatusCode::NOT_FOUND, "repository not found").into_response()); + } + + let _permit = state.git_semaphore.try_acquire().map_err(|_| { + Response::builder() + .status(StatusCode::SERVICE_UNAVAILABLE) + .header("Retry-After", "5") + .body(Body::from("git service busy")) + .unwrap() + })?; + + let mut cmd = Command::new("git"); + cmd.arg(service) + .arg("--stateless-rpc") + .arg(&validated.repo_path) + .stdin(std::process::Stdio::piped()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .kill_on_drop(true); + harden_git_env(&mut cmd); + // Pass extra env vars (e.g., hook callback URL and HMAC secret). + for (key, value) in extra_env { + cmd.env(key, value); + } + let mut child = cmd.spawn().map_err(|e| { + error!(error = %e, "git subprocess failed to spawn"); + (StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response() + })?; + + // Stream request body to git stdin. + let mut stdin = child.stdin.take().unwrap(); + let body_task = tokio::spawn(async move { + use futures_util::StreamExt; + let mut stream = body.into_data_stream(); + while let Some(chunk) = stream.next().await { + match chunk { + Ok(bytes) => { + if tokio::io::AsyncWriteExt::write_all(&mut stdin, &bytes) + .await + .is_err() + { + break; + } + } + Err(_) => break, + } + } + drop(stdin); // Close stdin → EOF for git. + }); + // Grab an abort handle before moving body_task into the timeout block. + // On timeout, we abort it explicitly — dropping a JoinHandle only detaches + // the task (it keeps running). A stalled client could otherwise keep the + // spawned task alive indefinitely waiting on stream.next().await. + let body_abort = body_task.abort_handle(); + + // Timeout covers both body streaming and subprocess completion. + // On timeout: child is killed via kill_on_drop, body_task via abort_handle. + let timeout_result = tokio::time::timeout(PACK_OPS_TIMEOUT, async { + let _ = body_task.await; + child.wait_with_output().await + }) + .await; + + let output = match timeout_result { + Err(_elapsed) => { + body_abort.abort(); + warn!(service = %service, timeout_secs = PACK_OPS_TIMEOUT.as_secs(), "git subprocess timed out"); + return Err((StatusCode::GATEWAY_TIMEOUT, "git operation timed out").into_response()); + } + Ok(Err(e)) => { + error!(error = %e, "git subprocess failed"); + return Err((StatusCode::INTERNAL_SERVER_ERROR, "git error").into_response()); + } + Ok(Ok(out)) => out, + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + warn!(stderr = %stderr, service = %service, "git subprocess exited with error"); + // Still return output — git protocol errors are communicated in-band. + } + + let content_type = format!("application/x-git-{service}-result"); + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, content_type) + .header(header::CACHE_CONTROL, "no-cache") + .body(Body::from(output.stdout)) + .unwrap()) +} + +// ── Post-Push Event Publishing ─────────────────────────────────────────────── + +/// Quick snapshot of current refs — used to detect whether a push changed anything. +/// +/// Returns the raw `git for-each-ref` output as a string. Comparison is by +/// string equality — cheap and sufficient (same refs + same SHAs = same string). +/// Returns empty string on error (conservative: will trigger publish on failure). +async fn snapshot_refs(repo_path: &std::path::Path) -> String { + let mut cmd = Command::new("git"); + cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"]) + .current_dir(repo_path); + harden_git_env(&mut cmd); + match cmd.output().await { + Ok(output) if output.status.success() => { + String::from_utf8_lossy(&output.stdout).into_owned() + } + _ => String::new(), // Error → empty → won't match after → publish fires (safe default) + } +} + +/// Publish kind:30618 (repo state) after a successful push. +/// +/// Reads current refs from the repo and publishes a relay-signed event +/// with the pusher's pubkey in a `p` tag. This makes pushes subscribable. +async fn publish_ref_state( + state: &Arc, + owner: &str, + repo: &str, + pusher: &nostr::PublicKey, +) -> anyhow::Result<()> { + let validated = validate_repo_path(owner, repo, &state.config.git_repo_path) + .map_err(|_| anyhow::anyhow!("invalid repo path"))?; + + // Read current refs. + let mut cmd = Command::new("git"); + cmd.args(["for-each-ref", "--format=%(refname) %(objectname)"]) + .current_dir(&validated.repo_path); + harden_git_env(&mut cmd); + let output = cmd.output().await?; + + if !output.status.success() { + return Err(anyhow::anyhow!("git for-each-ref failed")); + } + + let refs_output = String::from_utf8_lossy(&output.stdout); + + // Get HEAD symbolic ref. + let mut head_cmd = Command::new("git"); + head_cmd + .args(["symbolic-ref", "HEAD"]) + .current_dir(&validated.repo_path); + harden_git_env(&mut head_cmd); + let head_output = head_cmd.output().await.ok(); + + let repo_name = repo.strip_suffix(".git").unwrap_or(repo); + + // Build NIP-34 kind:30618 tags. + let mut tags = vec![nostr::Tag::custom(nostr::TagKind::custom("d"), [repo_name])]; + + for line in refs_output.lines() { + let parts: Vec<&str> = line.splitn(2, ' ').collect(); + if parts.len() != 2 { + continue; + } + let (ref_name, sha) = (parts[0], parts[1]); + + // NIP-34 kind:30618 only includes heads and tags — skip stash, notes, remotes. + if !ref_name.starts_with("refs/heads/") && !ref_name.starts_with("refs/tags/") { + continue; + } + // Validate ref name and SHA. + if ref_name.starts_with('/') + || ref_name.contains("//") + || !ref_name + .chars() + .all(|c| c.is_ascii_alphanumeric() || "/_.-".contains(c)) + { + continue; + } + if sha.len() != 40 || !sha.chars().all(|c| c.is_ascii_hexdigit()) { + continue; + } + + tags.push(nostr::Tag::custom(nostr::TagKind::custom(ref_name), [sha])); + } + + // HEAD tag. + if let Some(head) = head_output { + if head.status.success() { + let head_ref = String::from_utf8_lossy(&head.stdout).trim().to_string(); + if !head_ref.is_empty() { + tags.push(nostr::Tag::custom( + nostr::TagKind::custom("HEAD"), + [format!("ref: {head_ref}")], + )); + } + } + } + + // Pusher pubkey in p tag. + tags.push(nostr::Tag::public_key(*pusher)); + + info!( + repo = %repo_name, + owner = %owner, + ref_count = tags.len().saturating_sub(2), + "publishing kind:30618 ref state" + ); + + // Sign with relay keypair — the relay is the authoritative source of ref state. + let event = nostr::EventBuilder::new(nostr::Kind::Custom(30618), "", tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:30618: {e}"))?; + + // Store globally (channel_id = None) and fan out to subscribers. + let (stored, was_inserted) = state.db.insert_event(&event, None).await?; + if was_inserted { + let matches = state.sub_registry.fan_out(&stored); + for (conn_id, sub_id) in matches { + let _ = state.conn_manager.send_to( + conn_id, + crate::protocol::RelayMessage::event(&sub_id, &stored.event), + ); + } + } + + Ok(()) +} + +// ── Router Builder ─────────────────────────────────────────────────────────── + +/// Build the git sub-router with its own body limit. +/// +/// Mounted at `/git/{owner}/{repo}/...` with a configurable max pack size. +pub fn git_router(state: Arc) -> Router { + let body_limit = state.config.git_max_pack_bytes as usize; + + Router::new() + .route("/git/{owner}/{repo}/info/refs", get(info_refs)) + .route("/git/{owner}/{repo}/git-upload-pack", post(upload_pack)) + .route("/git/{owner}/{repo}/git-receive-pack", post(receive_pack)) + .layer(RequestBodyLimitLayer::new(body_limit)) + .with_state(state) +} diff --git a/crates/sprout-relay/src/api/media.rs b/crates/sprout-relay/src/api/media.rs index 20ecf02bdb..2153845e1d 100644 --- a/crates/sprout-relay/src/api/media.rs +++ b/crates/sprout-relay/src/api/media.rs @@ -86,6 +86,16 @@ impl FromRequestParts> for AuthenticatedUpload { sprout_auth::require_scope(&scopes, Scope::FilesWrite) .map_err(|_| MediaError::InsufficientScope)?; + // 5. Relay membership gate (NIP-43). + let auth_tag = headers.get("x-auth-tag").and_then(|v| v.to_str().ok()); + crate::api::relay_members::enforce_relay_membership( + state, + &auth_event.pubkey.serialize(), + auth_tag, + ) + .await + .map_err(|_| MediaError::RelayMembershipRequired)?; + Ok(AuthenticatedUpload { auth_event, scopes }) } } diff --git a/crates/sprout-relay/src/api/members.rs b/crates/sprout-relay/src/api/members.rs deleted file mode 100644 index c93af245f2..0000000000 --- a/crates/sprout-relay/src/api/members.rs +++ /dev/null @@ -1,86 +0,0 @@ -//! Channel membership REST API. -//! -//! Endpoints: -//! GET /api/channels/{channel_id}/members — List members - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use uuid::Uuid; - -use crate::state::AppState; - -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, - internal_error, scope_error, -}; - -/// `GET /api/channels/{channel_id}/members` — List members of a channel. -/// -/// Requires channel membership or open visibility. -pub async fn list_members( - State(state): State>, - headers: HeaderMap, - Path(channel_id): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = Uuid::parse_str(&channel_id) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel_id"))?; - check_token_channel_access(&ctx, &channel_id)?; - - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let members = state - .db - .get_members(channel_id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - // Resolve display names in bulk. - let member_pubkeys: Vec> = members.iter().map(|m| m.pubkey.clone()).collect(); - let user_records = state - .db - .get_users_bulk(&member_pubkeys) - .await - .unwrap_or_else(|e| { - tracing::warn!("list_members: failed to load user records: {e}"); - vec![] - }); - - let display_name_map: HashMap = user_records - .into_iter() - .filter_map(|u| { - let hex = nostr_hex::encode(&u.pubkey); - u.display_name.map(|name| (hex, name)) - }) - .collect(); - - let result: Vec = members - .iter() - .map(|m| { - let hex = nostr_hex::encode(&m.pubkey); - let display_name = display_name_map.get(&hex).cloned(); - serde_json::json!({ - "pubkey": hex, - "role": m.role, - "joined_at": m.joined_at.to_rfc3339(), - "display_name": display_name, - }) - }) - .collect(); - - Ok(Json(serde_json::json!({ - "members": result, - "next_cursor": serde_json::Value::Null, - }))) -} diff --git a/crates/sprout-relay/src/api/messages.rs b/crates/sprout-relay/src/api/messages.rs deleted file mode 100644 index bcd9d8478f..0000000000 --- a/crates/sprout-relay/src/api/messages.rs +++ /dev/null @@ -1,1242 +0,0 @@ -//! Channel messages and thread REST API. -//! -//! Endpoints: -//! GET /api/channels/:channel_id/messages — list top-level messages -//! GET /api/channels/:channel_id/threads/:event_id — full thread tree - -use std::sync::Arc; - -use axum::{ - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use chrono::Utc; -use nostr::util::hex as nostr_hex; -use serde::Deserialize; - -use crate::state::AppState; - -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, - internal_error, not_found, -}; - -/// Validate imeta tags for correctness and safety. -/// -/// Shared between REST (send_message) and WebSocket (handle_event) paths. -/// Returns Ok(()) if all tags are valid, or a human-readable error string. -pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result<(), String> { - const ALLOWED_IMETA_KEYS: &[&str] = &[ - "url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration", - "bitrate", "image", - ]; - const SINGLETON_KEYS: &[&str] = &[ - "url", "m", "x", "size", "dim", "blurhash", "thumb", "alt", "duration", "bitrate", "image", - ]; - const ALLOWED_MIME: &[&str] = &[ - "image/jpeg", - "image/png", - "image/gif", - "image/webp", - "video/mp4", - ]; - - for tag in tags { - if tag.first().map(|s| s.as_str()) != Some("imeta") { - return Err("only imeta tags allowed in media_tags".into()); - } - - let mut has_url = false; - let mut has_m = false; - let mut has_x = false; - let mut has_size = false; - let mut seen_keys = std::collections::HashSet::new(); - let mut url_value = String::new(); - let mut x_value = String::new(); - let mut m_value = String::new(); - let mut thumb_value = String::new(); - - for part in tag.iter().skip(1) { - let mut parts = part.splitn(2, ' '); - let key = parts.next().unwrap_or(""); - let value = parts.next().unwrap_or(""); - - if !ALLOWED_IMETA_KEYS.contains(&key) { - return Err(format!("disallowed imeta key: {key}")); - } - if SINGLETON_KEYS.contains(&key) && !seen_keys.insert(key.to_string()) { - return Err(format!("duplicate imeta key: {key}")); - } - - match key { - "url" => { - if !is_local_media_url(value, media_base_url) { - return Err("imeta url must be a local /media/ path".into()); - } - if value.contains(".thumb.") { - return Err( - "imeta url must not be a thumbnail path; use thumb field".into() - ); - } - url_value = value.to_string(); - has_url = true; - } - "m" => { - if !ALLOWED_MIME.contains(&value) { - return Err( - "imeta m must be a supported MIME type (image/jpeg, image/png, image/gif, image/webp, video/mp4)" - .into(), - ); - } - m_value = value.to_string(); - has_m = true; - } - "x" => { - if value.len() != 64 - || !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) - { - return Err("imeta x must be a 64-char lowercase hex SHA-256".into()); - } - x_value = value.to_string(); - has_x = true; - } - "size" => { - match value.parse::() { - Ok(0) | Err(_) => { - return Err("imeta size must be a positive integer".into()) - } - Ok(_) => {} - } - has_size = true; - } - "thumb" => { - if !is_local_media_url(value, media_base_url) || !value.ends_with(".thumb.jpg") - { - return Err("imeta thumb must be a local .thumb.jpg path".into()); - } - thumb_value = value.to_string(); - } - "duration" => { - // NIP-71 standard field: seconds as float, strictly positive. - // Zero-duration videos are semantically invalid; server-side - // validate_video_file() also catches this via mvhd timescale. - if let Ok(d) = value.parse::() { - if d <= 0.0 || d.is_nan() || d.is_infinite() { - return Err("imeta duration must be a positive finite number".into()); - } - } else { - return Err("imeta duration must be a valid float".into()); - } - } - "bitrate" if value.parse::().map_or(true, |b| b == 0) => { - return Err("imeta bitrate must be a positive integer".into()); - } - "image" => { - // NIP-71 poster frame — must be a local media URL with an image extension. - // Poster frames are independent blobs, NOT thumbnails. - // Video URLs (e.g. .mp4) and thumbnail URLs (.thumb.jpg) are rejected. - const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; - if !is_local_media_url(value, media_base_url) { - return Err("imeta image must be a local /media/ path".into()); - } - if value.contains(".thumb.") { - return Err( - "imeta image must reference a standalone poster frame, not a thumbnail" - .into(), - ); - } - let ext = value.rsplit('.').next().unwrap_or(""); - if !IMAGE_EXTS.contains(&ext) { - return Err( - "imeta image must reference an image file (jpg, png, gif, webp), not video" - .into(), - ); - } - } - _ => {} - } - } - - if !has_url || !has_m || !has_x || !has_size { - return Err("imeta tag must include url, m, x, and size".into()); - } - - // Video-only NIP-71 fields must not appear on image blobs. - let is_video = m_value == "video/mp4"; - if !is_video { - for key in &["duration", "bitrate", "image"] { - if seen_keys.contains(*key) { - return Err(format!( - "imeta {key} is only valid for video/mp4, not {m_value}" - )); - } - } - } - - // Cross-check internal consistency: url hash must match x, url ext must match m. - if let Some(hash_in_url) = extract_hash_from_media_url(&url_value) { - if hash_in_url != x_value { - return Err("imeta url hash does not match x".into()); - } - } - if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) { - let expected_ext = mime_to_canonical_ext(&m_value); - if ext_in_url != expected_ext { - return Err("imeta url extension does not match m".into()); - } - } - // Thumb URL hash segment must match x — thumbnails are keyed by their - // parent blob's hash (e.g. {video_hash}.thumb.jpg), not by their own - // content hash. This checks URL consistency, not content identity. - if !thumb_value.is_empty() { - if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) { - if thumb_hash != x_value { - return Err("imeta thumb hash does not match x".into()); - } - } - } - // NIP-71 poster frame (`image`) is an independent blob with its own - // content hash — it cannot match the video's `x` hash. Validated as a - // local media URL with an image extension only (no hash cross-check). - } - Ok(()) -} - -/// Verify that every imeta tag references a blob that actually exists in storage -/// and that the claimed metadata (size, MIME) matches the sidecar. -/// -/// Called after syntactic validation. Returns Ok(()) if all blobs exist and match, -/// or a human-readable error string. This prevents clients from referencing -/// nonexistent blobs or lying about size/MIME in imeta tags. -pub async fn verify_imeta_blobs( - tags: &[Vec], - storage: &sprout_media::MediaStorage, -) -> Result<(), String> { - for tag in tags { - let mut x_value = String::new(); - let mut m_value = String::new(); - let mut size_value: u64 = 0; - let mut thumb_value = String::new(); - let mut image_value = String::new(); - let mut duration_value: f64 = 0.0; - - for part in tag.iter().skip(1) { - let mut parts = part.splitn(2, ' '); - let key = parts.next().unwrap_or(""); - let value = parts.next().unwrap_or(""); - match key { - "x" => x_value = value.to_string(), - "m" => m_value = value.to_string(), - "size" => size_value = value.parse().unwrap_or(0), - "thumb" => thumb_value = value.to_string(), - "image" => image_value = value.to_string(), - "duration" => duration_value = value.parse().unwrap_or(0.0), - _ => {} - } - } - - if x_value.is_empty() { - continue; // syntactic validation already caught this - } - - // 1. Sidecar must exist — proves the upload pipeline completed. - let sidecar = storage - .get_sidecar(&x_value) - .await - .map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?; - - // 2. HEAD the actual blob object — sidecar alone is not proof of blob existence. - let blob_key = format!("{x_value}.{}", sidecar.ext); - let blob_exists = storage - .head(&blob_key) - .await - .map_err(|e| format!("storage error checking blob {x_value}: {e}"))?; - if !blob_exists { - return Err(format!("imeta blob object missing in storage: {x_value}")); - } - - // 3. Cross-check claimed metadata against sidecar. - if !m_value.is_empty() && sidecar.mime_type != m_value { - return Err(format!( - "imeta m ({m_value}) does not match stored MIME ({})", - sidecar.mime_type - )); - } - if size_value > 0 && sidecar.size != size_value { - return Err(format!( - "imeta size ({size_value}) does not match stored size ({})", - sidecar.size - )); - } - // Duration cross-check: if sidecar has duration and client claims one, - // they must agree within 0.1s tolerance (float rounding from mvhd). - if let Some(stored_dur) = sidecar.duration_secs { - if duration_value > 0.0 && (duration_value - stored_dur).abs() > 0.1 { - return Err(format!( - "imeta duration ({duration_value}) does not match stored duration ({stored_dur})" - )); - } - } - - // 4. If thumb is claimed, HEAD the thumbnail object too. - if !thumb_value.is_empty() { - let thumb_key = format!("{x_value}.thumb.jpg"); - let thumb_exists = storage - .head(&thumb_key) - .await - .map_err(|e| format!("storage error checking thumbnail: {e}"))?; - if !thumb_exists { - return Err(format!( - "imeta thumb references missing thumbnail: {x_value}" - )); - } - } - - // 5. If image (poster frame) is claimed, verify sidecar + blob. - // Poster frames are independent blobs — extract hash from the image - // URL itself, not from x_value. Sidecar must exist (serving is gated - // on it) and MIME must be an image type. - if !image_value.is_empty() { - let img_hash = extract_hash_from_media_url(&image_value) - .ok_or_else(|| format!("imeta image URL has no extractable hash: {image_value}"))?; - - let img_sidecar = storage - .get_sidecar(img_hash) - .await - .map_err(|_| format!("imeta image references nonexistent poster: {img_hash}"))?; - - // Poster frame must be an image, not video or other type. - const IMAGE_MIMES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; - if !IMAGE_MIMES.contains(&img_sidecar.mime_type.as_str()) { - return Err(format!( - "imeta image poster MIME must be image type, got {}", - img_sidecar.mime_type - )); - } - - // URL extension must match sidecar's canonical extension. - // Mismatch means the URL would 404 on serve (GET resolves via sidecar). - if let Some(url_ext) = extract_ext_from_media_url(&image_value) { - if url_ext != img_sidecar.ext { - return Err(format!( - "imeta image extension ({url_ext}) does not match stored extension ({})", - img_sidecar.ext - )); - } - } - - let img_key = format!("{img_hash}.{}", img_sidecar.ext); - let img_exists = storage - .head(&img_key) - .await - .map_err(|e| format!("storage error checking poster image: {e}"))?; - if !img_exists { - return Err(format!( - "imeta image references missing poster frame: {img_hash}" - )); - } - } - } - Ok(()) -} - -/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` or `/media/{hash}.thumb.jpg` URL. -fn extract_hash_from_media_url(url: &str) -> Option<&str> { - let after = url.rsplit("/media/").next()?; - let hash = after.split('.').next()?; - if hash.len() == 64 && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { - Some(hash) - } else { - None - } -} - -/// Extract the primary extension from a `/media/{hash}.{ext}` URL (not thumb). -fn extract_ext_from_media_url(url: &str) -> Option<&str> { - let after = url.rsplit("/media/").next()?; - let segments: Vec<&str> = after.split('.').collect(); - if segments.len() == 2 { - Some(segments[1]) - } else { - None // bare hash or thumb — no primary ext to check - } -} - -/// Map MIME to canonical extension (must match sprout-media's mime_to_ext). -fn mime_to_canonical_ext(mime: &str) -> &str { - match mime { - "image/jpeg" => "jpg", - "image/png" => "png", - "image/gif" => "gif", - "image/webp" => "webp", - "video/mp4" => "mp4", - _ => "bin", - } -} - -/// Validate that a URL references a valid local media blob path. -/// -/// Accepts: -/// - Relative: `/media/.` or `/media/.thumb.jpg` -/// - Absolute: `/.` or `/.thumb.jpg` -/// -/// Where sha256 is exactly 64 lowercase hex chars and ext is an allowed image extension. -/// Thumbnails are always JPEG — only `.thumb.jpg` is accepted. -/// Rejects percent-encoded traversal, query strings, fragments, and external origins. -fn is_local_media_url(url: &str, media_base_url: &str) -> bool { - const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"]; - - // Extract the path portion after /media/ - let path_after_media = if let Some(rest) = url.strip_prefix("/media/") { - rest - } else { - let base = media_base_url.trim_end_matches('/'); - let prefix = format!("{}/", base); - if let Some(rest) = url.strip_prefix(&prefix) { - rest - } else { - return false; - } - }; - - // Reject query strings and fragments - if path_after_media.contains('?') || path_after_media.contains('#') { - return false; - } - - // Reject percent-encoding (no legitimate blob path needs it) - if path_after_media.contains('%') { - return false; - } - - // Parse segments: must be {sha256}.{ext} or {sha256}.thumb.{ext} - let segments: Vec<&str> = path_after_media.split('.').collect(); - match segments.len() { - 2 => { - // {sha256}.{ext} - let hash = segments[0]; - let ext = segments[1]; - hash.len() == 64 - && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) - && ALLOWED_EXTS.contains(&ext) - } - 3 => { - // {sha256}.thumb.jpg — thumbnails are always JPEG - let hash = segments[0]; - hash.len() == 64 - && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) - && segments[1] == "thumb" - && segments[2] == "jpg" - } - _ => false, - } -} - -/// Extract the effective message author from a stored event. -/// -/// REST-created messages are signed by the relay keypair and attribute the real -/// sender via a `p` tag. For user-signed events (WebSocket), `event.pubkey` is -/// the author. This helper returns the correct author bytes in both cases. -fn effective_author(event: &nostr::Event, relay_pubkey: &nostr::PublicKey) -> Vec { - if event.pubkey == *relay_pubkey { - // Relay-signed: real author is in the first p tag. - for tag in event.tags.iter() { - if tag.kind().to_string() == "p" { - if let Some(hex) = tag.content() { - if let Ok(bytes) = nostr_hex::decode(hex) { - if bytes.len() == 32 { - return bytes; - } - } - } - } - } - } - // User-signed or no p tag found: pubkey is the author. - event.pubkey.serialize().to_vec() -} - -/// Resolve the effective author pubkey from stored (non-Event) data. -/// -/// REST-created messages are signed by the relay keypair and carry the real -/// sender in the first `p` tag. This helper mirrors `effective_author` but -/// works with raw bytes + stored tags JSON rather than a `nostr::Event`. -fn effective_author_bytes( - msg_pubkey: &[u8], - tags: &serde_json::Value, - relay_pubkey_bytes: &[u8], -) -> Vec { - if msg_pubkey == relay_pubkey_bytes { - // Relay-signed: real author is in the first p tag. - if let Some(tags_arr) = tags.as_array() { - for tag in tags_arr { - if let Some(arr) = tag.as_array() { - if arr.len() >= 2 && arr[0].as_str() == Some("p") { - if let Some(hex) = arr[1].as_str() { - if let Ok(bytes) = nostr_hex::decode(hex) { - if bytes.len() == 32 { - return bytes; - } - } - } - } - } - } - } - } - msg_pubkey.to_vec() -} - -/// Serialize a slice of reaction summaries to JSON. -fn reactions_to_json(reactions: &[sprout_db::reaction::ReactionSummary]) -> serde_json::Value { - serde_json::json!(reactions - .iter() - .map(|r| serde_json::json!({ - "emoji": r.emoji, - "count": r.count, - })) - .collect::>()) -} - -// ── GET /api/channels/:channel_id/messages ──────────────────────────────────── - -/// Query parameters for listing top-level channel messages. -#[derive(Debug, Deserialize)] -pub struct ListMessagesParams { - /// Maximum messages to return. Default: 50, max: 200. - pub limit: Option, - /// Pagination cursor — Unix timestamp (seconds). Returns messages created - /// strictly before this time. - pub before: Option, - /// Pagination cursor — Unix timestamp (seconds). Returns messages created - /// strictly after this time. Results are ordered oldest-first when `since` - /// is provided without `before`. - pub since: Option, - /// Legacy parameter (thread summaries are now always included). Kept for backward compatibility. - #[serde(default)] - pub with_threads: bool, - /// Comma-separated event kind numbers to filter by (e.g. "45001" or "9,45001"). - #[serde(default)] - pub kinds: Option, -} - -/// List top-level messages in a channel. -/// -/// Default ordering is newest-first (DESC). When `since` is provided without -/// `before`, ordering flips to oldest-first (ASC) for chronological polling. -/// -/// Returns root messages and broadcast replies. Thread summaries (reply counts, -/// participant pubkeys) are always included. Thread replies themselves are excluded — -/// use `get_thread` to fetch the full reply tree for a specific message. -pub async fn list_messages( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; - - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let limit = params.limit.unwrap_or(50).min(200); - - let before_cursor: Option> = params - .before - .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); - - let since_cursor: Option> = params - .since - .and_then(|ts| chrono::DateTime::from_timestamp(ts, 0)); - - let kind_filter: Option> = params - .kinds - .as_deref() - .map(|s| { - s.split(',') - .map(|k| k.trim().parse::()) - .collect::, _>>() - }) - .transpose() - .map_err(|_| { - api_error( - StatusCode::BAD_REQUEST, - "Invalid 'kinds' parameter — expected comma-separated integers (e.g. '45001' or '9,45001')", - ) - })?; - - let mut messages = state - .db - .get_channel_messages_top_level( - channel_id, - limit, - before_cursor, - since_cursor, - kind_filter.as_deref(), - ) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - // Always enrich with thread summaries for messages that have replies. - // The `with_threads` param is kept for backward compatibility but summaries - // are now included by default. - for msg in &mut messages { - if let Ok(summary) = state.db.get_thread_summary(&msg.event_id).await { - msg.thread_summary = summary; - } - } - - // Bulk-fetch reaction counts for all messages in this page. - let event_pairs: Vec<(&[u8], chrono::DateTime)> = messages - .iter() - .map(|m| (m.event_id.as_slice(), m.created_at)) - .collect(); - let bulk_reactions = state - .db - .get_reactions_bulk(&event_pairs) - .await - .unwrap_or_default(); - - // Index reactions by event_id for O(1) lookup during serialization. - let reaction_map: std::collections::HashMap, &[sprout_db::reaction::ReactionSummary]> = - bulk_reactions - .iter() - .map(|entry| (entry.event_id.clone(), entry.reactions.as_slice())) - .collect(); - - // Determine next_cursor from the oldest message in this page. - let next_cursor = messages.last().map(|m| m.created_at.timestamp()); - - // Compute relay pubkey bytes once for effective-author resolution. - let relay_pk_bytes = state.relay_keypair.public_key().serialize().to_vec(); - - let result: Vec = messages - .iter() - .map(|m| { - let author = effective_author_bytes(&m.pubkey, &m.tags, &relay_pk_bytes); - let mut obj = serde_json::json!({ - "event_id": nostr_hex::encode(&m.event_id), - "pubkey": nostr_hex::encode(&author), - "content": m.content, - "kind": m.kind, - "created_at": m.created_at.timestamp(), - "channel_id": m.channel_id.to_string(), - "tags": m.tags, - }); - - if let Some(ref ts) = m.thread_summary { - obj["thread_summary"] = serde_json::json!({ - "reply_count": ts.reply_count, - "descendant_count": ts.descendant_count, - "last_reply_at": ts.last_reply_at.map(|t| t.timestamp()), - "participants": ts.participants.iter() - .map(nostr_hex::encode) - .collect::>(), - }); - } - - // Embed reaction counts if any exist for this message. - if let Some(reactions) = reaction_map.get(&m.event_id) { - obj["reactions"] = reactions_to_json(reactions); - } - - obj - }) - .collect(); - - Ok(Json(serde_json::json!({ - "messages": result, - "next_cursor": next_cursor, - }))) -} - -// ── GET /api/channels/:channel_id/threads/:event_id ────────────────────────── - -/// Query parameters for fetching a thread tree. -#[derive(Debug, Deserialize)] -pub struct GetThreadParams { - /// Maximum reply depth to include. Omit for unlimited. - pub depth_limit: Option, - /// Maximum replies to return. Default: 100, max: 500. - pub limit: Option, - /// Keyset pagination cursor — hex-encoded event_id of the last seen reply. - pub cursor: Option, -} - -/// Fetch the full reply tree for a thread rooted at `event_id`. -/// -/// Returns the root event details, all replies (optionally depth-limited), -/// and pagination info. -pub async fn get_thread( - State(state): State>, - headers: HeaderMap, - Path((channel_id_str, event_id_hex)): Path<(String, String)>, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; - - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let root_id_bytes = nostr_hex::decode(&event_id_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id hex"))?; - - // Fetch the root event. - let root_event = state - .db - .get_event_by_id(&root_id_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))? - .ok_or_else(|| not_found("event not found"))?; - - // Verify the root event belongs to the requested channel. - if let Some(root_channel) = root_event.channel_id { - if root_channel != channel_id { - return Err(api_error( - StatusCode::BAD_REQUEST, - "event belongs to a different channel", - )); - } - } - - // Fetch thread summary for the root. - let summary = state - .db - .get_thread_summary(&root_id_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let limit = params.limit.unwrap_or(100).min(500); - - // Decode optional cursor. - // The cursor is a hex-encoded 8-byte big-endian i64 Unix timestamp (seconds), - // matching the encoding produced when building next_cursor below (F8). - let cursor_bytes: Option> = match params.cursor { - Some(ref hex) => { - let bytes = nostr_hex::decode(hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid cursor hex"))?; - if bytes.len() != 8 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "cursor must be 8 bytes (timestamp)", - )); - } - Some(bytes) - } - None => None, - }; - - let replies = state - .db - .get_thread_replies( - &root_id_bytes, - params.depth_limit, - limit, - cursor_bytes.as_deref(), - ) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - // Encode next_cursor as hex of the last reply's created_at timestamp (8-byte big-endian i64). - // Using created_at (not event_id) because the ORDER BY is on event_created_at and binary - // event IDs do not correlate with chronological order (F8). - let next_cursor = replies.last().map(|r| { - let secs: i64 = r.created_at.timestamp(); - nostr_hex::encode(secs.to_be_bytes()) - }); - - let total_replies = summary.as_ref().map(|s| s.descendant_count).unwrap_or(0); - - // Serialize root event. - let root_created_at = root_event.event.created_at.as_u64() as i64; - let relay_pk = state.relay_keypair.public_key(); - let relay_pk_bytes = relay_pk.serialize().to_vec(); - let root_author = effective_author(&root_event.event, &relay_pk); - let root_tags = - serde_json::to_value(&root_event.event.tags).unwrap_or(serde_json::Value::Array(vec![])); - let mut root_obj = serde_json::json!({ - "event_id": root_event.event.id.to_hex(), - "pubkey": nostr_hex::encode(&root_author), - "content": root_event.event.content, - "kind": root_event.event.kind.as_u16(), - "tags": root_tags, - "created_at": root_created_at, - "channel_id": channel_id.to_string(), - "thread_summary": summary.as_ref().map(|s| serde_json::json!({ - "reply_count": s.reply_count, - "descendant_count": s.descendant_count, - "last_reply_at": s.last_reply_at.map(|t| t.timestamp()), - "participants": s.participants.iter() - .map(nostr_hex::encode) - .collect::>(), - })), - }); - - // Bulk-fetch reaction counts for root + all replies. - let root_created_at_dt = - chrono::DateTime::from_timestamp(root_created_at, 0).unwrap_or_else(Utc::now); - let mut thread_event_pairs: Vec<(&[u8], chrono::DateTime)> = - vec![(root_id_bytes.as_slice(), root_created_at_dt)]; - for r in &replies { - thread_event_pairs.push((r.event_id.as_slice(), r.created_at)); - } - let thread_bulk_reactions = state - .db - .get_reactions_bulk(&thread_event_pairs) - .await - .unwrap_or_default(); - let thread_reaction_map: std::collections::HashMap< - Vec, - &[sprout_db::reaction::ReactionSummary], - > = thread_bulk_reactions - .iter() - .map(|entry| (entry.event_id.clone(), entry.reactions.as_slice())) - .collect(); - - // Attach reactions to root event. - if let Some(reactions) = thread_reaction_map.get(&root_id_bytes) { - root_obj["reactions"] = reactions_to_json(reactions); - } - - // Serialize replies. - let reply_objs: Vec = replies - .iter() - .map(|r| { - let reply_author = effective_author_bytes(&r.pubkey, &r.tags, &relay_pk_bytes); - let mut obj = serde_json::json!({ - "event_id": nostr_hex::encode(&r.event_id), - "parent_event_id": r.parent_event_id.as_ref().map(nostr_hex::encode), - "root_event_id": r.root_event_id.as_ref().map(nostr_hex::encode), - "channel_id": r.channel_id.to_string(), - "pubkey": nostr_hex::encode(&reply_author), - "content": r.content, - "kind": r.kind, - "depth": r.depth, - "created_at": r.created_at.timestamp(), - "broadcast": r.broadcast, - "tags": r.tags, - }); - - if let Some(reactions) = thread_reaction_map.get(&r.event_id) { - obj["reactions"] = reactions_to_json(reactions); - } - - obj - }) - .collect(); - - Ok(Json(serde_json::json!({ - "root": root_obj, - "replies": reply_objs, - "total_replies": total_replies, - "next_cursor": next_cursor, - }))) -} - -#[cfg(test)] -mod tests { - use super::*; - - // ── local media URL tests ─────────────────────────────────────────────── - - const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; - const BASE: &str = "https://relay.example.com/media"; - - #[test] - fn test_local_media_url_relative() { - assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE)); - assert!(is_local_media_url(&format!("/media/{HASH}.png"), BASE)); - assert!(is_local_media_url(&format!("/media/{HASH}.gif"), BASE)); - assert!(is_local_media_url(&format!("/media/{HASH}.webp"), BASE)); - } - - #[test] - fn test_local_media_url_absolute() { - assert!(is_local_media_url(&format!("{BASE}/{HASH}.jpg"), BASE)); - } - - #[test] - fn test_local_media_url_thumb_jpg_only() { - assert!(is_local_media_url( - &format!("/media/{HASH}.thumb.jpg"), - BASE - )); - // Other thumb extensions rejected - assert!(!is_local_media_url( - &format!("/media/{HASH}.thumb.png"), - BASE - )); - assert!(!is_local_media_url( - &format!("/media/{HASH}.thumb.webp"), - BASE - )); - } - - #[test] - fn test_local_media_url_rejects_external() { - assert!(!is_local_media_url( - &format!("https://evil.com/media/{HASH}.jpg"), - BASE - )); - } - - #[test] - fn test_local_media_url_rejects_query_string() { - assert!(!is_local_media_url( - &format!("/media/{HASH}.jpg?foo=bar"), - BASE - )); - } - - #[test] - fn test_local_media_url_rejects_fragment() { - assert!(!is_local_media_url( - &format!("/media/{HASH}.jpg#frag"), - BASE - )); - } - - #[test] - fn test_local_media_url_rejects_percent_encoding() { - assert!(!is_local_media_url(&format!("/media/{HASH}%2e.jpg"), BASE)); - assert!(!is_local_media_url("/media/%2e%2e/etc/passwd", BASE)); - } - - #[test] - fn test_local_media_url_rejects_uppercase_hash() { - let upper = "ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789ABCDEF0123456789"; - assert!(!is_local_media_url(&format!("/media/{upper}.jpg"), BASE)); - } - - #[test] - fn test_local_media_url_rejects_short_hash() { - assert!(!is_local_media_url("/media/abc123.jpg", BASE)); - } - - #[test] - fn test_local_media_url_rejects_bad_ext() { - assert!(!is_local_media_url(&format!("/media/{HASH}.svg"), BASE)); - assert!(!is_local_media_url(&format!("/media/{HASH}.exe"), BASE)); - // .jpeg is not canonical — uploads produce .jpg only - assert!(!is_local_media_url(&format!("/media/{HASH}.jpeg"), BASE)); - } - - /// Thumb validation requires BOTH is_local_media_url AND .thumb.jpg suffix. - /// A full-size blob URL must not be accepted as a thumbnail. - #[test] - fn test_thumb_must_be_thumb_jpg() { - let thumb = format!("/media/{HASH}.thumb.jpg"); - let blob = format!("/media/{HASH}.jpg"); - assert!(is_local_media_url(&thumb, BASE) && thumb.ends_with(".thumb.jpg")); - assert!(is_local_media_url(&blob, BASE)); - assert!(!blob.ends_with(".thumb.jpg")); - } - - // ── imeta consistency cross-checks ────────────────────────────────────── - - #[test] - fn test_imeta_url_hash_must_match_x() { - let other = "b".repeat(64); - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.jpg"), - "m image/jpeg".into(), - format!("x {other}"), - "size 100".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("url hash does not match x"), "{err}"); - } - - #[test] - fn test_imeta_url_ext_must_match_m() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.png"), - "m image/jpeg".into(), - format!("x {HASH}"), - "size 100".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("url extension does not match m"), "{err}"); - } - - #[test] - fn test_imeta_thumb_hash_must_match_x() { - let other = "c".repeat(64); - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.jpg"), - "m image/jpeg".into(), - format!("x {HASH}"), - "size 100".into(), - format!("thumb /media/{other}.thumb.jpg"), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("thumb hash does not match x"), "{err}"); - } - - #[test] - fn test_imeta_consistent_tags_pass() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.jpg"), - "m image/jpeg".into(), - format!("x {HASH}"), - "size 100".into(), - format!("thumb /media/{HASH}.thumb.jpg"), - ]; - assert!(validate_imeta_tags(&[tag], BASE).is_ok()); - } - - // ── kinds filter parsing ──────────────────────────────────────────────── - - /// Helper: simulate the kinds-parsing logic from `list_messages`. - fn parse_kinds(input: Option<&str>) -> Result>, ()> { - input - .map(|s| { - s.split(',') - .map(|k| k.trim().parse::()) - .collect::, _>>() - }) - .transpose() - .map_err(|_| ()) - } - - #[test] - fn kinds_none_returns_none() { - assert_eq!(parse_kinds(None), Ok(None)); - } - - #[test] - fn kinds_single_value() { - assert_eq!(parse_kinds(Some("45001")), Ok(Some(vec![45001]))); - } - - #[test] - fn kinds_multiple_values() { - assert_eq!( - parse_kinds(Some("9,45001,45002")), - Ok(Some(vec![9, 45001, 45002])) - ); - } - - #[test] - fn kinds_with_whitespace() { - assert_eq!( - parse_kinds(Some("45001 , 45002")), - Ok(Some(vec![45001, 45002])) - ); - } - - #[test] - fn kinds_empty_string_is_error() { - assert!(parse_kinds(Some("")).is_err()); - } - - #[test] - fn kinds_non_numeric_is_error() { - assert!(parse_kinds(Some("abc")).is_err()); - } - - #[test] - fn kinds_mixed_valid_invalid_is_error() { - assert!(parse_kinds(Some("45001,abc")).is_err()); - } - - #[test] - fn kinds_negative_is_error() { - assert!(parse_kinds(Some("-1")).is_err()); - } - - // ── video / NIP-71 imeta tests ────────────────────────────────────────── - - #[test] - fn test_imeta_video_mp4_accepted() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - ]; - assert!(validate_imeta_tags(&[tag], BASE).is_ok()); - } - - #[test] - fn test_imeta_duration_valid() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "duration 29.5".into(), - ]; - assert!(validate_imeta_tags(&[tag], BASE).is_ok()); - } - - #[test] - fn test_imeta_duration_negative_rejected() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "duration -5".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("positive finite number"), "{err}"); - } - - #[test] - fn test_imeta_duration_zero_rejected() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "duration 0".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("positive finite number"), "{err}"); - } - - #[test] - fn test_imeta_duration_non_float_rejected() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "duration abc".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("valid float"), "{err}"); - } - - #[test] - fn test_imeta_bitrate_valid() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "bitrate 1500000".into(), - ]; - assert!(validate_imeta_tags(&[tag], BASE).is_ok()); - } - - #[test] - fn test_imeta_bitrate_zero_rejected() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - "bitrate 0".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("positive integer"), "{err}"); - } - - #[test] - fn test_imeta_image_poster_frame_accepted() { - // Poster frame is an independent blob with its own hash — different from - // the video's x hash. This must be accepted (no hash cross-check). - let poster_hash = "b".repeat(64); - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - format!("image /media/{poster_hash}.jpg"), - ]; - assert!(validate_imeta_tags(&[tag], BASE).is_ok()); - } - - #[test] - fn test_imeta_image_video_url_rejected() { - // NIP-71 image field is a still poster frame — .mp4 must be rejected - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - format!("image /media/{HASH}.mp4"), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!( - err.contains("image file") && err.contains("not video"), - "{err}" - ); - } - - #[test] - fn test_imeta_image_thumbnail_url_rejected() { - // Poster frame must be a standalone blob, not a thumbnail variant. - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/mp4".into(), - format!("x {HASH}"), - "size 5000000".into(), - format!("image /media/{HASH}.thumb.jpg"), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("standalone poster frame"), "{err}"); - } - - #[test] - fn test_imeta_duration_on_image_rejected() { - // Video-only NIP-71 fields must not appear on image blobs. - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.jpg"), - "m image/jpeg".into(), - format!("x {HASH}"), - "size 100000".into(), - "duration 5.0".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("only valid for video/mp4"), "{err}"); - } - - #[test] - fn test_imeta_video_quicktime_rejected() { - let tag = vec![ - "imeta".into(), - format!("url /media/{HASH}.mp4"), - "m video/quicktime".into(), - format!("x {HASH}"), - "size 5000000".into(), - ]; - let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); - assert!(err.contains("supported MIME type"), "{err}"); - } - - #[test] - fn test_local_media_url_mp4_accepted() { - assert!(is_local_media_url(&format!("/media/{HASH}.mp4"), BASE)); - } -} diff --git a/crates/sprout-relay/src/api/mod.rs b/crates/sprout-relay/src/api/mod.rs index 3167d06a83..bd799b19a2 100644 --- a/crates/sprout-relay/src/api/mod.rs +++ b/crates/sprout-relay/src/api/mod.rs @@ -1,474 +1,17 @@ -//! HTTP REST API handlers for the Sprout relay. -//! -//! Endpoints are split into focused submodules: -//! - `channels` — GET/POST /api/channels -//! - `events` — GET /api/events/:id -//! - `search` — GET /api/search -//! - `agents` — GET /api/agents -//! - `presence` — GET/PUT /api/presence -//! - `workflows` — workflow CRUD + trigger + webhook -//! - `approvals` — approval grant/deny -//! - `feed` — GET /api/feed +//! HTTP API — media, git, NIP-05, and the Nostr HTTP bridge. -/// Agent directory and status endpoints. -pub mod agents; -/// Workflow approval grant/deny endpoints. -pub mod approvals; -/// Canvas (shared document) endpoints. -pub mod canvas; -/// Channel CRUD and membership endpoints. -pub mod channels; -/// Channel metadata endpoints (get, update, topic, purpose, archive). -pub mod channels_metadata; -/// Direct message endpoints. -pub mod dms; -/// Event lookup endpoint. +pub mod bridge; pub mod events; -/// Personalized home feed endpoint. -pub mod feed; -/// Blossom-compatible media upload, retrieval, and existence check endpoints. +pub mod git; pub mod media; -/// Channel membership endpoints. -pub mod members; -/// Message and thread endpoints. -pub mod messages; -/// NIP-05 identity verification endpoint. pub mod nip05; -/// Presence status endpoints. -pub mod presence; -/// Reaction endpoints. -pub mod reactions; -/// Full-text search endpoint. -pub mod search; -/// Self-service API token minting, listing, and revocation endpoints. -pub mod tokens; -/// User profile endpoints. -pub mod users; -/// Shared helpers for workflow API handlers. -pub mod workflow_helpers; -/// Workflow CRUD, trigger, and webhook endpoints. -pub mod workflows; -// Re-export all public handlers so router.rs can use `api::*_handler` unchanged. -pub use agents::agents_handler; -pub use approvals::{deny_approval, deny_approval_by_hash, grant_approval, grant_approval_by_hash}; -pub use canvas::get_canvas; -pub use channels::channels_handler; -pub use channels_metadata::get_channel_handler; -pub use dms::{add_dm_member_handler, hide_dm_handler, list_dms_handler, open_dm_handler}; -pub use events::get_event; -pub use feed::feed_handler; -pub use members::list_members; -pub use messages::{get_thread, list_messages, validate_imeta_tags, verify_imeta_blobs}; -pub use presence::{presence_handler, set_presence_handler}; -pub use reactions::list_reactions_handler; -pub use search::search_handler; -pub use users::{ - get_contact_list, get_profile, get_user_notes, get_user_profile, get_users_batch, - put_channel_add_policy, search_users, -}; -pub use workflows::{ - create_workflow, delete_workflow, get_workflow, list_channel_workflows, list_run_approvals, - list_workflow_runs, trigger_workflow, update_workflow, workflow_webhook, -}; +// Re-export imeta helpers used by ingest pipeline. +pub use crate::handlers::imeta::{validate_imeta_tags, verify_imeta_blobs}; -// ── Shared helpers ──────────────────────────────────────────────────────────── +// ── Shared helpers (used by media.rs, bridge.rs) ────────────────────────────── -#[cfg(any(test, feature = "dev"))] -use std::collections::HashMap; -use std::time::{Duration, Instant}; - -use axum::{ - http::{HeaderMap, StatusCode}, - response::Json, -}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use sprout_auth::Scope; - -use crate::state::AppState; - -// ── Auth context types ──────────────────────────────────────────────────────── - -/// How the REST request was authenticated. -#[derive(Debug, Clone, PartialEq, Eq)] -pub enum RestAuthMethod { - /// `Authorization: Bearer sprout_*` — API token verified against DB hash. - ApiToken, - /// `Authorization: Bearer eyJ*` — Okta JWT validated via JWKS. - OktaJwt, - /// `Authorization: Nostr ` — NIP-98 HTTP Auth (bootstrap path only). - Nip98, - /// `X-Pubkey: ` — dev mode only (`require_auth_token=false`). - DevPubkey, -} - -/// Full authentication context returned to REST handlers. -/// -/// Replaces the old `(pubkey, pubkey_bytes)` tuple from `extract_auth_pubkey`. -/// The `pubkey` and `pubkey_bytes` fields are identical to the old return value, -/// so existing handler logic is unchanged; scope and channel checks can now be -/// layered on top. -#[derive(Debug, Clone)] -pub struct RestAuthContext { - /// The authenticated Nostr public key. - pub pubkey: nostr::PublicKey, - /// Compressed (32-byte) serialisation of `pubkey`. - pub pubkey_bytes: Vec, - /// Permission scopes granted to this request. - /// - /// Empty for the NIP-98 bootstrap path — the caller must be `POST /api/tokens`. - pub scopes: Vec, - /// How the request was authenticated. - pub auth_method: RestAuthMethod, - /// The UUID of the API token used, if auth_method is `ApiToken`. - pub token_id: Option, - /// Token-level channel restriction, if any. - /// - /// `None` means unrestricted (all channels the pubkey is a member of). - /// `Some([])` means no channels are permitted. - pub channel_ids: Option>, -} - -/// Extract the full auth context from request headers. -/// -/// Auth resolution order: -/// 1. `Authorization: Bearer sprout_*` — API token; revocation + expiry checked here -/// 2. `Authorization: Bearer eyJ*` — Okta JWT -/// 3. `X-Pubkey: ` — dev mode only -/// -/// NIP-98 (`Authorization: Nostr `) is **not** handled here — it is only -/// valid for `POST /api/tokens` and is verified directly in that handler. Any -/// request that sends a `Nostr` auth header to a non-token endpoint will receive -/// a 401 with `"nip98_not_supported"`. -/// -/// Returns a populated [`RestAuthContext`] on success, or a 401 response on failure. -pub(crate) async fn extract_auth_context( - headers: &HeaderMap, - state: &AppState, -) -> Result)> { - let require_auth = state.config.require_auth_token; - - if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) { - // ── 1. Reject NIP-98 on non-token endpoints ─────────────────────────── - // NIP-98 auth is only valid for POST /api/tokens (handled directly in - // post_tokens). Sending it here is a client error — reject explicitly - // rather than falling through to a confusing "authentication failed". - if auth_header.starts_with("Nostr ") { - tracing::warn!("auth: NIP-98 auth header sent to non-token endpoint"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "nip98_not_supported", - "message": "NIP-98 auth is only supported for POST /api/tokens" - })), - )); - } - - // ── 2. Bearer token path ────────────────────────────────────────────── - if let Some(token) = auth_header.strip_prefix("Bearer ") { - // ── 2a. API token (sprout_*) ────────────────────────────────────── - if token.starts_with("sprout_") { - let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); - - // Use the new including-revoked query so we can return distinct - // token_revoked vs invalid_token errors. - let record = match state - .db - .get_api_token_by_hash_including_revoked(&hash) - .await - { - Ok(Some(r)) => r, - Ok(None) => { - tracing::warn!("auth: API token not found"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "invalid_token" })), - )); - } - Err(e) => { - tracing::warn!("auth: API token lookup failed: {e}"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "invalid_token" })), - )); - } - }; - - // Relay-layer revocation check (before hash verification). - if record.revoked_at.is_some() { - tracing::warn!("auth: API token is revoked"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "token_revoked" })), - )); - } - - // Relay-layer expiry check (before hash verification). - if let Some(exp) = record.expires_at { - if exp < chrono::Utc::now() { - tracing::warn!("auth: API token is expired"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "token_expired" })), - )); - } - } - - let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) { - Ok(pk) => pk, - Err(e) => { - tracing::warn!("auth: API token owner pubkey invalid: {e}"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "invalid_token" })), - )); - } - }; - - match state.auth.verify_api_token_against_hash( - token, - &record.token_hash, - &owner_pubkey, - &owner_pubkey, - record.expires_at, - &record.scopes, - ) { - Ok((pubkey, scopes)) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed: {e}"); - } - - // Debounced last_used_at update — at most once per 5 min per token. - let should_update = state - .last_used_cache - .get(&record.id) - .map(|t| t.elapsed() > Duration::from_secs(300)) - .unwrap_or(true); - if should_update { - state.last_used_cache.insert(record.id, Instant::now()); - let db = state.db.clone(); - let hash_copy = hash; - tokio::spawn(async move { - let _ = db.update_token_last_used(&hash_copy).await; - }); - } - - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes, - auth_method: RestAuthMethod::ApiToken, - token_id: Some(record.id), - channel_ids: record.channel_ids, - }); - } - Err(_) => { - tracing::warn!("auth: API token hash verification failed"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "invalid_token" })), - )); - } - } - } - - // ── 2b. Okta JWT (eyJ*) ─────────────────────────────────────────── - if require_auth { - match state.auth.validate_bearer_jwt(token).await { - Ok((pubkey, scopes)) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed: {e}"); - } - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes, - auth_method: RestAuthMethod::OktaJwt, - token_id: None, - channel_ids: None, - }); - } - Err(_) => { - tracing::warn!("auth: JWT validation failed"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - } - } else { - // Dev mode: decode JWT payload without JWKS validation. - // Only compiled when the `dev` feature is enabled — disabled in release builds. - #[cfg(any(test, feature = "dev"))] - { - match decode_jwt_payload_unverified(token) { - Ok(claims) => { - if let Some(username) = - claims.get("preferred_username").and_then(|v| v.as_str()) - { - match sprout_auth::derive_pubkey_from_username(username) { - Ok(pubkey) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed: {e}"); - } - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes: vec![Scope::MessagesRead, Scope::MessagesWrite], - auth_method: RestAuthMethod::OktaJwt, - token_id: None, - channel_ids: None, - }); - } - Err(_) => { - tracing::warn!("auth: key derivation failed for username"); - return Err(( - StatusCode::UNAUTHORIZED, - Json( - serde_json::json!({ "error": "authentication failed" }), - ), - )); - } - } - } - tracing::warn!("auth: JWT missing preferred_username claim"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - Err(_) => { - tracing::warn!("auth: malformed JWT"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - } - } - #[cfg(not(any(test, feature = "dev")))] - { - tracing::warn!("auth: dev-mode JWT auth disabled in release builds"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - } - } - } - - // ── 4. Dev fallback: X-Pubkey ───────────────────────────────────────────── - if !require_auth { - if let Some(hex_val) = headers.get("x-pubkey").and_then(|v| v.to_str().ok()) { - match nostr::PublicKey::from_hex(hex_val) { - Ok(pubkey) => { - let pubkey_bytes = pubkey.serialize().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed: {e}"); - } - // Dev mode grants all scopes (including admin) — it's a development convenience. - // Production deployments MUST set SPROUT_REQUIRE_AUTH_TOKEN=true. - return Ok(RestAuthContext { - pubkey, - pubkey_bytes, - scopes: Scope::all_known(), - auth_method: RestAuthMethod::DevPubkey, - token_id: None, - channel_ids: None, - }); - } - Err(_) => { - tracing::warn!("auth: invalid X-Pubkey header value"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication failed" })), - )); - } - } - } - } - - Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ "error": "authentication required" })), - )) -} - -// ── Step 8: Token-level channel access enforcement ──────────────────────────── - -/// Check whether a token is permitted to access the given channel. -/// -/// This is a **token-level** check — it verifies that `channel_id` is in the -/// token's `channel_ids` restriction list. It is **in addition to** the -/// membership check (`check_channel_access`) and scope check (`require_scope`); -/// all three must pass. -/// -/// Tokens with `channel_ids = None` (no restriction) always pass this check. -/// Tokens with `channel_ids = Some([])` (empty list) deny all channels. -pub fn check_token_channel_access( - ctx: &RestAuthContext, - channel_id: &Uuid, -) -> Result<(), (StatusCode, Json)> { - if let Some(ref allowed) = ctx.channel_ids { - if !allowed.contains(channel_id) { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "channel_not_permitted", - "message": "Token does not have access to this channel" - })), - )); - } - } - Ok(()) -} - -/// Intersect an owner-accessible channel list with a token's `channel_ids`, when present. -pub(crate) fn constrain_channel_ids( - mut channel_ids: Vec, - allowed: Option<&[Uuid]>, -) -> Vec { - if let Some(allowed) = allowed { - channel_ids.retain(|channel_id| allowed.contains(channel_id)); - } - channel_ids -} - -/// Filter accessible channel records against a token's `channel_ids`, when present. -pub(crate) fn constrain_accessible_channels( - mut channels: Vec, - allowed: Option<&[Uuid]>, -) -> Vec { - if let Some(allowed) = allowed { - channels.retain(|channel| allowed.contains(&channel.channel.id)); - } - channels -} - -/// Convert a scope-check failure into a 403 Forbidden response. -/// -/// Used by handlers to propagate `require_scope` errors via `?`. -pub(crate) fn scope_error(e: sprout_auth::AuthError) -> (StatusCode, Json) { - match e { - sprout_auth::AuthError::InsufficientScope { required, .. } => ( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "insufficient_scope", - "message": format!("token missing required scope: {required}"), - })), - ), - other => { - tracing::warn!("scope_error: unexpected auth error: {other}"); - api_error(StatusCode::FORBIDDEN, "insufficient_scope") - } - } -} +use axum::{http::StatusCode, response::Json}; /// Standard error envelope. pub(crate) fn api_error(status: StatusCode, msg: &str) -> (StatusCode, Json) { @@ -480,375 +23,156 @@ pub(crate) fn internal_error(msg: &str) -> (StatusCode, Json) api_error(StatusCode::INTERNAL_SERVER_ERROR, "internal server error") } +#[allow(dead_code)] pub(crate) fn not_found(msg: &str) -> (StatusCode, Json) { api_error(StatusCode::NOT_FOUND, msg) } -pub(crate) fn forbidden(msg: &str) -> (StatusCode, Json) { - api_error(StatusCode::FORBIDDEN, msg) -} - -/// Decode a JWT payload segment without signature verification. -/// Used in dev mode (`require_auth_token=false`) to extract `preferred_username`. -#[cfg(any(test, feature = "dev"))] -fn decode_jwt_payload_unverified( - token: &str, -) -> Result, String> { - use base64::Engine as _; - let parts: Vec<&str> = token.split('.').collect(); - if parts.len() < 2 { - return Err("malformed JWT".into()); - } - let decoded = base64::engine::general_purpose::URL_SAFE_NO_PAD - .decode(parts[1]) - .map_err(|_| "authentication failed".to_string())?; - serde_json::from_slice(&decoded).map_err(|_| "authentication failed".to_string()) -} - -/// Check channel membership access: member OR open-visibility channel. +/// Relay membership enforcement — single gate for all authenticated entry points. /// -/// Open channels (visibility = "open") allow any authenticated user to read/write. -/// This is the **membership** check — separate from the token-level channel restriction -/// check ([`check_token_channel_access`]). -/// -/// # Note -/// This function is also exported as [`check_channel_access`] for backward compatibility -/// while Step 7 migrates all handlers to use [`extract_auth_context`]. -pub(crate) async fn check_channel_membership( - state: &AppState, - channel_id: uuid::Uuid, - pubkey_bytes: &[u8], -) -> Result<(), (StatusCode, Json)> { - let is_member = state - .is_member_cached(channel_id, pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - if is_member { - return Ok(()); - } - let is_open = state - .db - .get_channel(channel_id) - .await - .map(|ch| ch.visibility == "open") - .unwrap_or(false); - if is_open { - Ok(()) - } else { - Err(forbidden("not a member of this channel")) - } -} +/// Moved here from the deleted `relay_members` module. Called by `media.rs`, `bridge.rs`, +/// `git/transport.rs`, and `audio/handler.rs`. +pub mod relay_members { + use axum::{http::StatusCode, response::Json}; + use tracing::debug; -/// Backward-compatible alias for [`check_channel_membership`]. -/// -/// Step 7 will replace all call sites with `check_channel_membership` and remove this alias. -#[allow(dead_code)] -pub(crate) async fn check_channel_access( - state: &AppState, - channel_id: uuid::Uuid, - pubkey_bytes: &[u8], -) -> Result<(), (StatusCode, Json)> { - check_channel_membership(state, channel_id, pubkey_bytes).await -} + use crate::state::AppState; -// ── Custom JSON extractor ───────────────────────────────────────────────────── - -use axum::extract::{rejection::JsonRejection, FromRequest, Request}; - -/// A JSON extractor that returns our standard `{"error": "..."}` envelope -/// on deserialization failure instead of Axum's default plain-text 422. -pub struct ApiJson(pub T); - -impl FromRequest for ApiJson -where - axum::Json: FromRequest, - S: Send + Sync, -{ - type Rejection = (StatusCode, Json); - - async fn from_request(req: Request, state: &S) -> Result { - match axum::Json::::from_request(req, state).await { - Ok(axum::Json(value)) => Ok(ApiJson(value)), - Err(rejection) => Err(api_error(rejection.status(), &rejection.body_text())), + /// Enforce relay membership for a pubkey, with NIP-OA agent delegation fallback. + /// + /// Returns `Ok(Some(owner_pubkey))` when the agent is not a direct member but + /// its NIP-OA owner *is* — access is granted via delegation. + /// + /// On open relays (`require_relay_membership = false`), returns `Ok(None)` + /// immediately — no membership check is performed. Callers that need NIP-OA + /// owner extraction on open relays should call [`extract_nip_oa_owner`] directly. + /// + /// Returns `Ok(None)` when the caller is a direct member (closed relay) or when + /// no NIP-OA tag is present/applicable (open relay without auth tag). + pub async fn enforce_relay_membership( + state: &AppState, + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Result, (StatusCode, Json)> { + if !state.config.require_relay_membership { + return Ok(None); } + + let pubkey_hex = hex::encode(pubkey_bytes); + let is_member = state.db.is_relay_member(&pubkey_hex).await.map_err(|e| { + tracing::error!("relay membership check failed: {e}"); + super::internal_error(&format!("relay membership check failed: {e}")) + })?; + + if is_member { + return Ok(None); + } + + // NIP-OA fallback: check if agent's owner is a relay member. + if state.config.allow_nip_oa_auth { + if let Some(tag_json) = auth_tag_header { + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).map_err(|e| { + super::internal_error(&format!("invalid agent pubkey for NIP-OA check: {e}")) + })?; + + match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + Ok(owner_pubkey) => { + let owner_hex = owner_pubkey.to_hex(); + let owner_is_member = + state.db.is_relay_member(&owner_hex).await.map_err(|e| { + super::internal_error(&format!( + "relay membership check (owner) failed: {e}" + )) + })?; + + if owner_is_member { + debug!( + agent = %pubkey_hex, + owner = %owner_hex, + "NIP-OA membership granted via owner" + ); + return Ok(Some(owner_pubkey)); + } + } + Err(e) => { + debug!(agent = %pubkey_hex, "NIP-OA auth tag invalid: {e}"); + } + } + } + } + + Err(( + StatusCode::FORBIDDEN, + Json(serde_json::json!({ + "error": "relay_membership_required", + "message": "You must be a relay member to access this relay" + })), + )) } -} -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use chrono::Utc; - - use super::*; - - fn accessible_channel(id: Uuid) -> sprout_db::channel::AccessibleChannel { - let now = Utc::now(); - sprout_db::channel::AccessibleChannel { - channel: sprout_db::channel::ChannelRecord { - id, - name: "restricted".to_string(), - channel_type: "stream".to_string(), - visibility: "private".to_string(), - description: None, - canvas: None, - created_by: vec![0; 32], - created_at: now, - updated_at: now, - archived_at: None, - deleted_at: None, - nip29_group_id: None, - topic_required: false, - max_members: None, - topic: None, - topic_set_by: None, - topic_set_at: None, - purpose: None, - purpose_set_by: None, - purpose_set_at: None, - ttl_seconds: None, - ttl_deadline: None, - }, - is_member: true, + /// Extract NIP-OA owner from an auth tag without membership enforcement. + /// + /// Used on open relays (`require_relay_membership = false`) to opportunistically + /// extract the owner pubkey for agent→owner backfill. The NIP-OA signature is + /// cryptographically self-proving, so no feature flag is needed — if the tag + /// verifies, the owner relationship is authentic. Returns `None` if the tag + /// is absent or invalid. + pub fn extract_nip_oa_owner( + pubkey_bytes: &[u8], + auth_tag_header: Option<&str>, + ) -> Option { + let tag_json = auth_tag_header?; + let agent_pubkey = nostr::PublicKey::from_slice(pubkey_bytes).ok()?; + match sprout_sdk::nip_oa::verify_auth_tag(tag_json, &agent_pubkey) { + Ok(owner) => Some(owner), + Err(e) => { + debug!("extract_nip_oa_owner: invalid auth tag: {e}"); + None + } } } - // ── decode_jwt_payload_unverified ───────────────────────────────────────── - // - // This private helper is the core of the dev-mode JWT path in - // `extract_auth_pubkey`. We test it directly since it contains the - // security-critical base64 + JSON parsing logic. + #[cfg(test)] + mod tests { + use super::*; + use nostr::Keys; + use sprout_sdk::nip_oa::compute_auth_tag; - fn make_jwt(payload_json: &str) -> String { - use base64::Engine as _; - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(r#"{"alg":"HS256","typ":"JWT"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(payload_json); - // Signature segment is irrelevant for unverified decode — use a placeholder. - format!("{header}.{payload}.fakesig") - } + /// Valid NIP-OA auth tag → returns Some(owner_pubkey). + #[test] + fn valid_nip_oa_returns_owner() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); - #[test] - fn decode_jwt_valid_payload_returns_claims() { - let jwt = make_jwt(r#"{"preferred_username":"alice","sub":"u1"}"#); - let claims = decode_jwt_payload_unverified(&jwt).expect("should decode"); - assert_eq!( - claims.get("preferred_username").and_then(|v| v.as_str()), - Some("alice") - ); - assert_eq!(claims.get("sub").and_then(|v| v.as_str()), Some("u1")); - } + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("compute_auth_tag must succeed"); - #[test] - fn decode_jwt_missing_preferred_username_still_decodes() { - // The function decodes successfully even if the claim is absent; - // the caller (`extract_auth_pubkey`) is responsible for checking the claim. - let jwt = make_jwt(r#"{"sub":"u1","email":"alice@example.com"}"#); - let claims = decode_jwt_payload_unverified(&jwt).expect("should decode"); - assert!(!claims.contains_key("preferred_username")); - assert_eq!( - claims.get("email").and_then(|v| v.as_str()), - Some("alice@example.com") - ); - } + let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some(&tag_json)); - #[test] - fn decode_jwt_too_few_segments_returns_error() { - // Only one segment — no payload segment at all. - let err = decode_jwt_payload_unverified("onlyone").unwrap_err(); - assert_eq!(err, "malformed JWT"); - } + assert_eq!(result, Some(owner_keys.public_key())); + } - #[test] - fn decode_jwt_two_segments_is_accepted() { - // Two segments is the minimum required (header.payload). - use base64::Engine as _; - let header = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(r#"{"alg":"none"}"#); - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD - .encode(r#"{"preferred_username":"bob"}"#); - let jwt = format!("{header}.{payload}"); - let claims = decode_jwt_payload_unverified(&jwt).expect("two-segment JWT should decode"); - assert_eq!( - claims.get("preferred_username").and_then(|v| v.as_str()), - Some("bob") - ); - } + /// No auth tag → returns None. + #[test] + fn no_auth_tag_returns_none() { + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); - #[test] - fn decode_jwt_invalid_base64_returns_error() { - // Payload segment is not valid base64. - let err = decode_jwt_payload_unverified("header.!!!invalid_base64!!!.sig").unwrap_err(); - assert_eq!(err, "authentication failed"); - } + let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), None); - #[test] - fn decode_jwt_non_json_payload_returns_error() { - use base64::Engine as _; - let payload = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode("not json at all"); - let jwt = format!("header.{payload}.sig"); - let err = decode_jwt_payload_unverified(&jwt).unwrap_err(); - assert_eq!(err, "authentication failed"); - } + assert_eq!(result, None); + } - #[test] - fn decode_jwt_empty_string_returns_error() { - let err = decode_jwt_payload_unverified("").unwrap_err(); - assert_eq!(err, "malformed JWT"); - } + /// Invalid auth tag → returns None. + #[test] + fn invalid_auth_tag_returns_none() { + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); - #[test] - fn decode_jwt_preserves_numeric_and_array_claims() { - let jwt = - make_jwt(r#"{"preferred_username":"carol","iat":1700000000,"scp":["read","write"]}"#); - let claims = decode_jwt_payload_unverified(&jwt).expect("should decode"); - assert_eq!( - claims.get("iat").and_then(|v| v.as_i64()), - Some(1_700_000_000) - ); - let scopes: Vec<&str> = claims - .get("scp") - .and_then(|v| v.as_array()) - .map(|arr| arr.iter().filter_map(|v| v.as_str()).collect()) - .unwrap_or_default(); - assert_eq!(scopes, vec!["read", "write"]); - } + let result = extract_nip_oa_owner(&agent_pubkey.to_bytes(), Some("not valid json")); - // ── extract_auth_pubkey — header-level logic ────────────────────────────── - // - // `extract_auth_pubkey` requires a full `AppState` (which needs a live DB - // connection, Redis, etc.) and cannot be unit-tested without integration - // infrastructure. The security-critical parsing logic it delegates to is - // covered above via `decode_jwt_payload_unverified`. - // - // The tests below exercise the *header extraction* logic that is independent - // of AppState by calling the function with a minimal stub-like approach: - // we verify that the Authorization header parsing, X-Pubkey header parsing, - // and the "no header → 401" path all behave correctly at the HTTP layer. - // - // Full integration tests (JWT → JWKS validation → pubkey) require a running - // Okta mock and are tracked in the integration test suite. - - #[test] - fn authorization_header_bearer_prefix_is_stripped_correctly() { - // Verify that the Bearer prefix stripping logic works as expected. - // This mirrors the `strip_prefix("Bearer ")` call in extract_auth_pubkey. - let header_value = "Bearer eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig"; - let token = header_value.strip_prefix("Bearer ").unwrap(); - assert_eq!(token, "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJ1MSJ9.sig"); - } - - #[test] - fn authorization_header_without_bearer_prefix_is_not_stripped() { - // Without the "Bearer " prefix, strip_prefix returns None — no token extracted. - let header_value = "Basic dXNlcjpwYXNz"; - assert!(header_value.strip_prefix("Bearer ").is_none()); - } - - // ── X-Pubkey header parsing (dev-mode path) ─────────────────────────────── - - #[test] - fn valid_nostr_pubkey_hex_parses_correctly() { - // Verify that a valid 64-char hex pubkey parses via nostr::PublicKey::from_hex. - // This is the exact call made in the X-Pubkey branch of extract_auth_pubkey. - let pubkey = - sprout_auth::derive_pubkey_from_username("testuser").expect("derive should succeed"); - let hex = pubkey.to_hex(); - let parsed = nostr::PublicKey::from_hex(&hex).expect("should parse"); - assert_eq!(parsed, pubkey); - } - - #[test] - fn invalid_hex_pubkey_fails_to_parse() { - // Garbage hex → from_hex returns Err, triggering the 401 branch. - assert!(nostr::PublicKey::from_hex("notahex").is_err()); - assert!(nostr::PublicKey::from_hex("").is_err()); - assert!(nostr::PublicKey::from_hex("gggggggg").is_err()); - } - - #[test] - fn pubkey_serialize_roundtrip() { - // Verify that serialize() → from_hex() roundtrip works correctly. - // This is the exact pattern used in extract_auth_pubkey to produce pubkey_bytes. - let pubkey = sprout_auth::derive_pubkey_from_username("roundtrip_user") - .expect("derive should succeed"); - let bytes = pubkey.serialize().to_vec(); - assert_eq!(bytes.len(), 32, "compressed pubkey should be 32 bytes"); - } - - // ── check_channel_access — logic documentation ──────────────────────────── - // - // `check_channel_access` delegates entirely to two DB calls: - // 1. `db.is_member(channel_id, pubkey_bytes)` — returns bool - // 2. `db.get_channel(channel_id)` — returns channel record with `.visibility` - // - // The logic is: member → Ok, else open channel → Ok, else → 403 Forbidden. - // - // Unit tests for this function require a live Postgres connection (no mock Db - // exists in the codebase). The logic is simple enough that it is fully - // covered by the integration tests in `tests/` which run against a test DB. - // - // What we CAN verify here is the error message format used by the forbidden path: - - #[test] - fn forbidden_error_message_matches_expected_format() { - let (status, body) = forbidden("not a member of this channel"); - assert_eq!(status, StatusCode::FORBIDDEN); - assert_eq!(body.0["error"], "not a member of this channel"); - } - - #[test] - fn internal_error_returns_500_with_generic_message() { - let (status, body) = internal_error("db error: connection refused"); - assert_eq!(status, StatusCode::INTERNAL_SERVER_ERROR); - // Internal errors must NOT leak implementation details to callers. - assert_eq!(body.0["error"], "internal server error"); - } - - #[test] - fn api_error_helper_sets_correct_status_and_body() { - let (status, body) = api_error(StatusCode::UNAUTHORIZED, "authentication required"); - assert_eq!(status, StatusCode::UNAUTHORIZED); - assert_eq!(body.0["error"], "authentication required"); - } - - #[test] - fn not_found_helper_sets_404() { - let (status, body) = not_found("approval not found"); - assert_eq!(status, StatusCode::NOT_FOUND); - assert_eq!(body.0["error"], "approval not found"); - } - - #[test] - fn constrain_channel_ids_intersects_with_token_allowlist() { - let allowed = uuid::Uuid::new_v4(); - let denied = uuid::Uuid::new_v4(); - - let constrained = constrain_channel_ids(vec![allowed, denied], Some(&[allowed])); - - assert_eq!(constrained, vec![allowed]); - } - - #[test] - fn constrain_channel_ids_leaves_unrestricted_lists_unchanged() { - let a = uuid::Uuid::new_v4(); - let b = uuid::Uuid::new_v4(); - - let constrained = constrain_channel_ids(vec![a, b], None); - - assert_eq!(constrained, vec![a, b]); - } - - #[test] - fn constrain_accessible_channels_respects_token_allowlist() { - let allowed = uuid::Uuid::new_v4(); - let denied = uuid::Uuid::new_v4(); - - let constrained = constrain_accessible_channels( - vec![accessible_channel(allowed), accessible_channel(denied)], - Some(&[allowed]), - ); - - assert_eq!(constrained.len(), 1); - assert_eq!(constrained[0].channel.id, allowed); + assert_eq!(result, None); + } } } diff --git a/crates/sprout-relay/src/api/presence.rs b/crates/sprout-relay/src/api/presence.rs deleted file mode 100644 index 05d5859106..0000000000 --- a/crates/sprout-relay/src/api/presence.rs +++ /dev/null @@ -1,122 +0,0 @@ -//! Presence API — GET /api/presence (bulk lookup) and PUT /api/presence (set status). - -use std::sync::Arc; - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use serde::Deserialize; -use sprout_core::PresenceStatus; -use sprout_pubsub::presence::PRESENCE_TTL_SECS; - -use crate::state::AppState; - -use super::extract_auth_context; - -/// Query parameters for the presence endpoint. -#[derive(Debug, Deserialize)] -pub struct PresenceParams { - /// Comma-separated list of hex-encoded public keys to look up. - pub pubkeys: Option, -} - -/// Bulk presence lookup for a comma-separated list of hex pubkeys. -/// -/// Caps at 200 pubkeys to prevent DoS. Returns `"offline"` for any pubkey -/// not found in the presence store. -pub async fn presence_handler( - State(state): State>, - headers: HeaderMap, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(super::scope_error)?; - - let pubkeys_param = params.pubkeys.unwrap_or_default(); - - // Parse comma-separated hex pubkeys; skip invalid ones. Cap at 200 to prevent DoS. - let pubkeys: Vec = pubkeys_param - .split(',') - .map(|s| s.trim()) - .filter(|s| !s.is_empty()) - .take(200) - .filter_map(|hex| nostr::PublicKey::from_hex(hex).ok()) - .collect(); - - if pubkeys.is_empty() { - return Ok(Json(serde_json::json!({}))); - } - - let presence_map = state - .pubsub - .get_presence_bulk(&pubkeys) - .await - .unwrap_or_default(); - - let mut result = serde_json::Map::new(); - for pk in &pubkeys { - let hex = pk.to_hex(); - let status = presence_map - .get(&hex) - .cloned() - .unwrap_or_else(|| "offline".to_string()); - result.insert(hex, serde_json::Value::String(status)); - } - - Ok(Json(serde_json::Value::Object(result))) -} - -/// Request body for `PUT /api/presence`. -#[derive(Debug, Deserialize)] -pub struct SetPresenceBody { - /// Presence status to set. - pub status: PresenceStatus, -} - -/// Set the authenticated user's presence status. -/// -/// Accepts `{"status": "online" | "away" | "offline"}` (case-sensitive). -/// Serde rejects unknown variants automatically, returning a 422. -/// - `"offline"` clears the presence entry (TTL 0). -/// - `"online"` / `"away"` upsert the entry with a 90-second TTL. -/// -/// Returns `{"status": "...", "ttl_seconds": N}`. -/// -/// **Note:** The WebSocket path (kind:20001) accepts arbitrary status strings -/// for forward-compatibility, but the REST/MCP surface intentionally restricts -/// to the curated enum above. Aligning the WebSocket path is tracked separately. -pub async fn set_presence_handler( - State(state): State>, - headers: HeaderMap, - super::ApiJson(body): super::ApiJson, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(super::scope_error)?; - let pubkey = ctx.pubkey; - - match body.status { - PresenceStatus::Online | PresenceStatus::Away => { - state - .pubsub - .set_presence(&pubkey, body.status.as_str()) - .await - .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; - } - PresenceStatus::Offline => { - state - .pubsub - .clear_presence(&pubkey) - .await - .map_err(|e| super::internal_error(&format!("presence error: {e}")))?; - } - } - - Ok(Json(serde_json::json!({ - "status": body.status, - "ttl_seconds": if body.status == PresenceStatus::Offline { 0 } else { PRESENCE_TTL_SECS }, - }))) -} diff --git a/crates/sprout-relay/src/api/reactions.rs b/crates/sprout-relay/src/api/reactions.rs deleted file mode 100644 index ff6872d2c2..0000000000 --- a/crates/sprout-relay/src/api/reactions.rs +++ /dev/null @@ -1,175 +0,0 @@ -//! Reaction REST API. -//! -//! Endpoints: -//! GET /api/messages/:event_id/reactions — list reactions -//! -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use chrono::{TimeZone, Utc}; -use nostr::util::hex as nostr_hex; -use serde::Deserialize; - -use crate::state::AppState; - -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, - internal_error, not_found, -}; - -// ── Request / query types ───────────────────────────────────────────────────── - -/// Query parameters for listing reactions. -#[derive(Debug, Deserialize)] -pub struct ListReactionsParams { - /// Opaque pagination cursor (reserved for future use). - pub cursor: Option, - /// Maximum number of emoji groups to return. Default: 50. Max: 200. - pub limit: Option, -} - -// ── Helpers ─────────────────────────────────────────────────────────────────── - -/// Decode a hex event_id path segment into 32 bytes. -/// -/// Returns a 400 error if the string is not valid hex or not exactly 32 bytes. -fn decode_event_id(hex: &str) -> Result, (StatusCode, Json)> { - hex::decode(hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid event_id: not valid hex")) - .and_then(|bytes| { - if bytes.len() == 32 { - Ok(bytes) - } else { - Err(api_error( - StatusCode::BAD_REQUEST, - "invalid event_id: must be 32 bytes (64 hex chars)", - )) - } - }) -} - -// ── GET /api/messages/:event_id/reactions ──────────────────────────────────── - -/// List all active reactions for a message, grouped by emoji. -/// -/// Resolves display names for reacting users where available. -/// Supports optional `cursor` and `limit` query parameters. -pub async fn list_reactions_handler( - State(state): State>, - headers: HeaderMap, - Path(event_id_hex): Path, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let limit = params.limit.unwrap_or(50).min(200); - let cursor = params.cursor.as_deref(); - - let event_id_bytes = decode_event_id(&event_id_hex)?; - - // Look up the event to get its created_at and channel_id. - let stored = state - .db - .get_event_by_id(&event_id_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))? - .ok_or_else(|| not_found("event not found"))?; - - // Verify channel access if the event belongs to a channel. - if let Some(channel_id) = stored.channel_id { - // Token-level channel restriction check (channel_id from event lookup). - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } - - let event_created_at = Utc - .timestamp_opt(stored.event.created_at.as_u64() as i64, 0) - .single() - .unwrap_or_default(); - - let groups = state - .db - .get_reactions(&event_id_bytes, event_created_at, limit, cursor) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - // Collect all unique pubkeys across all groups for bulk display-name resolution. - let all_pubkeys: Vec> = { - let mut seen = std::collections::HashSet::new(); - let mut pks = Vec::new(); - for g in &groups { - for u in &g.users { - if seen.insert(u.pubkey.clone()) { - pks.push(u.pubkey.clone()); - } - } - } - pks - }; - - // Resolve display names via bulk user lookup. - let display_names: HashMap = if all_pubkeys.is_empty() { - HashMap::new() - } else { - state - .db - .get_users_bulk(&all_pubkeys) - .await - .unwrap_or_else(|e| { - tracing::warn!("reactions: failed to resolve display names: {e}"); - vec![] - }) - .into_iter() - .filter_map(|u| { - let hex = nostr_hex::encode(&u.pubkey); - u.display_name.map(|name| (hex, name)) - }) - .collect() - }; - - // Build the response, enriching each user with their display name. - let reaction_list: Vec = groups - .into_iter() - .map(|g| { - let users: Vec = g - .users - .into_iter() - .map(|u| { - let hex = nostr_hex::encode(&u.pubkey); - let name = display_names - .get(&hex) - .cloned() - .unwrap_or_else(|| hex[..8.min(hex.len())].to_string()); - let mut obj = serde_json::json!({ - "pubkey": hex, - "display_name": name, - }); - if let Some(ref reid) = u.reaction_event_id { - obj["reaction_event_id"] = serde_json::json!(nostr_hex::encode(reid)); - } - obj - }) - .collect(); - - serde_json::json!({ - "emoji": g.emoji, - "count": g.count, - "users": users, - }) - }) - .collect(); - - // next_cursor is reserved for future keyset pagination. - Ok(Json(serde_json::json!({ - "reactions": reaction_list, - "next_cursor": serde_json::Value::Null, - }))) -} diff --git a/crates/sprout-relay/src/api/search.rs b/crates/sprout-relay/src/api/search.rs deleted file mode 100644 index 34d5006c35..0000000000 --- a/crates/sprout-relay/src/api/search.rs +++ /dev/null @@ -1,123 +0,0 @@ -//! GET /api/search — full-text search (Typesense-backed). - -use std::collections::HashMap; -use std::sync::Arc; - -use axum::{ - extract::{Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use serde::Deserialize; - -use sprout_search::SearchQuery; - -use crate::state::AppState; - -use super::{constrain_channel_ids, extract_auth_context}; - -/// Query parameters for the search endpoint. -#[derive(Debug, Deserialize)] -pub struct SearchParams { - /// Full-text search query string. Defaults to `"*"` (match all) when absent. - pub q: Option, - /// Maximum number of results to return. Defaults to 20, capped at 100. - pub limit: Option, -} - -/// Full-text search over messages accessible to the authenticated user. -/// -/// Scopes results to channels the requester can access. Degrades gracefully -/// if the search backend is unavailable (returns empty results). -pub async fn search_handler( - State(state): State>, - headers: HeaderMap, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(super::scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let query_str = params.q.unwrap_or_default(); - let per_page = params.limit.unwrap_or(20).min(100); - - let channel_ids = constrain_channel_ids( - state - .get_accessible_channel_ids_cached(&pubkey_bytes) - .await - .unwrap_or_default(), - ctx.channel_ids.as_deref(), - ); - - // Channel-restricted tokens must stay within their allowlist; unrestricted callers - // may also see global events. - let include_global = ctx.channel_ids.is_none(); - if channel_ids.is_empty() && !include_global { - return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); - } - let filter_by = if channel_ids.is_empty() { - Some("channel_id:=__global__".to_string()) - } else if include_global { - let ids: Vec = channel_ids.iter().map(|id| id.to_string()).collect(); - Some(format!( - "(channel_id:=[{}] || channel_id:=__global__)", - ids.join(",") - )) - } else { - let ids: Vec = channel_ids.iter().map(|id| id.to_string()).collect(); - Some(format!("channel_id:=[{}]", ids.join(","))) - }; - - let search_query = SearchQuery { - q: if query_str.is_empty() { - "*".into() - } else { - query_str - }, - filter_by, - per_page, - ..Default::default() - }; - - // Execute search — gracefully degrade on failure. - let search_result = match state.search.search(&search_query).await { - Ok(r) => r, - Err(_) => { - return Ok(Json(serde_json::json!({ "hits": [], "found": 0 }))); - } - }; - - let all_channels = state.db.list_channels(None).await.unwrap_or_default(); - let channel_name_map: HashMap = all_channels - .into_iter() - .map(|c| (c.id.to_string(), c.name)) - .collect(); - - // Global events have channel_id: null — include them in results. - let hits: Vec = search_result - .hits - .into_iter() - .map(|hit| { - let channel_name: Option<&String> = hit - .channel_id - .as_deref() - .and_then(|id| channel_name_map.get(id)); - serde_json::json!({ - "event_id": hit.event_id, - "content": hit.content, - "kind": hit.kind, - "pubkey": hit.pubkey, - "channel_id": hit.channel_id, - "channel_name": channel_name, - "created_at": hit.created_at, - "score": hit.score, - }) - }) - .collect(); - - Ok(Json(serde_json::json!({ - "hits": hits, - "found": hits.len(), - }))) -} diff --git a/crates/sprout-relay/src/api/tokens.rs b/crates/sprout-relay/src/api/tokens.rs deleted file mode 100644 index 275017d33b..0000000000 --- a/crates/sprout-relay/src/api/tokens.rs +++ /dev/null @@ -1,934 +0,0 @@ -//! Self-service API token minting, listing, and revocation endpoints. -//! -//! ## Routes -//! - `POST /api/tokens` — mint a new token (NIP-98 bootstrap or Bearer) -//! - `GET /api/tokens` — list own tokens (Bearer required) -//! - `DELETE /api/tokens/{id}` — revoke one token by UUID (Bearer required) -//! - `DELETE /api/tokens` — revoke all tokens / panic button (Bearer required) -//! -//! ## Rate Limiting -//! [`MintRateLimiter`] enforces a configurable per-pubkey-per-hour limit -//! (default 50, override with `SPROUT_MINT_RATE_LIMIT`) using a bounded -//! in-memory rolling-window cache (moka). Resets on restart — acceptable since -//! the limit is a DoS guard, not a hard security cap. - -use std::collections::VecDeque; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use axum::{ - extract::{Path, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use chrono::Utc; -use moka::sync::Cache; -use serde::{Deserialize, Serialize}; -use sha2::{Digest, Sha256}; -use uuid::Uuid; - -use nostr::JsonUtil; -use sprout_auth::{is_self_mintable, Scope}; - -use super::{api_error, extract_auth_context, internal_error, RestAuthMethod}; -use crate::state::AppState; - -// ── Rate limiter ────────────────────────────────────────────────────────────── - -/// Default: 50 mints per pubkey per hour. Override with `SPROUT_MINT_RATE_LIMIT`. -const DEFAULT_MINT_LIMIT: usize = 50; -const MINT_WINDOW: Duration = Duration::from_secs(3600); -const RATE_LIMITER_MAX_ENTRIES: u64 = 100_000; - -/// Per-pubkey rolling-window rate limiter for `POST /api/tokens`. -/// -/// Uses a bounded moka cache (max 100,000 entries) to prevent OOM from an -/// attacker flooding with unique pubkeys. Evicted entries lose their window -/// history — worst case is a few extra mints, not a security bypass. -pub struct MintRateLimiter { - cache: Cache<[u8; 32], Arc>>>, - limit: usize, -} - -impl MintRateLimiter { - /// Create a new rate limiter. - /// - /// `limit` is the max mints per pubkey per hour. Reads `SPROUT_MINT_RATE_LIMIT` - /// env var at construction; falls back to [`DEFAULT_MINT_LIMIT`] (50). - pub fn new() -> Self { - let limit = std::env::var("SPROUT_MINT_RATE_LIMIT") - .ok() - .and_then(|v| v.parse::().ok()) - .unwrap_or(DEFAULT_MINT_LIMIT); - tracing::info!("mint rate limiter: {limit} mints/hr/pubkey"); - Self { - cache: Cache::builder() - .max_capacity(RATE_LIMITER_MAX_ENTRIES) - .time_to_idle(Duration::from_secs(3600)) - .build(), - limit, - } - } - - /// Check whether `pubkey_bytes` is within the rate limit and record the attempt. - /// - /// Returns `Ok(())` if the mint is allowed, or `Err(retry_after)` with the - /// duration until the oldest entry in the window expires. - pub fn check_and_record(&self, pubkey_bytes: &[u8; 32]) -> Result<(), Duration> { - let now = Instant::now(); - let entry = self - .cache - .entry(*pubkey_bytes) - .or_insert_with(|| Arc::new(std::sync::Mutex::new(VecDeque::new()))); - let mut timestamps = entry.value().lock().unwrap_or_else(|e| e.into_inner()); - - // Evict timestamps that have fallen outside the rolling window. - while timestamps - .front() - .map(|t| now.duration_since(*t) > MINT_WINDOW) - .unwrap_or(false) - { - timestamps.pop_front(); - } - - if timestamps.len() >= self.limit { - // SAFETY: vec is guaranteed non-empty by prior check (len >= limit > 0) - let retry_after = MINT_WINDOW - now.duration_since(*timestamps.front().unwrap()); - return Err(retry_after); - } - - timestamps.push_back(now); - Ok(()) - } - - /// The configured limit (for error messages). - pub fn limit(&self) -> usize { - self.limit - } -} - -impl Default for MintRateLimiter { - fn default() -> Self { - Self::new() - } -} - -// ── Request / response types ────────────────────────────────────────────────── - -/// Request body for `POST /api/tokens`. -#[derive(Debug, Deserialize)] -pub struct MintTokenRequest { - /// Human-readable label for the token (1–100 chars). - pub name: String, - /// Scope strings to grant (e.g. `["messages:read", "channels:read"]`). - pub scopes: Vec, - /// Optional channel UUIDs to restrict the token to. - /// Absent means unrestricted (unless caller's token is channel-restricted, in which case - /// channel_ids is required). Empty array is rejected for restricted callers. - pub channel_ids: Option>, - /// Optional expiry in days (1–365). Omit for no expiry. - pub expires_in_days: Option, - /// Optional owner pubkey (hex). Only accepted via NIP-98 auth (bootstrap mint). - /// Sets `agent_owner_pubkey` on the agent's user record. This proves the caller - /// holds the agent's private key and is designating another pubkey as the owner. - /// Rejected if auth is Bearer (child token minting cannot reassign ownership). - pub owner_pubkey: Option, -} - -/// Response body for `POST /api/tokens` (token shown once only). -#[derive(Debug, Serialize)] -pub struct MintTokenResponse { - /// Unique token identifier (UUID). - pub id: Uuid, - /// Raw token value — shown **once only**. Only the SHA-256 hash is stored. - pub token: String, - /// Human-readable label. - pub name: String, - /// Scope strings granted to this token. - pub scopes: Vec, - /// Channel UUIDs this token is restricted to (empty = unrestricted). - pub channel_ids: Vec, - /// ISO 8601 creation timestamp. - pub created_at: String, - /// ISO 8601 expiry timestamp, or `null` if no expiry. - pub expires_at: Option, -} - -/// A single token entry in the `GET /api/tokens` response. -#[derive(Debug, Serialize)] -pub struct TokenListItem { - /// Unique token identifier (UUID). - pub id: Uuid, - /// Human-readable label. - pub name: String, - /// Scope strings granted to this token. - pub scopes: Vec, - /// Channel UUIDs this token is restricted to (empty = unrestricted). - pub channel_ids: Vec, - /// ISO 8601 creation timestamp. - pub created_at: String, - /// ISO 8601 expiry timestamp, or `null` if no expiry. - pub expires_at: Option, - /// ISO 8601 timestamp of last use, or `null` if never used. - pub last_used_at: Option, - /// ISO 8601 revocation timestamp, or `null` if not revoked. - pub revoked_at: Option, -} - -/// Response body for `GET /api/tokens`. -#[derive(Debug, Serialize)] -pub struct TokenListResponse { - /// All tokens owned by the authenticated pubkey (including revoked). - pub tokens: Vec, -} - -/// Response body for `DELETE /api/tokens` (panic button). -#[derive(Debug, Serialize)] -pub struct RevokeAllResponse { - /// Number of tokens that were newly revoked (0 if all already revoked). - pub revoked_count: u64, -} - -fn ensure_requested_scopes_within_caller( - ctx: &super::RestAuthContext, - requested_scopes: &[Scope], -) -> Result<(), (StatusCode, Json)> { - if matches!(ctx.auth_method, RestAuthMethod::Nip98) { - return Ok(()); - } - - for scope in requested_scopes { - if !ctx.scopes.contains(scope) { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "scope_escalation", - "message": format!("Cannot mint scope '{}' — not in your token's scopes", scope) - })), - )); - } - } - - Ok(()) -} - -// ── Handlers ────────────────────────────────────────────────────────────────── - -/// `POST /api/tokens` — mint a new API token. -/// -/// Accepts NIP-98 HTTP Auth (bootstrap — no existing token required) or an -/// existing Bearer API token. Validates scopes, rate-limits, checks channel -/// membership if `channel_ids` is provided, then inserts via the conditional -/// INSERT that enforces the 10-token-per-pubkey limit atomically. -pub async fn post_tokens( - State(state): State>, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - // Parse the request body as JSON. - let req: MintTokenRequest = serde_json::from_slice(&body).map_err(|e| { - api_error( - StatusCode::BAD_REQUEST, - &format!("invalid request body: {e}"), - ) - })?; - - // Extract auth context — NIP-98 or Bearer. - // For NIP-98 we re-verify here with the raw body for payload hash checking. - let ctx = if let Some(auth_header) = headers.get("authorization").and_then(|v| v.to_str().ok()) - { - if let Some(encoded) = auth_header.strip_prefix("Nostr ") { - // Reconstruct canonical URL for NIP-98 verification. - let canonical_url = reconstruct_canonical_url_for_tokens(&state.config.relay_url); - - // The Authorization: Nostr header value is base64-encoded JSON. - // Decode it before passing to verify_nip98_event which expects JSON. - let decoded_bytes = { - use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; - BASE64.decode(encoded).map_err(|_| { - tracing::warn!("post_tokens: NIP-98 base64 decode failed"); - ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "invalid_auth", - "message": "NIP-98 verification failed" - })), - ) - })? - }; - let event_json = String::from_utf8(decoded_bytes).map_err(|_| { - tracing::warn!("post_tokens: NIP-98 decoded bytes are not valid UTF-8"); - ( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "invalid_auth", - "message": "NIP-98 verification failed" - })), - ) - })?; - - match sprout_auth::verify_nip98_event(&event_json, &canonical_url, "POST", Some(&body)) - { - Ok(pubkey) => { - // POST /api/tokens requires the payload tag — body must be - // cryptographically bound to the signed event. - let event: nostr::Event = - // SAFETY: event_json was already parsed and verified by verify_nip98_event above - nostr::Event::from_json(&event_json).expect("SAFETY: already verified by verify_nip98_event"); - let has_payload = event.tags.find(nostr::TagKind::Payload).is_some(); - if !has_payload { - tracing::warn!("post_tokens: NIP-98 event missing required payload tag"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "invalid_auth", - "message": "NIP-98 payload tag required for POST /api/tokens" - })), - )); - } - let pubkey_bytes = pubkey.to_bytes().to_vec(); - if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { - tracing::warn!("ensure_user failed for NIP-98 pubkey: {e}"); - } - super::RestAuthContext { - pubkey, - pubkey_bytes, - scopes: vec![], - auth_method: RestAuthMethod::Nip98, - token_id: None, - channel_ids: None, - } - } - Err(e) => { - tracing::warn!("post_tokens: NIP-98 verification failed: {e}"); - return Err(( - StatusCode::UNAUTHORIZED, - Json(serde_json::json!({ - "error": "invalid_auth", - "message": "NIP-98 verification failed" - })), - )); - } - } - } else { - extract_auth_context(&headers, &state).await? - } - } else { - extract_auth_context(&headers, &state).await? - }; - - // ── Validate name ───────────────────────────────────────────────────────── - if req.name.is_empty() || req.name.len() > 100 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "invalid_name: name must be 1–100 characters", - )); - } - - // ── Validate scopes ─────────────────────────────────────────────────────── - if req.scopes.is_empty() { - return Err(api_error( - StatusCode::BAD_REQUEST, - "invalid_scopes: scopes must not be empty", - )); - } - - let mut parsed_scopes: Vec = Vec::with_capacity(req.scopes.len()); - for s in &req.scopes { - // SAFETY: Scope::from_str is infallible — unknown values map to Scope::Unknown(_) - let scope: Scope = s.parse().expect("SAFETY: Scope::from_str is infallible"); - match &scope { - Scope::Unknown(_) => { - return Err(( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_scopes", - "message": format!("unknown scope: {s}") - })), - )); - } - _ => { - if !is_self_mintable(&scope) { - return Err(( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_scopes", - "message": format!("scope requires admin: {s}") - })), - )); - } - } - } - parsed_scopes.push(scope); - } - - // Deduplicate scopes (order-preserving). - let mut seen = std::collections::HashSet::new(); - parsed_scopes.retain(|s| seen.insert(s.clone())); - - // ── Scope escalation prevention (all non-bootstrap callers) ────────────── - // Any caller that arrived with an already-authorized identity must stay within - // the scopes granted to that identity. NIP-98 bootstrap mints are the only - // exception because they deliberately authenticate ownership, not preexisting - // relay scopes. - ensure_requested_scopes_within_caller(&ctx, &parsed_scopes)?; - - if !matches!(ctx.auth_method, RestAuthMethod::Nip98) { - // If caller has channel_ids restriction, child must also be restricted - // to a subset of those channels. - if let Some(ref caller_channels) = ctx.channel_ids { - match &req.channel_ids { - None => { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "channel_escalation", - "message": "Your token is channel-restricted; minted tokens must also specify channel_ids (subset of yours)" - })), - )); - } - Some(requested_raw) => { - // Parse requested channel IDs (already validated above, but we need UUIDs here). - for raw in requested_raw { - if let Ok(cid) = raw.parse::() { - if !caller_channels.contains(&cid) { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "channel_escalation", - "message": format!("Cannot mint access to channel {} — not in your token's channel_ids", cid) - })), - )); - } - } - } - } - } - } - } - - // ── Rate limit ──────────────────────────────────────────────────────────── - let pubkey_bytes_arr: [u8; 32] = ctx - .pubkey_bytes - .as_slice() - .try_into() - .map_err(|_| internal_error("pubkey bytes length mismatch"))?; - - if let Err(retry_after) = state.mint_rate_limiter.check_and_record(&pubkey_bytes_arr) { - let retry_secs = retry_after.as_secs(); - return Err(( - StatusCode::TOO_MANY_REQUESTS, - Json(serde_json::json!({ - "error": "rate_limited", - "message": format!( - "Mint limit exceeded: {} per hour. Try again in {} seconds.", - state.mint_rate_limiter.limit(), retry_secs - ), - "retry_after_seconds": retry_secs - })), - )); - } - - // ── Validate and verify channel_ids ────────────────────────────────────── - let validated_channel_ids: Option> = if let Some(ref raw_ids) = req.channel_ids { - if raw_ids.is_empty() { - // Empty array is treated as "no restriction" — but if the caller's - // token is channel-restricted, this would be an escalation. The subset - // check above already rejects `None` for restricted callers, so we - // must also reject empty arrays here. - if ctx.channel_ids.is_some() { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "channel_escalation", - "message": "Your token is channel-restricted; minted tokens must specify non-empty channel_ids (subset of yours)" - })), - )); - } - None - } else { - let mut uuids = Vec::with_capacity(raw_ids.len()); - for raw in raw_ids { - let cid: Uuid = raw.parse().map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_channel_ids", - "message": format!("malformed UUID: {raw}") - })), - ) - })?; - - // Verify channel exists. - let channel = state.db.get_channel(cid).await.map_err(|_| { - ( - StatusCode::BAD_REQUEST, - Json(serde_json::json!({ - "error": "invalid_channel_ids", - "message": format!("channel not found: {cid}") - })), - ) - })?; - let _ = channel; // existence confirmed - - // Verify caller is a member of the channel. - let is_member = state - .is_member_cached(cid, &ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - if !is_member { - return Err(( - StatusCode::FORBIDDEN, - Json(serde_json::json!({ - "error": "not_channel_member", - "message": format!("not a member of channel: {cid}") - })), - )); - } - - uuids.push(cid); - } - // Deduplicate channel_ids (sorted; UUIDs have no meaningful order). - uuids.sort(); - uuids.dedup(); - Some(uuids) - } - } else { - None - }; - - // ── Validate owner_pubkey (before token insert) ───────────────────────── - let validated_owner_bytes: Option> = if let Some(ref owner_hex) = req.owner_pubkey { - if ctx.auth_method != super::RestAuthMethod::Nip98 { - return Err(api_error( - StatusCode::FORBIDDEN, - "owner_pubkey can only be set via NIP-98 auth (bootstrap mint)", - )); - } - let bytes = nostr::util::hex::decode(owner_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid owner_pubkey hex"))?; - if bytes.len() != 32 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "owner_pubkey must be 32 bytes (64 hex chars)", - )); - } - Some(bytes) - } else { - None - }; - - // ── Validate expires_in_days ────────────────────────────────────────────── - if let Some(days) = req.expires_in_days { - if days == 0 || days > 365 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "invalid expires_in_days: must be 1–365", - )); - } - } - - let expires_at = req - .expires_in_days - .map(|days| Utc::now() + chrono::Duration::days(days as i64)); - - // ── Set agent owner BEFORE token creation ──────────────────────────────── - // Ownership must be settled before the token is inserted. This eliminates - // the orphaned-token failure mode: if ownership fails, no token exists to - // revoke. set_agent_owner is atomic (UPDATE ... WHERE agent_owner_pubkey - // IS NULL) — concurrent bootstrap mints are serialized by the DB. - let owner_bytes = validated_owner_bytes.unwrap_or_else(|| ctx.pubkey_bytes.clone()); - - // ── Enforce shutdown-required scopes ───────────────────────────────────── - // Check BEFORE any side effects (ownership, token creation). Two triggers: - // 1. Explicit owner_pubkey in request (bootstrap mint) - // 2. Agent already has an owner in the DB (re-mint must preserve controllability) - // Fail closed: if the DB lookup errors, assume owned and enforce scopes. - // A transient DB error must not open a bypass for stripping shutdown scopes. - let has_existing_owner = match state.db.get_agent_channel_policy(&ctx.pubkey_bytes).await { - Ok(Some((_, Some(_)))) => true, - Ok(_) => false, - Err(e) => { - tracing::warn!("owner lookup failed (assuming owned, enforcing scopes): {e}"); - true // fail closed - } - }; - let needs_scope_check = req.owner_pubkey.is_some() || has_existing_owner; - if needs_scope_check { - let required = [ - "users:read", - "messages:read", - "messages:write", - "channels:read", - ]; - let scope_strs: Vec = parsed_scopes.iter().map(|s| s.to_string()).collect(); - for r in &required { - if !scope_strs.iter().any(|s| s == r) { - return Err(api_error( - StatusCode::BAD_REQUEST, - &format!("owned agents require the '{r}' scope for controllability"), - )); - } - } - } - - // ── Set agent owner (only when explicitly requested) ───────────────────── - // Self-mints without owner_pubkey do NOT assign ownership. Only bootstrap - // mints with an explicit owner_pubkey write the ownership relationship. - // This preserves the semantics that omitting owner_pubkey means "don't - // set agent owner" — important because self-ownership would force - // controllability scopes on all future re-mints. - if req.owner_pubkey.is_some() { - state - .db - .ensure_user(&owner_bytes) - .await - .map_err(|e| internal_error(&format!("ensure_user for owner failed: {e}")))?; - - match state - .db - .set_agent_owner(&ctx.pubkey_bytes, &owner_bytes) - .await - { - Ok(true) => { - tracing::debug!("agent owner set successfully"); - } - Ok(false) => { - let existing = state - .db - .get_agent_channel_policy(&ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error checking owner: {e}")))? - .and_then(|(_, owner)| owner); - if existing.as_deref() != Some(owner_bytes.as_slice()) { - return Err(api_error( - StatusCode::CONFLICT, - "agent already has a different owner", - )); - } - tracing::debug!("agent already owned by the requested pubkey — no change needed"); - } - Err(e) => { - return Err(internal_error(&format!("failed to set agent owner: {e}"))); - } - } - } - - // ── Generate token (after scope validation + ownership settled) ─────────── - let raw_token = sprout_auth::generate_token(); - let token_hash: Vec = Sha256::digest(raw_token.as_bytes()).to_vec(); - let scope_strings: Vec = parsed_scopes.iter().map(|s| s.to_string()).collect(); - - // ── Conditional INSERT (enforces 10-token limit atomically) ────────────── - let channel_ids_slice = validated_channel_ids.as_deref(); - let token_id = state - .db - .create_api_token_if_under_limit( - &token_hash, - &ctx.pubkey_bytes, - &req.name, - &scope_strings, - channel_ids_slice, - expires_at, - ) - .await - .map_err(|e| internal_error(&format!("db error creating token: {e}")))?; - - let token_id = match token_id { - Some(id) => id, - None => { - return Err(( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": "token_limit_exceeded", - "message": "Maximum of 10 active tokens per pubkey" - })), - )); - } - }; - - // ── Build response ──────────────────────────────────────────────────────── - let channel_ids_response: Vec = validated_channel_ids - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|id| id.to_string()) - .collect(); - - let resp = MintTokenResponse { - id: token_id, - token: raw_token, - name: req.name, - scopes: scope_strings, - channel_ids: channel_ids_response, - created_at: Utc::now().to_rfc3339(), - expires_at: expires_at.map(|t| t.to_rfc3339()), - }; - - // SAFETY: MintTokenResponse contains only String/Uuid/Vec fields — serialization is infallible - Ok(( - StatusCode::CREATED, - Json( - serde_json::to_value(resp) - .expect("SAFETY: MintTokenResponse serialization is infallible"), - ), - )) -} - -/// `GET /api/tokens` — list all tokens owned by the authenticated pubkey. -/// -/// Returns all tokens including revoked ones (for audit). Token values are -/// **never** returned. Requires Bearer token or Okta JWT (not NIP-98 bootstrap). -pub async fn get_tokens( - State(state): State>, - headers: HeaderMap, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - - // NIP-98 bootstrap is not allowed for listing — caller must have a real token. - if ctx.auth_method == RestAuthMethod::Nip98 { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "Bearer token required to list tokens", - )); - } - - let records = state - .db - .list_tokens_by_owner(&ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let tokens: Vec = records - .into_iter() - .map(|r| { - let channel_ids: Vec = r - .channel_ids - .as_deref() - .unwrap_or(&[]) - .iter() - .map(|id| id.to_string()) - .collect(); - TokenListItem { - id: r.id, - name: r.name, - scopes: r.scopes, - channel_ids, - created_at: r.created_at.to_rfc3339(), - expires_at: r.expires_at.map(|t| t.to_rfc3339()), - last_used_at: r.last_used_at.map(|t| t.to_rfc3339()), - revoked_at: r.revoked_at.map(|t| t.to_rfc3339()), - } - }) - .collect(); - - Ok(Json(serde_json::json!({ "tokens": tokens }))) -} - -/// `DELETE /api/tokens/{id}` — revoke a single token by UUID. -/// -/// The caller must own the token. Returns 204 on success, 404 if not found -/// or not owned by the caller, 409 if already revoked. -pub async fn delete_token( - State(state): State>, - headers: HeaderMap, - Path(id): Path, -) -> Result)> { - let ctx = extract_auth_context(&headers, &state).await?; - - if ctx.auth_method == RestAuthMethod::Nip98 { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "Bearer token required to revoke tokens", - )); - } - - // First, check if the token exists and is owned by the caller — to distinguish - // "not found / not owned" (404) from "already revoked" (409). - // We use get_api_token_by_hash_including_revoked is not applicable here (we have - // the UUID, not the hash). Instead, we attempt the revoke and then check existence. - let revoked = state - .db - .revoke_token(id, &ctx.pubkey_bytes, &ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - if revoked { - return Ok(StatusCode::NO_CONTENT); - } - - // rows_affected == 0: either not found, not owned, or already revoked. - // Check if the token exists at all (including revoked) to distinguish the cases. - let all_tokens = state - .db - .list_tokens_by_owner(&ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let found = all_tokens.iter().find(|t| t.id == id); - match found { - Some(t) if t.revoked_at.is_some() => Err(( - StatusCode::CONFLICT, - Json(serde_json::json!({ - "error": "already_revoked", - "message": "Token is already revoked" - })), - )), - _ => Err(( - StatusCode::NOT_FOUND, - Json(serde_json::json!({ - "error": "not_found", - "message": "Token not found or not owned by caller" - })), - )), - } -} - -/// `DELETE /api/tokens` — revoke all tokens (panic button). -/// -/// Revokes all active tokens for the authenticated pubkey, including the token -/// used to make this call. Skips already-revoked tokens (idempotent). -/// Returns `{ "revoked_count": N }` with 200. -pub async fn delete_all_tokens( - State(state): State>, - headers: HeaderMap, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - - if ctx.auth_method == RestAuthMethod::Nip98 { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "Bearer token required to revoke tokens", - )); - } - - let revoked_count = state - .db - .revoke_all_tokens(&ctx.pubkey_bytes, &ctx.pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - Ok(Json(serde_json::json!({ "revoked_count": revoked_count }))) -} - -// ── Internal helpers ────────────────────────────────────────────────────────── - -/// Derive the canonical token-mint URL from the configured relay identity. -fn reconstruct_canonical_url_for_tokens(relay_url: &str) -> String { - let base = relay_url - .trim() - .trim_end_matches('/') - .replace("wss://", "https://") - .replace("ws://", "http://"); - format!("{base}/api/tokens") -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use nostr::Keys; - - fn auth_context( - auth_method: RestAuthMethod, - scopes: Vec, - channel_ids: Option>, - ) -> super::super::RestAuthContext { - let keys = Keys::generate(); - let pubkey = keys.public_key(); - super::super::RestAuthContext { - pubkey, - pubkey_bytes: pubkey.to_bytes().to_vec(), - scopes, - auth_method, - token_id: None, - channel_ids, - } - } - - #[test] - fn rate_limiter_allows_up_to_limit() { - let limiter = MintRateLimiter::new(); - let key = [0u8; 32]; - for _ in 0..DEFAULT_MINT_LIMIT { - assert!(limiter.check_and_record(&key).is_ok()); - } - // 6th call should be denied. - assert!(limiter.check_and_record(&key).is_err()); - } - - #[test] - fn rate_limiter_different_pubkeys_are_independent() { - let limiter = MintRateLimiter::new(); - let key_a = [1u8; 32]; - let key_b = [2u8; 32]; - for _ in 0..DEFAULT_MINT_LIMIT { - assert!(limiter.check_and_record(&key_a).is_ok()); - } - // key_b should still be allowed. - assert!(limiter.check_and_record(&key_b).is_ok()); - } - - #[test] - fn rate_limiter_returns_retry_after_duration() { - let limiter = MintRateLimiter::new(); - let key = [3u8; 32]; - for _ in 0..DEFAULT_MINT_LIMIT { - let _ = limiter.check_and_record(&key); - } - let err = limiter.check_and_record(&key).unwrap_err(); - // retry_after should be ≤ MINT_WINDOW and > 0. - assert!(err > Duration::ZERO); - assert!(err <= MINT_WINDOW); - } - - #[test] - fn mint_token_request_deserializes() { - let json = r#"{ - "name": "my-agent", - "scopes": ["messages:read", "messages:write"], - "expires_in_days": 30 - }"#; - let req: MintTokenRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.name, "my-agent"); - assert_eq!(req.scopes.len(), 2); - assert_eq!(req.expires_in_days, Some(30)); - assert!(req.channel_ids.is_none()); - } - - #[test] - fn mint_token_request_with_channel_ids() { - let json = r#"{ - "name": "channel-agent", - "scopes": ["messages:read"], - "channel_ids": ["01950a3b-c000-7000-8000-000000000001"] - }"#; - let req: MintTokenRequest = serde_json::from_str(json).unwrap(); - assert_eq!(req.channel_ids.as_ref().unwrap().len(), 1); - } - - #[test] - fn canonical_token_url_uses_configured_relay_identity() { - let url = reconstruct_canonical_url_for_tokens("wss://relay.example.test/"); - assert_eq!(url, "https://relay.example.test/api/tokens"); - } - - #[test] - fn bearer_callers_cannot_self_mint_new_scopes() { - let ctx = auth_context(RestAuthMethod::OktaJwt, vec![Scope::MessagesRead], None); - - let err = ensure_requested_scopes_within_caller(&ctx, &[Scope::MessagesWrite]) - .expect_err("bearer-auth callers must stay within their granted scopes"); - - assert_eq!(err.0, StatusCode::FORBIDDEN); - assert_eq!(err.1 .0["error"].as_str(), Some("scope_escalation")); - } - - #[test] - fn nip98_bootstrap_mints_are_not_limited_by_existing_scope_list() { - let ctx = auth_context(RestAuthMethod::Nip98, Vec::new(), None); - - assert!(ensure_requested_scopes_within_caller(&ctx, &[Scope::MessagesWrite]).is_ok()); - } -} diff --git a/crates/sprout-relay/src/api/users.rs b/crates/sprout-relay/src/api/users.rs deleted file mode 100644 index 1f9e033f8b..0000000000 --- a/crates/sprout-relay/src/api/users.rs +++ /dev/null @@ -1,446 +0,0 @@ -//! User profile REST API. -//! -//! Endpoints: -//! GET /api/users/me/profile — get own profile -//! GET /api/users/{pubkey}/profile — get any user's profile by pubkey hex -//! POST /api/users/batch — resolve display names for multiple pubkeys -//! GET /api/users/search — search users by display name, NIP-05, or pubkey -//! PUT /api/users/me/channel-add-policy — set channel add policy (DB-native setting) - -use std::sync::Arc; - -use axum::{ - extract::{Json as ExtractJson, Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use nostr::util::hex as nostr_hex; -use serde::Deserialize; - -use crate::state::AppState; - -use super::{api_error, extract_auth_context, internal_error, scope_error}; - -use sprout_core::kind::{KIND_CONTACT_LIST, KIND_TEXT_NOTE}; -use sprout_db::event::EventQuery; - -/// `GET /api/users/me/profile` — get the authenticated user's profile. -/// -/// Returns: `{ "pubkey": "", "display_name": "...", "avatar_url": "...", "about": "...", "nip05_handle": "..." }` -pub async fn get_profile( - State(state): State>, - headers: HeaderMap, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let profile = state - .db - .get_user(&pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - match profile { - Some(p) => { - let (_, 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!({ - "pubkey": nostr_hex::encode(&p.pubkey), - "display_name": p.display_name, - "avatar_url": p.avatar_url, - "about": p.about, - "nip05_handle": p.nip05_handle, - "agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)), - }))) - } - None => Err(api_error(StatusCode::NOT_FOUND, "user not found")), - } -} - -/// `GET /api/users/{pubkey}/profile` — get any user's profile by pubkey hex. -pub async fn get_user_profile( - State(state): State>, - headers: HeaderMap, - Path(pubkey_hex): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; - - let pubkey_bytes = nostr_hex::decode(&pubkey_hex) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; - if pubkey_bytes.len() != 32 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "pubkey must be 32 bytes", - )); - } - - let profile = state - .db - .get_user(&pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))? - .ok_or_else(|| api_error(StatusCode::NOT_FOUND, "user not found"))?; - - let (_, 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!({ - "pubkey": nostr_hex::encode(&profile.pubkey), - "display_name": profile.display_name, - "avatar_url": profile.avatar_url, - "about": profile.about, - "nip05_handle": profile.nip05_handle, - "agent_owner_pubkey": owner_pk.map(|b| nostr_hex::encode(&b)), - }))) -} - -/// Request body for the batch profile resolution endpoint. -#[derive(Debug, Deserialize)] -pub struct BatchProfilesRequest { - /// List of pubkey hex strings to resolve (max 200). - pub pubkeys: Vec, -} - -/// Query string for user search. -#[derive(Debug, Deserialize)] -pub struct SearchUsersQuery { - /// Case-insensitive search query. - pub q: String, - /// Maximum number of results to return (default 8, max 50). - pub limit: Option, -} - -/// `POST /api/users/batch` — resolve profile summaries for multiple pubkeys. -pub async fn get_users_batch( - State(state): State>, - headers: HeaderMap, - ExtractJson(body): ExtractJson, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; - - if body.pubkeys.len() > 200 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "max 200 pubkeys per request", - )); - } - - // Partition inputs: valid hex (64 chars, valid hex) vs invalid (wrong length or bad hex). - // Both wrong-length and 64-char-non-hex inputs go to the missing list. - let mut invalid_inputs: Vec = Vec::new(); - let mut valid_hex_set: std::collections::HashSet = std::collections::HashSet::new(); - - for p in &body.pubkeys { - if p.len() != 64 { - invalid_inputs.push(p.clone()); - } else { - let lower = p.to_lowercase(); - if nostr_hex::decode(&lower) - .map(|b| b.len() == 32) - .unwrap_or(false) - { - valid_hex_set.insert(lower); - } else { - invalid_inputs.push(p.clone()); - } - } - } - - let mut normalized: Vec = valid_hex_set.into_iter().collect(); - normalized.sort(); - - let pubkey_bytes: Vec> = normalized - .iter() - .filter_map(|h| nostr_hex::decode(h).ok()) - .filter(|b| b.len() == 32) - .collect(); - - let records = state - .db - .get_users_bulk(&pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let found_pubkeys: std::collections::HashSet = records - .iter() - .map(|r| nostr_hex::encode(&r.pubkey)) - .collect(); - - let mut profiles = serde_json::Map::new(); - for r in records { - let hex = nostr_hex::encode(&r.pubkey); - profiles.insert( - hex, - serde_json::json!({ - "display_name": r.display_name, - "avatar_url": r.avatar_url, - "nip05_handle": r.nip05_handle, - }), - ); - } - - let mut missing: Vec = normalized - .iter() - .filter(|p| !found_pubkeys.contains(p.as_str())) - .cloned() - .collect(); - missing.extend(invalid_inputs); - - Ok(Json(serde_json::json!({ - "profiles": profiles, - "missing": missing, - }))) -} - -/// `GET /api/users/search` — search users by display name, NIP-05, or pubkey prefix. -pub async fn search_users( - State(state): State>, - headers: HeaderMap, - Query(query): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; - - let q = query.q.trim(); - if q.is_empty() { - return Ok(Json(serde_json::json!({ "users": [] }))); - } - if q.len() > 200 { - return Err(api_error( - StatusCode::BAD_REQUEST, - "search query too long (max 200 characters)", - )); - } - - let results = state - .db - .search_users(q, query.limit.unwrap_or(8).min(50)) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - Ok(Json(serde_json::json!({ - "users": results.into_iter().map(|user| { - serde_json::json!({ - "pubkey": nostr_hex::encode(&user.pubkey), - "display_name": user.display_name, - "avatar_url": user.avatar_url, - "nip05_handle": user.nip05_handle, - }) - }).collect::>(), - }))) -} - -/// 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 ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersWrite).map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - 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)), - }))) -} - -// ── Social endpoints ───────────────────────────────────────────────────────── - -/// Query params for [`get_user_notes`]. -#[derive(Debug, Deserialize)] -pub struct NotesQuery { - /// Maximum number of notes to return (capped at 100, default 50). - pub limit: Option, - /// Unix timestamp cursor. When used alone (backward compat), events strictly - /// before this timestamp are returned (subtracts 1 second). When used together - /// with `before_id`, enables composite keyset pagination that correctly handles - /// same-second events. - pub before: Option, - /// Hex event ID cursor. When provided with `before`, enables composite keyset - /// pagination: returns events where `created_at < before` OR - /// `(created_at = before AND id > before_id)`. This prevents same-second events - /// from being skipped during pagination. - pub before_id: Option, -} - -/// `GET /api/users/{pubkey}/notes` — list kind:1 text notes by a user. -/// -/// Returns `{id, pubkey, created_at, content}` per note (tags and sig omitted -/// for brevity). Use `GET /api/events/{id}` for the full event including tags -/// and signature. -pub async fn get_user_notes( - State(state): State>, - headers: HeaderMap, - Path(pubkey): Path, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::MessagesRead) - .map_err(scope_error)?; - - let pubkey_bytes = nostr_hex::decode(&pubkey) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; - if pubkey_bytes.len() != 32 { - return Err(api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex")); - } - - let limit = params.limit.unwrap_or(50).clamp(1, 100) as i64; - - // Composite cursor: when both `before` and `before_id` are provided, use exact - // keyset pagination (no ±1 adjustment needed — the DB clause is strictly exclusive). - // Simple cursor (backward compat): subtract 1 second so the last event on the - // previous page is not repeated (DB uses `<=` for the simple case). - let (until, before_id_bytes) = match (params.before, params.before_id.as_deref()) { - (Some(ts), Some(id_hex)) => { - // Composite cursor — validate and decode the event ID. - let id_bytes = nostr_hex::decode(id_hex) - .ok() - .filter(|b| b.len() == 32) - .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before_id hex"))?; - let dt = chrono::DateTime::from_timestamp(ts, 0) - .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before timestamp"))?; - (Some(dt), Some(id_bytes)) - } - (Some(ts), None) => { - // Simple cursor: subtract 1 second for exclusivity (DB uses <=). - let dt = chrono::DateTime::from_timestamp(ts.saturating_sub(1), 0) - .ok_or_else(|| api_error(StatusCode::BAD_REQUEST, "invalid before timestamp"))?; - (Some(dt), None) - } - (None, Some(_)) => { - return Err(api_error( - StatusCode::BAD_REQUEST, - "before_id requires before", - )); - } - (None, None) => (None, None), - }; - - let q = EventQuery { - pubkey: Some(pubkey_bytes), - kinds: Some(vec![KIND_TEXT_NOTE as i32]), - limit: Some(limit), - until, - before_id: before_id_bytes, - // Defensive: only return global events (channel_id IS NULL). - // kind:1 is always stored globally by is_global_only_kind(), but - // this explicit filter prevents information disclosure if that - // invariant ever changes. - global_only: true, - ..Default::default() - }; - - let events = state - .db - .query_events(&q) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - // Composite cursor: include both timestamp and event ID so the next page - // can use exact keyset pagination without same-second skipping. - let next_cursor = if events.len() == limit as usize { - events.last().map(|e| { - serde_json::json!({ - "before": e.event.created_at.as_u64() as i64, - "before_id": e.event.id.to_hex(), - }) - }) - } else { - None - }; - let notes: Vec<_> = events - .into_iter() - .map(|e| { - serde_json::json!({ - "id": e.event.id.to_hex(), - "pubkey": e.event.pubkey.to_hex(), - "created_at": e.event.created_at.as_u64(), - "content": e.event.content, - }) - }) - .collect(); - - Ok(Json( - serde_json::json!({ "notes": notes, "next_cursor": next_cursor }), - )) -} - -/// `GET /api/users/{pubkey}/contact-list` — get a user's kind:3 contact list. -pub async fn get_contact_list( - State(state): State>, - headers: HeaderMap, - Path(pubkey): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::UsersRead).map_err(scope_error)?; - - let pubkey_bytes = nostr_hex::decode(&pubkey) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex"))?; - if pubkey_bytes.len() != 32 { - return Err(api_error(StatusCode::BAD_REQUEST, "invalid pubkey hex")); - } - - let event = state - .db - .get_latest_global_replaceable(KIND_CONTACT_LIST as i32, &pubkey_bytes) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - match event { - Some(e) => { - let tags = serde_json::to_value(&e.event.tags) - .map_err(|err| internal_error(&format!("tag serialization error: {err}")))?; - Ok(Json(serde_json::json!({ - "id": e.event.id.to_hex(), - "pubkey": e.event.pubkey.to_hex(), - "created_at": e.event.created_at.as_u64(), - "tags": tags, - "content": e.event.content, - }))) - } - None => Err(api_error(StatusCode::NOT_FOUND, "contact list not found")), - } -} diff --git a/crates/sprout-relay/src/api/workflow_helpers.rs b/crates/sprout-relay/src/api/workflow_helpers.rs deleted file mode 100644 index be241cfeb5..0000000000 --- a/crates/sprout-relay/src/api/workflow_helpers.rs +++ /dev/null @@ -1,434 +0,0 @@ -//! Shared helpers for workflow endpoints: serialization, SSRF validation, and async execution. - -use std::sync::Arc; - -use nostr::util::hex as nostr_hex; -use sha2::{Digest, Sha256}; - -// ── Serialization ───────────────────────────────────────────────────────────── - -/// Strip `_webhook_secret` from a workflow definition before returning it to clients. -/// -/// The secret is an internal field used only for webhook authentication; it must never -/// be exposed via GET responses. -fn sanitize_definition(def: &serde_json::Value) -> serde_json::Value { - crate::webhook_secret::strip_secret(def) -} - -/// Serialize a [`WorkflowRecord`] to a JSON value safe for API responses. -pub(crate) fn workflow_record_to_json( - w: &sprout_db::workflow::WorkflowRecord, -) -> serde_json::Value { - serde_json::json!({ - "id": w.id.to_string(), - "name": w.name, - "owner_pubkey": nostr_hex::encode(&w.owner_pubkey), - "channel_id": w.channel_id.map(|id| id.to_string()), - "definition": sanitize_definition(&w.definition), - "status": w.status, - "created_at": w.created_at.timestamp(), - "updated_at": w.updated_at.timestamp(), - }) -} - -/// Serialize a [`WorkflowRunRecord`] to a JSON value. -pub(crate) fn run_record_to_json(r: &sprout_db::workflow::WorkflowRunRecord) -> serde_json::Value { - serde_json::json!({ - "id": r.id.to_string(), - "workflow_id": r.workflow_id.to_string(), - "status": r.status, - "current_step": r.current_step, - "execution_trace": r.execution_trace, - "started_at": r.started_at.map(|t| t.timestamp()), - "completed_at": r.completed_at.map(|t| t.timestamp()), - "error_message": r.error_message, - "created_at": r.created_at.timestamp(), - }) -} - -/// Serialize an [`ApprovalRecord`] to a JSON value. -pub(crate) fn approval_record_to_json( - a: &sprout_db::workflow::ApprovalRecord, -) -> serde_json::Value { - serde_json::json!({ - "token": hex::encode(&a.token), - "workflow_id": a.workflow_id.to_string(), - "run_id": a.run_id.to_string(), - "step_id": a.step_id, - "step_index": a.step_index, - "approver_spec": a.approver_spec, - "status": a.status.to_string(), - "approver_pubkey": a.approver_pubkey.as_ref().map(nostr_hex::encode), - "note": a.note, - "expires_at": a.expires_at.to_rfc3339(), - "created_at": a.created_at.timestamp(), - }) -} - -// ── SSRF prevention ─────────────────────────────────────────────────────────── - -/// Validate all CallWebhook URLs in a workflow definition. -/// -/// Rejects non-http(s) schemes, known metadata endpoints, literal private IPs, -/// and hostnames that resolve to private/loopback/link-local addresses (SSRF via DNS). -/// -/// Uses `tokio::net::lookup_host` for async DNS resolution to avoid blocking the executor. -pub(crate) async fn validate_webhook_urls( - def: &sprout_workflow::WorkflowDef, -) -> Result<(), String> { - for step in &def.steps { - if let sprout_workflow::ActionDef::CallWebhook { url, .. } = &step.action { - let parsed = url::Url::parse(url) - .map_err(|e| format!("invalid webhook URL in step '{}': {e}", step.id))?; - - match parsed.scheme() { - "http" | "https" => {} - s => { - return Err(format!( - "webhook URL scheme '{}' not allowed in step '{}' (only http/https)", - s, step.id - )) - } - } - - if let Some(host) = parsed.host_str() { - // Block loopback hostnames and cloud metadata endpoints. - if matches!( - host, - "localhost" | "127.0.0.1" | "::1" | "[::1]" | "::" | "[::]" - ) { - return Err(format!( - "webhook URL in step '{}' targets loopback address", - step.id - )); - } - if matches!(host, "169.254.169.254" | "metadata.google.internal") { - return Err(format!( - "webhook URL in step '{}' targets cloud metadata endpoint", - step.id - )); - } - - if let Ok(ip) = host.parse::() { - // Literal IP — check directly. - if sprout_core::network::is_private_ip(&ip) { - return Err(format!( - "webhook URL in step '{}' targets private/internal network", - step.id - )); - } - } else { - // Hostname — resolve DNS asynchronously and check all resolved IPs (SSRF via DNS). - match tokio::net::lookup_host(format!("{}:80", host)).await { - Ok(addrs) => { - for addr in addrs { - if sprout_core::network::is_private_ip(&addr.ip()) { - return Err(format!( - "webhook URL in step '{}' resolves to private/internal address", - step.id - )); - } - } - } - Err(e) => { - // DNS resolution failed — reject to be safe (fail-closed). - tracing::warn!( - step_id = %step.id, - host = %host, - "webhook URL hostname DNS resolution failed: {e}" - ); - return Err(format!( - "webhook URL in step '{}' hostname could not be resolved", - step.id - )); - } - } - } - } - } - } - Ok(()) -} - -// ── Webhook secret helpers ──────────────────────────────────────────────────── - -/// Inject or preserve webhook secret in a definition JSON value, returning the secret used. -/// -/// If the existing definition already has a secret, it is preserved and returned. -/// Otherwise a new secret is generated, injected, and returned. -pub(crate) fn ensure_webhook_secret( - definition_json: &mut serde_json::Value, - existing_definition: Option<&serde_json::Value>, -) -> String { - if let Some(existing) = existing_definition { - if let Some(s) = crate::webhook_secret::extract_secret(existing) { - crate::webhook_secret::inject_secret(definition_json, &s); - return s; - } - } - let secret = crate::webhook_secret::generate_webhook_secret(); - crate::webhook_secret::inject_secret(definition_json, &secret); - secret -} - -/// Compute SHA-256 hash of a JSON string for storage. -pub(crate) fn definition_hash(json_str: &str) -> Vec { - Sha256::digest(json_str.as_bytes()).to_vec() -} - -// ── Async workflow execution ────────────────────────────────────────────────── - -/// Spawn an async workflow execution task. -/// -/// Handles the full lifecycle: Pending → (executor sets Running) → Completed / Failed. -/// Uses [`WorkflowEngine::finalize_run`] for the result→DB-status mapping. -/// Used by trigger and webhook paths to avoid code duplication. -pub(crate) fn spawn_workflow_execution( - engine: Arc, - db: sprout_db::Db, - run_id: uuid::Uuid, - workflow_def_value: serde_json::Value, - trigger_ctx: sprout_workflow::executor::TriggerContext, -) { - tokio::spawn(async move { - let def: sprout_workflow::WorkflowDef = match serde_json::from_value(workflow_def_value) { - Ok(d) => d, - Err(e) => { - tracing::error!("workflow run {run_id}: failed to parse definition: {e}"); - if let Err(db_err) = db - .update_workflow_run( - run_id, - sprout_db::workflow::RunStatus::Failed, - 0, - &serde_json::json!([]), - Some(&format!("definition parse error: {e}")), - ) - .await - { - tracing::error!("workflow run {run_id}: failed to set Failed status: {db_err}"); - } - return; - } - }; - - let result = - sprout_workflow::executor::execute_run(&engine, run_id, &def, &trigger_ctx).await; - engine.finalize_run(run_id, result, None).await; - }); -} - -// ── Tests ───────────────────────────────────────────────────────────────────── - -#[cfg(test)] -mod tests { - use super::*; - use sprout_workflow::{ActionDef, Step, TriggerDef, WorkflowDef}; - - // ── Helpers ─────────────────────────────────────────────────────────────── - - fn make_workflow(steps: Vec) -> WorkflowDef { - WorkflowDef { - name: "test-workflow".to_string(), - description: None, - trigger: TriggerDef::Webhook, - steps, - enabled: true, - } - } - - fn webhook_step(id: &str, url: &str) -> Step { - Step { - id: id.to_string(), - name: None, - if_expr: None, - timeout_secs: None, - action: ActionDef::CallWebhook { - url: url.to_string(), - method: None, - headers: None, - body: None, - }, - } - } - - fn send_message_step(id: &str) -> Step { - Step { - id: id.to_string(), - name: None, - if_expr: None, - timeout_secs: None, - action: ActionDef::SendMessage { - text: "hello".to_string(), - channel: None, - }, - } - } - - // ── No webhook steps ────────────────────────────────────────────────────── - - #[tokio::test] - async fn empty_workflow_passes_validation() { - let def = make_workflow(vec![]); - assert!(validate_webhook_urls(&def).await.is_ok()); - } - - #[tokio::test] - async fn non_webhook_steps_pass_validation() { - let def = make_workflow(vec![send_message_step("step1"), send_message_step("step2")]); - assert!(validate_webhook_urls(&def).await.is_ok()); - } - - // ── Valid public URLs ───────────────────────────────────────────────────── - // - // Use literal public IPs to avoid DNS resolution in the test environment. - // `validate_webhook_urls` is fail-closed: unresolvable hostnames are rejected. - // 8.8.8.8 (Google Public DNS) is a well-known public IP that is never private. - - #[tokio::test] - async fn valid_https_literal_public_ip_passes() { - let def = make_workflow(vec![webhook_step("s1", "https://8.8.8.8/notify")]); - assert!(validate_webhook_urls(&def).await.is_ok()); - } - - #[tokio::test] - async fn valid_http_literal_public_ip_passes() { - let def = make_workflow(vec![webhook_step("s1", "http://8.8.8.8/webhook")]); - assert!(validate_webhook_urls(&def).await.is_ok()); - } - - // ── Loopback / private literal IPs ─────────────────────────────────────── - - #[tokio::test] - async fn loopback_127_0_0_1_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://127.0.0.1/evil")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("loopback") || err.contains("private"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn loopback_localhost_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://localhost/evil")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("loopback") || err.contains("private"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn private_10_network_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://10.0.0.1/internal")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("private") || err.contains("internal"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn private_192_168_network_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://192.168.1.100/internal")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("private") || err.contains("internal"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn cloud_metadata_endpoint_is_rejected() { - let def = make_workflow(vec![webhook_step( - "s1", - "http://169.254.169.254/latest/meta-data/", - )]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("metadata") || err.contains("loopback") || err.contains("private"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn ipv6_loopback_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://[::1]/evil")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("loopback") || err.contains("private"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn ipv6_unspecified_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "http://[::]/evil")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("loopback") || err.contains("private") || err.contains("internal"), - "unexpected error: {err}" - ); - } - - // ── Non-http(s) schemes ─────────────────────────────────────────────────── - - #[tokio::test] - async fn ftp_scheme_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "ftp://files.example.com/data")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("scheme") || err.contains("not allowed"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn file_scheme_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "file:///etc/passwd")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("scheme") || err.contains("not allowed"), - "unexpected error: {err}" - ); - } - - // ── Multiple steps — one invalid ────────────────────────────────────────── - - #[tokio::test] - async fn multiple_steps_one_invalid_is_rejected() { - // First step is a valid public IP, third step is a private IP — must reject. - let def = make_workflow(vec![ - webhook_step("s1", "https://8.8.8.8/ok"), - send_message_step("s2"), - webhook_step("s3", "http://10.0.0.1/bad"), - ]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("private") || err.contains("internal"), - "unexpected error: {err}" - ); - } - - #[tokio::test] - async fn multiple_valid_webhook_steps_all_pass() { - // Both steps use literal public IPs — no DNS resolution needed. - let def = make_workflow(vec![ - webhook_step("s1", "https://8.8.8.8/first"), - webhook_step("s2", "https://1.1.1.1/second"), - ]); - assert!(validate_webhook_urls(&def).await.is_ok()); - } - - // ── Invalid URL format ──────────────────────────────────────────────────── - - #[tokio::test] - async fn malformed_url_is_rejected() { - let def = make_workflow(vec![webhook_step("s1", "not a url at all")]); - let err = validate_webhook_urls(&def).await.unwrap_err(); - assert!( - err.contains("invalid webhook URL"), - "unexpected error: {err}" - ); - } -} diff --git a/crates/sprout-relay/src/api/workflows.rs b/crates/sprout-relay/src/api/workflows.rs deleted file mode 100644 index 132be08fe0..0000000000 --- a/crates/sprout-relay/src/api/workflows.rs +++ /dev/null @@ -1,711 +0,0 @@ -//! Workflow CRUD endpoints and execution triggers. -//! -//! Endpoints: -//! GET /api/channels/:channel_id/workflows — list workflows in a channel -//! POST /api/channels/:channel_id/workflows — create workflow -//! GET /api/workflows/:id — get workflow -//! PUT /api/workflows/:id — update workflow -//! DELETE /api/workflows/:id — delete workflow -//! GET /api/workflows/:id/runs — list workflow runs -//! POST /api/workflows/:id/trigger — manually trigger workflow -//! POST /api/workflows/:id/webhook — webhook trigger (no auth) - -use std::sync::Arc; - -use axum::{ - extract::{Path, Query, State}, - http::{HeaderMap, StatusCode}, - response::Json, -}; -use serde::Deserialize; - -use crate::state::AppState; - -use super::workflow_helpers::{ - approval_record_to_json, definition_hash, ensure_webhook_secret, run_record_to_json, - spawn_workflow_execution, validate_webhook_urls, workflow_record_to_json, -}; -use super::{ - api_error, check_channel_access, check_token_channel_access, extract_auth_context, forbidden, - internal_error, not_found, scope_error, -}; - -// ── GET /api/channels/:channel_id/workflows ─────────────────────────────────── - -/// List all workflows in a channel. -pub async fn list_channel_workflows( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; - - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let workflows = state - .db - .list_channel_workflows(channel_id, None, None) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let result: Vec = workflows.iter().map(workflow_record_to_json).collect(); - Ok(Json(serde_json::json!(result))) -} - -// ── POST /api/channels/:channel_id/workflows ────────────────────────────────── - -/// Request body for creating a new workflow. -#[derive(Debug, Deserialize)] -pub struct CreateWorkflowBody { - /// YAML workflow definition string. - pub yaml_definition: String, -} - -fn require_workflow_owner( - workflow: &sprout_db::workflow::WorkflowRecord, - caller_pubkey: &[u8], - action: &str, -) -> Result<(), (StatusCode, Json)> { - if workflow.owner_pubkey != caller_pubkey { - return Err(forbidden(&format!( - "not authorized to {action} this workflow" - ))); - } - Ok(()) -} - -fn validate_send_message_targets( - def: &sprout_workflow::WorkflowDef, - workflow_channel_id: Option, -) -> Result<(), (StatusCode, Json)> { - for step in &def.steps { - if let sprout_workflow::ActionDef::SendMessage { - channel: Some(channel), - .. - } = &step.action - { - let trimmed = channel.trim(); - if trimmed.is_empty() { - continue; - } - - let target_channel = uuid::Uuid::parse_str(trimmed).map_err(|_| { - api_error( - StatusCode::BAD_REQUEST, - &format!( - "invalid workflow YAML: step '{}' has an invalid send_message.channel UUID", - step.id - ), - ) - })?; - - if let Some(workflow_channel_id) = workflow_channel_id { - if target_channel != workflow_channel_id { - return Err(api_error( - StatusCode::BAD_REQUEST, - &format!( - "invalid workflow YAML: step '{}' cannot override send_message.channel outside workflow channel {}", - step.id, workflow_channel_id - ), - )); - } - } - } - } - - Ok(()) -} - -/// Create a new workflow in a channel. -/// -/// Parses and validates the YAML definition, generates a webhook secret if needed, -/// and stores the workflow. Returns the webhook secret in the response (only time it's visible). -pub async fn create_workflow( - State(state): State>, - headers: HeaderMap, - Path(channel_id_str): Path, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let channel_id = uuid::Uuid::parse_str(&channel_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid channel UUID"))?; - - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - - let (def, definition_json_str) = - sprout_workflow::WorkflowEngine::parse_yaml(&body.yaml_definition).map_err(|e| { - api_error( - StatusCode::BAD_REQUEST, - &format!("invalid workflow YAML: {e}"), - ) - })?; - validate_send_message_targets(&def, Some(channel_id))?; - - validate_webhook_urls(&def) - .await - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?; - - let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) - .map_err(|e| internal_error(&format!("json parse error: {e}")))?; - - // I5: Generate a webhook secret if this workflow uses a Webhook trigger. - let webhook_secret = if matches!(def.trigger, sprout_workflow::TriggerDef::Webhook) { - Some(ensure_webhook_secret(&mut definition_json, None)) - } else { - None - }; - - // C5: Compute SHA-256 hash AFTER secret injection so hash matches stored definition. - let definition_json_final = serde_json::to_string(&definition_json) - .map_err(|e| internal_error(&format!("json serialize error: {e}")))?; - let hash = definition_hash(&definition_json_final); - - let workflow_id = state - .db - .create_workflow( - Some(channel_id), - &pubkey_bytes, - &def.name, - &definition_json_final, - &hash, - ) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let workflow = state - .db - .get_workflow(workflow_id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let mut resp = workflow_record_to_json(&workflow); - // Return the webhook secret in the creation response (only time it's visible). - if let Some(secret) = &webhook_secret { - resp["webhook_secret"] = serde_json::Value::String(secret.clone()); - } - Ok(Json(resp)) -} - -// ── GET /api/workflows/:id ──────────────────────────────────────────────────── - -/// Get a single workflow by ID. -pub async fn get_workflow( - State(state): State>, - headers: HeaderMap, - Path(id_str): Path, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = workflow.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); - } - - Ok(Json(workflow_record_to_json(&workflow))) -} - -// ── PUT /api/workflows/:id ──────────────────────────────────────────────────── - -/// Request body for updating an existing workflow. -#[derive(Debug, Deserialize)] -pub struct UpdateWorkflowBody { - /// Replacement YAML workflow definition string. - pub yaml_definition: String, -} - -/// Update an existing workflow's definition. -/// -/// Preserves the webhook secret across updates if the trigger type remains Webhook. -/// If the trigger changes TO Webhook, a new secret is generated and returned. -pub async fn update_workflow( - State(state): State>, - headers: HeaderMap, - Path(id_str): Path, - Json(body): Json, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let existing = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = existing.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } - require_workflow_owner(&existing, &pubkey_bytes, "update")?; - - let (def, definition_json_str) = - sprout_workflow::WorkflowEngine::parse_yaml(&body.yaml_definition).map_err(|e| { - api_error( - StatusCode::BAD_REQUEST, - &format!("invalid workflow YAML: {e}"), - ) - })?; - validate_send_message_targets(&def, existing.channel_id)?; - - validate_webhook_urls(&def) - .await - .map_err(|e| api_error(StatusCode::BAD_REQUEST, &e))?; - - let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) - .map_err(|e| internal_error(&format!("json parse error: {e}")))?; - - // N3: Preserve (or regenerate) the webhook secret across updates. - let is_webhook_now = matches!(def.trigger, sprout_workflow::TriggerDef::Webhook); - let new_secret: Option = if is_webhook_now { - let had_existing = crate::webhook_secret::extract_secret(&existing.definition).is_some(); - let secret = ensure_webhook_secret(&mut definition_json, Some(&existing.definition)); - // Only return the secret in the response if it was newly generated. - if had_existing { - None - } else { - Some(secret) - } - } else { - None - }; - - let definition_json_str_final = serde_json::to_string(&definition_json) - .map_err(|e| internal_error(&format!("json serialize error: {e}")))?; - let hash = definition_hash(&definition_json_str_final); - - state - .db - .update_workflow(id, &def.name, &definition_json_str_final, &hash) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let updated = state - .db - .get_workflow(id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let mut resp = workflow_record_to_json(&updated); - // M4: If a new webhook secret was generated during this update, include it in the - // response so the caller can store it. It will not be retrievable again. - if let Some(secret) = new_secret { - resp["webhook_secret"] = serde_json::Value::String(secret); - } - Ok(Json(resp)) -} - -// ── DELETE /api/workflows/:id ───────────────────────────────────────────────── - -/// Delete a workflow. Only the workflow owner may delete it. -pub async fn delete_workflow( - State(state): State>, - headers: HeaderMap, - Path(id_str): Path, -) -> Result)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = workflow.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes) - .await - .map_err(|_| forbidden("not authorized to delete this workflow"))?; - } - require_workflow_owner(&workflow, &pubkey_bytes, "delete")?; - - state - .db - .delete_workflow(id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - use axum::response::IntoResponse; - Ok(StatusCode::NO_CONTENT.into_response()) -} - -// ── GET /api/workflows/:id/runs ─────────────────────────────────────────────── - -/// Query parameters for the workflow runs list endpoint. -#[derive(Debug, Deserialize)] -pub struct RunsParams { - /// Maximum number of runs to return. Defaults to 20. - pub limit: Option, -} - -/// List recent runs for a workflow. -pub async fn list_workflow_runs( - State(state): State>, - headers: HeaderMap, - Path(id_str): Path, - Query(params): Query, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = workflow.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } - require_workflow_owner(&workflow, &pubkey_bytes, "trigger")?; - - let limit = params.limit.unwrap_or(20).min(100) as i64; - let runs = state - .db - .list_workflow_runs(id, limit) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let result: Vec = runs.iter().map(run_record_to_json).collect(); - Ok(Json(serde_json::json!(result))) -} - -// ── GET /api/workflows/:id/runs/:run_id/approvals ──────────────────────────── - -/// List all approval records for a workflow run. -pub async fn list_run_approvals( - State(state): State>, - headers: HeaderMap, - Path((id_str, run_id_str)): Path<(String, String)>, -) -> Result, (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsRead) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - let run_id = uuid::Uuid::parse_str(&run_id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid run UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = workflow.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); - } - - let approvals = state - .db - .get_run_approvals(id, run_id) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - let result: Vec = approvals.iter().map(approval_record_to_json).collect(); - Ok(Json(serde_json::json!(result))) -} - -// ── POST /api/workflows/:id/trigger ────────────────────────────────────────── - -/// Manually trigger a workflow. Returns 202 Accepted; execution is async. -pub async fn trigger_workflow( - State(state): State>, - headers: HeaderMap, - Path(id_str): Path, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let ctx = extract_auth_context(&headers, &state).await?; - sprout_auth::require_scope(&ctx.scopes, sprout_auth::Scope::ChannelsWrite) - .map_err(scope_error)?; - let pubkey_bytes = ctx.pubkey_bytes.clone(); - - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - if let Some(channel_id) = workflow.channel_id { - check_token_channel_access(&ctx, &channel_id)?; - check_channel_access(&state, channel_id, &pubkey_bytes).await?; - } else if workflow.owner_pubkey != pubkey_bytes { - return Err(forbidden("not authorized to access this workflow")); - } - - let trigger_ctx = sprout_workflow::executor::TriggerContext { - channel_id: workflow - .channel_id - .map(|channel_id| channel_id.to_string()) - .unwrap_or_default(), - ..Default::default() - }; - let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); - - let run_id = state - .db - .create_workflow_run(id, None, trigger_ctx_json.as_ref()) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - spawn_workflow_execution( - Arc::clone(&state.workflow_engine), - state.db.clone(), - run_id, - workflow.definition.clone(), - trigger_ctx, - ); - - Ok(( - StatusCode::ACCEPTED, - Json(serde_json::json!({ - "run_id": run_id.to_string(), - "workflow_id": id.to_string(), - "status": "pending", - })), - )) -} - -// ── POST /api/workflows/:id/webhook ────────────────────────────────────────── - -/// Query parameters for the webhook trigger endpoint. -#[derive(Debug, Deserialize)] -pub struct WebhookQuery { - /// Webhook secret for authentication. Prefer the `X-Webhook-Secret` header instead. - pub secret: Option, -} - -/// Webhook trigger endpoint. No user auth — the webhook secret authenticates the caller. -/// -/// Prefers `X-Webhook-Secret` header over `?secret=` query param (headers aren't logged -/// by most proxies). Returns 202 Accepted; execution is async. -pub async fn workflow_webhook( - State(state): State>, - Path(id_str): Path, - Query(query): Query, - headers: HeaderMap, - body: axum::body::Bytes, -) -> Result<(StatusCode, Json), (StatusCode, Json)> { - let id = uuid::Uuid::parse_str(&id_str) - .map_err(|_| api_error(StatusCode::BAD_REQUEST, "invalid workflow UUID"))?; - - let workflow = state - .db - .get_workflow(id) - .await - .map_err(|_| not_found("workflow not found"))?; - - let def: sprout_workflow::WorkflowDef = serde_json::from_value(workflow.definition.clone()) - .map_err(|e| internal_error(&format!("corrupt workflow definition: {e}")))?; - - if !matches!(def.trigger, sprout_workflow::TriggerDef::Webhook) { - return Err(api_error( - StatusCode::BAD_REQUEST, - "workflow does not have a webhook trigger", - )); - } - - // I5: Verify webhook secret. Prefer header (not logged by proxies); fall back to query param. - let stored_secret = crate::webhook_secret::extract_secret(&workflow.definition); - let provided_secret = headers - .get("x-webhook-secret") - .and_then(|v| v.to_str().ok()) - .map(|s| s.to_string()) - .or_else(|| query.secret.clone()) - .unwrap_or_default(); - - match &stored_secret { - Some(secret) => { - if !crate::webhook_secret::verify_secret(&provided_secret, secret) { - tracing::warn!("webhook: invalid secret for workflow {id}"); - return Err(api_error(StatusCode::UNAUTHORIZED, "authentication failed")); - } - } - None => { - return Err(api_error( - StatusCode::UNAUTHORIZED, - "webhook secret required but not configured — re-save the workflow to generate one", - )); - } - } - - // Parse optional JSON body as trigger context. Return 400 if the body is - // non-empty but not valid JSON so callers get actionable error feedback. - let body_json: Option = - if body.is_empty() { - None - } else { - Some(serde_json::from_slice(&body).map_err(|e| { - api_error(StatusCode::BAD_REQUEST, &format!("invalid JSON body: {e}")) - })?) - }; - - // Build trigger context from webhook body fields before creating the run so - // we can persist it immediately (needed for post-approval resume). - let mut trigger_ctx = sprout_workflow::executor::TriggerContext { - channel_id: workflow - .channel_id - .map(|channel_id| channel_id.to_string()) - .unwrap_or_default(), - ..Default::default() - }; - if let Some(serde_json::Value::Object(ref map)) = body_json { - for (k, v) in map { - let val_str = match v { - serde_json::Value::String(s) => s.clone(), - other => other.to_string(), - }; - trigger_ctx.webhook_fields.insert(k.clone(), val_str); - } - } - let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); - - let run_id = state - .db - .create_workflow_run(id, None, trigger_ctx_json.as_ref()) - .await - .map_err(|e| internal_error(&format!("db error: {e}")))?; - - spawn_workflow_execution( - Arc::clone(&state.workflow_engine), - state.db.clone(), - run_id, - workflow.definition.clone(), - trigger_ctx, - ); - - Ok(( - StatusCode::ACCEPTED, - Json(serde_json::json!({ - "run_id": run_id.to_string(), - "workflow_id": id.to_string(), - "status": "pending", - })), - )) -} - -#[cfg(test)] -mod tests { - use chrono::Utc; - use nostr::Keys; - use sprout_db::workflow::{WorkflowRecord, WorkflowStatus}; - use sprout_workflow::{ActionDef, Step, TriggerDef, WorkflowDef}; - - use super::*; - - fn workflow_record(owner_pubkey: Vec) -> WorkflowRecord { - WorkflowRecord { - id: uuid::Uuid::new_v4(), - name: "regression".to_string(), - owner_pubkey, - channel_id: Some(uuid::Uuid::new_v4()), - definition: serde_json::json!({}), - definition_hash: vec![0; 32], - status: WorkflowStatus::Active, - enabled: true, - created_at: Utc::now(), - updated_at: Utc::now(), - } - } - - fn workflow_with_send_message_target(channel_id: uuid::Uuid) -> WorkflowDef { - WorkflowDef { - name: "cross-channel".to_string(), - description: None, - trigger: TriggerDef::MessagePosted { filter: None }, - steps: vec![Step { - id: "notify".to_string(), - name: None, - if_expr: None, - timeout_secs: None, - action: ActionDef::SendMessage { - text: "hello".to_string(), - channel: Some(channel_id.to_string()), - }, - }], - enabled: true, - } - } - - #[test] - fn workflow_mutations_require_the_owner_pubkey() { - let owner = Keys::generate().public_key().serialize().to_vec(); - let caller = Keys::generate().public_key().serialize().to_vec(); - let workflow = workflow_record(owner); - - let err = require_workflow_owner(&workflow, &caller, "update") - .expect_err("non-owners must not be able to update workflows"); - - assert_eq!(err.0, StatusCode::FORBIDDEN); - assert_eq!( - err.1 .0["error"].as_str(), - Some("not authorized to update this workflow") - ); - } - - #[test] - fn channel_workflows_cannot_override_send_message_destination() { - let workflow_channel_id = uuid::Uuid::new_v4(); - let other_channel_id = uuid::Uuid::new_v4(); - let def = workflow_with_send_message_target(other_channel_id); - - let err = validate_send_message_targets(&def, Some(workflow_channel_id)) - .expect_err("channel workflows must not be able to send outside their channel"); - let expected = format!( - "invalid workflow YAML: step 'notify' cannot override send_message.channel outside workflow channel {}", - workflow_channel_id - ); - - assert_eq!(err.0, StatusCode::BAD_REQUEST); - assert_eq!(err.1 .0["error"].as_str(), Some(expected.as_str())); - } -} diff --git a/crates/sprout-relay/src/audio/handler.rs b/crates/sprout-relay/src/audio/handler.rs index 33547bf115..63c889b49d 100644 --- a/crates/sprout-relay/src/audio/handler.rs +++ b/crates/sprout-relay/src/audio/handler.rs @@ -117,6 +117,9 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc, channe } }; + // Extract NIP-OA auth tag before verify_auth_event consumes the event. + let auth_tag_json = crate::handlers::auth::extract_auth_tag_json(&auth_msg.event); + let relay_url = state.config.relay_url.clone(); let auth_ctx = match state .auth @@ -142,6 +145,26 @@ async fn handle_audio_connection(socket: WebSocket, state: Arc, channe let pubkey_bytes = pubkey.serialize().to_vec(); let parent_channel_id = auth_msg.parent_channel_id; + // ── Relay membership gate (with NIP-OA fallback) ──────────────────────────── + if crate::api::relay_members::enforce_relay_membership( + &state, + &pubkey.serialize(), + auth_tag_json.as_deref(), + ) + .await + .is_err() + { + warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio: relay membership denied"); + let _ = ws_send + .send(WsMessage::Text( + serde_json::json!({"type": "error", "message": "restricted: not a relay member"}) + .to_string() + .into(), + )) + .await; + return; + } + // ── Step 3: membership check / auto-add ─────────────────────────────────── if let Err(e) = ensure_membership(&state, channel_id, &pubkey_bytes, parent_channel_id).await { warn!(channel_id = %channel_id, pubkey = %pubkey_hex, "audio membership denied: {e}"); diff --git a/crates/sprout-relay/src/config.rs b/crates/sprout-relay/src/config.rs index 95a379aced..7458bd98a3 100644 --- a/crates/sprout-relay/src/config.rs +++ b/crates/sprout-relay/src/config.rs @@ -11,6 +11,9 @@ pub enum ConfigError { /// The `SPROUT_BIND_ADDR` environment variable could not be parsed as a socket address. #[error("invalid SPROUT_BIND_ADDR: {0}")] InvalidBindAddr(String), + /// A configuration value failed validation. + #[error("invalid config: {0}")] + InvalidValue(String), } /// Relay runtime configuration, loaded from environment variables. @@ -54,11 +57,37 @@ pub struct Config { /// TCP port for the Prometheus metrics exporter (`GET /metrics`). pub metrics_port: u16, - /// When true, NIP-42 pubkey-only authentication (no JWT or API token) is + /// When true, NIP-42 pubkey-only authentication (no API token) is /// restricted to pubkeys in the `pubkey_allowlist` table. Users with valid - /// API tokens or Okta JWTs bypass the allowlist entirely. + /// API tokens bypass the allowlist entirely. /// Applies to all NIP-42 pubkey-only connections, regardless of `require_auth_token`. pub pubkey_allowlist_enabled: bool, + + /// When true, every authenticated request must also pass a relay-level + /// membership check against the `relay_members` table. + /// When false (default), the check is a no-op and all authenticated callers + /// are permitted regardless of auth method (API token, NIP-42). + pub require_relay_membership: bool, + + /// Optional hex-encoded pubkey of the relay owner. + /// When set, this pubkey is automatically bootstrapped into `relay_members` + /// with the `owner` role on first startup. + pub relay_owner_pubkey: Option, + + /// Allow NIP-OA owner attestation for relay membership. + /// + /// When `true` and `require_relay_membership` is also `true`, agents + /// bearing a valid NIP-OA `auth` tag can authenticate by proving their + /// owner is a relay member. The agent gets session-scoped access. + /// + /// On open relays (`require_relay_membership = false`), NIP-OA owner + /// extraction for agent→owner backfill happens unconditionally (the + /// signature is cryptographically self-proving). This flag only controls + /// whether NIP-OA can grant membership access on closed relays. + /// + /// Default: `false`. Set via `SPROUT_ALLOW_NIP_OA_AUTH=true`. + pub allow_nip_oa_auth: bool, + /// Media storage configuration (S3/MinIO). pub media: sprout_media::MediaConfig, @@ -68,6 +97,26 @@ pub struct Config { /// Example: `SPROUT_EPHEMERAL_TTL_OVERRIDE=60` → all ephemeral channels expire /// 60 seconds after the last message. pub ephemeral_ttl_override: Option, + + // ── Git server configuration ───────────────────────────────────────────── + /// Root directory for bare git repositories. + /// Repos are stored at `{git_repo_path}/{owner_hex}/{repo_id}.git/`. + pub git_repo_path: std::path::PathBuf, + /// Maximum pack file size for git push (bytes). Default: 500 MB. + pub git_max_pack_bytes: u64, + /// Maximum number of repos per pubkey. Default: 100. + pub git_max_repos_per_pubkey: u32, + /// Maximum concurrent git subprocess operations. Default: 20. + pub git_max_concurrent_ops: usize, + /// HMAC secret for git pre-receive hook callbacks. + /// Used to authenticate internal policy endpoint requests. + pub git_hook_hmac_secret: String, + + // ── Web UI serving ──────────────────────────────────────────────────────── + /// Optional path to the web UI `dist/` directory. + /// When set, the relay serves the SPA from this directory for browser requests. + /// When unset, no static file serving happens (relay behaves as before). + pub web_dir: Option, } impl Config { @@ -116,18 +165,35 @@ impl Config { .map(|v| v == "true" || v == "1") .unwrap_or(false); - let mut auth = sprout_auth::AuthConfig::default(); - auth.okta.require_token = require_auth_token; + let require_relay_membership = std::env::var("SPROUT_REQUIRE_RELAY_MEMBERSHIP") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); - if let Ok(issuer) = std::env::var("OKTA_ISSUER") { - auth.okta.issuer = issuer; - } - if let Ok(audience) = std::env::var("OKTA_AUDIENCE") { - auth.okta.audience = audience; - } - if let Ok(jwks_uri) = std::env::var("OKTA_JWKS_URI") { - auth.okta.jwks_uri = jwks_uri; - } + let allow_nip_oa_auth = std::env::var("SPROUT_ALLOW_NIP_OA_AUTH") + .map(|v| v == "true" || v == "1") + .unwrap_or(false); + + // Note: intentionally not prefixed with SPROUT_ — this is a relay-identity + // config that may be shared across multiple services (e.g., ACP agent). + let relay_owner_pubkey = std::env::var("RELAY_OWNER_PUBKEY") + .ok() + .map(|s| s.trim().to_lowercase()) + .filter(|s| !s.is_empty()) + .and_then(|s| { + // Must be exactly 64 lowercase hex characters (32-byte pubkey). + let valid = s.len() == 64 && s.chars().all(|c| c.is_ascii_hexdigit()); + if valid { + Some(s) + } else { + warn!( + "RELAY_OWNER_PUBKEY is not a valid 64-char hex pubkey — ignoring. \ + Got: {s:?}" + ); + None + } + }); + + let auth = sprout_auth::AuthConfig::default(); if !require_auth_token { warn!( @@ -217,6 +283,58 @@ impl Config { ); } + // Git server config + let git_repo_path: std::path::PathBuf = std::env::var("SPROUT_GIT_REPO_PATH") + .unwrap_or_else(|_| "./repos".to_string()) + .into(); + let git_max_pack_bytes: u64 = std::env::var("SPROUT_GIT_MAX_PACK_BYTES") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(500 * 1024 * 1024); // 500 MB + let git_max_repos_per_pubkey: u32 = std::env::var("SPROUT_GIT_MAX_REPOS_PER_PUBKEY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(100); + let git_max_concurrent_ops: usize = std::env::var("SPROUT_GIT_MAX_CONCURRENT_OPS") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(20); + let git_hook_hmac_secret: String = std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET") + .unwrap_or_else(|_| { + // Generate a random secret if not configured (dev mode). + let secret: [u8; 32] = rand::random(); + hex::encode(secret) + }); + // Web UI static file serving + let web_dir = std::env::var("SPROUT_WEB_DIR") + .ok() + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .map(std::path::PathBuf::from); + + if let Some(ref dir) = web_dir { + if !dir.join("index.html").is_file() { + return Err(ConfigError::InvalidValue(format!( + "SPROUT_WEB_DIR={} does not contain index.html", + dir.display() + ))); + } + tracing::info!( + "SPROUT_WEB_DIR={} — serving web UI from relay", + dir.display() + ); + } + + // Reject explicitly-configured secrets that are too short. + // The auto-generated fallback is always 64 hex chars (32 bytes), so this + // only fires when someone sets SPROUT_GIT_HOOK_HMAC_SECRET to a weak value. + if std::env::var("SPROUT_GIT_HOOK_HMAC_SECRET").is_ok() && git_hook_hmac_secret.len() < 32 { + return Err(ConfigError::InvalidValue( + "SPROUT_GIT_HOOK_HMAC_SECRET must be at least 32 characters (16 bytes hex)" + .to_string(), + )); + } + Ok(Self { bind_addr, database_url, @@ -235,8 +353,17 @@ impl Config { health_port, metrics_port, pubkey_allowlist_enabled, + require_relay_membership, + relay_owner_pubkey, + allow_nip_oa_auth, media, ephemeral_ttl_override, + git_repo_path, + git_max_pack_bytes, + git_max_repos_per_pubkey, + git_max_concurrent_ops, + git_hook_hmac_secret, + web_dir, }) } } @@ -263,6 +390,18 @@ mod tests { !config.pubkey_allowlist_enabled, "pubkey_allowlist_enabled should default to false" ); + assert!( + !config.require_relay_membership, + "require_relay_membership should default to false" + ); + assert!( + config.relay_owner_pubkey.is_none(), + "relay_owner_pubkey should default to None" + ); + assert!( + !config.allow_nip_oa_auth, + "allow_nip_oa_auth should default to false" + ); } #[test] diff --git a/crates/sprout-relay/src/connection.rs b/crates/sprout-relay/src/connection.rs index 9aea360877..ff2be97667 100644 --- a/crates/sprout-relay/src/connection.rs +++ b/crates/sprout-relay/src/connection.rs @@ -384,6 +384,23 @@ async fn handle_text_message(text: String, conn: Arc, state: Ar drop(permit); }); } + ClientMessage::Count { sub_id, filters } => { + let conn = Arc::clone(&conn); + let state = Arc::clone(&state); + let permit = match state.handler_semaphore.clone().try_acquire_owned() { + Ok(p) => p, + Err(_) => { + conn.send(RelayMessage::notice( + "rate-limited: too many concurrent requests", + )); + return; + } + }; + tokio::spawn(async move { + handlers::count::handle_count(sub_id, filters, conn, state).await; + drop(permit); + }); + } ClientMessage::Close(sub_id) => { handlers::close::handle_close(sub_id, Arc::clone(&conn), Arc::clone(&state)).await; } diff --git a/crates/sprout-relay/src/handlers/auth.rs b/crates/sprout-relay/src/handlers/auth.rs index 8d15535324..54e559c471 100644 --- a/crates/sprout-relay/src/handlers/auth.rs +++ b/crates/sprout-relay/src/handlers/auth.rs @@ -1,25 +1,45 @@ //! NIP-42 AUTH handler — verify challenge response, transition auth state. +//! +//! Relay membership enforcement uses the shared +//! [`crate::api::relay_members::enforce_relay_membership`] helper, which supports +//! NIP-OA owner-delegation fallback on closed relays. On open relays, the auth +//! handler calls [`crate::api::relay_members::extract_nip_oa_owner`] directly to +//! extract the owner pubkey for agent→owner backfill (observer frame auth). +//! +//! For WebSocket auth, the NIP-OA `auth` tag is extracted from the signed AUTH +//! event itself (the tag is integrity-protected by the event signature). use std::sync::Arc; -use sha2::{Digest, Sha256}; use tracing::{debug, info, warn}; use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -fn verify_api_token_nip42_binding( - event: &nostr::Event, - challenge: &str, - relay_url: &str, -) -> Result<(), sprout_auth::AuthError> { - sprout_auth::verify_nip42_event(event, challenge, relay_url) +/// Extract a NIP-OA `auth` tag from a verified AUTH event and serialize it as +/// the JSON-array string that [`sprout_sdk::nip_oa::verify_auth_tag`] expects. +/// +/// Returns `None` if no `auth` tag is present (direct-member auth path) or if +/// more than one `auth` tag exists (per NIP-OA spec: >1 auth tag ⇒ no valid tag). +pub fn extract_auth_tag_json(event: &nostr::Event) -> Option { + let mut iter = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")); + let first = iter.next()?; + if iter.next().is_some() { + return None; // NIP-OA spec: treat >1 auth tag as no valid auth tag + } + serde_json::to_string(first.as_slice()).ok() } -/// Handle a NIP-42 AUTH message: verify the challenge response and transition the connection to authenticated state. +/// Handle a NIP-42 AUTH message: verify the challenge response and transition +/// the connection to authenticated state. +/// +/// Pure crypto verification — no API tokens, no JWT, no DB token lookups. pub async fn handle_auth(event: nostr::Event, conn: Arc, state: Arc) { - let event_id_hex_early = event.id.to_hex(); + let event_id_hex = event.id.to_hex(); let (challenge, conn_id) = { let auth = conn.auth_state.read().await; match &*auth { @@ -27,7 +47,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: AuthState::Authenticated(_) => { debug!(conn_id = %conn.conn_id, "AUTH received but already authenticated"); conn.send(RelayMessage::ok( - &event_id_hex_early, + &event_id_hex, false, "auth-required: already authenticated", )); @@ -36,7 +56,7 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: AuthState::Failed => { debug!(conn_id = %conn.conn_id, "AUTH received after failed auth"); conn.send(RelayMessage::ok( - &event_id_hex_early, + &event_id_hex, false, "auth-required: authentication already failed", )); @@ -45,164 +65,27 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } }; + // Extract the NIP-OA auth tag before verification consumes the event. + // The tag is integrity-protected by the event's Schnorr signature — if + // tampered, NIP-42 verification will fail before we ever inspect it. + let auth_tag_json = extract_auth_tag_json(&event); + let relay_url = state.config.relay_url.clone(); let auth_svc = Arc::clone(&state.auth); - let event_id_hex = event.id.to_hex(); - // Extract the auth_token tag before dispatching — API tokens (sprout_*) must be - // intercepted here because verify_auth_event() has no DB access and rejects them. - let auth_token = event.tags.iter().find_map(|tag| { - let vec = tag.as_slice(); - if vec.len() >= 2 && vec[0] == "auth_token" { - Some(vec[1].to_string()) - } else { - None - } - }); + metrics::counter!("sprout_auth_attempts_total", "method" => "nip42").increment(1); - metrics::counter!("sprout_auth_attempts_total", "method" => if auth_token.as_ref().is_some_and(|t| t.starts_with("sprout_")) { "api_token" } else { "nip42" }).increment(1); - - if let Some(ref token) = auth_token { - if token.starts_with("sprout_") { - // ── API token path ────────────────────────────────────────────── - let event_clone = event.clone(); - let challenge_owned = challenge.clone(); - let relay_owned = relay_url.clone(); - match tokio::task::spawn_blocking(move || { - verify_api_token_nip42_binding(&event_clone, &challenge_owned, &relay_owned) - }) - .await - { - Ok(Ok(())) => {} - Ok(Err(e)) => { - warn!(conn_id = %conn_id, error = %e, "API token auth failed NIP-42 verification"); - metrics::counter!("sprout_auth_failures_total", "reason" => "nip42_invalid") - .increment(1); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - return; - } - Err(e) => { - warn!(conn_id = %conn_id, error = %e, "API token NIP-42 verification task failed"); - metrics::counter!("sprout_auth_failures_total", "reason" => "nip42_internal") - .increment(1); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - return; - } - } - - // Hash the raw token and look it up in the DB. The relay owns this - // path; sprout-auth has no DB access. - let hash: [u8; 32] = Sha256::digest(token.as_bytes()).into(); - - let record = match state.db.get_api_token_by_hash(&hash).await { - Ok(Some(r)) => r, - Ok(None) => { - warn!(conn_id = %conn_id, "API token not found"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: invalid token", - )); - return; - } - Err(e) => { - warn!(conn_id = %conn_id, error = %e, "API token lookup failed"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - return; - } - }; - - // Reconstruct the owner pubkey from the stored raw bytes. - let owner_pubkey = match nostr::PublicKey::from_slice(&record.owner_pubkey) { - Ok(pk) => pk, - Err(e) => { - warn!(conn_id = %conn_id, error = %e, "API token owner pubkey invalid"); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - return; - } - }; - - // Verify hash, expiry, and pubkey match via the auth service. - match auth_svc.verify_api_token_against_hash( - token, - &record.token_hash, - &owner_pubkey, - &event.pubkey, - record.expires_at, - &record.scopes, - ) { - Ok((pubkey, scopes)) => { - info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "API token auth successful"); - // Update last_used_at asynchronously — non-fatal if it fails. - let db = state.db.clone(); - let hash_owned = hash; - tokio::spawn(async move { - if let Err(e) = db.update_token_last_used(&hash_owned).await { - warn!("update_token_last_used failed: {e}"); - } - }); - let auth_ctx = sprout_auth::AuthContext { - pubkey, - scopes, - channel_ids: record.channel_ids, - auth_method: sprout_auth::AuthMethod::Nip42ApiToken, - }; - // API token users have already proven authorization via their token — - // the pubkey allowlist does not apply here. - *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); - state - .conn_manager - .set_authenticated_pubkey(conn_id, pubkey.serialize().to_vec()); - conn.send(RelayMessage::ok(&event_id_hex, true, "")); - } - Err(e) => { - warn!(conn_id = %conn_id, error = %e, "API token verification failed"); - metrics::counter!("sprout_auth_failures_total", "reason" => "api_token_invalid").increment(1); - *conn.auth_state.write().await = AuthState::Failed; - conn.send(RelayMessage::ok( - &event_id_hex, - false, - "auth-required: verification failed", - )); - } - } - return; - } - } - - // ── Okta JWT / pubkey-only path ───────────────────────────────────────── - // Non-sprout_ tokens (eyJ* JWTs) and no-token (open-relay) fall through here. + // Pure NIP-42 verification — crypto only, no DB lookups. match auth_svc .verify_auth_event(event, &challenge, &relay_url) .await { - Ok(auth_ctx) => { + Ok(mut auth_ctx) => { let pubkey = auth_ctx.pubkey; - // Pubkey allowlist gate — only for pubkey-only auth (no JWT/token). - // Users with valid API tokens or Okta JWTs bypass the allowlist. + + // Pubkey allowlist gate — only for pubkey-only auth. if state.config.pubkey_allowlist_enabled - && auth_ctx.auth_method == sprout_auth::AuthMethod::Nip42PubkeyOnly + && auth_ctx.auth_method == sprout_auth::AuthMethod::Nip42 { let allowed = match state.db.is_pubkey_allowed(&pubkey.serialize()).await { Ok(v) => v, @@ -225,6 +108,112 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: return; } } + + // Relay membership gate — uses the shared helper with NIP-OA fallback. + let nip_oa_owner = match crate::api::relay_members::enforce_relay_membership( + &state, + &pubkey.serialize(), + auth_tag_json.as_deref(), + ) + .await + { + Ok(owner) => owner, + Err(_) => { + warn!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "not a relay member"); + metrics::counter!("sprout_auth_failures_total", "reason" => "not_relay_member") + .increment(1); + *conn.auth_state.write().await = AuthState::Failed; + conn.send(RelayMessage::ok( + &event_id_hex, + false, + "restricted: not a relay member", + )); + return; + } + }; + + // Open relay NIP-OA backfill: extract owner for agent→owner DB mapping + // (needed for observer frame auth). Only runs on open relays — on closed + // relays, enforce_relay_membership already handles NIP-OA delegation. + // No feature flag needed: NIP-OA is cryptographically self-proving. + let nip_oa_owner = nip_oa_owner.or_else(|| { + if !state.config.require_relay_membership && auth_tag_json.is_some() { + crate::api::relay_members::extract_nip_oa_owner( + &pubkey.serialize(), + auth_tag_json.as_deref(), + ) + } else { + None + } + }); + + // Stash NIP-OA owner on the auth context (session-scoped) only if + // the DB confirms this owner relationship (first-write-wins). + if let Some(owner) = nip_oa_owner { + // Ensure both agent and owner have users rows (BYO agents may not, + // and agent_owner_pubkey has a FK constraint to users.pubkey). + if let Err(e) = state.db.ensure_user(&pubkey.serialize()).await { + warn!(conn_id = %conn_id, error = %e, "ensure_user(agent) failed during NIP-OA backfill"); + } + if let Err(e) = state.db.ensure_user(&owner.serialize()).await { + warn!(conn_id = %conn_id, error = %e, "ensure_user(owner) failed during NIP-OA backfill"); + } + + // Idempotent backfill: record agent→owner in DB so cross-connection + // features (observer frames, channel policy) work for BYO agents. + // Returns Ok(true) if written, Ok(false) if already owned by someone else. + match state + .db + .set_agent_owner(&pubkey.serialize(), &owner.serialize()) + .await + { + Ok(true) => { + // Successfully materialized — this owner is authoritative. + auth_ctx.agent_owner_pubkey = Some(owner); + // Pre-warm the observer cache to avoid stale negatives. + let cache_key = (pubkey.serialize().to_vec(), owner.serialize().to_vec()); + state.observer_owner_cache.insert(cache_key, true); + } + Ok(false) => { + // Agent already owned by someone else. Verify if this + // owner matches the existing DB record before trusting it. + match state + .db + .is_agent_owner(&pubkey.serialize(), &owner.serialize()) + .await + { + Ok(true) => { + auth_ctx.agent_owner_pubkey = Some(owner); + } + Ok(false) => { + warn!( + conn_id = %conn_id, + agent = %pubkey.to_hex(), + nip_oa_owner = %owner.to_hex(), + "NIP-OA owner differs from DB owner — session will not get owner fast-path" + ); + } + Err(e) => { + warn!( + conn_id = %conn_id, + error = %e, + "is_agent_owner check failed after set_agent_owner conflict" + ); + } + } + } + Err(e) => { + warn!( + conn_id = %conn_id, + agent = %pubkey.to_hex(), + owner = %owner.to_hex(), + error = %e, + "failed to backfill agent_owner_pubkey" + ); + } + } + } + info!(conn_id = %conn_id, pubkey = %pubkey.to_hex(), "NIP-42 auth successful"); *conn.auth_state.write().await = AuthState::Authenticated(auth_ctx); state @@ -245,50 +234,3 @@ pub async fn handle_auth(event: nostr::Event, conn: Arc, state: } } } - -#[cfg(test)] -mod tests { - use nostr::{Event, EventBuilder, Keys, Tag, Url}; - use sprout_auth::AuthError; - - use super::*; - - const TEST_RELAY: &str = "wss://relay.example.com"; - - fn make_api_token_auth_event( - keys: &Keys, - challenge: &str, - relay_url: &str, - token: &str, - ) -> Event { - let url: Url = relay_url.parse().expect("valid relay url"); - let auth_token = Tag::parse(&["auth_token", token]).expect("valid auth_token tag"); - EventBuilder::auth(challenge, url) - .add_tags(vec![auth_token]) - .sign_with_keys(keys) - .expect("signing failed") - } - - #[test] - fn api_token_auth_still_requires_a_valid_nip42_challenge() { - let keys = Keys::generate(); - let challenge = sprout_auth::generate_challenge(); - let event = - make_api_token_auth_event(&keys, &challenge, TEST_RELAY, "sprout_test_api_token"); - - assert!(matches!( - verify_api_token_nip42_binding(&event, "wrong-challenge", TEST_RELAY), - Err(AuthError::ChallengeMismatch) - )); - } - - #[test] - fn api_token_auth_accepts_a_valid_nip42_proof() { - let keys = Keys::generate(); - let challenge = sprout_auth::generate_challenge(); - let event = - make_api_token_auth_event(&keys, &challenge, TEST_RELAY, "sprout_test_api_token"); - - assert!(verify_api_token_nip42_binding(&event, &challenge, TEST_RELAY).is_ok()); - } -} diff --git a/crates/sprout-relay/src/handlers/command_executor.rs b/crates/sprout-relay/src/handlers/command_executor.rs new file mode 100644 index 0000000000..3254d8b241 --- /dev/null +++ b/crates/sprout-relay/src/handlers/command_executor.rs @@ -0,0 +1,1135 @@ +//! Command executor — transactional event processing for command kinds. +//! +//! Command kinds (41010–41012, 30620, 46020, 46030–46031) are processed +//! transactionally: validate → begin tx → insert event → execute mutations → commit. +//! +//! SECURITY: This module is only reachable AFTER the ingest pipeline has verified: +//! 1. Event signature (verify_event) +//! 2. Timestamp freshness (±15 min) +//! 3. Pubkey/auth identity match +//! 4. Per-kind scope authorization + +use std::sync::Arc; + +use chrono::Utc; +use nostr::Event; +use sha2::{Digest, Sha256}; +use tracing::warn; +use uuid::Uuid; + +use sprout_core::kind::*; +use sprout_db::workflow::{ApprovalStatus, RunStatus}; +use sprout_workflow::executor::TriggerContext; + +use crate::state::AppState; +use crate::webhook_secret; + +use super::ingest::{extract_channel_id, IngestAuth, IngestError, IngestResult}; +use super::side_effects::{ + emit_group_discovery_events, emit_membership_notification, emit_system_message, +}; + +/// Route a command-kind event to the appropriate handler. +pub async fn handle_command( + state: &Arc, + event: Event, + auth: IngestAuth, +) -> Result { + // Ensure the authenticated user exists in the users table (foreign key requirement). + // The old REST handlers did this via extract_auth_context; command executor must do it explicitly. + let pubkey_bytes = auth.pubkey().serialize().to_vec(); + if let Err(e) = state.db.ensure_user(&pubkey_bytes).await { + tracing::warn!("command_executor: ensure_user failed: {e}"); + } + + let kind = event.kind.as_u16() as u32; + match kind { + KIND_DM_OPEN => handle_dm_open(state, &event, &auth).await, + KIND_DM_ADD_MEMBER => handle_dm_add_member(state, &event, &auth).await, + KIND_DM_HIDE => handle_dm_hide(state, &event, &auth).await, + KIND_WORKFLOW_DEF => handle_workflow_def(state, &event, &auth).await, + KIND_WORKFLOW_TRIGGER => handle_workflow_trigger(state, &event, &auth).await, + KIND_APPROVAL_GRANT => handle_approval_grant(state, &event, &auth).await, + KIND_APPROVAL_DENY => handle_approval_deny(state, &event, &auth).await, + _ => Err(IngestError::Rejected(format!( + "unknown command kind: {kind}" + ))), + } +} + +/// Result of persisting a command event: either a duplicate (already processed) +/// or an open transaction that the handler must commit after executing mutations. +enum PersistResult { + /// Event was already processed — return idempotent success. + Duplicate, + /// Event inserted — transaction is open, handler must commit after mutations. + Inserted(sqlx::Transaction<'static, sqlx::Postgres>), +} + +/// Persist a command event inside a transaction. Returns the OPEN transaction +/// as an idempotency guard — if the event was already stored, `Duplicate` is +/// returned and the handler skips execution. +/// +/// If the event is a duplicate (ON CONFLICT DO NOTHING), the transaction is +/// rolled back and `PersistResult::Duplicate` is returned — no mutations needed. +/// +/// NOTE: Domain mutations (open_dm, create_workflow, etc.) execute on the +/// connection pool, NOT inside this transaction. The pattern is idempotent but +/// not strictly atomic: if a mutation succeeds but commit fails, the mutation +/// persists without the event record. On retry, the event INSERT succeeds +/// (no conflict), and the mutation re-executes — which is safe for idempotent +/// operations (open_dm, hide_dm, update_approval) but may create duplicates +/// for non-idempotent ones (create_workflow). This is acceptable for the +/// current command set where create_workflow uses a client-generated d-tag +/// as the natural dedup key. +async fn persist_command_event( + state: &Arc, + event: &Event, +) -> Result { + let channel_id = extract_channel_id(event); + + let mut tx = state + .db + .begin_transaction() + .await + .map_err(|e| IngestError::Internal(format!("error: begin transaction: {e}")))?; + + // INSERT with ON CONFLICT DO NOTHING — idempotency guard. + let id_bytes = event.id.as_bytes(); + let pubkey_bytes = event.pubkey.to_bytes(); + let sig_bytes = event.sig.serialize(); + let tags_json = serde_json::to_value(&event.tags) + .map_err(|e| IngestError::Internal(format!("error: serialize tags: {e}")))?; + let kind_i32 = event.kind.as_u16() as i32; + let created_at_secs = event.created_at.as_u64() as i64; + let created_at = chrono::DateTime::from_timestamp(created_at_secs, 0).ok_or_else(|| { + IngestError::Rejected(format!("invalid: bad timestamp {created_at_secs}")) + })?; + let received_at = chrono::Utc::now(); + + // Extract d_tag for parameterized replaceable kinds (NIP-33) + let d_tag: Option = if is_parameterized_replaceable(event.kind.as_u16() as u32) { + event.tags.iter().find_map(|t| { + if t.kind().to_string() == "d" { + t.content().map(|s| s.to_string()) + } else { + None + } + }) + } else { + None + }; + + let result = sqlx::query( + r#" + INSERT INTO events (id, pubkey, created_at, kind, tags, content, sig, received_at, channel_id, d_tag) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) + ON CONFLICT DO NOTHING + "#, + ) + .bind(id_bytes.as_slice()) + .bind(pubkey_bytes.as_slice()) + .bind(created_at) + .bind(kind_i32) + .bind(&tags_json) + .bind(&event.content) + .bind(sig_bytes.as_slice()) + .bind(received_at) + .bind(channel_id) + .bind(d_tag.as_deref()) + .execute(tx.as_mut()) + .await + .map_err(|e| IngestError::Internal(format!("error: insert event: {e}")))?; + + if result.rows_affected() == 0 { + // Duplicate — rollback (implicit on drop) and signal idempotent success. + Ok(PersistResult::Duplicate) + } else { + Ok(PersistResult::Inserted(tx)) + } +} + +// ── Tag extraction helpers ─────────────────────────────────────────────────── + +/// Extract all `p` tag values (hex pubkeys) from an event. +fn extract_p_tags(event: &Event) -> Vec { + event + .tags + .iter() + .filter_map(|t| { + if t.kind().to_string() == "p" { + t.content().map(|s| s.to_string()) + } else { + None + } + }) + .collect() +} + +/// Extract the first `h` tag value (channel UUID) from an event. +fn extract_h_tag(event: &Event) -> Option { + event.tags.iter().find_map(|t| { + if t.kind().to_string() == "h" { + t.content().map(|s| s.to_string()) + } else { + None + } + }) +} + +/// Extract the first `d` tag value from an event. +fn extract_d_tag(event: &Event) -> Option { + event.tags.iter().find_map(|t| { + if t.kind().to_string() == "d" { + t.content().map(|s| s.to_string()) + } else { + None + } + }) +} + +/// Extract the first `e` tag value from an event. +fn extract_e_tag(event: &Event) -> Option { + event.tags.iter().find_map(|t| { + if t.kind().to_string() == "e" { + t.content().map(|s| s.to_string()) + } else { + None + } + }) +} + +/// Extract a tag value by name. +fn extract_tag(event: &Event, tag_name: &str) -> Option { + event.tags.iter().find_map(|t| { + if t.kind().to_string() == tag_name { + t.content().map(|s| s.to_string()) + } else { + None + } + }) +} + +/// Decode a hex pubkey string to 32 bytes. +fn decode_pubkey(hex_str: &str) -> Result, IngestError> { + let bytes = hex::decode(hex_str) + .map_err(|_| IngestError::Rejected(format!("invalid: bad pubkey hex: {hex_str}")))?; + if bytes.len() != 32 { + return Err(IngestError::Rejected(format!( + "invalid: pubkey must be 32 bytes: {hex_str}" + ))); + } + Ok(bytes) +} + +/// Compute SHA-256 hash of a string, returning raw bytes. +fn compute_definition_hash(json_str: &str) -> Vec { + Sha256::digest(json_str.as_bytes()).to_vec() +} + +// ── DM commands (41010–41012) ──────────────────────────────────────────────── + +async fn handle_dm_open( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + let self_hex = hex::encode(&self_bytes); + + // 1. Extract participant pubkeys from `p` tags + let p_tags = extract_p_tags(event); + + // 2. Validate: at least 1 other participant, max 8 others (9 total) + if p_tags.is_empty() { + return Err(IngestError::Rejected( + "invalid: pubkeys must contain at least 1 other participant".into(), + )); + } + if p_tags.len() > 8 { + return Err(IngestError::Rejected( + "invalid: pubkeys may contain at most 8 other participants (9 total)".into(), + )); + } + + // Decode all provided pubkeys + let mut other_bytes: Vec> = Vec::with_capacity(p_tags.len()); + for hex_str in &p_tags { + other_bytes.push(decode_pubkey(hex_str)?); + } + + // 3. Build full participant set (self + others, deduplicated) + let mut all_bytes: Vec> = vec![self_bytes.clone()]; + for ob in &other_bytes { + if !all_bytes.iter().any(|b| b == ob) { + all_bytes.push(ob.clone()); + } + } + + // Persist the command event (idempotency) — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 4. Execute: open_dm + let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); + let (channel, was_created) = state + .db + .open_dm(&all_refs, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; + + // Commit: event + mutation succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 5. Side effects if newly created (post-commit, best-effort) + if was_created { + // Invalidate caches for all participants + for pk in &all_bytes { + state.invalidate_membership(channel.id, pk); + } + + let participant_hexes: Vec = all_bytes.iter().map(hex::encode).collect(); + if let Err(e) = emit_system_message( + state, + channel.id, + serde_json::json!({ + "type": "dm_created", + "actor": self_hex, + "participants": participant_hexes, + }), + ) + .await + { + warn!("DM open: system message failed: {e}"); + } + + if let Err(e) = emit_group_discovery_events(state, channel.id).await { + warn!(channel = %channel.id, "DM open: discovery emission failed: {e}"); + } + + for participant in &all_bytes { + if let Err(e) = emit_membership_notification( + state, + channel.id, + participant, + &self_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!("DM open: membership notification failed: {e}"); + } + } + } + + // 6. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!( + "response:{}", + serde_json::json!({ + "channel_id": channel.id.to_string(), + "created": was_created, + }) + ), + }) +} + +async fn handle_dm_add_member( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + + // 1. Extract target channel from `h` tag, new member pubkeys from `p` tags + let channel_id_str = extract_h_tag(event) + .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; + let channel_id = Uuid::parse_str(&channel_id_str) + .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; + + let p_tags = extract_p_tags(event); + if p_tags.is_empty() { + return Err(IngestError::Rejected( + "invalid: must specify at least 1 new participant in p tags".into(), + )); + } + + // 2. Validate caller is member of existing DM + let is_member = state + .is_member_cached(channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; + if !is_member { + return Err(IngestError::Rejected( + "forbidden: not a member of this DM".into(), + )); + } + + // 3. Validate channel is type "dm" + let existing_channel = state + .db + .get_channel(channel_id) + .await + .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; + if existing_channel.channel_type != "dm" { + return Err(IngestError::Rejected("invalid: channel is not a DM".into())); + } + + // 4. Get existing members, merge with new + let existing_members = state + .db + .get_members(channel_id) + .await + .map_err(|e| IngestError::Internal(format!("error: get members: {e}")))?; + + let mut all_bytes: Vec> = existing_members.into_iter().map(|m| m.pubkey).collect(); + + // Decode and merge new pubkeys + for hex_str in &p_tags { + let bytes = decode_pubkey(hex_str)?; + if !all_bytes.iter().any(|b| b == &bytes) { + all_bytes.push(bytes); + } + } + + // 5. Enforce max 9 participants + if all_bytes.len() > 9 { + return Err(IngestError::Rejected( + "invalid: DM supports at most 9 participants".into(), + )); + } + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 6. Execute: open_dm with expanded set (creates NEW DM — DM sets are immutable) + let all_refs: Vec<&[u8]> = all_bytes.iter().map(|b| b.as_slice()).collect(); + let (new_channel, was_created) = state + .db + .open_dm(&all_refs, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: db open_dm: {e}")))?; + + // Commit: event + mutation succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 7. Cache invalidation + notifications for new DM (post-commit, best-effort) + if was_created { + for pk in &all_bytes { + state.invalidate_membership(new_channel.id, pk); + } + + if let Err(e) = emit_group_discovery_events(state, new_channel.id).await { + warn!(channel = %new_channel.id, "DM add_member: discovery emission failed: {e}"); + } + + for participant_bytes in &all_bytes { + if let Err(e) = emit_membership_notification( + state, + new_channel.id, + participant_bytes, + &self_bytes, + KIND_MEMBER_ADDED_NOTIFICATION, + ) + .await + { + warn!("DM add_member: membership notification failed: {e}"); + } + } + } + + // 8. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!( + "response:{}", + serde_json::json!({ + "channel_id": new_channel.id.to_string(), + }) + ), + }) +} + +async fn handle_dm_hide( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + + // 1. Extract channel from `h` tag + let channel_id_str = extract_h_tag(event) + .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; + let channel_id = Uuid::parse_str(&channel_id_str) + .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; + + // 2. Validate caller is member of the DM + let is_member = state + .is_member_cached(channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; + if !is_member { + return Err(IngestError::Rejected( + "forbidden: not a member of this DM".into(), + )); + } + + // 3. Validate channel is type "dm" + let channel = state + .db + .get_channel(channel_id) + .await + .map_err(|_| IngestError::Rejected("invalid: DM not found".into()))?; + if channel.channel_type != "dm" { + return Err(IngestError::Rejected("invalid: channel is not a DM".into())); + } + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 4. Execute: hide_dm + state + .db + .hide_dm(channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: db hide_dm: {e}")))?; + + // Commit: event + mutation succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 5. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "{}".into(), + }) +} + +// ── Workflow commands ───────────────────────────────────────────────────────── + +async fn handle_workflow_def( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + + // 1. Extract channel from `h` tag, workflow name from `name` tag or d-tag + let channel_id_str = extract_h_tag(event) + .ok_or_else(|| IngestError::Rejected("invalid: missing h tag (channel_id)".into()))?; + let channel_id = Uuid::parse_str(&channel_id_str) + .map_err(|_| IngestError::Rejected("invalid: bad channel_id format".into()))?; + + let workflow_name = extract_tag(event, "name") + .or_else(|| extract_d_tag(event)) + .ok_or_else(|| { + IngestError::Rejected("invalid: missing workflow name (name or d tag)".into()) + })?; + + // 2. Validate caller has channel access (minimum: is a member) + let is_member = state + .is_member_cached(channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; + if !is_member { + return Err(IngestError::Rejected( + "forbidden: not a member of this channel".into(), + )); + } + + // 3. Parse YAML from event.content + let (def, definition_json_str) = sprout_workflow::WorkflowEngine::parse_yaml(&event.content) + .map_err(|e| IngestError::Rejected(format!("invalid: workflow YAML parse error: {e}")))?; + + let mut definition_json: serde_json::Value = serde_json::from_str(&definition_json_str) + .map_err(|e| IngestError::Internal(format!("error: json parse of definition: {e}")))?; + + // Generate webhook secret if this workflow uses a Webhook trigger + let webhook_secret = if matches!(def.trigger, sprout_workflow::TriggerDef::Webhook) { + let secret = webhook_secret::generate_webhook_secret(); + webhook_secret::inject_secret(&mut definition_json, &secret); + Some(secret) + } else { + None + }; + + // Compute hash AFTER secret injection + let definition_json_final = serde_json::to_string(&definition_json) + .map_err(|e| IngestError::Internal(format!("error: json serialize: {e}")))?; + let hash = compute_definition_hash(&definition_json_final); + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 4. Execute: create_workflow + let workflow_id = state + .db + .create_workflow( + Some(channel_id), + &self_bytes, + &workflow_name, + &definition_json_final, + &hash, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: db create_workflow: {e}")))?; + + // Commit: event + workflow creation succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 5. Return response + let mut resp = serde_json::json!({ + "workflow_id": workflow_id.to_string(), + }); + if let Some(secret) = webhook_secret { + resp["webhook_secret"] = serde_json::Value::String(secret); + } + + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!("response:{}", resp), + }) +} + +async fn handle_workflow_trigger( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + + // 1. Extract workflow reference from `d` tag or `e` tag + let workflow_id_str = extract_d_tag(event) + .or_else(|| extract_e_tag(event)) + .ok_or_else(|| { + IngestError::Rejected("invalid: missing workflow reference (d or e tag)".into()) + })?; + let workflow_id = Uuid::parse_str(&workflow_id_str) + .map_err(|_| IngestError::Rejected("invalid: bad workflow_id format".into()))?; + + // 2. Validate workflow exists + let workflow = state + .db + .get_workflow(workflow_id) + .await + .map_err(|_| IngestError::Rejected("invalid: workflow not found".into()))?; + + // 3. Validate caller has channel access (if workflow is channel-scoped) + if let Some(channel_id) = workflow.channel_id { + let is_member = state + .is_member_cached(channel_id, &self_bytes) + .await + .map_err(|e| IngestError::Internal(format!("error: membership check: {e}")))?; + if !is_member { + return Err(IngestError::Rejected( + "forbidden: not a member of the workflow's channel".into(), + )); + } + } else if workflow.owner_pubkey != self_bytes { + return Err(IngestError::Rejected( + "forbidden: not authorized to trigger this workflow".into(), + )); + } + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 4. Execute: create workflow run + let trigger_ctx = TriggerContext { + channel_id: workflow + .channel_id + .map(|id| id.to_string()) + .unwrap_or_default(), + author: hex::encode(&self_bytes), + ..Default::default() + }; + let trigger_ctx_json = serde_json::to_value(&trigger_ctx).ok(); + + let event_id_bytes = event.id.as_bytes().to_vec(); + let run_id = state + .db + .create_workflow_run( + workflow_id, + Some(&event_id_bytes), + trigger_ctx_json.as_ref(), + ) + .await + .map_err(|e| IngestError::Internal(format!("error: db create_workflow_run: {e}")))?; + + // Commit: event + run creation succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 5. Spawn workflow execution + let engine = Arc::clone(&state.workflow_engine); + let db = state.db.clone(); + let def_value = workflow.definition.clone(); + let trigger_ctx_clone = trigger_ctx.clone(); + tokio::spawn(async move { + let def: sprout_workflow::WorkflowDef = match serde_json::from_value(def_value) { + Ok(d) => d, + Err(e) => { + tracing::error!("workflow_trigger: failed to parse definition: {e}"); + if let Err(db_err) = db + .update_workflow_run( + run_id, + RunStatus::Failed, + 0, + &serde_json::json!([]), + Some(&format!("definition parse error: {e}")), + ) + .await + { + tracing::error!("workflow_trigger: failed to mark run as failed: {db_err}"); + } + return; + } + }; + + let result = sprout_workflow::executor::execute_from_step( + &engine, + run_id, + &def, + &trigger_ctx_clone, + 0, + None, + ) + .await; + engine.finalize_run(run_id, result, None).await; + }); + + // 6. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!( + "response:{}", + serde_json::json!({ + "run_id": run_id.to_string(), + }) + ), + }) +} + +// ── Approval commands ──────────────────────────────────────────────────────── + +/// Enforce the approver_spec field against the requesting pubkey. +/// +/// Accepted specs: +/// - `""` or `"any"` — any authenticated user may approve. +/// - 64-char lowercase hex string — only that exact pubkey may approve. +/// +/// All other formats are rejected (fail-closed). +fn check_approver_spec(approver_spec: &str, requester_hex: &str) -> Result<(), IngestError> { + let spec = approver_spec.trim(); + + // Empty or "any" — anyone may approve + if spec.is_empty() || spec == "any" { + return Ok(()); + } + + // Exact pubkey match (64-char hex, case-insensitive) + if spec.len() == 64 && spec.chars().all(|c| c.is_ascii_hexdigit()) { + if requester_hex.to_lowercase() == spec.to_lowercase() { + return Ok(()); + } + return Err(IngestError::Rejected( + "forbidden: not the designated approver for this request".into(), + )); + } + + // Role-based or unrecognised — fail closed + Err(IngestError::Rejected(format!( + "forbidden: approver spec '{}' is not yet supported", + spec + ))) +} + +async fn handle_approval_grant( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + let self_hex = hex::encode(&self_bytes); + + // 1. Extract approval reference from `e` tag (references the approval-requested event) + // or `d` tag (contains the token hash hex) + let token_hash_hex = extract_d_tag(event) + .or_else(|| extract_e_tag(event)) + .ok_or_else(|| { + IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) + })?; + + let token_hash = hex::decode(&token_hash_hex) + .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; + + // 2. Look up the approval record + let approval = state + .db + .get_approval_by_stored_hash(&token_hash) + .await + .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; + + // 3. Validate approval is pending and not expired + if approval.status != ApprovalStatus::Pending { + return Err(IngestError::Rejected(format!( + "invalid: approval already {}", + approval.status + ))); + } + if Utc::now() > approval.expires_at { + return Err(IngestError::Rejected( + "invalid: approval token has expired".into(), + )); + } + + // 4. Validate caller is authorized approver + check_approver_spec(&approval.approver_spec, &self_hex)?; + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 5. Execute: update approval status to granted + let note = if event.content.is_empty() { + None + } else { + Some(event.content.as_str()) + }; + + let updated = state + .db + .update_approval_by_stored_hash( + &token_hash, + ApprovalStatus::Granted, + Some(&self_bytes), + note, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; + + if !updated { + return Err(IngestError::Rejected( + "invalid: approval already acted on (race)".into(), + )); + } + + // Commit: event + approval update succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 6. Resume workflow execution (post-commit, async) + let run_id = approval.run_id; + let workflow_id = approval.workflow_id; + let resume_index = approval.step_index as usize + 1; + let engine = Arc::clone(&state.workflow_engine); + let db = state.db.clone(); + + tokio::spawn(async move { + resume_workflow_after_approval(engine, db, run_id, workflow_id, resume_index).await; + }); + + // 7. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!( + "response:{}", + serde_json::json!({ + "status": "granted", + "run_id": run_id.to_string(), + }) + ), + }) +} + +async fn handle_approval_deny( + state: &Arc, + event: &Event, + auth: &IngestAuth, +) -> Result { + let self_bytes = auth.pubkey().to_bytes().to_vec(); + let self_hex = hex::encode(&self_bytes); + + // 1. Extract approval reference + let token_hash_hex = extract_d_tag(event) + .or_else(|| extract_e_tag(event)) + .ok_or_else(|| { + IngestError::Rejected("invalid: missing approval reference (d or e tag)".into()) + })?; + + let token_hash = hex::decode(&token_hash_hex) + .map_err(|_| IngestError::Rejected("invalid: bad approval token hash hex".into()))?; + + // 2. Look up the approval record + let approval = state + .db + .get_approval_by_stored_hash(&token_hash) + .await + .map_err(|_| IngestError::Rejected("invalid: approval not found".into()))?; + + // 3. Validate approval is pending and not expired + if approval.status != ApprovalStatus::Pending { + return Err(IngestError::Rejected(format!( + "invalid: approval already {}", + approval.status + ))); + } + if Utc::now() > approval.expires_at { + return Err(IngestError::Rejected( + "invalid: approval token has expired".into(), + )); + } + + // 4. Validate caller is authorized approver + check_approver_spec(&approval.approver_spec, &self_hex)?; + + // Persist the command event — returns open transaction + let tx = match persist_command_event(state, event).await? { + PersistResult::Duplicate => { + return Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: "duplicate: already processed".into(), + }); + } + PersistResult::Inserted(tx) => tx, + }; + + // 5. Execute: update approval status to denied + let note = if event.content.is_empty() { + None + } else { + Some(event.content.as_str()) + }; + + let updated = state + .db + .update_approval_by_stored_hash( + &token_hash, + ApprovalStatus::Denied, + Some(&self_bytes), + note, + ) + .await + .map_err(|e| IngestError::Internal(format!("error: db update_approval: {e}")))?; + + if !updated { + return Err(IngestError::Rejected( + "invalid: approval already acted on (race)".into(), + )); + } + + // Commit: event + approval denial succeeded atomically. + tx.commit() + .await + .map_err(|e| IngestError::Internal(format!("error: commit transaction: {e}")))?; + + // 6. Cancel the workflow run (post-commit, async) + let run_id = approval.run_id; + let pubkey_hex = self_hex.clone(); + let db = state.db.clone(); + + tokio::spawn(async move { + let run = match db.get_workflow_run(run_id).await { + Ok(r) => r, + Err(e) => { + tracing::error!("approval_deny: failed to fetch run {run_id}: {e}"); + return; + } + }; + + if run.status != RunStatus::WaitingApproval { + tracing::warn!( + "approval_deny: run {run_id} has status '{}', expected 'waiting_approval'", + run.status + ); + return; + } + + let cancel_msg = format!("workflow cancelled: approval denied by {pubkey_hex}"); + if let Err(e) = db + .update_workflow_run( + run_id, + RunStatus::Cancelled, + run.current_step, + &run.execution_trace, + Some(&cancel_msg), + ) + .await + { + tracing::error!("approval_deny: failed to cancel run {run_id}: {e}"); + } + }); + + // 7. Return response + Ok(IngestResult { + event_id: event.id.to_hex(), + accepted: true, + message: format!( + "response:{}", + serde_json::json!({ + "status": "denied", + "run_id": run_id.to_string(), + }) + ), + }) +} + +// ── Approval resume helper ─────────────────────────────────────────────────── + +/// Resume a suspended workflow run after an approval gate has been granted. +async fn resume_workflow_after_approval( + engine: Arc, + db: sprout_db::Db, + run_id: Uuid, + workflow_id: Uuid, + resume_index: usize, +) { + let run = match db.get_workflow_run(run_id).await { + Ok(r) => r, + Err(e) => { + tracing::error!("resume_workflow: failed to fetch run {run_id}: {e}"); + return; + } + }; + + // Guard: only resume runs that are actually waiting for approval + if run.status != RunStatus::WaitingApproval { + tracing::warn!( + "resume_workflow: run {run_id} has status '{}', expected 'waiting_approval'", + run.status + ); + return; + } + + let workflow = match db.get_workflow(workflow_id).await { + Ok(w) => w, + Err(e) => { + tracing::error!("resume_workflow: failed to fetch workflow {workflow_id}: {e}"); + return; + } + }; + + let def: sprout_workflow::WorkflowDef = + match serde_json::from_value(workflow.definition.clone()) { + Ok(d) => d, + Err(e) => { + tracing::error!("resume_workflow: failed to parse workflow definition: {e}"); + if let Err(db_err) = db + .update_workflow_run( + run_id, + RunStatus::Failed, + run.current_step, + &run.execution_trace, + Some(&format!("definition parse error: {e}")), + ) + .await + { + tracing::error!("resume_workflow: failed to mark run as failed: {db_err}"); + } + return; + } + }; + + // Reconstruct step_outputs from execution trace for template resolution + let mut initial_outputs: std::collections::HashMap = + std::collections::HashMap::new(); + if let Some(trace_arr) = run.execution_trace.as_array() { + for entry in trace_arr { + if let (Some(step_id), Some(output)) = ( + entry.get("step_id").and_then(|v| v.as_str()), + entry.get("output"), + ) { + initial_outputs.insert(step_id.to_string(), output.clone()); + } + } + } + + // Restore trigger context for {{trigger.*}} templates + let trigger_ctx: TriggerContext = run + .trigger_context + .as_ref() + .and_then(|v| serde_json::from_value(v.clone()).ok()) + .unwrap_or_default(); + + // Execute remaining steps + let existing_trace = run.execution_trace.as_array().cloned(); + let result = sprout_workflow::executor::execute_from_step( + &engine, + run_id, + &def, + &trigger_ctx, + resume_index, + Some(initial_outputs), + ) + .await; + engine.finalize_run(run_id, result, existing_trace).await; +} diff --git a/crates/sprout-relay/src/handlers/count.rs b/crates/sprout-relay/src/handlers/count.rs new file mode 100644 index 0000000000..f7be69f4c4 --- /dev/null +++ b/crates/sprout-relay/src/handlers/count.rs @@ -0,0 +1,149 @@ +//! NIP-45 COUNT handler — aggregate queries with channel access enforcement. + +use std::sync::Arc; + +use nostr::Filter; +use tracing::warn; + +use crate::connection::{AuthState, ConnectionState}; +use crate::protocol::RelayMessage; +use crate::state::AppState; + +/// Extract a channel UUID from a single filter's `#h` tag. +fn extract_channel_from_filter(filter: &Filter) -> Option { + let h_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::H); + filter.generic_tags.get(&h_tag).and_then(|vs| { + if vs.len() == 1 { + vs.iter().next()?.parse::().ok() + } else { + None + } + }) +} + +/// Handle a COUNT message: require auth, enforce channel access, execute filters, +/// return aggregate count. +pub async fn handle_count( + sub_id: String, + filters: Vec, + conn: Arc, + state: Arc, +) { + // Require auth + let pubkey_bytes = { + let auth = conn.auth_state.read().await; + match &*auth { + AuthState::Authenticated(ctx) => ctx.pubkey.serialize().to_vec(), + _ => { + conn.send(RelayMessage::closed( + &sub_id, + "auth-required: not authenticated", + )); + return; + } + } + }; + + // P-gated kinds (gift wraps, member notifications, observer frames) require + // the caller's own pubkey in the #p tag — same enforcement as WS REQ handler. + let authed_pubkey_hex = hex::encode(&pubkey_bytes); + if !super::req::p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: p-gated kinds require #p tag matching your pubkey", + )); + return; + } + + // Get channels this user can access — same enforcement as WS REQ handler. + let accessible_channels = match state.get_accessible_channel_ids_cached(&pubkey_bytes).await { + Ok(ids) => ids, + Err(e) => { + warn!(sub_id = %sub_id, "Failed to get accessible channels: {e}"); + conn.send(RelayMessage::closed(&sub_id, "error: database error")); + return; + } + }; + + // For each filter, count matching events with channel access enforcement. + let mut total: u64 = 0; + for filter in &filters { + if let Some(ch_id) = extract_channel_from_filter(filter) { + // Filter targets a specific channel — verify access. + if !accessible_channels.contains(&ch_id) { + continue; // Skip filters targeting inaccessible channels. + } + // Channel is accessible — count with pushability check. + let query = + super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await; + if super::req::filter_fully_pushable(filter) { + match state.db.count_events(&query).await { + Ok(n) => total += n as u64, + Err(e) => { + conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + return; + } + } + } else { + // Fallback: query + post-filter for non-pushable constraints. + let mut q = query; + q.limit = Some(100_000); + q.max_limit = Some(100_000); + match state.db.query_events(&q).await { + Ok(stored_events) => { + for se in stored_events { + if sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) + { + total += 1; + } + } + } + Err(e) => { + conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + return; + } + } + } + } else { + // No channel filter — use SQL-level channel_ids pushdown to count + // only events in accessible channels (+ global events). + // + // If the filter has generic tags beyond what SQL can push down + // (#h, #p single, #d single, #e), we must fall back to + // query + post-filter to avoid overcounting. + let mut query = + super::req::build_event_query_from_filter(filter, &pubkey_bytes, &state).await; + query.channel_ids = Some(accessible_channels.to_vec()); + + if super::req::filter_fully_pushable(filter) { + query.limit = None; // COUNT doesn't need a row limit + match state.db.count_events(&query).await { + Ok(n) => total += n as u64, + Err(e) => { + conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + return; + } + } + } else { + // Fallback: query with high limit + post-filter for correctness. + query.limit = Some(100_000); + query.max_limit = Some(100_000); + match state.db.query_events(&query).await { + Ok(stored_events) => { + for se in stored_events { + if sprout_core::filter::filters_match(std::slice::from_ref(filter), &se) + { + total += 1; + } + } + } + Err(e) => { + conn.send(RelayMessage::closed(&sub_id, &format!("error: {e}"))); + return; + } + } + } + } + } + conn.send(RelayMessage::count(&sub_id, total)); +} diff --git a/crates/sprout-relay/src/handlers/event.rs b/crates/sprout-relay/src/handlers/event.rs index 7725269d62..e5bb83df53 100644 --- a/crates/sprout-relay/src/handlers/event.rs +++ b/crates/sprout-relay/src/handlers/event.rs @@ -4,9 +4,15 @@ use std::sync::Arc; use tracing::{debug, error, info, warn}; -use nostr::Event; +use nostr::{Event, PublicKey}; use sprout_core::event::StoredEvent; -use sprout_core::kind::{event_kind_u32, is_ephemeral, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE}; +use sprout_core::kind::{ + event_kind_u32, is_ephemeral, KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_PRESENCE_UPDATE, +}; +use sprout_core::observer::{ + content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, + OBSERVER_FRAME_TELEMETRY, +}; use sprout_core::verification::verify_event; use crate::connection::{AuthState, ConnectionState}; @@ -24,9 +30,9 @@ fn reject(reason: &'static str) { fn bounded_kind_label(kind: u32) -> String { match kind { 0..=9 | 1059 | 1063 => kind.to_string(), - 9000..=9022 | 9100 | 9110 | 9900 => kind.to_string(), + 9000..=9022 | 9030..=9032 | 9100 | 9110 | 9900 => kind.to_string(), 20000..=29999 => kind.to_string(), - 30023 | 39000..=39003 => kind.to_string(), + 30023 | 30315 | 39000..=39003 => kind.to_string(), 40002..=40100 => kind.to_string(), 41001..=41003 => kind.to_string(), 42001..=42003 => kind.to_string(), @@ -126,6 +132,7 @@ pub(crate) async fn dispatch_persistent_event( .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("sprout:workflow")); if !sprout_core::kind::is_workflow_execution_kind(kind_u32) + && !sprout_core::kind::is_command_kind(kind_u32) && !is_relay_workflow_msg && kind_u32 != KIND_GIFT_WRAP { @@ -207,6 +214,24 @@ pub async fn handle_event(event: Event, conn: Arc, state: Arc 128 { + // Accept both bare strings ("online") and legacy JSON ({"status":"online"}). + let raw = event.content.to_string(); + let status = if raw.starts_with('{') { + serde_json::from_str::(&raw) + .ok() + .and_then(|v| v.get("status")?.as_str().map(String::from)) + .unwrap_or(raw) + } else if raw.len() > 128 { let mut end = 128; - while !status.is_char_boundary(end) { + while !raw.is_char_boundary(end) { end -= 1; } - status[..end].to_string() + raw[..end].to_string() } else { - status + raw }; if status == "offline" { @@ -435,11 +466,277 @@ async fn handle_ephemeral_event( conn.send(RelayMessage::ok(event_id_hex, true, "")); } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum AgentObserverDirection { + Telemetry, + Control, +} + +#[derive(Debug, Clone, Copy)] +struct AgentObserverRoute { + agent: PublicKey, + owner: PublicKey, + direction: AgentObserverDirection, +} + +/// Handle encrypted agent observer frames (kind 24200). +/// +/// These frames bypass storage and are routed as global ephemeral events. The +/// relay gates publication by the existing `agent_owner_pubkey` mapping and +/// gates subscription in the REQ handler via the cleartext `p` tag. +async fn handle_agent_observer_event( + event: Event, + conn_id: uuid::Uuid, + event_id_hex: &str, + conn: Arc, + state: Arc, +) { + let event_clone = event.clone(); + let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; + match verify_result { + Ok(Ok(())) => {} + Ok(Err(e)) => { + conn.send(RelayMessage::ok( + event_id_hex, + false, + &format!("invalid: {e}"), + )); + return; + } + Err(_) => { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal error", + )); + return; + } + } + + // Freshness check: reject observer frames with stale/future timestamps + let now = chrono::Utc::now().timestamp(); + let event_ts = event.created_at.as_u64() as i64; + if (event_ts - now).unsigned_abs() > 300 { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "invalid: observer frame timestamp outside ±5 minute freshness window", + )); + return; + } + + let route = match agent_observer_route(&event) { + Ok(Some(route)) => route, + Ok(None) => { + // Unknown frame value — silently drop, no error to publisher. + conn.send(RelayMessage::ok(event_id_hex, true, "")); + return; + } + Err(message) => { + reject("invalid"); + conn.send(RelayMessage::ok(event_id_hex, false, &message)); + return; + } + }; + + // Fast path: if this connection authenticated via NIP-OA and the verified + // owner matches the observer frame's target owner, skip the DB lookup entirely. + let session_owner_match = { + let auth = conn.auth_state.read().await; + if let crate::connection::AuthState::Authenticated(ctx) = &*auth { + ctx.agent_owner_pubkey.as_ref() == Some(&route.owner) + } else { + false + } + }; + + let agent_bytes = route.agent.serialize().to_vec(); + let owner_bytes = route.owner.serialize().to_vec(); + let cache_key = (agent_bytes.clone(), owner_bytes.clone()); + let is_owner = if session_owner_match { + true + } else { + match state.observer_owner_cache.get(&cache_key) { + Some(cached) => cached, + None => { + let result = state.db.is_agent_owner(&agent_bytes, &owner_bytes).await; + match result { + Ok(v) => { + state.observer_owner_cache.insert(cache_key, v); + v + } + Err(e) => { + warn!(conn_id = %conn_id, event_id = %event_id_hex, "agent observer owner check failed: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + } + } + } + }; + if !is_owner { + reject("auth"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "restricted: observer frame is not authorized for this agent owner", + )); + return; + } + + // Rate limit telemetry frames only (100/sec per agent). + // Control frames (owner → agent) bypass the limiter — they are rare and must not + // be starved by bursty telemetry from the agent. + if matches!(route.direction, AgentObserverDirection::Telemetry) { + let agent_key: [u8; 32] = agent_bytes.as_slice().try_into().unwrap_or([0u8; 32]); + let now = std::time::Instant::now(); + let mut entry = state + .observer_rate_limiter + .entry(agent_key) + .or_insert((0, now)); + let (count, window_start) = entry.value_mut(); + if now.duration_since(*window_start).as_secs() >= 1 { + *count = 1; + *window_start = now; + } else { + *count += 1; + if *count > 100 { + conn.send(RelayMessage::ok( + event_id_hex, + false, + "rate-limited: observer frame rate exceeded (100/sec per agent)", + )); + return; + } + } + } + + let event_json = match serde_json::to_string(&event) { + Ok(json) => json, + Err(e) => { + error!(event_id = %event_id_hex, "Failed to serialize agent observer event: {e}"); + conn.send(RelayMessage::ok( + event_id_hex, + false, + "error: internal server error", + )); + return; + } + }; + + state.mark_local_event(&event.id); + if let Err(e) = state.pubsub.publish_event(uuid::Uuid::nil(), &event).await { + state.local_event_ids.invalidate(&event.id.to_bytes()); + warn!(conn_id = %conn_id, event_id = %event_id_hex, "Agent observer publish failed: {e}"); + } + + let stored_event = StoredEvent::new(event.clone(), None); + let matches = state.sub_registry.fan_out(&stored_event); + metrics::histogram!("sprout_fanout_recipients").record(matches.len() as f64); + debug!( + event_id = %event_id_hex, + agent = %route.agent.to_hex(), + owner = %route.owner.to_hex(), + direction = ?route.direction, + match_count = matches.len(), + "Agent observer fan-out" + ); + + let mut drop_count = 0u32; + for (target_conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + if !state.conn_manager.send_to(*target_conn_id, msg) { + drop_count += 1; + } + } + if drop_count > 0 { + tracing::warn!( + event_id = %event_id_hex, + drop_count, + "agent observer fan-out: {drop_count} connection(s) cancelled due to full/closed buffers" + ); + } + + conn.send(RelayMessage::ok(event_id_hex, true, "")); +} + +fn agent_observer_route(event: &Event) -> Result, String> { + if !content_looks_like_nip44(&event.content) { + return Err("invalid: observer content must be NIP-44 encrypted".into()); + } + + let recipient = parse_single_pubkey_tag(event, "p")?; + let agent = parse_single_pubkey_tag(event, OBSERVER_AGENT_TAG)?; + let frame = single_tag_content(event, OBSERVER_FRAME_TAG)?; + + let (owner, direction, expected_frame) = if event.pubkey == agent && recipient != agent { + ( + recipient, + AgentObserverDirection::Telemetry, + OBSERVER_FRAME_TELEMETRY, + ) + } else if recipient == agent && event.pubkey != agent { + ( + event.pubkey, + AgentObserverDirection::Control, + OBSERVER_FRAME_CONTROL, + ) + } else { + return Err( + "invalid: observer frame must be agent-to-owner telemetry or owner-to-agent control" + .into(), + ); + }; + + if frame != expected_frame { + // Unknown frame value — silently drop without notifying the publisher. + return Ok(None); + } + + Ok(Some(AgentObserverRoute { + agent, + owner, + direction, + })) +} + +fn parse_single_pubkey_tag(event: &Event, tag_name: &str) -> Result { + let value = single_tag_content(event, tag_name)?; + PublicKey::from_hex(value) + .map_err(|_| format!("invalid: observer {tag_name} tag must be a hex pubkey")) +} + +fn single_tag_content<'a>(event: &'a Event, tag_name: &str) -> Result<&'a str, String> { + let mut values = event + .tags + .iter() + .filter(|tag| tag.kind().to_string() == tag_name) + .filter_map(|tag| tag.content()); + let Some(value) = values.next() else { + return Err(format!("invalid: observer frame missing {tag_name} tag")); + }; + if values.next().is_some() { + return Err(format!( + "invalid: observer frame has multiple {tag_name} tags" + )); + } + Ok(value) +} + #[cfg(test)] mod tests { + use nostr::{EventBuilder, Keys, Kind, Tag}; use sprout_core::kind::{ - KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, - KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_AGENT_OBSERVER_FRAME, KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, + KIND_FORUM_VOTE, KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + }; + use sprout_core::observer::{ + encrypt_observer_payload, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, + OBSERVER_FRAME_TELEMETRY, }; #[test] @@ -470,4 +767,84 @@ mod tests { "presence updates are global/ephemeral" ); } + + #[test] + fn agent_observer_route_accepts_agent_to_owner_telemetry() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let encrypted = encrypt_observer_payload( + &agent, + &owner.public_key(), + &serde_json::json!({"type": "acp_read"}), + ) + .expect("encrypt observer payload"); + let event = EventBuilder::new( + Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), + encrypted, + [ + Tag::parse(&["p", &owner.public_key().to_hex()]).expect("p tag"), + Tag::parse(&[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]).expect("agent tag"), + Tag::parse(&[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]).expect("frame tag"), + ], + ) + .sign_with_keys(&agent) + .expect("sign event"); + + let route = super::agent_observer_route(&event) + .expect("observer route") + .expect("route should be Some"); + assert_eq!(route.agent, agent.public_key()); + assert_eq!(route.owner, owner.public_key()); + assert_eq!(route.direction, super::AgentObserverDirection::Telemetry); + } + + #[test] + fn agent_observer_route_accepts_owner_to_agent_control() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let encrypted = encrypt_observer_payload( + &owner, + &agent.public_key(), + &serde_json::json!({"type": "cancel_turn"}), + ) + .expect("encrypt observer payload"); + let event = EventBuilder::new( + Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), + encrypted, + [ + Tag::parse(&["p", &agent.public_key().to_hex()]).expect("p tag"), + Tag::parse(&[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]).expect("agent tag"), + Tag::parse(&[OBSERVER_FRAME_TAG, OBSERVER_FRAME_CONTROL]).expect("frame tag"), + ], + ) + .sign_with_keys(&owner) + .expect("sign event"); + + let route = super::agent_observer_route(&event) + .expect("observer route") + .expect("route should be Some"); + assert_eq!(route.agent, agent.public_key()); + assert_eq!(route.owner, owner.public_key()); + assert_eq!(route.direction, super::AgentObserverDirection::Control); + } + + #[test] + fn agent_observer_route_rejects_plaintext_content() { + let agent = Keys::generate(); + let owner = Keys::generate(); + let event = EventBuilder::new( + Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), + "not encrypted", + [ + Tag::parse(&["p", &owner.public_key().to_hex()]).expect("p tag"), + Tag::parse(&[OBSERVER_AGENT_TAG, &agent.public_key().to_hex()]).expect("agent tag"), + Tag::parse(&[OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY]).expect("frame tag"), + ], + ) + .sign_with_keys(&agent) + .expect("sign event"); + + let err = super::agent_observer_route(&event).expect_err("route should reject plaintext"); + assert!(err.contains("NIP-44")); + } } diff --git a/crates/sprout-relay/src/handlers/imeta.rs b/crates/sprout-relay/src/handlers/imeta.rs new file mode 100644 index 0000000000..880ea3a002 --- /dev/null +++ b/crates/sprout-relay/src/handlers/imeta.rs @@ -0,0 +1,428 @@ +//! imeta tag validation helpers — shared between ingest pipeline and bridge. + +/// Validate imeta tags for correctness and safety. +/// +/// Shared between REST (send_message) and WebSocket (handle_event) paths. +/// Returns Ok(()) if all tags are valid, or a human-readable error string. +pub fn validate_imeta_tags(tags: &[Vec], media_base_url: &str) -> Result<(), String> { + const ALLOWED_IMETA_KEYS: &[&str] = &[ + "url", "m", "x", "size", "dim", "blurhash", "alt", "thumb", "fallback", "duration", + "bitrate", "image", + ]; + const SINGLETON_KEYS: &[&str] = &[ + "url", "m", "x", "size", "dim", "blurhash", "thumb", "alt", "duration", "bitrate", "image", + ]; + const ALLOWED_MIME: &[&str] = &[ + "image/jpeg", + "image/png", + "image/gif", + "image/webp", + "video/mp4", + ]; + + for tag in tags { + if tag.first().map(|s| s.as_str()) != Some("imeta") { + return Err("only imeta tags allowed in media_tags".into()); + } + + let mut has_url = false; + let mut has_m = false; + let mut has_x = false; + let mut has_size = false; + let mut seen_keys = std::collections::HashSet::new(); + let mut url_value = String::new(); + let mut x_value = String::new(); + let mut m_value = String::new(); + let mut thumb_value = String::new(); + + for part in tag.iter().skip(1) { + let mut parts = part.splitn(2, ' '); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + + if !ALLOWED_IMETA_KEYS.contains(&key) { + return Err(format!("disallowed imeta key: {key}")); + } + if SINGLETON_KEYS.contains(&key) && !seen_keys.insert(key.to_string()) { + return Err(format!("duplicate imeta key: {key}")); + } + + match key { + "url" => { + if !is_local_media_url(value, media_base_url) { + return Err("imeta url must be a local /media/ path".into()); + } + if value.contains(".thumb.") { + return Err( + "imeta url must not be a thumbnail path; use thumb field".into() + ); + } + url_value = value.to_string(); + has_url = true; + } + "m" => { + if !ALLOWED_MIME.contains(&value) { + return Err( + "imeta m must be a supported MIME type (image/jpeg, image/png, image/gif, image/webp, video/mp4)" + .into(), + ); + } + m_value = value.to_string(); + has_m = true; + } + "x" => { + if value.len() != 64 + || !value.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + { + return Err("imeta x must be a 64-char lowercase hex SHA-256".into()); + } + x_value = value.to_string(); + has_x = true; + } + "size" => { + match value.parse::() { + Ok(0) | Err(_) => { + return Err("imeta size must be a positive integer".into()) + } + Ok(_) => {} + } + has_size = true; + } + "thumb" => { + if !is_local_media_url(value, media_base_url) || !value.ends_with(".thumb.jpg") + { + return Err("imeta thumb must be a local .thumb.jpg path".into()); + } + thumb_value = value.to_string(); + } + "duration" => { + if let Ok(d) = value.parse::() { + if d <= 0.0 || d.is_nan() || d.is_infinite() { + return Err("imeta duration must be a positive finite number".into()); + } + } else { + return Err("imeta duration must be a valid float".into()); + } + } + "bitrate" if value.parse::().map_or(true, |b| b == 0) => { + return Err("imeta bitrate must be a positive integer".into()); + } + "image" => { + const IMAGE_EXTS: &[&str] = &["jpg", "png", "gif", "webp"]; + if !is_local_media_url(value, media_base_url) { + return Err("imeta image must be a local /media/ path".into()); + } + if value.contains(".thumb.") { + return Err( + "imeta image must reference a standalone poster frame, not a thumbnail" + .into(), + ); + } + let ext = value.rsplit('.').next().unwrap_or(""); + if !IMAGE_EXTS.contains(&ext) { + return Err( + "imeta image must reference an image file (jpg, png, gif, webp), not video" + .into(), + ); + } + } + _ => {} + } + } + + if !has_url || !has_m || !has_x || !has_size { + return Err("imeta tag must include url, m, x, and size".into()); + } + + // Video-only NIP-71 fields must not appear on image blobs. + let is_video = m_value == "video/mp4"; + if !is_video { + for key in &["duration", "bitrate", "image"] { + if seen_keys.contains(*key) { + return Err(format!( + "imeta {key} is only valid for video/mp4, not {m_value}" + )); + } + } + } + + // Cross-check internal consistency: url hash must match x, url ext must match m. + if let Some(hash_in_url) = extract_hash_from_media_url(&url_value) { + if hash_in_url != x_value { + return Err("imeta url hash does not match x".into()); + } + } + if let Some(ext_in_url) = extract_ext_from_media_url(&url_value) { + let expected_ext = mime_to_canonical_ext(&m_value); + if ext_in_url != expected_ext { + return Err("imeta url extension does not match m".into()); + } + } + if !thumb_value.is_empty() { + if let Some(thumb_hash) = extract_hash_from_media_url(&thumb_value) { + if thumb_hash != x_value { + return Err("imeta thumb hash does not match x".into()); + } + } + } + } + Ok(()) +} + +/// Verify that every imeta tag references a blob that actually exists in storage +/// and that the claimed metadata (size, MIME) matches the sidecar. +pub async fn verify_imeta_blobs( + tags: &[Vec], + storage: &sprout_media::MediaStorage, +) -> Result<(), String> { + for tag in tags { + let mut x_value = String::new(); + let mut m_value = String::new(); + let mut size_value: u64 = 0; + let mut thumb_value = String::new(); + let mut image_value = String::new(); + let mut duration_value: f64 = 0.0; + + for part in tag.iter().skip(1) { + let mut parts = part.splitn(2, ' '); + let key = parts.next().unwrap_or(""); + let value = parts.next().unwrap_or(""); + match key { + "x" => x_value = value.to_string(), + "m" => m_value = value.to_string(), + "size" => size_value = value.parse().unwrap_or(0), + "thumb" => thumb_value = value.to_string(), + "image" => image_value = value.to_string(), + "duration" => duration_value = value.parse().unwrap_or(0.0), + _ => {} + } + } + + if x_value.is_empty() { + continue; + } + + // 1. Sidecar must exist + let sidecar = storage + .get_sidecar(&x_value) + .await + .map_err(|_| format!("imeta references nonexistent blob: {x_value}"))?; + + // 2. HEAD the actual blob object + let blob_key = format!("{x_value}.{}", sidecar.ext); + let blob_exists = storage + .head(&blob_key) + .await + .map_err(|e| format!("storage error checking blob {x_value}: {e}"))?; + if !blob_exists { + return Err(format!("imeta blob object missing in storage: {x_value}")); + } + + // 3. Cross-check claimed metadata against sidecar. + if !m_value.is_empty() && sidecar.mime_type != m_value { + return Err(format!( + "imeta m ({m_value}) does not match stored MIME ({})", + sidecar.mime_type + )); + } + if size_value > 0 && sidecar.size != size_value { + return Err(format!( + "imeta size ({size_value}) does not match stored size ({})", + sidecar.size + )); + } + if let Some(stored_dur) = sidecar.duration_secs { + if duration_value > 0.0 && (duration_value - stored_dur).abs() > 0.1 { + return Err(format!( + "imeta duration ({duration_value}) does not match stored duration ({stored_dur})" + )); + } + } + + // 4. If thumb is claimed, HEAD the thumbnail object too. + if !thumb_value.is_empty() { + let thumb_key = format!("{x_value}.thumb.jpg"); + let thumb_exists = storage + .head(&thumb_key) + .await + .map_err(|e| format!("storage error checking thumbnail: {e}"))?; + if !thumb_exists { + return Err(format!( + "imeta thumb references missing thumbnail: {x_value}" + )); + } + } + + // 5. If image (poster frame) is claimed, verify sidecar + blob. + if !image_value.is_empty() { + let img_hash = extract_hash_from_media_url(&image_value) + .ok_or_else(|| format!("imeta image URL has no extractable hash: {image_value}"))?; + + let img_sidecar = storage + .get_sidecar(img_hash) + .await + .map_err(|_| format!("imeta image references nonexistent poster: {img_hash}"))?; + + const IMAGE_MIMES: &[&str] = &["image/jpeg", "image/png", "image/gif", "image/webp"]; + if !IMAGE_MIMES.contains(&img_sidecar.mime_type.as_str()) { + return Err(format!( + "imeta image poster MIME must be image type, got {}", + img_sidecar.mime_type + )); + } + + if let Some(url_ext) = extract_ext_from_media_url(&image_value) { + if url_ext != img_sidecar.ext { + return Err(format!( + "imeta image extension ({url_ext}) does not match stored extension ({})", + img_sidecar.ext + )); + } + } + + let img_key = format!("{img_hash}.{}", img_sidecar.ext); + let img_exists = storage + .head(&img_key) + .await + .map_err(|e| format!("storage error checking poster image: {e}"))?; + if !img_exists { + return Err(format!( + "imeta image references missing poster frame: {img_hash}" + )); + } + } + } + Ok(()) +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Extract the 64-char hex hash from a `/media/{hash}.{ext}` URL. +fn extract_hash_from_media_url(url: &str) -> Option<&str> { + let after = url.rsplit("/media/").next()?; + let hash = after.split('.').next()?; + if hash.len() == 64 && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) { + Some(hash) + } else { + None + } +} + +/// Extract the primary extension from a `/media/{hash}.{ext}` URL (not thumb). +fn extract_ext_from_media_url(url: &str) -> Option<&str> { + let after = url.rsplit("/media/").next()?; + let segments: Vec<&str> = after.split('.').collect(); + if segments.len() == 2 { + Some(segments[1]) + } else { + None + } +} + +/// Map MIME to canonical extension (must match sprout-media's mime_to_ext). +fn mime_to_canonical_ext(mime: &str) -> &str { + match mime { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/gif" => "gif", + "image/webp" => "webp", + "video/mp4" => "mp4", + _ => "bin", + } +} + +/// Validate that a URL references a valid local media blob path. +fn is_local_media_url(url: &str, media_base_url: &str) -> bool { + const ALLOWED_EXTS: &[&str] = &["jpg", "png", "gif", "webp", "mp4"]; + + let path_after_media = if let Some(rest) = url.strip_prefix("/media/") { + rest + } else { + let base = media_base_url.trim_end_matches('/'); + let prefix = format!("{}/", base); + if let Some(rest) = url.strip_prefix(&prefix) { + rest + } else { + return false; + } + }; + + if path_after_media.contains('?') || path_after_media.contains('#') { + return false; + } + if path_after_media.contains('%') { + return false; + } + + let segments: Vec<&str> = path_after_media.split('.').collect(); + match segments.len() { + 2 => { + let hash = segments[0]; + let ext = segments[1]; + hash.len() == 64 + && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + && ALLOWED_EXTS.contains(&ext) + } + 3 => { + let hash = segments[0]; + hash.len() == 64 + && hash.chars().all(|c| matches!(c, '0'..='9' | 'a'..='f')) + && segments[1] == "thumb" + && segments[2] == "jpg" + } + _ => false, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const HASH: &str = "abcdef0123456789abcdef0123456789abcdef0123456789abcdef0123456789"; + const BASE: &str = "https://relay.example.com/media"; + + #[test] + fn test_local_media_url_relative() { + assert!(is_local_media_url(&format!("/media/{HASH}.jpg"), BASE)); + assert!(is_local_media_url(&format!("/media/{HASH}.png"), BASE)); + } + + #[test] + fn test_local_media_url_absolute() { + assert!(is_local_media_url(&format!("{BASE}/{HASH}.jpg"), BASE)); + } + + #[test] + fn test_local_media_url_rejects_external() { + assert!(!is_local_media_url( + &format!("https://evil.com/media/{HASH}.jpg"), + BASE + )); + } + + #[test] + fn test_imeta_consistent_tags_pass() { + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.jpg"), + "m image/jpeg".into(), + format!("x {HASH}"), + "size 100".into(), + ]; + assert!(validate_imeta_tags(&[tag], BASE).is_ok()); + } + + #[test] + fn test_imeta_url_hash_must_match_x() { + let other = "b".repeat(64); + let tag = vec![ + "imeta".into(), + format!("url /media/{HASH}.jpg"), + "m image/jpeg".into(), + format!("x {other}"), + "size 100".into(), + ]; + let err = validate_imeta_tags(&[tag], BASE).unwrap_err(); + assert!(err.contains("url hash does not match x"), "{err}"); + } +} diff --git a/crates/sprout-relay/src/handlers/ingest.rs b/crates/sprout-relay/src/handlers/ingest.rs index 72fbe6c2a0..ddfea3db68 100644 --- a/crates/sprout-relay/src/handlers/ingest.rs +++ b/crates/sprout-relay/src/handlers/ingest.rs @@ -12,17 +12,24 @@ use uuid::Uuid; use nostr::Event; use sprout_auth::Scope; use sprout_core::kind::{ - event_kind_u32, is_parameterized_replaceable, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, - KIND_DELETION, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_GIFT_WRAP, - KIND_HUDDLE_ENDED, KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, - KIND_HUDDLE_PARTICIPANT_LEFT, KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, - KIND_HUDDLE_TRACK_PUBLISHED, KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, - KIND_NIP29_DELETE_GROUP, KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, - KIND_NIP29_LEAVE_REQUEST, KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_PRESENCE_UPDATE, - KIND_PROFILE, KIND_REACTION, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_BOOKMARKED, - KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, KIND_STREAM_MESSAGE_PINNED, - KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, KIND_STREAM_REMINDER, KIND_TEXT_NOTE, + event_kind_u32, is_parameterized_replaceable, is_relay_admin_kind, KIND_APPROVAL_DENY, + KIND_APPROVAL_GRANT, KIND_AUTH, KIND_CANVAS, KIND_CONTACT_LIST, KIND_DELETION, + KIND_DM_ADD_MEMBER, KIND_DM_HIDE, KIND_DM_OPEN, KIND_FORUM_COMMENT, KIND_FORUM_POST, + KIND_FORUM_VOTE, KIND_GIFT_WRAP, KIND_GIT_ISSUE, KIND_GIT_PATCH, KIND_GIT_PR_UPDATE, + KIND_GIT_PULL_REQUEST, KIND_GIT_REPO_ANNOUNCEMENT, KIND_GIT_REPO_STATE, KIND_GIT_STATUS_CLOSED, + KIND_GIT_STATUS_DRAFT, KIND_GIT_STATUS_MERGED, KIND_GIT_STATUS_OPEN, KIND_HUDDLE_ENDED, + KIND_HUDDLE_GUIDELINES, KIND_HUDDLE_PARTICIPANT_JOINED, KIND_HUDDLE_PARTICIPANT_LEFT, + KIND_HUDDLE_RECORDING_AVAILABLE, KIND_HUDDLE_STARTED, KIND_HUDDLE_TRACK_PUBLISHED, + KIND_LONG_FORM, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_NIP29_CREATE_GROUP, KIND_NIP29_DELETE_EVENT, KIND_NIP29_DELETE_GROUP, + KIND_NIP29_EDIT_METADATA, KIND_NIP29_JOIN_REQUEST, KIND_NIP29_LEAVE_REQUEST, + KIND_NIP29_PUT_USER, KIND_NIP29_REMOVE_USER, KIND_NIP43_LEAVE_REQUEST, KIND_PRESENCE_UPDATE, + KIND_PROFILE, KIND_REACTION, KIND_READ_STATE, KIND_STREAM_MESSAGE, + KIND_STREAM_MESSAGE_BOOKMARKED, KIND_STREAM_MESSAGE_DIFF, KIND_STREAM_MESSAGE_EDIT, + KIND_STREAM_MESSAGE_PINNED, KIND_STREAM_MESSAGE_SCHEDULED, KIND_STREAM_MESSAGE_V2, + KIND_STREAM_REMINDER, KIND_TEXT_NOTE, KIND_USER_STATUS, KIND_WORKFLOW_DEF, + KIND_WORKFLOW_TRIGGER, RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, + RELAY_ADMIN_REMOVE_MEMBER, }; use sprout_core::verification::verify_event; @@ -35,11 +42,9 @@ use super::event::dispatch_persistent_event; /// How the HTTP caller authenticated (for [`IngestAuth::Http`]). #[derive(Debug, Clone)] pub enum HttpAuthMethod { - /// `Authorization: Bearer sprout_*` API token. - ApiToken, - /// `Authorization: Bearer eyJ*` Okta JWT. - OktaJwt, - /// `X-Pubkey: ` dev-mode header. + /// `Authorization: Nostr ` — NIP-98 HTTP Auth. + Nip98, + /// `X-Pubkey: ` dev-mode header (backward compat during transition). DevPubkey, } @@ -57,7 +62,7 @@ pub enum IngestAuth { /// WebSocket connection identifier. conn_id: Uuid, }, - /// HTTP REST authenticated request. + /// HTTP bridge authenticated request (NIP-98 or dev X-Pubkey). Http { /// The authenticated Nostr public key. pubkey: nostr::PublicKey, @@ -65,10 +70,6 @@ pub enum IngestAuth { scopes: Vec, /// How the HTTP request was authenticated. auth_method: HttpAuthMethod, - /// API token UUID, if auth_method is `ApiToken`. - token_id: Option, - /// Token-level channel restriction, if any. - channel_ids: Option>, }, } @@ -100,16 +101,14 @@ impl IngestAuth { } } - /// Token-level channel restriction (Http/ApiToken only). + /// Token-level channel restriction (WS connections with scoped tokens — legacy). + /// In pure Nostr mode this always returns None; channel access is enforced + /// via NIP-29 membership checks instead. pub fn channel_ids(&self) -> Option<&[Uuid]> { match self { Self::Nip42 { channel_ids: Some(ids), .. - } - | Self::Http { - channel_ids: Some(ids), - .. } => Some(ids), _ => None, } @@ -151,7 +150,7 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::UsersWrite), KIND_TEXT_NOTE | KIND_LONG_FORM => Ok(Scope::MessagesWrite), - KIND_CONTACT_LIST => Ok(Scope::UsersWrite), + KIND_CONTACT_LIST | KIND_READ_STATE | KIND_USER_STATUS => Ok(Scope::UsersWrite), KIND_DELETION | KIND_REACTION | KIND_GIFT_WRAP @@ -170,6 +169,13 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result { Ok(Scope::AdminChannels) } + // NIP-43: relay membership admin commands (9030–9032) + k if k == RELAY_ADMIN_ADD_MEMBER + || k == RELAY_ADMIN_REMOVE_MEMBER + || k == RELAY_ADMIN_CHANGE_ROLE => + { + Ok(Scope::AdminUsers) + } KIND_NIP29_EDIT_METADATA => { // kind:9002 scope split: archived tag → AdminChannels, else ChannelsWrite let has_archived = event @@ -183,7 +189,9 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), - KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST => Ok(Scope::ChannelsRead), + KIND_NIP29_JOIN_REQUEST | KIND_NIP29_LEAVE_REQUEST | KIND_NIP43_LEAVE_REQUEST => { + Ok(Scope::ChannelsRead) + } // Huddle lifecycle events + guidelines KIND_HUDDLE_STARTED | KIND_HUDDLE_PARTICIPANT_JOINED @@ -192,6 +200,20 @@ fn required_scope_for_kind(kind: u32, event: &Event) -> Result Ok(Scope::ChannelsWrite), + // NIP-34: Git repository events + KIND_GIT_REPO_ANNOUNCEMENT | KIND_GIT_REPO_STATE => Ok(Scope::ReposWrite), + KIND_GIT_PATCH + | KIND_GIT_PULL_REQUEST + | KIND_GIT_PR_UPDATE + | KIND_GIT_ISSUE + | KIND_GIT_STATUS_OPEN + | KIND_GIT_STATUS_MERGED + | KIND_GIT_STATUS_CLOSED + | KIND_GIT_STATUS_DRAFT => Ok(Scope::MessagesWrite), + // Command kinds — DM management, workflows, approvals + KIND_DM_OPEN | KIND_DM_ADD_MEMBER | KIND_DM_HIDE => Ok(Scope::MessagesWrite), + KIND_WORKFLOW_DEF | KIND_WORKFLOW_TRIGGER => Ok(Scope::MessagesWrite), + KIND_APPROVAL_GRANT | KIND_APPROVAL_DENY => Ok(Scope::MessagesWrite), _ => Err("restricted: unknown event kind"), } } @@ -272,7 +294,30 @@ pub(crate) async fn derive_reaction_channel( pub(crate) fn is_global_only_kind(kind: u32) -> bool { matches!( kind, - KIND_PROFILE | KIND_TEXT_NOTE | KIND_CONTACT_LIST | KIND_LONG_FORM + KIND_PROFILE + | KIND_TEXT_NOTE + | KIND_CONTACT_LIST + | KIND_LONG_FORM + | KIND_USER_STATUS + | KIND_READ_STATE + // NIP-34: git events use `a` tags (repo reference), not `h` tags (channel scope). + // Parameterized replaceable kinds are keyed by (pubkey, kind, d_tag). + | KIND_GIT_REPO_ANNOUNCEMENT + | KIND_GIT_REPO_STATE + | KIND_GIT_PATCH + | KIND_GIT_PULL_REQUEST + | KIND_GIT_PR_UPDATE + | KIND_GIT_ISSUE + | KIND_GIT_STATUS_OPEN + | KIND_GIT_STATUS_MERGED + | KIND_GIT_STATUS_CLOSED + | KIND_GIT_STATUS_DRAFT + // NIP-43: relay admin commands and leave requests are global — they + // must never be channel-scoped, even if the event carries a stray `h` tag. + | RELAY_ADMIN_ADD_MEMBER + | RELAY_ADMIN_REMOVE_MEMBER + | RELAY_ADMIN_CHANGE_ROLE + | KIND_NIP43_LEAVE_REQUEST ) } @@ -757,6 +802,11 @@ pub async fn ingest_event( ))); } + // ── 1c. Reject relay-only kinds from external submission ───────────── + if sprout_core::kind::is_relay_only_kind(kind_u32) { + return Err(IngestError::Rejected("restricted: relay-only kind".into())); + } + // ── 2. Signature verification ──────────────────────────────────────── let event_clone = event.clone(); let verify_result = tokio::task::spawn_blocking(move || verify_event(&event_clone)).await; @@ -810,6 +860,29 @@ pub async fn ingest_event( Ok(scope) => scope, Err(msg) => return Err(IngestError::Rejected(msg.into())), }; + // NIP-43: relay admin commands must NOT be submitted via proxy — they require + // the actual admin's signed event for authorization. + if auth.has_proxy_scope() && is_relay_admin_kind(event.kind.as_u16() as u32) { + return Err(IngestError::Rejected( + "invalid: relay admin commands cannot be submitted via proxy".into(), + )); + } + // NIP-43: relay admin commands are global — channel-scoped tokens cannot + // issue them even if the event has no `h` tag (is_global_only_kind strips + // channel_id, but we still need to reject the token itself). + if is_relay_admin_kind(kind_u32) && auth.channel_ids().is_some() { + return Err(IngestError::AuthFailed( + "restricted: relay admin commands require a global token, not a channel-scoped token" + .into(), + )); + } + // NIP-43: leave requests are also global — channel-scoped tokens cannot + // issue them. + if kind_u32 == KIND_NIP43_LEAVE_REQUEST && auth.channel_ids().is_some() { + return Err(IngestError::AuthFailed( + "restricted: leave requests require a global token".into(), + )); + } if !auth.has_proxy_scope() && !auth.scopes().contains(&required) { return Err(IngestError::AuthFailed(format!( "restricted: insufficient scope (need {})", @@ -817,6 +890,13 @@ pub async fn ingest_event( ))); } + // ── 4b. Route command kinds to command executor ────────────────────── + // Command kinds are routed AFTER signature verification, timestamp check, + // pubkey/auth match, and scope validation — never before. + if sprout_core::kind::is_command_kind(kind_u32) { + return super::command_executor::handle_command(state, event, auth).await; + } + // ── 5. Channel resolution ──────────────────────────────────────────── let mut channel_id = if kind_u32 == KIND_REACTION { match derive_reaction_channel(&state.db, &event).await { @@ -919,6 +999,103 @@ pub async fn ingest_event( } } + // ── 9a. Relay admin commands (kinds 9030–9032) ─────────────────────── + // Handled directly — these mutate relay_members and do NOT get stored. + if is_relay_admin_kind(event.kind.as_u16() as u32) { + crate::handlers::relay_admin::handle_relay_admin_event(state, &event) + .await + .map_err(|e| IngestError::Rejected(format!("invalid: {e}")))?; + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: String::new(), + }); + } + + // ── 9b. NIP-43 leave request (kind 28936) ──────────────────────────── + // Handled directly — removes the sender from relay_members. NOT stored. + if kind_u32 == KIND_NIP43_LEAVE_REQUEST { + if !state.config.require_relay_membership { + return Err(IngestError::Rejected( + "invalid: relay membership is not enabled".into(), + )); + } + + // Freshness check: reject events outside ±120s of now (same as admin commands). + { + let event_ts = event.created_at.as_u64() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(IngestError::Rejected(format!( + "invalid: leave request timestamp out of range (delta={}s, max ±120s)", + event_ts - now + ))); + } + } + + // NIP-43 spec: "This event MUST include a NIP-70 `-` tag." + let has_protected_tag = event + .tags + .iter() + .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some("-")); + if !has_protected_tag { + return Err(IngestError::Rejected( + "invalid: leave request must include NIP-70 protected event tag [\"-\"]".into(), + )); + } + + let sender_hex = event.pubkey.to_hex(); + + // remove_relay_member handles both the NotFound and IsOwner cases atomically. + let remove_result = state + .db + .remove_relay_member(&sender_hex) + .await + .map_err(|e| IngestError::Internal(format!("database error: {e}")))?; + + match remove_result { + sprout_db::relay_members::RemoveResult::Removed => {} + sprout_db::relay_members::RemoveResult::NotFound => { + return Err(IngestError::Rejected( + "invalid: you are not a relay member".into(), + )); + } + sprout_db::relay_members::RemoveResult::IsOwner => { + return Err(IngestError::Rejected( + "invalid: relay owner cannot leave".into(), + )); + } + sprout_db::relay_members::RemoveResult::RoleMismatch => { + // remove_relay_member (no role filter) never returns RoleMismatch — + // this arm is unreachable but exhaustiveness requires it. + return Err(IngestError::Internal( + "unexpected RoleMismatch from remove_relay_member".into(), + )); + } + } + + // Publish NIP-43 announcements — fire-and-forget. + if let Err(e) = + crate::handlers::side_effects::publish_nip43_member_removed(state, &sender_hex).await + { + warn!(error = %e, "failed to publish NIP-43 member removed event"); + } + if let Err(e) = crate::handlers::side_effects::publish_nip43_membership_list(state).await { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } + + info!(pubkey = %sender_hex, "relay member left via NIP-43 leave request"); + + return Ok(IngestResult { + event_id: event_id_hex, + accepted: true, + message: "info: you have left this relay".into(), + }); + } + // ── 9. Admin validation (kinds 9000–9022) ──────────────────────────── if crate::handlers::side_effects::is_admin_kind(kind_u32) { crate::handlers::side_effects::validate_admin_event(kind_u32, &event, state) @@ -952,11 +1129,18 @@ pub async fn ingest_event( } // ── 12. Single-target enforcement (kind:9005, kind:5) ──────────────── + // NIP-09: kind:5 may reference targets via `e` tag (regular events) OR + // `a` tag (addressable/parameterized-replaceable events like kind:30620). if kind_u32 == KIND_NIP29_DELETE_EVENT || kind_u32 == KIND_DELETION { let e_count = count_e_tags(&event); - if e_count != 1 { + let a_count = event + .tags + .iter() + .filter(|t| t.kind().to_string() == "a") + .count(); + if (e_count + a_count) != 1 { return Err(IngestError::Rejected(format!( - "invalid: deletion events must reference exactly one target (got {e_count})" + "invalid: deletion events must reference exactly one target via e or a tag (got e={e_count}, a={a_count})" ))); } } @@ -1364,7 +1548,7 @@ mod tests { use super::*; use sprout_core::kind::{ KIND_CANVAS, KIND_FORUM_COMMENT, KIND_FORUM_POST, KIND_FORUM_VOTE, KIND_LONG_FORM, - KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, + KIND_PRESENCE_UPDATE, KIND_STREAM_MESSAGE, KIND_STREAM_MESSAGE_DIFF, KIND_USER_STATUS, }; #[test] @@ -1450,6 +1634,25 @@ mod tests { assert!(is_global_only_kind(KIND_LONG_FORM)); } + #[test] + fn user_status_requires_users_write_scope() { + let dummy = make_dummy_event(); + assert_eq!( + required_scope_for_kind(KIND_USER_STATUS, &dummy).unwrap(), + Scope::UsersWrite, + ); + } + + #[test] + fn user_status_is_global_only() { + assert!(is_global_only_kind(KIND_USER_STATUS)); + } + + #[test] + fn user_status_does_not_require_h_tag() { + assert!(!requires_h_channel_scope(KIND_USER_STATUS)); + } + #[test] fn global_only_and_channel_scoped_are_disjoint() { // A kind cannot be both global-only and channel-scoped @@ -1489,6 +1692,7 @@ mod tests { KIND_FORUM_VOTE, KIND_FORUM_COMMENT, KIND_LONG_FORM, + KIND_USER_STATUS, ]; for kind in migrated { assert!( @@ -1522,9 +1726,7 @@ mod tests { let http_auth = IngestAuth::Http { pubkey: keys.public_key(), scopes: vec![], - auth_method: HttpAuthMethod::ApiToken, - token_id: None, - channel_ids: None, + auth_method: HttpAuthMethod::Nip98, }; assert!( http_auth.is_http(), diff --git a/crates/sprout-relay/src/handlers/mod.rs b/crates/sprout-relay/src/handlers/mod.rs index 1cb7325869..0d10190013 100644 --- a/crates/sprout-relay/src/handlers/mod.rs +++ b/crates/sprout-relay/src/handlers/mod.rs @@ -2,9 +2,19 @@ pub mod auth; /// Subscription close (CLOSE) handler. pub mod close; +/// Command executor — transactional processing for command kinds. +pub mod command_executor; +/// NIP-45 COUNT handler. +pub mod count; +/// EVENT handler — WS dispatcher → ingest pipeline → fan-out. pub mod event; +/// imeta tag validation helpers. +pub mod imeta; /// Transport-neutral event ingestion pipeline. pub mod ingest; +/// NIP-43 relay membership admin command handler (kinds 9030–9032). +pub mod relay_admin; +/// REQ handler — subscribe, deliver historical events, then EOSE. pub mod req; /// NIP-29 and NIP-25 side-effect handlers. pub mod side_effects; diff --git a/crates/sprout-relay/src/handlers/relay_admin.rs b/crates/sprout-relay/src/handlers/relay_admin.rs new file mode 100644 index 0000000000..e50e649a0a --- /dev/null +++ b/crates/sprout-relay/src/handlers/relay_admin.rs @@ -0,0 +1,375 @@ +//! NIP-43 relay membership admin command handler (kinds 9030–9032). +//! +//! These events are processed directly — they mutate the `relay_members` table +//! and return without being stored as regular Nostr events. +//! +//! ## Permission matrix +//! +//! | Kind | Operation | Required sender role | +//! |------|-----------------|----------------------| +//! | 9030 | Add member | admin or owner | +//! | 9031 | Remove member | admin or owner | +//! | 9032 | Change role | owner only | + +use std::sync::Arc; + +use nostr::Event; +use tracing::{info, warn}; + +use sprout_core::kind::{ + RELAY_ADMIN_ADD_MEMBER, RELAY_ADMIN_CHANGE_ROLE, RELAY_ADMIN_REMOVE_MEMBER, +}; +use sprout_db::relay_members::RemoveResult; + +use crate::handlers::side_effects::{ + publish_nip43_member_added, publish_nip43_member_removed, publish_nip43_membership_list, +}; +use crate::state::AppState; + +// ── Tag extraction helpers ──────────────────────────────────────────────────── + +/// Extract the hex pubkey from the first `p` tag, returning it as a `String`. +fn extract_p_tag_hex(event: &Event) -> Option { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some("p") { + if let Some(val) = parts.get(1).map(|s| s.as_str()) { + // Must be exactly 64 hex chars (uncompressed pubkey representation). + if val.len() == 64 && val.chars().all(|c| c.is_ascii_hexdigit()) { + return Some(val.to_string()); + } + } + } + } + None +} + +/// Extract the value of the first tag with the given name. +fn extract_tag_value(event: &Event, name: &str) -> Option { + for tag in event.tags.iter() { + let parts = tag.as_slice(); + if parts.first().map(|s| s.as_str()) == Some(name) { + return parts.get(1).map(|s| s.to_string()); + } + } + None +} + +// ── Public handler ──────────────────────────────────────────────────────────── + +/// Validate and execute a relay admin command (kinds 9030–9032). +/// +/// The handler: +/// 1. Extracts the target pubkey from the `["p", ...]` tag. +/// 2. Extracts the role from the `["role", ...]` tag (kinds 9030 and 9032). +/// 3. Looks up the sender's current role in `relay_members`. +/// 4. Enforces the permission matrix. +/// 5. Applies the change via the DB. +/// +/// Returns `Ok(())` on success. Returns `Err(msg)` — where `msg` is a +/// human-readable rejection reason — on any validation failure. +pub async fn handle_relay_admin_event(state: &Arc, event: &Event) -> Result<(), String> { + let kind = event.kind.as_u16() as u32; + let sender_hex = event.pubkey.to_hex(); + + // ── Replay protection: reject events outside ±120s of now ──────────── + // This mirrors the NIP-42 auth event freshness check and prevents replay + // of captured admin commands. The window is intentionally tight — admin + // events should be freshly signed. + { + let event_ts = event.created_at.as_u64() as i64; + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .map(|d| d.as_secs() as i64) + .unwrap_or(0); + if (event_ts - now).abs() > 120 { + return Err(format!( + "event timestamp out of range: created_at={event_ts}, now={now}, delta={}s (max ±120s)", + event_ts - now + )); + } + } + + // ── Extract target pubkey ───────────────────────────────────────────── + let target_hex = extract_p_tag_hex(event) + .ok_or_else(|| "missing or invalid p tag".to_string())? + .to_ascii_lowercase(); + + // ── Look up sender's relay role ─────────────────────────────────────── + let sender_member = state + .db + .get_relay_member(&sender_hex) + .await + .map_err(|e| format!("database error: {e}"))?; + + let sender_role = sender_member + .as_ref() + .map(|m| m.role.as_str()) + .unwrap_or(""); + + // ── Dispatch by kind ────────────────────────────────────────────────── + match kind { + // kind:9030 — Add relay member + k if k == RELAY_ADMIN_ADD_MEMBER => { + // Sender must be admin or owner. + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".to_string()); + } + + // Default role is "member" when no role tag is present. + let role = extract_tag_value(event, "role").unwrap_or_else(|| "member".to_string()); + + // Owners can add admins or members; admins can only add members. + if role == "owner" { + return Err("invalid role: use kind:9032 to promote to owner".to_string()); + } + if role == "admin" && sender_role != "owner" { + return Err("actor not authorized: only owner can grant admin role".to_string()); + } + if role != "admin" && role != "member" { + return Err(format!("invalid role: {role}")); + } + + // Note: idempotent — if target already exists at any role, this is a + // silent no-op. The existing role is NOT overwritten. Use kind:9032 + // to change an existing member's role. + let was_inserted = state + .db + .add_relay_member(&target_hex, &role, Some(&sender_hex)) + .await + .map_err(|e| format!("database error: {e}"))?; + + info!( + sender = %sender_hex, + target = %target_hex, + role = %role, + was_inserted, + "relay member add attempted" + ); + + // Only publish NIP-43 announcements when the row was actually inserted — + // skip on no-op re-adds to avoid spurious kind:8000 events. + if was_inserted { + if let Err(e) = publish_nip43_member_added(state, &target_hex).await { + warn!(error = %e, "failed to publish NIP-43 member added event"); + } + if let Err(e) = publish_nip43_membership_list(state).await { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } + } + } + + // kind:9031 — Remove relay member + k if k == RELAY_ADMIN_REMOVE_MEMBER => { + // Sender must be admin or owner. + if sender_role != "admin" && sender_role != "owner" { + return Err("actor not authorized: must be admin or owner".to_string()); + } + + // Cannot remove yourself. + if target_hex == sender_hex { + return Err("cannot remove yourself".to_string()); + } + + // Dispatch removal by sender role: + // - Admins: atomic conditional delete, only removes 'member' targets. + // This eliminates the TOCTOU race where the target could be promoted + // between a prior role read and the delete. + // - Owners: can remove admins and members, not other owners. + let remove_result = if sender_role == "admin" { + state + .db + .remove_relay_member_if_role(&target_hex, "member") + .await + .map_err(|e| format!("database error: {e}"))? + } else { + // Owner path — atomic delete that refuses to remove other owners. + state + .db + .remove_relay_member(&target_hex) + .await + .map_err(|e| format!("database error: {e}"))? + }; + + match remove_result { + RemoveResult::Removed => {} + RemoveResult::IsOwner => { + return Err("cannot remove the relay owner".to_string()); + } + RemoveResult::NotFound => { + return Err(format!("member not found: {target_hex}")); + } + RemoveResult::RoleMismatch => { + return Err("actor not authorized: admins can only remove members".to_string()); + } + } + + info!( + sender = %sender_hex, + target = %target_hex, + "relay member removed" + ); + + if let Err(e) = publish_nip43_member_removed(state, &target_hex).await { + warn!(error = %e, "failed to publish NIP-43 member removed event"); + } + if let Err(e) = publish_nip43_membership_list(state).await { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } + } + + // kind:9032 — Change relay member role + k if k == RELAY_ADMIN_CHANGE_ROLE => { + // Only owners may change roles. + if sender_role != "owner" { + return Err("actor not authorized: must be owner".to_string()); + } + + // Cannot change your own role. + if target_hex == sender_hex { + return Err("cannot change your own role".to_string()); + } + + let new_role = + extract_tag_value(event, "role").ok_or_else(|| "missing role tag".to_string())?; + + // DESIGN: Ownership transfer via kind:9032 is intentionally blocked. + // Transferring ownership is a high-risk operation that could permanently + // lock out the current owner. Use RELAY_OWNER_PUBKEY config to change ownership. + if new_role == "owner" { + return Err("cannot set role to owner".to_string()); + } + if new_role != "admin" && new_role != "member" { + return Err(format!("invalid role: {new_role}")); + } + + let updated = state + .db + .update_relay_member_role(&target_hex, &new_role) + .await + .map_err(|e| format!("database error: {e}"))?; + + if !updated { + // Distinguish "owner (protected)" from "doesn't exist" + let exists = state + .db + .get_relay_member(&target_hex) + .await + .map_err(|e| format!("database error: {e}"))?; + return Err(if exists.is_some() { + "cannot change the relay owner's role".to_string() + } else { + format!("member not found: {target_hex}") + }); + } + + info!( + sender = %sender_hex, + target = %target_hex, + new_role = %new_role, + "relay member role changed" + ); + + if let Err(e) = publish_nip43_membership_list(state).await { + warn!(error = %e, "failed to publish NIP-43 membership list"); + } + } + + other => { + return Err(format!("unexpected relay admin kind: {other}")); + } + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a minimal signed Event with the given kind and tags. + /// The pubkey will be randomly generated — sufficient for tag extraction tests. + fn make_test_event(kind: u16, tags: Vec>) -> Event { + let keys = Keys::generate(); + let nostr_tags: Vec = tags + .into_iter() + .map(|parts| Tag::parse(&parts).expect("valid tag")) + .collect(); + EventBuilder::new(Kind::from(kind), "", nostr_tags) + .sign_with_keys(&keys) + .expect("signing failed") + } + + // ── extract_p_tag_hex ───────────────────────────────────────────────── + + #[test] + fn extract_p_tag_valid_hex() { + let hex = "a".repeat(64); + let event = make_test_event( + 9030, + vec![vec!["p", Box::leak(hex.clone().into_boxed_str())]], + ); + assert_eq!(extract_p_tag_hex(&event), Some(hex)); + } + + #[test] + fn extract_p_tag_rejects_short_hex() { + let event = make_test_event(9030, vec![vec!["p", "abcd"]]); + assert_eq!(extract_p_tag_hex(&event), None); + } + + #[test] + fn extract_p_tag_rejects_non_hex() { + // 'g' is not a hex digit + let event = make_test_event( + 9030, + vec![vec![ + "p", + "gggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggggg", + ]], + ); + assert_eq!(extract_p_tag_hex(&event), None); + } + + #[test] + fn extract_p_tag_missing() { + let event = make_test_event(9030, vec![]); + assert_eq!(extract_p_tag_hex(&event), None); + } + + #[test] + fn extract_p_tag_ignores_non_p_tags() { + let event = make_test_event(9030, vec![vec!["role", "admin"]]); + assert_eq!(extract_p_tag_hex(&event), None); + } + + // ── extract_tag_value ───────────────────────────────────────────────── + + #[test] + fn extract_tag_value_found() { + let event = make_test_event(9030, vec![vec!["role", "admin"]]); + assert_eq!(extract_tag_value(&event, "role"), Some("admin".to_string())); + } + + #[test] + fn extract_tag_value_missing() { + let event = make_test_event(9030, vec![]); + assert_eq!(extract_tag_value(&event, "role"), None); + } + + #[test] + fn extract_tag_value_returns_first_match() { + let event = make_test_event(9030, vec![vec!["role", "member"], vec!["role", "admin"]]); + assert_eq!( + extract_tag_value(&event, "role"), + Some("member".to_string()) + ); + } + + #[test] + fn extract_tag_value_wrong_name() { + let event = make_test_event(9030, vec![vec!["role", "admin"]]); + assert_eq!(extract_tag_value(&event, "p"), None); + } +} diff --git a/crates/sprout-relay/src/handlers/req.rs b/crates/sprout-relay/src/handlers/req.rs index f6ba2a0a6d..015ed35137 100644 --- a/crates/sprout-relay/src/handlers/req.rs +++ b/crates/sprout-relay/src/handlers/req.rs @@ -9,7 +9,8 @@ use hex; use nostr::Filter; use sprout_core::filter::filters_match; use sprout_core::kind::{ - KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_AGENT_OBSERVER_FRAME, KIND_GIFT_WRAP, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, }; use sprout_db::EventQuery; @@ -19,8 +20,14 @@ use crate::connection::{AuthState, ConnectionState}; use crate::protocol::RelayMessage; use crate::state::AppState; -const MAX_HISTORICAL_LIMIT: i64 = 500; +const MAX_HISTORICAL_LIMIT: i64 = 10_000; const MAX_SUBSCRIPTIONS: usize = 1024; +const P_GATED_KINDS: [u32; 4] = [ + KIND_AGENT_OBSERVER_FRAME, + KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, + KIND_GIFT_WRAP, +]; /// Handle a REQ message: register the subscription, deliver historical events, then send EOSE. pub async fn handle_req( @@ -113,33 +120,14 @@ pub async fn handle_req( // Only applies to GLOBAL subscriptions (channel_id = None). Channel-scoped // subscriptions can never receive globally-stored events — the fan_out() // invariant in subscription.rs prevents it. - const P_GATED_KINDS: [u32; 3] = [ - KIND_MEMBER_ADDED_NOTIFICATION, - KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_GIFT_WRAP, - ]; - if channel_id.is_none() { let authed_pubkey_hex = hex::encode(&pubkey_bytes); - let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); - - for filter in &filters { - let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| { - ks.iter() - .any(|k| P_GATED_KINDS.contains(&(k.as_u16() as u32))) - }); - if can_match_p_gated { - let has_matching_p = filter.generic_tags.get(&p_tag).is_some_and(|values| { - !values.is_empty() && values.iter().all(|v| *v == authed_pubkey_hex) - }); - if !has_matching_p { - conn.send(RelayMessage::closed( - &sub_id, - "restricted: p-gated events require #p matching your pubkey", - )); - return; - } - } + if !p_gated_filters_authorized(&filters, &authed_pubkey_hex) { + conn.send(RelayMessage::closed( + &sub_id, + "restricted: p-gated events require #p matching your pubkey", + )); + return; } } @@ -247,7 +235,7 @@ pub async fn handle_req( /// Maximum Typesense pages to fetch per filter (prevents unbounded loops). const MAX_SEARCH_PAGES: u32 = 10; -fn build_search_channel_scope_filter( +pub(crate) fn build_search_channel_scope_filter( accessible_channels: &[uuid::Uuid], include_global: bool, ) -> Option { @@ -451,6 +439,93 @@ async fn handle_search_req( conn.send(RelayMessage::eose(sub_id)); } +/// Convert a single NIP-01 filter into an [`EventQuery`] for the database. +/// +/// Public wrapper for use by the HTTP bridge and COUNT handler. +/// Resolves accessible channels for the given pubkey and builds the query. +pub async fn build_event_query_from_filter( + filter: &Filter, + _pubkey_bytes: &[u8], + _state: &AppState, +) -> EventQuery { + let channel_id = extract_channel_id_from_filter(filter); + filter_to_query_params(filter, channel_id) +} + +/// Returns `true` if all constraints in this filter can be fully represented +/// in SQL by `filter_to_query_params` — meaning `count_events()` will produce +/// an exact count without post-filtering. +/// +/// Pushed constraints: kinds, authors (single or multi), ids, since, until, +/// channel_id (#h single), #p (single), #d (single, NIP-33-only kinds), #e (any), +/// channel_ids (injected by caller). +/// +/// Anything else (multi-#p, #t, #a, search, multi-#h, #d on non-NIP-33) +/// requires post-filtering and cannot use the fast COUNT path. +pub fn filter_fully_pushable(filter: &Filter) -> bool { + // Check if filter exclusively targets NIP-33 kinds (needed for #d pushability). + let is_nip33_only = filter.kinds.as_ref().is_some_and(|ks| { + !ks.is_empty() + && ks + .iter() + .all(|k| sprout_core::kind::is_parameterized_replaceable(k.as_u16() as u32)) + }); + + for (tag_key, tag_values) in filter.generic_tags.iter() { + let key = tag_key.to_string(); + match key.as_str() { + "h" => { + // Single #h is pushed as channel_id; multi-#h is not. + if tag_values.len() > 1 { + return false; + } + } + "p" => { + // Single #p is pushed via event_mentions join; multi is not. + if tag_values.len() > 1 { + return false; + } + } + "d" => { + // #d is pushed (single or multi) ONLY for NIP-33-only kind filters. + // Otherwise it's silently ignored by SQL → overcount. + if !tag_values.is_empty() && !is_nip33_only { + return false; + } + } + "e" => { + // #e is fully pushed (any count) via JSONB containment. + } + _ => { + // Any other generic tag (#t, #a, etc.) is not pushed. + if !tag_values.is_empty() { + return false; + } + } + } + } + // search field is not pushed by filter_to_query_params + if filter.search.is_some() { + return false; + } + true +} + +/// Extract a channel UUID from a single filter's `#h` tag. +fn extract_channel_id_from_filter(filter: &Filter) -> Option { + for (tag_key, tag_values) in filter.generic_tags.iter() { + let key = tag_key.to_string(); + if key == "h" { + for val in tag_values { + if let Ok(id) = val.parse::() { + return Some(id); + } + } + } + } + None +} + /// Convert a single NIP-01 filter into an [`EventQuery`] for the database. /// /// Each filter is queried independently so that per-filter `limit` and time @@ -479,13 +554,42 @@ fn filter_to_query_params(filter: &Filter, channel_id: Option) -> Ev .map(|l| (l as i64).min(MAX_HISTORICAL_LIMIT)) .unwrap_or(MAX_HISTORICAL_LIMIT); - // Push single-author filter into SQL (EventQuery.pubkey is Option>). - // Multi-author filters fall through to in-memory filters_match post-filtering. - let pubkey = filter.authors.as_ref().and_then(|authors| { - if authors.len() == 1 { - authors.iter().next().map(|pk| pk.serialize().to_vec()) - } else { + // Push author filter into SQL. Single-author uses the indexed `pubkey` column; + // multi-author uses the `authors` IN-list pushdown added in the pure-nostr PR. + let (pubkey, authors) = match filter.authors.as_ref() { + Some(a) if a.len() == 1 => (a.iter().next().map(|pk| pk.serialize().to_vec()), None), + Some(a) if !a.is_empty() => ( + None, + Some( + a.iter() + .map(|pk| pk.serialize().to_vec()) + .collect::>(), + ), + ), + _ => (None, None), + }; + + // Push event IDs into SQL via the `ids` IN-list pushdown. + let ids = filter.ids.as_ref().and_then(|id_set| { + if id_set.is_empty() { None + } else { + Some( + id_set + .iter() + .map(|id| id.to_bytes().to_vec()) + .collect::>(), + ) + } + }); + + // Push #e tag filter into SQL via JSONB containment. + let e_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::E); + let e_tags = filter.generic_tags.get(&e_tag_key).and_then(|values| { + if values.is_empty() { + None + } else { + Some(values.iter().map(|v| v.to_string()).collect::>()) } }); @@ -517,16 +621,21 @@ fn filter_to_query_params(filter: &Filter, channel_id: Option) -> Ev .all(|&k| sprout_core::kind::is_parameterized_replaceable(k as u32)) }); let d_tag_key = nostr::SingleLetterTag::lowercase(nostr::Alphabet::D); - let d_tag = if filter_is_nip33_only { - filter.generic_tags.get(&d_tag_key).and_then(|values| { - if values.len() == 1 { - values.iter().next().map(|v| v.to_string()) - } else { - None - } - }) + let (d_tag, d_tags) = if filter_is_nip33_only { + let values = filter.generic_tags.get(&d_tag_key); + match values.map(|v| v.len()) { + Some(1) => ( + values.and_then(|vs| vs.iter().next().map(|v| v.to_string())), + None, + ), + Some(n) if n > 1 => ( + None, + values.map(|vs| vs.iter().map(|v| v.to_string()).collect::>()), + ), + _ => (None, None), + } } else { - None + (None, None) }; EventQuery { @@ -538,6 +647,10 @@ fn filter_to_query_params(filter: &Filter, channel_id: Option) -> Ev limit: Some(limit), p_tag_hex, d_tag, + d_tags, + authors, + ids, + e_tags, ..Default::default() } } @@ -581,6 +694,30 @@ fn extract_channel_id_from_filters(filters: &[Filter]) -> Option { found_id } +pub(crate) fn p_gated_filters_authorized(filters: &[Filter], authed_pubkey_hex: &str) -> bool { + let p_tag = nostr::SingleLetterTag::lowercase(nostr::Alphabet::P); + filters.iter().all(|filter| { + // Filters with explicit `ids` are targeting specific known events — they + // can't be used to fish for p-gated events you don't own because the + // caller already knows the event ID. Skip the p-gate check. + if filter.ids.as_ref().is_some_and(|ids| !ids.is_empty()) { + return true; + } + + let can_match_p_gated = filter.kinds.as_ref().is_none_or(|ks| { + ks.iter() + .any(|kind| P_GATED_KINDS.contains(&(kind.as_u16() as u32))) + }); + if !can_match_p_gated { + return true; + } + + filter.generic_tags.get(&p_tag).is_some_and(|values| { + !values.is_empty() && values.iter().all(|value| value == authed_pubkey_hex) + }) + }) +} + #[cfg(test)] mod tests { use super::*; @@ -663,6 +800,32 @@ mod tests { assert!(!has_non_search, "all-search filters should not be mixed"); } + #[test] + fn agent_observer_subscription_requires_matching_p_tag() { + let p_tag = SingleLetterTag::lowercase(Alphabet::P); + let authed = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let other = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + let missing_p = Filter::new().kind(nostr::Kind::Custom( + sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16, + )); + assert!(!p_gated_filters_authorized(&[missing_p], authed)); + + let wrong_p = Filter::new() + .kind(nostr::Kind::Custom( + sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16, + )) + .custom_tag(p_tag, [other]); + assert!(!p_gated_filters_authorized(&[wrong_p], authed)); + + let matching_p = Filter::new() + .kind(nostr::Kind::Custom( + sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16, + )) + .custom_tag(p_tag, [authed]); + assert!(p_gated_filters_authorized(&[matching_p], authed)); + } + #[test] fn d_tag_pushdown_only_for_nip33_kinds() { let d_tag = SingleLetterTag::lowercase(Alphabet::D); diff --git a/crates/sprout-relay/src/handlers/side_effects.rs b/crates/sprout-relay/src/handlers/side_effects.rs index 39a09932b1..4e7d3109e2 100644 --- a/crates/sprout-relay/src/handlers/side_effects.rs +++ b/crates/sprout-relay/src/handlers/side_effects.rs @@ -7,8 +7,9 @@ use tracing::{info, warn}; use uuid::Uuid; use sprout_core::kind::{ - event_kind_u32, KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, - KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, KIND_NIP29_GROUP_METADATA, KIND_REACTION, + event_kind_u32, KIND_GIT_REPO_ANNOUNCEMENT, KIND_MEMBER_ADDED_NOTIFICATION, + KIND_MEMBER_REMOVED_NOTIFICATION, KIND_NIP29_GROUP_ADMINS, KIND_NIP29_GROUP_MEMBERS, + KIND_NIP29_GROUP_METADATA, KIND_NIP43_MEMBERSHIP_LIST, KIND_REACTION, }; use sprout_db::channel::MemberRole; @@ -27,7 +28,7 @@ pub fn is_admin_kind(kind: u32) -> bool { /// handled in `ingest_event()` before storage so we can short-circuit on /// duplicates without storing the event at all. pub fn is_side_effect_kind(kind: u32) -> bool { - matches!(kind, 0 | 5 | 9000..=9022 | 41001..=41003 | 40099) + matches!(kind, 0 | 5 | 9000..=9022 | KIND_GIT_REPO_ANNOUNCEMENT | 41001..=41003 | 40099) } async fn evict_live_channel_subscriptions( @@ -85,6 +86,8 @@ pub async fn handle_side_effects( } 9021 => handle_join_request(event, state).await, 9022 => handle_leave_request(event, state).await, + // NIP-34: Git repo announcement → create bare repo on disk. + KIND_GIT_REPO_ANNOUNCEMENT => handle_git_repo_announcement(event, state).await, // kind:7 (reaction) handled inline in ingest_event() before storage. _ => Ok(()), } @@ -102,7 +105,23 @@ pub async fn validate_standard_deletion_event( let target_ids = extract_target_event_ids(event); if target_ids.is_empty() { - return Err(anyhow::anyhow!("missing e tag for target event")); + // a-tag deletion: verify author owns the addressable event + let a_tag = event + .tags + .iter() + .find(|t| t.kind().to_string() == "a") + .and_then(|t| t.content().map(|s| s.to_string())) + .ok_or_else(|| anyhow::anyhow!("missing e or a tag for target"))?; + let parts: Vec<&str> = a_tag.splitn(3, ':').collect(); + if parts.len() < 2 { + return Err(anyhow::anyhow!("invalid a-tag format")); + } + let target_pubkey_bytes = + hex::decode(parts[1]).map_err(|_| anyhow::anyhow!("invalid pubkey in a-tag"))?; + if target_pubkey_bytes != actor_bytes { + return Err(anyhow::anyhow!("must be event author")); + } + return Ok(()); } for target_id in target_ids { @@ -503,7 +522,35 @@ async fn emit_addressable_discovery_event( tags: Vec, relay_pubkey_hex: &str, ) -> anyhow::Result<()> { + // Ensure the new event's created_at is strictly greater than any existing event + // of the same (kind, pubkey, channel_id). Without this, rapid successive updates + // (e.g. set topic then set purpose in the same second) can produce events with + // identical created_at, causing the second to be rejected by stale-write protection + // (NIP-16 tiebreaker: lower event ID wins, which is random). + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let min_ts = { + let existing = state + .db + .query_events(&sprout_db::event::EventQuery { + kinds: Some(vec![kind as i32]), + channel_id: Some(channel_id), + limit: Some(1), + ..Default::default() + }) + .await + .unwrap_or_default(); + existing + .first() + .map(|e| e.event.created_at.as_u64() + 1) + .unwrap_or(now) + }; + let ts = now.max(min_ts); + let event = EventBuilder::new(Kind::Custom(kind as u16), "", tags) + .custom_created_at(nostr::Timestamp::from(ts)) .sign_with_keys(&state.relay_keypair) .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; @@ -547,14 +594,48 @@ pub async fn emit_group_discovery_events( } if channel.visibility == "private" { tags.push(Tag::parse(&["private"])?); + } else { + // Explicit "public" tag complements NIP-29's absence-of-"private" convention, + // making channel visibility self-describing for clients. + tags.push(Tag::parse(&["public"])?); } // NIP-29 hidden tag: hint to clients not to show DMs in public group lists. // Not a security boundary — access control is handled by channel-scoped storage. if channel.channel_type == "dm" { tags.push(Tag::parse(&["hidden"])?); + // Include participant pubkeys in kind:39000 for DMs so clients can + // resolve display names without a separate kind:39002 fetch. + for m in &members { + let pubkey_hex = hex::encode(&m.pubkey); + tags.push(Tag::parse(&["p", &pubkey_hex])?); + } } // Sprout channels always require explicit membership tags.push(Tag::parse(&["closed"])?); + // Channel type tag so clients can distinguish stream/forum/dm without inference + tags.push(Tag::parse(&["t", &channel.channel_type])?); + // Optional topic / purpose for richer client UX + if let Some(ref topic) = channel.topic { + if !topic.is_empty() { + tags.push(Tag::parse(&["topic", topic])?); + } + } + if let Some(ref purpose) = channel.purpose { + if !purpose.is_empty() { + tags.push(Tag::parse(&["purpose", purpose])?); + } + } + // Archived state — clients use this to hide channels from the sidebar. + if channel.archived_at.is_some() { + tags.push(Tag::parse(&["archived", "true"])?); + } + // Ephemeral channel TTL — clients use this to show countdown timers. + if let Some(ttl) = channel.ttl_seconds { + tags.push(Tag::parse(&["ttl", &ttl.to_string()])?); + } + if let Some(ref deadline) = channel.ttl_deadline { + tags.push(Tag::parse(&["ttl_deadline", &deadline.to_rfc3339()])?); + } emit_addressable_discovery_event( state, channel_id, @@ -590,7 +671,9 @@ pub async fn emit_group_discovery_events( let mut tags: Vec = vec![Tag::parse(&["d", &group_id])?]; for m in &members { let pubkey_hex = hex::encode(&m.pubkey); - tags.push(Tag::parse(&["p", &pubkey_hex])?); + // NIP-29 convention: ["p", pubkey, relay_url, role]. Empty relay_url + // because the canonical relay is implicit (this event is signed by it). + tags.push(Tag::parse(&["p", &pubkey_hex, "", &m.role])?); } emit_addressable_discovery_event( state, @@ -1242,13 +1325,81 @@ async fn handle_leave_request(event: &Event, state: &Arc) -> anyhow::R // handle_reaction() removed — kind:7 reaction dedup and DB writes are now // handled inline in ingest_event() before storage (see ingest.rs step 20a). +/// Handle NIP-09 deletion via `a` tag (addressable/parameterized-replaceable events). +/// Parses "kind:pubkey:d-tag" and deletes the corresponding DB record. +async fn handle_a_tag_deletion(event: &Event, state: &Arc) -> anyhow::Result<()> { + let a_value = event + .tags + .iter() + .find(|t| t.kind().to_string() == "a") + .and_then(|t| t.content().map(|s| s.to_string())) + .ok_or_else(|| anyhow::anyhow!("missing a tag for addressable deletion"))?; + + let parts: Vec<&str> = a_value.splitn(3, ':').collect(); + if parts.len() < 3 { + return Err(anyhow::anyhow!("invalid a-tag format: {a_value}")); + } + let kind_num: u32 = parts[0] + .parse() + .map_err(|_| anyhow::anyhow!("invalid kind in a-tag"))?; + let pubkey_hex = parts[1]; + let d_tag = parts[2]; + + match kind_num { + sprout_core::kind::KIND_WORKFLOW_DEF => { + // Try UUID first (workflow_id); fall back to name-based lookup. + if let Ok(wf_id) = uuid::Uuid::parse_str(d_tag) { + state + .db + .delete_workflow(wf_id) + .await + .map_err(|e| anyhow::anyhow!("failed to delete workflow {wf_id}: {e}"))?; + tracing::info!(workflow_id = %wf_id, "Workflow deleted via NIP-09 a-tag (UUID)"); + } else { + // Name-based lookup + let owner_bytes = hex::decode(pubkey_hex).unwrap_or_default(); + match state + .db + .find_workflow_by_owner_and_name(&owner_bytes, d_tag) + .await + { + Ok(Some(wf)) => { + state.db.delete_workflow(wf.id).await.map_err(|e| { + anyhow::anyhow!("failed to delete workflow {}: {e}", wf.id) + })?; + tracing::info!(workflow_id = %wf.id, name = d_tag, "Workflow deleted via NIP-09 a-tag (name)"); + } + Ok(None) => { + tracing::warn!( + "NIP-09 a-tag deletion: no workflow '{d_tag}' found for owner" + ); + } + Err(e) => { + tracing::warn!("NIP-09 a-tag deletion: DB lookup failed: {e}"); + } + } + } + } + _ => { + tracing::debug!( + kind = kind_num, + d_tag = d_tag, + "NIP-09 a-tag deletion for unhandled kind — no side effect" + ); + } + } + + Ok(()) +} + async fn handle_standard_deletion_event( event: &Event, state: &Arc, ) -> anyhow::Result<()> { let target_ids = extract_target_event_ids(event); if target_ids.is_empty() { - return Err(anyhow::anyhow!("missing e tag for target event")); + // NIP-09 a-tag deletion path for addressable events + return handle_a_tag_deletion(event, state).await; } for target_id in target_ids { @@ -1425,3 +1576,381 @@ fn extract_tag_value(event: &Event, tag_name: &str) -> Option { } None } + +// ── NIP-34: Git repository side effects ────────────────────────────────────── + +/// Validate a git repo identifier (d-tag value from kind:30617). +/// +/// Rules: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..`. +fn validate_repo_id(repo_id: &str) -> bool { + !repo_id.is_empty() + && repo_id.len() <= 64 + && !repo_id.starts_with('.') + && !repo_id.contains("..") + && repo_id + .chars() + .all(|c| c.is_ascii_alphanumeric() || c == '.' || c == '_' || c == '-') +} + +/// Handle kind:30617 (NIP-34 Git Repository Announcement). +/// +/// Creates a bare git repo on disk when a repo announcement event is stored. +/// The event's `d` tag is the repo identifier; the pubkey is the owner. +/// +/// Security hardening: +/// - Repo name validated: `[a-zA-Z0-9._-]{1,64}`, no leading dots, no `..` +/// - Owner pubkey validated: exactly 64 lowercase hex chars +/// - Path canonicalized and verified to start with repo root +/// - Pre-receive hook installed for permission enforcement (only hook enabled) +/// - Per-pubkey repo count limit enforced +async fn handle_git_repo_announcement(event: &Event, state: &Arc) -> anyhow::Result<()> { + use tokio::process::Command; + + // Extract repo identifier from d tag (required for NIP-33 parameterized replaceable events). + let repo_id = + extract_tag_value(event, "d").ok_or_else(|| anyhow::anyhow!("kind:30617 missing d tag"))?; + + if !validate_repo_id(&repo_id) { + return Err(anyhow::anyhow!( + "invalid repo identifier: must be [a-zA-Z0-9._-]{{1,64}}, no leading dots, no '..'" + )); + } + + let owner_hex = nostr::util::hex::encode(event.pubkey.serialize()); + + // Resolve repo path. + let git_repo_root = &state.config.git_repo_path; + let repo_dir = git_repo_root + .join(&owner_hex) + .join(format!("{repo_id}.git")); + + // If repo already exists, this is an update to the announcement — nothing to do on disk. + // Backfill the name reservation if missing (handles upgrade from pre-uniqueness-check state). + if repo_dir.exists() { + let names_dir = git_repo_root.join(".names"); + let _ = std::fs::create_dir_all(&names_dir); + let _ = std::fs::create_dir(names_dir.join(&repo_id)); + info!( + repo_id = %repo_id, + owner = %owner_hex, + "kind:30617 repo announcement updated (repo already exists)" + ); + return Ok(()); + } + + // Global uniqueness: repo names are unique across all owners (relay = single namespace). + // The relay signs kind:30618 ref-state with d-tag = repo_name, so collisions would + // cause one owner's ref state to overwrite another's. + // + // Atomicity: std::fs::create_dir fails with AlreadyExists if the directory exists, + // preventing TOCTOU races between concurrent kind:30617 events. The .names/ directory + // acts as a global name reservation index. + let names_dir = git_repo_root.join(".names"); + std::fs::create_dir_all(&names_dir) + .map_err(|e| anyhow::anyhow!("failed to create name reservation index: {e}"))?; + let reservation = names_dir.join(&repo_id); + match std::fs::create_dir(&reservation) { + Ok(()) => {} // Name claimed successfully. + Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { + return Err(anyhow::anyhow!( + "repo name '{repo_id}' already taken by another owner" + )); + } + Err(e) => { + return Err(anyhow::anyhow!( + "failed to reserve repo name '{repo_id}': {e}" + )); + } + } + + // Per-pubkey repo count limit. + let owner_dir = git_repo_root.join(&owner_hex); + if owner_dir.exists() { + let count = std::fs::read_dir(&owner_dir) + .map(|entries| entries.filter_map(|e| e.ok()).count()) + .unwrap_or(0); + let limit = state.config.git_max_repos_per_pubkey as usize; + if count >= limit { + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!("repo limit exceeded: {count} >= {limit}")); + } + } + + // Create parent directory. + if let Err(e) = tokio::fs::create_dir_all(&repo_dir).await { + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!( + "failed to create repo directory {}: {e}", + repo_dir.display() + )); + } + + // Path canonicalization: verify resolved path is under the repo root. + let canonical_root = match git_repo_root.canonicalize() { + Ok(p) => p, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&repo_dir).await; + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!("failed to canonicalize repo root: {e}")); + } + }; + let canonical_repo = match repo_dir.canonicalize() { + Ok(p) => p, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&repo_dir).await; + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!("failed to canonicalize repo path: {e}")); + } + }; + if !canonical_repo.starts_with(&canonical_root) { + let _ = tokio::fs::remove_dir_all(&repo_dir).await; + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!( + "repo path escapes root: {} not under {}", + canonical_repo.display(), + canonical_root.display() + )); + } + + // Initialize bare repo with main as default branch. + let output = match Command::new("git") + .arg("init") + .arg("--bare") + .arg("-b") + .arg("main") + .arg(&repo_dir) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null") + .output() + .await + { + Ok(o) => o, + Err(e) => { + let _ = tokio::fs::remove_dir_all(&repo_dir).await; + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!("git init --bare failed to spawn: {e}")); + } + }; + + if !output.status.success() { + let stderr = String::from_utf8_lossy(&output.stderr); + let _ = tokio::fs::remove_dir_all(&repo_dir).await; + let _ = std::fs::remove_dir(&reservation); + return Err(anyhow::anyhow!("git init --bare failed: {stderr}")); + } + + // Git config for Smart HTTP compatibility. + for (key, value) in [ + ("http.receivepack", "true"), + ("receive.denyNonFastForwards", "false"), + ("uploadpack.allowTipSHA1InWant", "true"), + ("uploadpack.allowReachableSHA1InWant", "true"), + ] { + let _ = Command::new("git") + .args(["config", "--file"]) + .arg(repo_dir.join("config")) + .args([key, value]) + .env_clear() + .env("PATH", std::env::var("PATH").unwrap_or_default()) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("HOME", "/dev/null") + .output() + .await; + } + + // Install pre-receive hook for permission enforcement. + // This replaces the old "disable all hooks" approach — we now have our own + // hook that calls back to the relay's internal policy endpoint. + // Only pre-receive is installed; all other hook slots remain empty (RCE prevention). + // SECURITY: Hook installation is FATAL. If the hook can't be installed, + // the repo would be unprotected. Better to fail repo creation than allow + // an unprotected repo to exist. The receive_pack handler also checks hook + // existence as a belt-and-suspenders measure. + crate::api::git::hook::install_hook(&repo_dir) + .await + .map_err(|e| { + // Clean up the repo directory since it's unusable without the hook. + let _ = std::fs::remove_dir_all(&repo_dir); + let _ = std::fs::remove_dir(&reservation); + anyhow::anyhow!("failed to install pre-receive hook: {e}") + })?; + + info!( + repo_id = %repo_id, + owner = %owner_hex, + path = %repo_dir.display(), + "bare git repo created from kind:30617 announcement" + ); + + Ok(()) +} + +// ── NIP-43 relay-level membership announcement events ──────────────────────── + +/// Publish a kind:13534 relay membership list event (NIP-43). +/// +/// Queries all current relay members and emits a relay-signed, NIP-70-protected +/// addressable event listing every member pubkey. Replaces any previous list. +pub async fn publish_nip43_membership_list(state: &Arc) -> anyhow::Result<()> { + let members = state.db.list_relay_members().await?; + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + + let mut tags: Vec = Vec::with_capacity(members.len() + 1); + + // NIP-70 protected-event marker — prevents re-broadcasting by third parties. + tags.push(Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?); + + for member in &members { + tags.push( + Tag::parse(&["member", &member.pubkey, &member.role]) + .map_err(|e| anyhow::anyhow!("failed to build member tag: {e}"))?, + ); + } + + let event = EventBuilder::new(Kind::Custom(KIND_NIP43_MEMBERSHIP_LIST as u16), "", tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:13534: {e}"))?; + + // NOTE: kind 13534 is technically a regular event (not in the NIP-16 replaceable + // range), but we intentionally use replace_addressable_event to get replacement + // semantics — only the latest membership snapshot matters. This function keys on + // (kind, pubkey, channel_id) and atomically replaces older events, which is exactly + // what Pyramid (the reference NIP-43 implementation) does with store.ReplaceEvent(). + let (stored, was_inserted) = state.db.replace_addressable_event(&event, None).await?; + if was_inserted { + dispatch_persistent_event( + state, + &stored, + KIND_NIP43_MEMBERSHIP_LIST, + &relay_pubkey_hex, + ) + .await; + } + + info!( + member_count = members.len(), + "NIP-43 membership list published" + ); + Ok(()) +} + +/// Shared helper: publish a NIP-43 membership delta event (kind 8000 or 8001). +/// +/// Signs a relay event with `["-"]` (NIP-70) + `["p", target]` tags, stores it +/// globally, and fans out to matching subscribers. +async fn publish_nip43_delta( + state: &Arc, + kind: u16, + target_pubkey_hex: &str, + label: &str, +) -> anyhow::Result<()> { + let relay_pubkey_hex = state.relay_keypair.public_key().to_hex(); + + let tags = vec![ + Tag::parse(&["-"]).map_err(|e| anyhow::anyhow!("failed to build '-' tag: {e}"))?, + Tag::parse(&["p", target_pubkey_hex]) + .map_err(|e| anyhow::anyhow!("failed to build p tag: {e}"))?, + ]; + + let event = EventBuilder::new(Kind::Custom(kind), "", tags) + .sign_with_keys(&state.relay_keypair) + .map_err(|e| anyhow::anyhow!("failed to sign kind:{kind}: {e}"))?; + + let (stored, was_inserted) = state.db.insert_event(&event, None).await?; + if !was_inserted { + return Ok(()); + } + + let matches = state.sub_registry.fan_out(&stored); + if !matches.is_empty() { + let event_json = match serde_json::to_string(&stored.event) { + Ok(json) => json, + Err(e) => { + warn!("failed to serialize kind:{kind} for fan-out: {e}"); + return Ok(()); + } + }; + for (target_conn_id, sub_id) in &matches { + let msg = format!(r#"["EVENT","{}",{}]"#, sub_id, event_json); + state.conn_manager.send_to(*target_conn_id, msg); + } + } + + info!( + target = %target_pubkey_hex, + relay = %relay_pubkey_hex, + "NIP-43 {label} event published" + ); + Ok(()) +} + +/// Publish a kind:8000 relay member-added announcement event (NIP-43). +pub async fn publish_nip43_member_added( + state: &Arc, + target_pubkey_hex: &str, +) -> anyhow::Result<()> { + publish_nip43_delta(state, 8000, target_pubkey_hex, "member-added").await +} + +/// Publish a kind:8001 relay member-removed announcement event (NIP-43). +pub async fn publish_nip43_member_removed( + state: &Arc, + target_pubkey_hex: &str, +) -> anyhow::Result<()> { + publish_nip43_delta(state, 8001, target_pubkey_hex, "member-removed").await +} + +/// Reconcile channels that exist in the DB but don't have kind:39000 events. +/// +/// This handles the case where channels were created via direct SQL inserts +/// (e.g. test seed scripts) rather than through the Nostr event pipeline. +/// Emits kind:39000 (metadata) and kind:39002 (members) for each channel +/// that is missing its discovery events. +/// +/// Idempotent: checks for existing kind:39000 events before emitting. +pub async fn reconcile_channel_events(state: &Arc) -> anyhow::Result<()> { + use sprout_db::event::EventQuery; + + let channels = state.db.list_channels(None).await?; + if channels.is_empty() { + return Ok(()); + } + + let mut reconciled = 0u32; + for channel in &channels { + // Check if kind:39000 event already exists for this channel. + let channel_id_str = channel.id.to_string(); + let existing = state + .db + .query_events(&EventQuery { + kinds: Some(vec![39000]), + d_tag: Some(channel_id_str.clone()), + limit: Some(1), + ..Default::default() + }) + .await + .unwrap_or_default(); + + if existing.is_empty() { + // No discovery event — emit one. + if let Err(e) = emit_group_discovery_events(state, channel.id).await { + tracing::debug!( + channel_id = %channel.id, + error = %e, + "reconcile: failed to emit discovery events" + ); + } else { + reconciled += 1; + } + } + } + + if reconciled > 0 { + tracing::info!(count = reconciled, "reconciled channel discovery events"); + } + Ok(()) +} diff --git a/crates/sprout-relay/src/main.rs b/crates/sprout-relay/src/main.rs index 0ecbfc954a..5ee724b4fa 100644 --- a/crates/sprout-relay/src/main.rs +++ b/crates/sprout-relay/src/main.rs @@ -59,6 +59,73 @@ async fn main() -> anyhow::Result<()> { error!("Failed to ensure partitions: {e}"); } + // NIP-43: if membership enforcement is on, a valid owner pubkey is required. + // config.rs already strips invalid values with a warning; catch the resulting + // None here so we fail fast with a clear message rather than starting a relay + // that no one can administer. + if config.require_relay_membership && config.relay_owner_pubkey.is_none() { + error!( + "SPROUT_REQUIRE_RELAY_MEMBERSHIP=true but RELAY_OWNER_PUBKEY is not set or invalid. \ + Set RELAY_OWNER_PUBKEY to a valid 64-char hex pubkey." + ); + return Err(anyhow::anyhow!( + "RELAY_OWNER_PUBKEY required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true" + )); + } + + // NIP-43: relay membership requires a stable signing key. + // Check this before any DB mutations so we fail fast — no point backfilling + // or bootstrapping if we'll reject the config anyway. + if config.require_relay_membership && config.relay_private_key.is_none() { + return Err(anyhow::anyhow!( + "SPROUT_RELAY_PRIVATE_KEY is required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true. \ + NIP-43 events signed with an ephemeral key become unverifiable after restart." + )); + } + + // NIP-43: migrate any existing pubkey_allowlist entries to relay_members. + // Idempotent — safe to run every startup. Must run before bootstrap_owner + // so that existing allowlist users become relay members before the owner + // is promoted (otherwise enabling membership locks everyone out). + match db.backfill_from_allowlist().await { + Ok(0) => {} + Ok(n) => info!("Backfilled {n} pubkey_allowlist entries into relay_members"), + Err(e) => { + if config.require_relay_membership { + error!( + "Fatal: failed to backfill allowlist with membership enforcement enabled: {e}" + ); + return Err(anyhow::anyhow!( + "Failed to backfill pubkey_allowlist (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}" + )); + } else { + error!("Failed to backfill pubkey_allowlist (non-fatal): {e}"); + } + } + } + + // NIP-43: ensure the configured relay owner always holds the owner role. + if let Some(ref owner_pubkey) = config.relay_owner_pubkey { + match db.bootstrap_owner(owner_pubkey).await { + Ok(()) => info!(pubkey = %owner_pubkey, "Relay owner bootstrapped"), + Err(e) => { + if config.require_relay_membership { + // Membership enforcement is on — a missing owner means no one + // can administer the relay. Fail fast rather than silently start + // in a broken state. + error!("Fatal: failed to bootstrap relay owner with membership enforcement enabled: {e}"); + return Err(anyhow::anyhow!( + "Failed to bootstrap relay owner (required when SPROUT_REQUIRE_RELAY_MEMBERSHIP=true): {e}" + )); + } else { + error!( + "Failed to bootstrap relay owner (non-fatal, membership not required): {e}" + ); + } + } + } + } + // NIP-33: backfill d_tag for any existing parameterized replaceable events // that predate the column addition. Idempotent — no-ops when fully populated. match db.backfill_d_tags().await { @@ -116,10 +183,24 @@ async fn main() -> anyhow::Result<()> { let relay_keypair = if let Some(hex) = &config.relay_private_key { nostr::Keys::parse(hex) .map_err(|e| anyhow::anyhow!("invalid SPROUT_RELAY_PRIVATE_KEY: {e}"))? - } else { - let keys = nostr::Keys::generate(); - tracing::info!("Generated relay keypair: {}", keys.public_key().to_hex()); + } else if !config.require_auth_token { + // Dev mode: use a deterministic keypair so addressable events (kind:39000/39001/39002) + // replace correctly across restarts. Without this, each restart generates a new pubkey + // and replace_addressable_event inserts duplicates instead of replacing. + const DEV_RELAY_PRIVKEY: &str = + "0000000000000000000000000000000000000000000000000000000000000001"; + let keys = nostr::Keys::parse(DEV_RELAY_PRIVKEY).expect("hardcoded dev key is valid"); + tracing::warn!( + pubkey = %keys.public_key().to_hex(), + "Using hardcoded dev relay keypair (SPROUT_REQUIRE_AUTH_TOKEN=false). \ + Set SPROUT_RELAY_PRIVATE_KEY for production." + ); keys + } else { + panic!( + "SPROUT_RELAY_PRIVATE_KEY must be set when SPROUT_REQUIRE_AUTH_TOKEN=true. \ + A stable relay identity is required for production." + ); }; config @@ -144,6 +225,49 @@ async fn main() -> anyhow::Result<()> { ); let state = Arc::new(app_state); + // NIP-43: publish the initial membership list on startup so clients can + // REQ kind:13534 immediately without waiting for the next membership change. + if config.require_relay_membership { + let startup_state = Arc::clone(&state); + tokio::spawn(async move { + if let Err(e) = + sprout_relay::handlers::side_effects::publish_nip43_membership_list(&startup_state) + .await + { + tracing::warn!(error = %e, "failed to publish initial NIP-43 membership list on startup"); + } else { + tracing::info!("NIP-43 membership list published on startup"); + } + }); + } + + // Emit kind:39000/39002 discovery events for channels that exist in the DB + // but don't have corresponding events (e.g. seeded via direct SQL inserts). + // Only runs when SPROUT_RECONCILE_CHANNELS=true (dev/CI environments). + // Production relays create channels through the event pipeline and don't need this. + if std::env::var("SPROUT_RECONCILE_CHANNELS").is_ok() { + let reconcile_state = Arc::clone(&state); + tokio::spawn(async move { + // Try immediately, then retry every 5s for up to 2 minutes. + // Handles CI pattern: relay starts → seed script inserts data → reconciliation. + for attempt in 0..24u32 { + if attempt > 0 { + tokio::time::sleep(std::time::Duration::from_secs(5)).await; + } + match sprout_relay::handlers::side_effects::reconcile_channel_events( + &reconcile_state, + ) + .await + { + Ok(()) => {} + Err(e) => { + tracing::debug!(error = %e, "channel reconciliation attempt failed"); + } + } + } + }); + } + // Wire the action sink — must happen after AppState (which creates // sub_registry, conn_manager) and before the cron loop starts. let action_sink = Arc::new(sprout_relay::workflow_sink::RelayActionSink::new(&state)); diff --git a/crates/sprout-relay/src/nip11.rs b/crates/sprout-relay/src/nip11.rs index c9cf2e62e6..1a1694c222 100644 --- a/crates/sprout-relay/src/nip11.rs +++ b/crates/sprout-relay/src/nip11.rs @@ -7,7 +7,7 @@ use crate::connection::MAX_FRAME_BYTES; /// NIPs supported by this relay, advertised in the NIP-11 document. /// Kept as a module-level constant so tests can verify it without constructing /// a full `Config` (which reads env vars and races with config.rs tests). -pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 42, 50]; +pub(crate) const SUPPORTED_NIPS: &[u32] = &[1, 2, 10, 11, 16, 17, 23, 25, 29, 33, 38, 42, 43, 50]; /// Relay information document served at `GET /` with `Accept: application/nostr+json`. #[derive(Debug, Clone, Serialize, Deserialize)] @@ -28,6 +28,9 @@ pub struct RelayInfo { pub version: String, /// Protocol and resource limits advertised to clients. pub limitation: Option, + /// Relay's own signing pubkey (NIP-11 `self` field, NIP-43). + #[serde(rename = "self", skip_serializing_if = "Option::is_none")] + pub relay_self: Option, } /// Protocol and resource limits advertised in the NIP-11 document. @@ -55,7 +58,10 @@ pub struct RelayLimitation { impl RelayInfo { /// Builds a `RelayInfo` document from the relay's runtime config. - pub fn from_config(config: &crate::config::Config) -> Self { + /// + /// `relay_pubkey` is the relay's own signing pubkey (hex), advertised as the + /// NIP-11 `self` field for NIP-43 membership verification. + pub fn from_config(config: &crate::config::Config, relay_pubkey: Option<&str>) -> Self { Self { name: "Sprout Relay".to_string(), description: "Sprout — private team communication relay".to_string(), @@ -68,13 +74,14 @@ impl RelayInfo { max_message_length: Some(MAX_FRAME_BYTES as u64), max_subscriptions: Some(1024), max_filters: Some(10), - max_limit: Some(500), + max_limit: Some(10_000), max_subid_length: Some(256), min_pow_difficulty: None, auth_required: config.require_auth_token, payment_required: false, restricted_writes: true, }), + relay_self: relay_pubkey.map(|s| s.to_string()), } } } @@ -83,7 +90,17 @@ impl RelayInfo { pub async fn relay_info_handler( axum::extract::State(state): axum::extract::State>, ) -> axum::response::Json { - axum::response::Json(RelayInfo::from_config(&state.config)) + // Only advertise the NIP-11 `self` field when a stable relay key is configured. + // Ephemeral (auto-generated) keys change on restart, making signed events unverifiable. + let relay_pubkey = if state.config.relay_private_key.is_some() { + Some(state.relay_keypair.public_key().to_hex()) + } else { + None + }; + axum::response::Json(RelayInfo::from_config( + &state.config, + relay_pubkey.as_deref(), + )) } #[cfg(test)] @@ -104,6 +121,14 @@ mod tests { ); } + #[test] + fn supported_nips_includes_nip38() { + assert!( + SUPPORTED_NIPS.contains(&38), + "NIP-38 (user statuses) must be advertised" + ); + } + #[test] fn supported_nips_are_sorted() { let mut sorted = SUPPORTED_NIPS.to_vec(); diff --git a/crates/sprout-relay/src/protocol.rs b/crates/sprout-relay/src/protocol.rs index 22aa4ac163..6cac612592 100644 --- a/crates/sprout-relay/src/protocol.rs +++ b/crates/sprout-relay/src/protocol.rs @@ -25,6 +25,13 @@ pub enum ClientMessage { }, /// A CLOSE message cancelling an active subscription. Close(String), + /// A COUNT message requesting aggregate counts (NIP-45). + Count { + /// The client-assigned subscription identifier. + sub_id: String, + /// The filters to count against. + filters: Vec, + }, /// An AUTH message responding to a NIP-42 challenge. Auth(Event), } @@ -98,6 +105,44 @@ impl ClientMessage { .collect::>>()?; Ok(ClientMessage::Req { sub_id, filters }) } + "COUNT" => { + if arr.len() < 2 { + return Err(RelayError::InvalidMessage( + "COUNT requires sub_id".to_string(), + )); + } + let sub_id = arr[1] + .as_str() + .ok_or_else(|| { + RelayError::InvalidMessage("COUNT sub_id must be a string".to_string()) + })? + .to_string(); + if sub_id.is_empty() { + return Err(RelayError::InvalidMessage( + "COUNT sub_id must not be empty".to_string(), + )); + } + if sub_id.len() > MAX_SUB_ID_LENGTH { + return Err(RelayError::InvalidMessage(format!( + "COUNT sub_id exceeds maximum length of {MAX_SUB_ID_LENGTH} bytes" + ))); + } + let filter_values = &arr[2..]; + if filter_values.len() > MAX_FILTERS_PER_REQ { + return Err(RelayError::InvalidMessage(format!( + "COUNT contains {} filters, maximum is {MAX_FILTERS_PER_REQ}", + filter_values.len() + ))); + } + let filters: Vec = filter_values + .iter() + .map(|v| { + serde_json::from_value(v.clone()) + .map_err(|e| RelayError::InvalidMessage(format!("invalid filter: {e}"))) + }) + .collect::>>()?; + Ok(ClientMessage::Count { sub_id, filters }) + } "CLOSE" => { if arr.len() < 2 { return Err(RelayError::InvalidMessage( @@ -164,6 +209,11 @@ impl RelayMessage { pub fn closed(sub_id: &str, message: &str) -> String { serde_json::json!(["CLOSED", sub_id, message]).to_string() } + + /// Format a COUNT response (NIP-45). + pub fn count(sub_id: &str, count: u64) -> String { + serde_json::json!(["COUNT", sub_id, {"count": count}]).to_string() + } } #[cfg(test)] diff --git a/crates/sprout-relay/src/router.rs b/crates/sprout-relay/src/router.rs index 169537e360..eed52be783 100644 --- a/crates/sprout-relay/src/router.rs +++ b/crates/sprout-relay/src/router.rs @@ -8,16 +8,16 @@ use axum::{ http::{HeaderMap, StatusCode}, middleware, response::{IntoResponse, Json}, - routing::{delete, get, post, put}, + routing::{get, post, put}, Router, }; use serde_json::json; use tower_http::cors::{AllowOrigin, CorsLayer}; use tower_http::limit::RequestBodyLimitLayer; +use tower_http::services::ServeDir; use tower_http::trace::TraceLayer; use crate::api; -use crate::api::tokens; use crate::audio; use crate::connection::handle_connection; use crate::metrics::track_metrics; @@ -26,16 +26,10 @@ use crate::state::AppState; /// Build the axum [`Router`] with all relay routes, middleware, and CORS configuration. /// -/// Uses a dual sub-router pattern so media routes can carry a 50 MB body limit -/// while all other routes remain capped at 1 MB. Each sub-router attaches its -/// own `RequestBodyLimitLayer` before merging; the outer layer adds tracing and -/// CORS once over the combined router. +/// Pure Nostr protocol: WebSocket (NIP-01), HTTP bridge (NIP-98), media (Blossom), +/// git (smart HTTP), NIP-05, and health probes. pub fn build_router(state: Arc) -> Router { // ── Media routes: body limit covers both images and video ──────────────── - // Transport cap is the larger of image and video limits. Video uploads stream - // to disk (never fully buffered); images collect to bytes within this limit. - // Per-MIME app-level limits (GIF: 10 MB) are enforced in sprout-media - // validation after MIME detection. let media_body_limit = state .config .media @@ -50,120 +44,82 @@ pub fn build_router(state: Arc) -> Router { .layer(RequestBodyLimitLayer::new(media_body_limit)) .with_state(state.clone()); + // ── Git routes: configurable body limit (default 500 MB) ───────────────── + let git_router = api::git::git_router(state.clone()); + + // ── Internal git policy route (pre-receive hook callback) ──────────────── + let git_policy_router = api::git::git_policy_router(state.clone()); + // ── All other routes: 1 MB body limit ──────────────────────────────────── let api_router = Router::new() + // WebSocket + NIP-11 .route("/", get(nip11_or_ws_handler)) .route("/info", get(relay_info_handler)) .route("/.well-known/nostr.json", get(api::nip05::nostr_nip05)) - // Health endpoints remain on the app router for backward compat (local dev). - // In CAKE, probes hit the dedicated health port (8080) instead. + // Health endpoints .route("/health", get(health_handler)) .route("/_liveness", get(liveness_handler)) .route("/_readiness", get(readiness_handler)) - // Token self-service routes - .route( - "/api/tokens", - post(tokens::post_tokens) - .get(tokens::get_tokens) - .delete(tokens::delete_all_tokens), - ) - .route("/api/tokens/{id}", delete(tokens::delete_token)) - .route("/api/channels", get(api::channels_handler)) - .route("/api/events", post(api::events::submit_event)) - .route("/api/events/{id}", get(api::get_event)) - .route("/api/search", get(api::search_handler)) - .route("/api/agents", get(api::agents_handler)) - .route( - "/api/presence", - get(api::presence_handler).put(api::set_presence_handler), - ) - // Workflow routes - .route( - "/api/channels/{channel_id}/workflows", - get(api::list_channel_workflows).post(api::create_workflow), - ) - .route( - "/api/workflows/{id}", - get(api::get_workflow) - .put(api::update_workflow) - .delete(api::delete_workflow), - ) - .route("/api/workflows/{id}/runs", get(api::list_workflow_runs)) - .route( - "/api/workflows/{id}/runs/{run_id}/approvals", - get(api::list_run_approvals), - ) - .route("/api/workflows/{id}/trigger", post(api::trigger_workflow)) - .route("/api/workflows/{id}/webhook", post(api::workflow_webhook)) - .route("/api/approvals/{token}/grant", post(api::grant_approval)) - .route("/api/approvals/{token}/deny", post(api::deny_approval)) - .route( - "/api/approvals/by-hash/{hash}/grant", - post(api::grant_approval_by_hash), - ) - .route( - "/api/approvals/by-hash/{hash}/deny", - post(api::deny_approval_by_hash), - ) + // Nostr HTTP bridge (NIP-98 auth) + .route("/events", post(api::bridge::submit_event)) + .route("/query", post(api::bridge::query_events)) + .route("/count", post(api::bridge::count_events)) + // Webhook trigger (secret-authenticated, no NIP-98) + .route("/hooks/{id}", post(api::bridge::workflow_webhook)) // Huddle audio WebSocket route .route( "/huddle/{channel_id}/audio", get(audio::handler::ws_audio_handler), ) - // Membership routes - .route("/api/channels/{channel_id}/members", get(api::list_members)) - // Channel detail + metadata routes - .route("/api/channels/{channel_id}", get(api::get_channel_handler)) - // Canvas routes - .route("/api/channels/{channel_id}/canvas", get(api::get_canvas)) - // Message + thread routes - .route( - "/api/channels/{channel_id}/messages", - get(api::list_messages), - ) - .route( - "/api/channels/{channel_id}/threads/{event_id}", - get(api::get_thread), - ) - // DM routes - .route( - "/api/dms", - get(api::list_dms_handler).post(api::open_dm_handler), - ) - .route( - "/api/dms/{channel_id}/members", - post(api::add_dm_member_handler), - ) - .route("/api/dms/{channel_id}/hide", post(api::hide_dm_handler)) - // Reaction routes - .route( - "/api/messages/{event_id}/reactions", - get(api::list_reactions_handler), - ) - // User profile routes - .route("/api/users/me/profile", get(api::get_profile)) - .route( - "/api/users/me/channel-add-policy", - put(api::put_channel_add_policy), - ) - .route("/api/users/search", get(api::search_users)) - .route("/api/users/{pubkey}/profile", get(api::get_user_profile)) - .route("/api/users/{pubkey}/notes", get(api::get_user_notes)) - .route( - "/api/users/{pubkey}/contact-list", - get(api::get_contact_list), - ) - .route("/api/users/batch", post(api::get_users_batch)) - // Feed route - .route("/api/feed", get(api::feed_handler)) // Reject request bodies larger than 1 MB to prevent resource exhaustion. .layer(RequestBodyLimitLayer::new(1024 * 1024)) .with_state(state.clone()); // Merge — each sub-router carries its own body limit. // Metrics → Trace → CORS applied once over the combined router. - api_router + let mut merged = api_router .merge(media_router) + .merge(git_router) + .merge(git_policy_router); + + // When SPROUT_WEB_DIR is set, serve the SPA as a fallback for unmatched routes. + if let Some(ref web_dir) = state.config.web_dir { + let index_path = web_dir.join("index.html"); + let spa_fallback = ServeDir::new(web_dir).not_found_service(tower::service_fn( + move |req: axum::extract::Request| { + let index = index_path.clone(); + async move { + let path = req.uri().path(); + // Reserved API prefixes must 404 normally, not serve index.html. + let reserved = path.starts_with("/api/") + || path.starts_with("/media/") + || path.starts_with("/git/") + || path.starts_with("/internal/") + || path.starts_with("/.well-known/") + || path.starts_with("/huddle/") + || path == "/health" + || path == "/_liveness" + || path == "/_readiness" + || path == "/_status" + || path == "/info"; + // Files with extensions (e.g. /assets/missing.js) should 404. + let has_ext = path.rsplit('/').next().is_some_and(|seg| seg.contains('.')); + if reserved || has_ext { + Ok(StatusCode::NOT_FOUND.into_response()) + } else { + // SPA client-side route → serve index.html + match tokio::fs::read(&index).await { + Ok(body) => Ok(axum::response::Html(body).into_response()), + Err(_) => Ok(StatusCode::INTERNAL_SERVER_ERROR.into_response()), + } + } + } + }, + )); + merged = merged.fallback_service(spa_fallback); + } + + merged .layer(middleware::from_fn(track_metrics)) .layer(TraceLayer::new_for_http()) .layer(build_cors_layer(&state.config.cors_origins)) @@ -172,7 +128,6 @@ pub fn build_router(state: Arc) -> Router { /// Build the health-only router for K8s probes (port 8080 in CAKE). /// /// No metrics middleware, no auth, no CORS, no body limit. -/// Separate from the app router so probes bypass Istio and don't pollute metrics. pub fn build_health_router(state: Arc) -> Router { Router::new() .route("/_liveness", get(liveness_handler)) @@ -182,20 +137,11 @@ pub fn build_health_router(state: Arc) -> Router { } /// Content-negotiated: NIP-11 JSON for plain HTTP, WebSocket upgrade otherwise. -/// -/// Uses `axum::extract::Request` to manually attempt WS upgrade, so non-WS -/// requests aren't rejected by the extractor. -/// -/// `ConnectInfo` is read from request extensions rather than as an extractor — -/// UDS connections have no `SocketAddr`, so the extractor would panic. -/// TCP connections populate it via `into_make_service_with_connect_info`; UDS -/// connections fall back to `0.0.0.0:0`. async fn nip11_or_ws_handler( State(state): State>, headers: HeaderMap, req: axum::extract::Request, ) -> impl IntoResponse { - // Read peer address from extensions (set by TCP serve; absent for UDS). let addr = req .extensions() .get::>() @@ -207,8 +153,14 @@ async fn nip11_or_ws_handler( .and_then(|v| v.to_str().ok()) .unwrap_or(""); + let relay_pubkey = if state.config.relay_private_key.is_some() { + Some(state.relay_keypair.public_key().to_hex()) + } else { + None + }; + if accept.contains("application/nostr+json") { - let info = RelayInfo::from_config(&state.config); + let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref()); return Json(info).into_response(); } @@ -217,8 +169,17 @@ async fn nip11_or_ws_handler( .on_upgrade(move |socket| handle_connection(socket, state, addr)) .into_response(), Err(_) => { + // Browser requesting HTML and web UI is configured → serve SPA. + if let Some(ref dir) = state.config.web_dir { + if accept.contains("text/html") { + let index = dir.join("index.html"); + if let Ok(body) = tokio::fs::read(&index).await { + return axum::response::Html(body).into_response(); + } + } + } // Not a WS request and not asking for nostr+json — serve NIP-11 as fallback. - let info = RelayInfo::from_config(&state.config); + let info = RelayInfo::from_config(&state.config, relay_pubkey.as_deref()); Json(info).into_response() } } @@ -233,13 +194,9 @@ async fn liveness_handler() -> impl IntoResponse { } /// Readiness probe — checks shutdown flag, Postgres, and Redis connectivity. -/// -/// Returns 503 immediately during graceful shutdown (SIGTERM received). -/// Otherwise returns 200 when both backends are reachable, or 503 with details. async fn readiness_handler(State(state): State>) -> impl IntoResponse { use std::time::Duration; - // CAKE: readiness must return 503 after graceful shutdown begins. if state.shutting_down.load(Ordering::Relaxed) { return ( StatusCode::SERVICE_UNAVAILABLE, @@ -270,7 +227,7 @@ async fn readiness_handler(State(state): State>) -> impl IntoRespo } } -/// Status endpoint — service name, version, uptime. Optional per CAKE contract. +/// Status endpoint — service name, version, uptime. async fn status_handler(State(state): State>) -> impl IntoResponse { let uptime_secs = state.started_at.elapsed().as_secs(); Json(json!({ @@ -281,10 +238,6 @@ async fn status_handler(State(state): State>) -> impl IntoResponse } /// Build a CORS layer from the configured origins list. -/// -/// If `cors_origins` is empty (dev default), returns a permissive layer. -/// Otherwise, parses each entry as an `http::HeaderValue` and restricts -/// `Allow-Origin` to that exact set. fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { if cors_origins.is_empty() { return CorsLayer::permissive(); @@ -301,7 +254,6 @@ fn build_cors_layer(cors_origins: &[String]) -> CorsLayer { refusing to fall back to permissive CORS. Fix the origins or unset \ the variable for development mode." ); - // Deny all cross-origin requests rather than silently allowing all. return CorsLayer::new(); } diff --git a/crates/sprout-relay/src/state.rs b/crates/sprout-relay/src/state.rs index 57836e0650..08f912eedd 100644 --- a/crates/sprout-relay/src/state.rs +++ b/crates/sprout-relay/src/state.rs @@ -22,7 +22,6 @@ use sprout_pubsub::PubSubManager; use sprout_search::SearchService; use sprout_workflow::WorkflowEngine; -use crate::api::tokens::MintRateLimiter; use crate::audio::AudioRoomManager; use crate::config::Config; use crate::connection::{ConnectionSubscriptions, SLOW_CLIENT_GRACE_LIMIT}; @@ -181,16 +180,17 @@ pub struct AppState { pub conn_semaphore: Arc, /// Semaphore limiting concurrent message handler tasks. pub handler_semaphore: Arc, + /// Semaphore limiting concurrent git subprocess operations. + pub git_semaphore: Arc, + /// Per-repo mutex map — prevents concurrent pushes to the same bare repo. + /// Key: canonical repo path. Value: mutex guarding exclusive push access. + pub git_repo_locks: Arc>>>, + /// Workflow engine for background processing. pub workflow_engine: Arc, /// Relay signing keypair — used to sign system messages (kind 40099). pub relay_keypair: nostr::Keys, - /// Rate limiter for `POST /api/tokens` — 5 mints per pubkey per hour. - pub mint_rate_limiter: Arc, - /// Debounce cache for `last_used_at` token updates — avoids a DB write on every request. - /// Entries map token UUID → last time we wrote `last_used_at` to the DB. - /// Resets on restart (acceptable — `last_used_at` is informational, not security-critical). - pub last_used_cache: Arc>, + /// Recently-published event IDs for local-echo deduplication. /// Events fanned out in-process are added here; the Redis subscriber /// consumer skips them to avoid double delivery. Entries expire after @@ -219,6 +219,20 @@ pub struct AppState { pub shutting_down: Arc, /// Process start time — used by `/_status` endpoint. pub started_at: Instant, + /// NIP-98 replay prevention: recently-seen event IDs. + /// 2× the ±60s tolerance window so entries outlive the acceptance window. + pub nip98_seen: Arc>, + + /// Per-agent sliding-window rate limiter for observer frames (kind 24200). + /// Key: agent pubkey bytes (32). Value: (count, window_start). + /// 100 events/sec per agent — prevents relay/DB pressure from bursty telemetry. + pub observer_rate_limiter: Arc>, + /// Cache for observer agent-owner authorization (kind 24200). + /// Key: (agent_pubkey_bytes, owner_pubkey_bytes). Value: is_owner. + /// agent_owner_pubkey is immutable so a long TTL (5 min) is safe. + /// Prevents repeated DB lookups from bursty observer traffic. + #[allow(clippy::type_complexity)] + pub observer_owner_cache: Arc, Vec), bool>>, } impl AppState { @@ -301,6 +315,7 @@ impl AppState { tracing::warn!("audit log worker exited (expected on shutdown)"); }); + let git_max_concurrent_ops = config.git_max_concurrent_ops; let state = Self { config: Arc::new(config), db, @@ -313,10 +328,11 @@ impl AppState { conn_manager: Arc::new(ConnectionManager::new()), conn_semaphore: Arc::new(Semaphore::new(max_connections)), handler_semaphore: Arc::new(Semaphore::new(max_concurrent_handlers)), + git_semaphore: Arc::new(Semaphore::new(git_max_concurrent_ops)), + git_repo_locks: Arc::new(DashMap::new()), workflow_engine, relay_keypair, - mint_rate_limiter: Arc::new(MintRateLimiter::new()), - last_used_cache: Arc::new(DashMap::new()), + local_event_ids: Arc::new( moka::sync::Cache::builder() .max_capacity(10_000) @@ -342,6 +358,19 @@ impl AppState { audio_rooms: Arc::new(AudioRoomManager::new()), shutting_down: Arc::new(AtomicBool::new(false)), started_at: Instant::now(), + nip98_seen: Arc::new( + moka::sync::Cache::builder() + .max_capacity(10_000) + .time_to_live(std::time::Duration::from_secs(120)) + .build(), + ), + observer_rate_limiter: Arc::new(DashMap::new()), + observer_owner_cache: Arc::new( + moka::sync::Cache::builder() + .max_capacity(1_000) + .time_to_live(std::time::Duration::from_secs(300)) + .build(), + ), }; ( state, diff --git a/crates/sprout-relay/src/subscription.rs b/crates/sprout-relay/src/subscription.rs index 084ed1a319..9c56c03ccf 100644 --- a/crates/sprout-relay/src/subscription.rs +++ b/crates/sprout-relay/src/subscription.rs @@ -1,9 +1,9 @@ -//! Subscription registry with (channel, kind) index for O(1) fan-out. +//! Subscription registry with active WebSocket indexes for targeted fan-out. -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; use dashmap::DashMap; -use nostr::{Filter, Kind}; +use nostr::{Alphabet, Filter, Kind, SingleLetterTag}; use uuid::Uuid; use sprout_core::{filter::filters_match, StoredEvent}; @@ -24,7 +24,13 @@ pub struct IndexKey { pub kind: Kind, } -/// Thread-safe registry of active subscriptions with a (channel, kind) index for O(1) fan-out. +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct GlobalPKindIndexKey { + kind: Kind, + p: String, +} + +/// Thread-safe registry of active subscriptions with targeted in-memory fan-out indexes. #[derive(Debug, Default)] pub struct SubscriptionRegistry { /// Maps conn_id → sub_id → (filters, channel_id). @@ -35,6 +41,8 @@ pub struct SubscriptionRegistry { channel_wildcard_index: DashMap>, /// Global subscriptions indexed by kind — avoids O(all_subs) scan for global events. global_kind_index: DashMap>, + /// Global subscriptions indexed by both kind and `#p` recipient. + global_p_kind_index: DashMap>, /// Global subscriptions with no kind filter — wildcard, receives all global events. global_wildcard_index: DashMap<(), Vec<(ConnId, SubId)>>, } @@ -91,22 +99,33 @@ impl SubscriptionRegistry { } } } else { - // Global subscription — index by kind for sub-linear fan-out. - match extract_kinds_from_filters(&filters) { - None => { - self.global_wildcard_index - .entry(()) + // Global subscription. Fully p-constrained filters can use the + // narrower (kind, #p) index; broader filters stay on the generic + // kind/wildcard indexes. + if let Some(keys) = extract_global_p_kind_index_keys(&filters) { + for key in keys { + self.global_p_kind_index + .entry(key) .or_default() .push((conn_id, sub_id.clone())); } - Some(kinds) if kinds.is_empty() => {} - Some(kinds) => { - for kind in kinds { - self.global_kind_index - .entry(kind) + } else { + match extract_kinds_from_filters(&filters) { + None => { + self.global_wildcard_index + .entry(()) .or_default() .push((conn_id, sub_id.clone())); } + Some(kinds) if kinds.is_empty() => {} + Some(kinds) => { + for kind in kinds { + self.global_kind_index + .entry(kind) + .or_default() + .push((conn_id, sub_id.clone())); + } + } } } } @@ -158,6 +177,7 @@ impl SubscriptionRegistry { /// Return all (conn_id, sub_id) pairs whose filters match the given event. pub fn fan_out(&self, event: &StoredEvent) -> Vec<(ConnId, SubId)> { let mut results = Vec::new(); + let mut seen = HashSet::new(); if let Some(channel_id) = event.channel_id { let key = IndexKey { @@ -166,52 +186,39 @@ impl SubscriptionRegistry { }; if let Some(candidates) = self.channel_kind_index.get(&key) { for (conn_id, sub_id) in candidates.iter() { - if let Some(conn_subs) = self.subs.get(conn_id) { - if let Some((filters, _)) = conn_subs.get(sub_id.as_str()) { - if filters_match(filters, event) { - results.push((*conn_id, sub_id.clone())); - } - } - } + self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); } } // Also check wildcard (channel-only, kindless) index if let Some(wildcards) = self.channel_wildcard_index.get(&channel_id) { for (conn_id, sub_id) in wildcards.iter() { - if let Some(conn_subs) = self.subs.get(conn_id) { - if let Some((filters, _)) = conn_subs.get(sub_id.as_str()) { - if filters_match(filters, event) { - results.push((*conn_id, sub_id.clone())); - } - } - } + self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); } } } else { // Global event (channel_id = None) — use global indexes for sub-linear fan-out. // Channel-scoped subscriptions are never in these indexes, preserving the // scoping invariant without an explicit skip check. + for p in event_p_tag_values(event) { + let key = GlobalPKindIndexKey { + kind: event.event.kind, + p, + }; + if let Some(candidates) = self.global_p_kind_index.get(&key) { + for (conn_id, sub_id) in candidates.iter() { + self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); + } + } + } if let Some(candidates) = self.global_kind_index.get(&event.event.kind) { for (conn_id, sub_id) in candidates.iter() { - if let Some(conn_subs) = self.subs.get(conn_id) { - if let Some((filters, _)) = conn_subs.get(sub_id.as_str()) { - if filters_match(filters, event) { - results.push((*conn_id, sub_id.clone())); - } - } - } + self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); } } // Also check global wildcard (kindless global subs). if let Some(wildcards) = self.global_wildcard_index.get(&()) { for (conn_id, sub_id) in wildcards.iter() { - if let Some(conn_subs) = self.subs.get(conn_id) { - if let Some((filters, _)) = conn_subs.get(sub_id.as_str()) { - if filters_match(filters, event) { - results.push((*conn_id, sub_id.clone())); - } - } - } + self.push_match(*conn_id, sub_id, event, &mut results, &mut seen); } } } @@ -243,6 +250,26 @@ impl SubscriptionRegistry { self.subs.len() } + fn push_match( + &self, + conn_id: ConnId, + sub_id: &str, + event: &StoredEvent, + results: &mut Vec<(ConnId, SubId)>, + seen: &mut HashSet<(ConnId, SubId)>, + ) { + if let Some(conn_subs) = self.subs.get(&conn_id) { + if let Some((filters, _)) = conn_subs.get(sub_id) { + if filters_match(filters, event) { + let entry = (conn_id, sub_id.to_string()); + if seen.insert(entry.clone()) { + results.push(entry); + } + } + } + } + } + /// Removes a subscription from the channel_kind_index (or channel_wildcard_index) using /// targeted O(k) lookup where k = number of kinds in the filters, instead of O(n) full-scan. /// @@ -290,25 +317,37 @@ impl SubscriptionRegistry { } } } else { - // Global subscription — remove from global indexes. - match extract_kinds_from_filters(filters) { - None => { - if let Some(mut entries) = self.global_wildcard_index.get_mut(&()) { + // Global subscription — remove from the same global index chosen at registration. + if let Some(keys) = extract_global_p_kind_index_keys(filters) { + for key in keys { + if let Some(mut entries) = self.global_p_kind_index.get_mut(&key) { entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); if entries.is_empty() { drop(entries); - self.global_wildcard_index.remove(&()); + self.global_p_kind_index.remove(&key); } } } - Some(kinds) if kinds.is_empty() => {} - Some(kinds) => { - for kind in kinds { - if let Some(mut entries) = self.global_kind_index.get_mut(&kind) { + } else { + match extract_kinds_from_filters(filters) { + None => { + if let Some(mut entries) = self.global_wildcard_index.get_mut(&()) { entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); if entries.is_empty() { drop(entries); - self.global_kind_index.remove(&kind); + self.global_wildcard_index.remove(&()); + } + } + } + Some(kinds) if kinds.is_empty() => {} + Some(kinds) => { + for kind in kinds { + if let Some(mut entries) = self.global_kind_index.get_mut(&kind) { + entries.retain(|(cid, sid)| !(*cid == conn_id && sid == sub_id)); + if entries.is_empty() { + drop(entries); + self.global_kind_index.remove(&kind); + } } } } @@ -318,6 +357,59 @@ impl SubscriptionRegistry { } } +fn p_tag() -> SingleLetterTag { + SingleLetterTag::lowercase(Alphabet::P) +} + +fn extract_global_p_kind_index_keys(filters: &[Filter]) -> Option> { + let mut seen = HashSet::new(); + let mut keys = Vec::new(); + let p_tag = p_tag(); + + for filter in filters { + let kinds = filter.kinds.as_ref()?; + if kinds.is_empty() { + continue; + } + + let p_values = filter.generic_tags.get(&p_tag)?; + if p_values.is_empty() { + return None; + } + + for kind in kinds { + for p in p_values { + let key = GlobalPKindIndexKey { + kind: *kind, + p: p.clone(), + }; + if seen.insert(key.clone()) { + keys.push(key); + } + } + } + } + + Some(keys) +} + +fn event_p_tag_values(event: &StoredEvent) -> Vec { + let mut seen = HashSet::new(); + let mut values = Vec::new(); + for tag in event.event.tags.iter() { + if tag.kind().to_string() != "p" { + continue; + } + if let Some(value) = tag.content() { + let value = value.to_string(); + if seen.insert(value.clone()) { + values.push(value); + } + } + } + values +} + /// Returns the union of all `kinds` across filters, or `None` if any filter /// lacks a `kinds` array (meaning that filter matches all kinds — wildcard). /// @@ -351,7 +443,7 @@ fn extract_kinds_from_filters(filters: &[Filter]) -> Option> { mod tests { use super::*; use chrono::Utc; - use nostr::{EventBuilder, Keys, Kind}; + use nostr::{EventBuilder, Keys, Kind, Tag}; use sprout_core::StoredEvent; fn make_stored_event(kind: Kind, channel_id: Option) -> StoredEvent { @@ -362,6 +454,14 @@ mod tests { StoredEvent::with_received_at(event, Utc::now(), channel_id, true) } + fn make_stored_event_with_p(kind: Kind, p: &str, channel_id: Option) -> StoredEvent { + let keys = Keys::generate(); + let event = EventBuilder::new(kind, "test", [Tag::parse(&["p", p]).expect("valid p tag")]) + .sign_with_keys(&keys) + .expect("sign"); + StoredEvent::with_received_at(event, Utc::now(), channel_id, true) + } + #[test] fn test_subscription_registry_register_and_fan_out() { let registry = SubscriptionRegistry::new(); @@ -862,6 +962,60 @@ mod tests { assert!(registry.fan_out(&event_custom).is_empty()); } + #[test] + fn test_global_p_kind_index_fan_out_targets_matching_p() { + let registry = SubscriptionRegistry::new(); + let conn_a = Uuid::new_v4(); + let conn_b = Uuid::new_v4(); + let kind = Kind::Custom(sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16); + let p_a = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let p_b = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"; + + registry.register( + conn_a, + "observer_a".to_string(), + vec![Filter::new().kind(kind).custom_tag(p_tag(), [p_a])], + None, + ); + registry.register( + conn_b, + "observer_b".to_string(), + vec![Filter::new().kind(kind).custom_tag(p_tag(), [p_b])], + None, + ); + + assert!( + registry.global_kind_index.get(&kind).is_none(), + "fully p-constrained global subscriptions should use the p-kind index" + ); + + let event = make_stored_event_with_p(kind, p_a, None); + let matches = registry.fan_out(&event); + assert_eq!(matches, vec![(conn_a, "observer_a".to_string())]); + } + + #[test] + fn test_global_p_kind_index_removal_cleanup() { + let registry = SubscriptionRegistry::new(); + let conn_id = Uuid::new_v4(); + let kind = Kind::Custom(sprout_core::kind::KIND_AGENT_OBSERVER_FRAME as u16); + let p = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"; + let filter = Filter::new().kind(kind).custom_tag(p_tag(), [p]); + let key = GlobalPKindIndexKey { + kind, + p: p.to_string(), + }; + + registry.register(conn_id, "observer".to_string(), vec![filter], None); + assert!(registry.global_p_kind_index.get(&key).is_some()); + + registry.remove_subscription(conn_id, "observer"); + assert!(registry.global_p_kind_index.get(&key).is_none()); + + let event = make_stored_event_with_p(kind, p, None); + assert!(registry.fan_out(&event).is_empty()); + } + #[test] fn test_global_wildcard_index_fan_out() { // A global subscription with no kind filter should receive all global events. diff --git a/crates/sprout-sdk/examples/compute_auth_tag.rs b/crates/sprout-sdk/examples/compute_auth_tag.rs new file mode 100644 index 0000000000..e8c268dd33 --- /dev/null +++ b/crates/sprout-sdk/examples/compute_auth_tag.rs @@ -0,0 +1,29 @@ +//! Compute a NIP-OA auth tag for an agent keypair. +//! +//! Usage: +//! cargo run --release --example compute_auth_tag -- [conditions] +//! +//! Prints the JSON auth tag to stdout. + +use nostr::{Keys, PublicKey}; +use sprout_sdk::nip_oa; + +fn main() { + let args: Vec = std::env::args().collect(); + if args.len() < 3 { + eprintln!( + "Usage: {} [conditions]", + args[0] + ); + std::process::exit(1); + } + + let owner_keys = Keys::parse(&args[1]).expect("invalid owner secret key"); + let agent_pubkey = PublicKey::from_hex(&args[2]).expect("invalid agent pubkey hex"); + let conditions = args.get(3).map(|s| s.as_str()).unwrap_or(""); + + let tag_json = nip_oa::compute_auth_tag(&owner_keys, &agent_pubkey, conditions) + .expect("failed to compute auth tag"); + + println!("{tag_json}"); +} diff --git a/crates/sprout-sdk/src/builders.rs b/crates/sprout-sdk/src/builders.rs index a3d5e68e76..25307d24fb 100644 --- a/crates/sprout-sdk/src/builders.rs +++ b/crates/sprout-sdk/src/builders.rs @@ -4,6 +4,13 @@ //! The caller signs: `builder.sign_with_keys(&keys)?`. use nostr::{EventBuilder, Kind, Tag}; +use sprout_core::{ + kind::KIND_AGENT_OBSERVER_FRAME, + observer::{ + content_looks_like_nip44, OBSERVER_AGENT_TAG, OBSERVER_FRAME_CONTROL, OBSERVER_FRAME_TAG, + OBSERVER_FRAME_TELEMETRY, + }, +}; use uuid::Uuid; use crate::{ChannelKind, DiffMeta, MemberRole, SdkError, ThreadRef, Visibility, VoteDirection}; @@ -35,6 +42,15 @@ fn check_hex_len(s: &str, min_len: usize, field: &str) -> Result<(), SdkError> { Ok(()) } +fn check_pubkey_hex(s: &str, field: &str) -> Result { + if s.len() != 64 || !s.chars().all(|c| c.is_ascii_hexdigit()) { + return Err(SdkError::InvalidInput(format!( + "{field} must be a 64-character hex pubkey" + ))); + } + Ok(s.to_ascii_lowercase()) +} + /// Emit NIP-10 e-tags for a `ThreadRef`. fn thread_tags(thread_ref: &ThreadRef, tags: &mut Vec) -> Result<(), SdkError> { let root = thread_ref.root_event_id.to_hex(); @@ -105,6 +121,45 @@ pub fn build_message( Ok(EventBuilder::new(Kind::Custom(9), content, tags)) } +// ── Builder: build_agent_observer_frame ───────────────────────────────────── + +/// Build an encrypted agent observer frame (kind 24200). +/// +/// `recipient_pubkey` is the cleartext `p` tag used by the relay for owner-only +/// routing. `agent_pubkey` identifies the managed agent whose observer stream +/// this frame belongs to. `encrypted_content` must be NIP-44 v2 ciphertext. +pub fn build_agent_observer_frame( + recipient_pubkey: &str, + agent_pubkey: &str, + frame: &str, + encrypted_content: &str, +) -> Result { + if frame != OBSERVER_FRAME_TELEMETRY && frame != OBSERVER_FRAME_CONTROL { + return Err(SdkError::InvalidInput(format!( + "observer frame must be {OBSERVER_FRAME_TELEMETRY:?} or {OBSERVER_FRAME_CONTROL:?}" + ))); + } + if !content_looks_like_nip44(encrypted_content) { + return Err(SdkError::InvalidInput( + "observer frame content must be NIP-44 v2 ciphertext".into(), + )); + } + + let recipient_pubkey = check_pubkey_hex(recipient_pubkey, "recipient_pubkey")?; + let agent_pubkey = check_pubkey_hex(agent_pubkey, "agent_pubkey")?; + let tags = vec![ + tag(&["p", &recipient_pubkey])?, + tag(&[OBSERVER_AGENT_TAG, &agent_pubkey])?, + tag(&[OBSERVER_FRAME_TAG, frame])?, + ]; + + Ok(EventBuilder::new( + Kind::Custom(KIND_AGENT_OBSERVER_FRAME as u16), + encrypted_content, + tags, + )) +} + // ── Builder 2: build_forum_post ─────────────────────────────────────────────── /// Build a forum post thread root (kind 45001). @@ -749,6 +804,50 @@ mod tests { assert!(has_tag(&ev, "h", &cid.to_string())); } + #[test] + fn agent_observer_frame_happy_path() { + let sender = keys(); + let recipient = keys(); + let agent = keys(); + let encrypted = sprout_core::observer::encrypt_observer_payload( + &sender, + &recipient.public_key(), + &serde_json::json!({"type": "acp_read"}), + ) + .unwrap(); + let ev = sign( + build_agent_observer_frame( + &recipient.public_key().to_hex(), + &agent.public_key().to_hex(), + OBSERVER_FRAME_TELEMETRY, + &encrypted, + ) + .unwrap(), + ); + + assert_eq!(ev.kind.as_u16(), KIND_AGENT_OBSERVER_FRAME as u16); + assert_eq!(ev.content, encrypted); + assert!(has_tag(&ev, "p", &recipient.public_key().to_hex())); + assert!(has_tag( + &ev, + OBSERVER_AGENT_TAG, + &agent.public_key().to_hex() + )); + assert!(has_tag(&ev, OBSERVER_FRAME_TAG, OBSERVER_FRAME_TELEMETRY)); + } + + #[test] + fn agent_observer_frame_rejects_plaintext_content() { + let err = build_agent_observer_frame( + &"a".repeat(64), + &"b".repeat(64), + OBSERVER_FRAME_TELEMETRY, + "not encrypted", + ) + .unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + #[test] fn message_direct_reply() { let cid = uuid(); diff --git a/crates/sprout-sdk/src/lib.rs b/crates/sprout-sdk/src/lib.rs index 1b94b2befb..c1c5dcf3ac 100644 --- a/crates/sprout-sdk/src/lib.rs +++ b/crates/sprout-sdk/src/lib.rs @@ -13,6 +13,7 @@ //! No keys are held here. No network calls are made. pub mod builders; +pub mod nip_oa; pub use builders::*; diff --git a/crates/sprout-sdk/src/nip_oa.rs b/crates/sprout-sdk/src/nip_oa.rs new file mode 100644 index 0000000000..4daff2d6b7 --- /dev/null +++ b/crates/sprout-sdk/src/nip_oa.rs @@ -0,0 +1,601 @@ +//! NIP-OA — Owner Attestation +//! +//! Computes and verifies `auth` tags that prove an owner key authorized +//! an agent key to publish events under the agent's own authorship. +//! +//! # Tag format +//! +//! ```json +//! ["auth", "", "", ""] +//! ``` +//! +//! # Signing preimage +//! +//! ```text +//! preimage = "nostr:agent-auth:" || agent_pubkey_hex || ":" || conditions +//! message = SHA256(preimage) +//! sig = BIP-340 Schnorr(message, owner_secret_key) +//! ``` + +use core::str::FromStr; + +use nostr::bitcoin::hashes::sha256::Hash as Sha256Hash; +use nostr::bitcoin::hashes::Hash; +use nostr::bitcoin::secp256k1::schnorr::Signature; +use nostr::bitcoin::secp256k1::{Message, XOnlyPublicKey}; +use nostr::{Keys, PublicKey, Tag, SECP256K1}; +use serde_json::Value; + +use crate::SdkError; + +// ── Internal helpers ────────────────────────────────────────────────────────── + +/// Validate the `conditions` string per the NIP-OA spec. +/// +/// Empty string is valid. Non-empty must be `clause` or `clause&clause&...` +/// where each clause is `kind=<0-65535>`, `created_at<<0-4294967295>`, or +/// `created_at><0-4294967295>`. Canonical decimals only (no leading zeros). +fn validate_conditions(conditions: &str) -> Result<(), SdkError> { + if conditions.is_empty() { + return Ok(()); + } + + // No whitespace anywhere + if conditions.bytes().any(|b| b.is_ascii_whitespace()) { + return Err(SdkError::InvalidInput( + "conditions must not contain whitespace".into(), + )); + } + + // Split on '&' — each part must be non-empty and a valid clause + for clause in conditions.split('&') { + if clause.is_empty() { + return Err(SdkError::InvalidInput( + "empty clause in conditions (leading/trailing/double '&')".into(), + )); + } + validate_clause(clause)?; + } + + Ok(()) +} + +fn validate_clause(clause: &str) -> Result<(), SdkError> { + if let Some(value) = clause.strip_prefix("kind=") { + validate_canonical_decimal(value, 0, 65535, "kind") + } else if let Some(value) = clause.strip_prefix("created_at<") { + validate_canonical_decimal(value, 0, 4294967295, "created_at<") + } else if let Some(value) = clause.strip_prefix("created_at>") { + validate_canonical_decimal(value, 0, 4294967295, "created_at>") + } else { + Err(SdkError::InvalidInput(format!( + "unsupported clause: {clause:?}" + ))) + } +} + +fn validate_canonical_decimal(s: &str, min: u64, max: u64, label: &str) -> Result<(), SdkError> { + if s.is_empty() { + return Err(SdkError::InvalidInput(format!( + "{label} value must not be empty" + ))); + } + + // No leading zeros except "0" itself + if s.len() > 1 && s.starts_with('0') { + return Err(SdkError::InvalidInput(format!( + "{label} value has leading zero: {s:?}" + ))); + } + + // Must be all digits + if !s.bytes().all(|b| b.is_ascii_digit()) { + return Err(SdkError::InvalidInput(format!( + "{label} value is not a valid decimal: {s:?}" + ))); + } + + let value: u64 = s + .parse() + .map_err(|e| SdkError::InvalidInput(format!("{label} value out of range: {e}")))?; + + if value < min || value > max { + return Err(SdkError::InvalidInput(format!( + "{label} value {value} out of range [{min}, {max}]" + ))); + } + + Ok(()) +} + +fn build_preimage(agent_pubkey: &PublicKey, conditions: &str) -> String { + format!("nostr:agent-auth:{}:{}", agent_pubkey.to_hex(), conditions) +} + +fn hash_preimage(preimage: &str) -> Message { + let digest = Sha256Hash::hash(preimage.as_bytes()); + Message::from_digest(digest.to_byte_array()) +} + +/// Check that a character is a lowercase hex digit (`0-9`, `a-f`). +/// Nostr convention requires lowercase hex for pubkeys and signatures. +fn is_lowercase_hex(c: char) -> bool { + c.is_ascii_digit() || matches!(c, 'a'..='f') +} + +fn parse_json_array(s: &str) -> Result, SdkError> { + let v: Value = serde_json::from_str(s) + .map_err(|e| SdkError::InvalidInput(format!("invalid JSON: {e}")))?; + match v { + Value::Array(arr) => Ok(arr), + _ => Err(SdkError::InvalidInput( + "auth tag must be a JSON array".into(), + )), + } +} + +// ── Public API ──────────────────────────────────────────────────────────────── + +/// Compute a NIP-OA `auth` tag authorizing `agent_pubkey` under `conditions`. +/// +/// Signs the preimage with `owner_keys` using BIP-340 Schnorr. +/// +/// Returns a JSON string of the form: +/// `["auth","","",""]` +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] if `owner_pubkey == agent_pubkey` +/// (self-attestation is meaningless and rejected). +pub fn compute_auth_tag( + owner_keys: &Keys, + agent_pubkey: &PublicKey, + conditions: &str, +) -> Result { + let owner_pubkey = owner_keys.public_key(); + if owner_pubkey == *agent_pubkey { + return Err(SdkError::InvalidInput( + "owner and agent pubkeys must differ (self-attestation rejected)".into(), + )); + } + + validate_conditions(conditions)?; + + let preimage = build_preimage(agent_pubkey, conditions); + let message = hash_preimage(&preimage); + let sig = owner_keys.sign_schnorr(&message); + + let tag_json = serde_json::json!(["auth", owner_pubkey.to_hex(), conditions, sig.to_string(),]); + Ok(tag_json.to_string()) +} + +/// Verify a NIP-OA `auth` tag JSON string against the given `agent_pubkey`. +/// +/// Reconstructs the preimage, hashes it, and verifies the Schnorr signature +/// against the owner pubkey embedded in the tag. +/// +/// Returns the owner's [`PublicKey`] on success. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for malformed JSON, wrong element count, +/// bad hex, self-attestation, or signature verification failure. +pub fn verify_auth_tag( + auth_tag_json: &str, + agent_pubkey: &PublicKey, +) -> Result { + let arr = parse_json_array(auth_tag_json)?; + + if arr.len() != 4 { + return Err(SdkError::InvalidInput(format!( + "auth tag must have 4 elements, got {}", + arr.len() + ))); + } + + let label = arr[0] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?; + if label != "auth" { + return Err(SdkError::InvalidInput(format!( + "first element must be \"auth\", got \"{label}\"" + ))); + } + + let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { + SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) + })?; + let conditions = arr[2] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + let sig_hex = arr[3] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; + + let owner_pubkey = PublicKey::from_hex(owner_pubkey_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid owner pubkey: {e}")))?; + + validate_conditions(conditions)?; + + if owner_pubkey == *agent_pubkey { + return Err(SdkError::InvalidInput( + "owner and agent pubkeys must differ (self-attestation rejected)".into(), + )); + } + + let sig = Signature::from_str(sig_hex) + .map_err(|e| SdkError::InvalidInput(format!("invalid signature hex: {e}")))?; + + let preimage = build_preimage(agent_pubkey, conditions); + let message = hash_preimage(&preimage); + + let xonly: &XOnlyPublicKey = &owner_pubkey; + SECP256K1 + .verify_schnorr(&sig, &message, xonly) + .map_err(|e| SdkError::InvalidInput(format!("signature verification failed: {e}")))?; + + Ok(owner_pubkey) +} + +/// Parse a NIP-OA `auth` tag JSON string into a [`Tag`] without verifying the +/// signature. +/// +/// Validates structure only: +/// - Exactly 4 elements +/// - First element is `"auth"` +/// - Second element is a 64-character hex string (owner pubkey) +/// - Fourth element is a 128-character hex string (signature) +/// +/// This is the fast path used at MCP startup — no crypto is performed. +/// +/// # Errors +/// +/// Returns [`SdkError::InvalidInput`] for any structural violation. +pub fn parse_auth_tag(json_str: &str) -> Result { + let arr = parse_json_array(json_str)?; + + if arr.len() != 4 { + return Err(SdkError::InvalidInput(format!( + "auth tag must have 4 elements, got {}", + arr.len() + ))); + } + + let label = arr[0] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 0 must be a string".into()))?; + if label != "auth" { + return Err(SdkError::InvalidInput(format!( + "first element must be \"auth\", got \"{label}\"" + ))); + } + + let owner_pubkey_hex = arr[1].as_str().ok_or_else(|| { + SdkError::InvalidInput("element 1 (owner pubkey) must be a string".into()) + })?; + if owner_pubkey_hex.len() != 64 || !owner_pubkey_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "owner pubkey must be 64 hex chars, got {:?}", + owner_pubkey_hex + ))); + } + + let conditions = arr[2] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 2 (conditions) must be a string".into()))?; + + validate_conditions(conditions)?; + + let sig_hex = arr[3] + .as_str() + .ok_or_else(|| SdkError::InvalidInput("element 3 (signature) must be a string".into()))?; + if sig_hex.len() != 128 || !sig_hex.chars().all(is_lowercase_hex) { + return Err(SdkError::InvalidInput(format!( + "signature must be 128 hex chars, got length {}", + sig_hex.len() + ))); + } + + Tag::parse(&["auth", owner_pubkey_hex, conditions, sig_hex]) + .map_err(|e| SdkError::InvalidInput(format!("failed to construct Tag: {e}"))) +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + + const OWNER_PUBKEY_HEX: &str = + "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + const AGENT_PUBKEY_HEX: &str = + "c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"; + const CONDITIONS: &str = "kind=1&created_at<1713957000"; + const SPEC_SIG_HEX: &str = + "8b7df2575caf0a108374f8471722b233c53f9ff827a8b0f91861966c3b9dd5cb2e189eae9f49d72187674c2f5bd244145e10ff86c9f257ffe65a1ee5f108b369"; + + /// Verify the spec's provided signature against the spec's known preimage. + #[test] + fn test_verify_spec_test_vector() { + let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap(); + let owner_pubkey = PublicKey::from_hex(OWNER_PUBKEY_HEX).unwrap(); + + let preimage = build_preimage(&agent_pubkey, CONDITIONS); + assert_eq!( + preimage, + format!("nostr:agent-auth:{}:{}", AGENT_PUBKEY_HEX, CONDITIONS) + ); + + let message = hash_preimage(&preimage); + + let sig = Signature::from_str(SPEC_SIG_HEX).expect("spec sig must parse"); + let xonly: &XOnlyPublicKey = &owner_pubkey; + SECP256K1 + .verify_schnorr(&sig, &message, xonly) + .expect("spec test vector signature must verify"); + } + + /// Sign with generated keys, then verify — round-trip without byte comparison. + #[test] + fn test_sign_then_verify_round_trip() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "kind=9") + .expect("compute_auth_tag must succeed"); + + let recovered = + verify_auth_tag(&tag_json, &agent_pubkey).expect("verify_auth_tag must succeed"); + + assert_eq!(recovered, owner_keys.public_key()); + } + + /// Empty conditions string is valid. + #[test] + fn test_empty_conditions() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let agent_pubkey = agent_keys.public_key(); + + let tag_json = compute_auth_tag(&owner_keys, &agent_pubkey, "") + .expect("empty conditions must succeed"); + + let recovered = verify_auth_tag(&tag_json, &agent_pubkey) + .expect("verify with empty conditions must succeed"); + + assert_eq!(recovered, owner_keys.public_key()); + } + + /// Self-attestation (owner == agent) must be rejected at both sign and verify. + #[test] + fn test_reject_self_attestation() { + let keys = Keys::generate(); + let pubkey = keys.public_key(); + + let err = compute_auth_tag(&keys, &pubkey, "kind=9") + .expect_err("self-attestation must be rejected"); + assert!( + matches!(err, SdkError::InvalidInput(_)), + "expected InvalidInput, got {err:?}" + ); + + // Craft a self-attesting tag and verify it's rejected. + let fake_json = + serde_json::json!(["auth", pubkey.to_hex(), "kind=9", "a".repeat(128),]).to_string(); + let err = verify_auth_tag(&fake_json, &pubkey) + .expect_err("self-attestation must be rejected at verify"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + /// Various malformed inputs to verify_auth_tag must return errors. + #[test] + fn test_reject_malformed_tag() { + let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap(); + + assert!(verify_auth_tag("not json", &agent_pubkey).is_err()); + assert!(verify_auth_tag(r#"{"auth":"x"}"#, &agent_pubkey).is_err()); + assert!(verify_auth_tag(r#"["auth","a","b"]"#, &agent_pubkey).is_err()); + + let bad_label = + serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)]) + .to_string(); + assert!(verify_auth_tag(&bad_label, &agent_pubkey).is_err()); + + let bad_pk = + serde_json::json!(["auth", "notahex", CONDITIONS, "a".repeat(128)]).to_string(); + assert!(verify_auth_tag(&bad_pk, &agent_pubkey).is_err()); + + let bad_sig = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "notasig"]).to_string(); + assert!(verify_auth_tag(&bad_sig, &agent_pubkey).is_err()); + + // Valid structure but wrong signature (won't verify). + let wrong_sig = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "0".repeat(128)]).to_string(); + assert!(verify_auth_tag(&wrong_sig, &agent_pubkey).is_err()); + } + + /// parse_auth_tag with a well-formed JSON array returns a Tag. + #[test] + fn test_parse_auth_tag_valid() { + let sig_hex = "a".repeat(128); + let json = serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, sig_hex,]).to_string(); + + let tag = parse_auth_tag(&json).expect("valid tag must parse"); + let slice = tag.as_slice(); + assert_eq!(slice[0], "auth"); + assert_eq!(slice[1], OWNER_PUBKEY_HEX); + assert_eq!(slice[2], CONDITIONS); + assert_eq!(slice[3], "a".repeat(128)); + } + + /// Various malformed inputs to parse_auth_tag must return errors. + #[test] + fn test_parse_auth_tag_malformed() { + assert!(parse_auth_tag("not json").is_err()); + assert!(parse_auth_tag(r#"{"auth":"x"}"#).is_err()); + assert!(parse_auth_tag(r#"["auth","a","b"]"#).is_err()); + + let bad_label = + serde_json::json!(["notauth", OWNER_PUBKEY_HEX, CONDITIONS, "a".repeat(128)]) + .to_string(); + assert!(parse_auth_tag(&bad_label).is_err()); + + let short_pk = serde_json::json!(["auth", "abcd", CONDITIONS, "a".repeat(128)]).to_string(); + assert!(parse_auth_tag(&short_pk).is_err()); + + let non_hex_pk = + serde_json::json!(["auth", "z".repeat(64), CONDITIONS, "a".repeat(128)]).to_string(); + assert!(parse_auth_tag(&non_hex_pk).is_err()); + + let short_sig = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "abcd"]).to_string(); + assert!(parse_auth_tag(&short_sig).is_err()); + + let non_hex_sig = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "z".repeat(128)]).to_string(); + assert!(parse_auth_tag(&non_hex_sig).is_err()); + } + + /// Uppercase hex must be rejected — Nostr convention is lowercase only. + #[test] + fn test_parse_auth_tag_rejects_uppercase_hex() { + // Uppercase in owner pubkey + let upper_pk = OWNER_PUBKEY_HEX.to_uppercase(); + let json = serde_json::json!(["auth", upper_pk, CONDITIONS, "a".repeat(128)]).to_string(); + assert!( + parse_auth_tag(&json).is_err(), + "uppercase owner pubkey must be rejected" + ); + + // Mixed case in owner pubkey + let mut mixed_pk = OWNER_PUBKEY_HEX.to_string(); + mixed_pk.replace_range(0..1, "A"); + let json = serde_json::json!(["auth", mixed_pk, CONDITIONS, "a".repeat(128)]).to_string(); + assert!( + parse_auth_tag(&json).is_err(), + "mixed-case owner pubkey must be rejected" + ); + + // Uppercase in signature + let json = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, CONDITIONS, "A".repeat(128)]).to_string(); + assert!( + parse_auth_tag(&json).is_err(), + "uppercase signature must be rejected" + ); + } + + /// Verify the spec's SHA-256 hash of the preimage matches the expected value. + #[test] + fn test_spec_sha256_hash() { + let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap(); + let preimage = build_preimage(&agent_pubkey, CONDITIONS); + let digest = Sha256Hash::hash(preimage.as_bytes()); + let expected = "08cdecd55af4c28d3801fd69615dcf5cc04fab3bc134b38a840bf157197069a6"; + assert_eq!(format!("{digest:x}"), expected); + } + + // ── Conditions validation ───────────────────────────────────────────── + + #[test] + fn test_valid_conditions() { + // These should all pass through validate_conditions + assert!(validate_conditions("").is_ok()); + assert!(validate_conditions("kind=1").is_ok()); + assert!(validate_conditions("kind=0").is_ok()); + assert!(validate_conditions("kind=65535").is_ok()); + assert!(validate_conditions("created_at<1713957000").is_ok()); + assert!(validate_conditions("created_at>0").is_ok()); + assert!(validate_conditions("created_at>4294967295").is_ok()); + assert!(validate_conditions("kind=1&created_at<1713957000").is_ok()); + assert!(validate_conditions("kind=9&created_at>100&created_at<200").is_ok()); + } + + #[test] + fn test_reject_trailing_ampersand() { + let err = validate_conditions("kind=1&").unwrap_err(); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn test_reject_leading_ampersand() { + assert!(validate_conditions("&kind=1").is_err()); + } + + #[test] + fn test_reject_double_ampersand() { + assert!(validate_conditions("kind=1&&created_at<100").is_err()); + } + + #[test] + fn test_reject_leading_zero() { + assert!(validate_conditions("kind=01").is_err()); + assert!(validate_conditions("created_at<01713957000").is_err()); + } + + #[test] + fn test_reject_whitespace() { + assert!(validate_conditions("kind=1 ").is_err()); + assert!(validate_conditions(" kind=1").is_err()); + assert!(validate_conditions("kind= 1").is_err()); + assert!(validate_conditions("kind=1\t").is_err()); + } + + #[test] + fn test_reject_unknown_clause() { + assert!(validate_conditions("foo=1").is_err()); + assert!(validate_conditions("Kind=1").is_err()); // case-sensitive + assert!(validate_conditions("CREATED_AT<100").is_err()); + assert!(validate_conditions("created_at=100").is_err()); // wrong operator + } + + #[test] + fn test_reject_out_of_range() { + assert!(validate_conditions("kind=65536").is_err()); + assert!(validate_conditions("created_at<4294967296").is_err()); + } + + #[test] + fn test_reject_non_decimal() { + assert!(validate_conditions("kind=abc").is_err()); + assert!(validate_conditions("kind=-1").is_err()); + assert!(validate_conditions("kind=1.0").is_err()); + } + + #[test] + fn test_reject_empty_value() { + assert!(validate_conditions("kind=").is_err()); + assert!(validate_conditions("created_at<").is_err()); + assert!(validate_conditions("created_at>").is_err()); + } + + // Verify conditions validation is wired into compute and verify + #[test] + fn test_compute_rejects_invalid_conditions() { + let owner_keys = Keys::generate(); + let agent_keys = Keys::generate(); + let err = compute_auth_tag(&owner_keys, &agent_keys.public_key(), "kind=01") + .expect_err("leading zero must be rejected"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn test_verify_rejects_invalid_conditions() { + let agent_pubkey = PublicKey::from_hex(AGENT_PUBKEY_HEX).unwrap(); + // Craft a tag with invalid conditions but valid structure + let bad_conditions = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=01", "a".repeat(128)]).to_string(); + let err = verify_auth_tag(&bad_conditions, &agent_pubkey) + .expect_err("leading zero must be rejected at verify"); + assert!(matches!(err, SdkError::InvalidInput(_))); + } + + #[test] + fn test_parse_rejects_invalid_conditions() { + let bad = + serde_json::json!(["auth", OWNER_PUBKEY_HEX, "kind=1&", "a".repeat(128)]).to_string(); + assert!(parse_auth_tag(&bad).is_err()); + } +} diff --git a/crates/sprout-test-client/tests/e2e_mcp.rs b/crates/sprout-test-client/tests/e2e_mcp.rs index f5d453fa27..2c05a51be0 100644 --- a/crates/sprout-test-client/tests/e2e_mcp.rs +++ b/crates/sprout-test-client/tests/e2e_mcp.rs @@ -336,8 +336,8 @@ async fn test_mcp_initialize_and_list_tools() { assert_eq!( tools.len(), - 44, - "expected exactly 44 tools, got {}. Tools: {:?}", + 49, + "expected exactly 49 tools, got {}. Tools: {:?}", tools.len(), tools .iter() diff --git a/crates/sprout-test-client/tests/e2e_user_status.rs b/crates/sprout-test-client/tests/e2e_user_status.rs new file mode 100644 index 0000000000..2a68f60e0d --- /dev/null +++ b/crates/sprout-test-client/tests/e2e_user_status.rs @@ -0,0 +1,282 @@ +//! End-to-end tests for NIP-38 user statuses (kind:30315). +//! +//! These tests require a running relay instance. By default they are marked +//! `#[ignore]` so that `cargo test` does not fail in CI when the relay is not +//! available. +//! +//! # Running +//! +//! Start the relay, then run: +//! +//! ```text +//! cargo test --test e2e_user_status -- --ignored +//! ``` +//! +//! Override the relay URL with the `RELAY_URL` environment variable: +//! +//! ```text +//! RELAY_URL=ws://relay.example.com cargo test --test e2e_user_status -- --ignored +//! ``` + +use std::time::Duration; + +use nostr::{Alphabet, EventBuilder, Filter, Keys, Kind, SingleLetterTag, Tag, Timestamp}; +use sprout_test_client::SproutTestClient; + +const KIND_USER_STATUS: u16 = 30315; + +fn relay_url() -> String { + std::env::var("RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn sub_id(name: &str) -> String { + format!("e2e-{name}-{}", uuid::Uuid::new_v4()) +} + +/// Build a kind:30315 event with a d-tag and content. +fn build_user_status_event( + keys: &Keys, + d_tag: &str, + content: &str, + extra_tags: Vec, +) -> nostr::Event { + let mut tags = vec![Tag::parse(&["d", d_tag]).unwrap()]; + tags.extend(extra_tags); + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), content, tags) + .sign_with_keys(keys) + .unwrap() +} + +// ── Tests ───────────────────────────────────────────────────────────────────── + +/// kind:30315 events are accepted by the relay. +#[tokio::test] +#[ignore] +async fn test_user_status_accepted() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let event = build_user_status_event(&keys, "general", "Working on NIP-38 support", vec![]); + + let ok = client.send_event(event).await.expect("send event"); + assert!( + ok.accepted, + "relay should accept kind:30315: {}", + ok.message + ); + + client.disconnect().await.expect("disconnect"); +} + +/// kind:30315 events are retrievable via REQ with kinds filter. +#[tokio::test] +#[ignore] +async fn test_user_status_retrievable() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("retrieve-{}", uuid::Uuid::new_v4().simple()); + let event = build_user_status_event(&keys, &d_tag, "Currently online", vec![]); + let event_id = event.id; + + let ok = client.send_event(event).await.expect("send event"); + assert!(ok.accepted, "relay should accept: {}", ok.message); + + // Query back by kind + author + let sid = sub_id("retrieve"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_USER_STATUS)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == event_id), + "should find the published user status in query results" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 replacement: publishing a newer kind:30315 with the same d-tag replaces the old one. +#[tokio::test] +#[ignore] +async fn test_user_status_nip33_replacement() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("replace-{}", uuid::Uuid::new_v4().simple()); + + // Publish v1 + let v1 = build_user_status_event(&keys, &d_tag, "Status v1", vec![]); + let ok1 = client.send_event(v1).await.expect("send v1"); + assert!(ok1.accepted, "v1 should be accepted: {}", ok1.message); + + // Small delay to ensure different created_at timestamps + tokio::time::sleep(Duration::from_secs(1)).await; + + // Publish v2 with the same d-tag + let v2 = build_user_status_event(&keys, &d_tag, "Status v2 — updated", vec![]); + let v2_id = v2.id; + let ok2 = client.send_event(v2).await.expect("send v2"); + assert!(ok2.accepted, "v2 should be accepted: {}", ok2.message); + + // Query — should only get v2 (v1 replaced) + let sid = sub_id("replace"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_USER_STATUS)) + .author(keys.public_key()) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!( + events.len(), + 1, + "should have exactly one event after replacement" + ); + assert_eq!(events[0].id, v2_id, "surviving event should be v2"); + assert!(events[0].content.contains("v2"), "content should be v2"); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-38: multiple d-tags coexist — "general" and "music" are independent status slots. +#[tokio::test] +#[ignore] +async fn test_user_status_multiple_d_tags_coexist() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let general_d = format!("general-{}", uuid::Uuid::new_v4().simple()); + let music_d = format!("music-{}", uuid::Uuid::new_v4().simple()); + + // Publish general status + let general = build_user_status_event(&keys, &general_d, "Working on code", vec![]); + let general_id = general.id; + let ok1 = client.send_event(general).await.expect("send general"); + assert!(ok1.accepted, "general should be accepted: {}", ok1.message); + + // Publish music status + let music = build_user_status_event(&keys, &music_d, "Listening to jazz", vec![]); + let music_id = music.id; + let ok2 = client.send_event(music).await.expect("send music"); + assert!(ok2.accepted, "music should be accepted: {}", ok2.message); + + // Query by kind + author — both should be returned + let sid = sub_id("multi-dtag"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_USER_STATUS)) + .author(keys.public_key()); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert!( + events.iter().any(|e| e.id == general_id), + "general status should be present" + ); + assert!( + events.iter().any(|e| e.id == music_id), + "music status should be present" + ); + + client.disconnect().await.expect("disconnect"); +} + +/// NIP-33 stale-write protection: an older event cannot replace a newer one. +#[tokio::test] +#[ignore] +async fn test_user_status_stale_write_rejected() { + let url = relay_url(); + let keys = Keys::generate(); + let mut client = SproutTestClient::connect(&url, &keys) + .await + .expect("connect"); + + let d_tag = format!("stale-{}", uuid::Uuid::new_v4().simple()); + + // Publish the "newer" event first (with a future-ish timestamp) + let newer = { + let tags = vec![Tag::parse(&["d", &d_tag]).unwrap()]; + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Newer status", tags) + .custom_created_at(Timestamp::from(nostr::Timestamp::now().as_u64() + 100)) + .sign_with_keys(&keys) + .unwrap() + }; + let newer_id = newer.id; + let ok1 = client.send_event(newer).await.expect("send newer"); + assert!(ok1.accepted, "newer should be accepted: {}", ok1.message); + + // Now try to publish an "older" event with the same d-tag but earlier timestamp + let older = { + let tags = vec![Tag::parse(&["d", &d_tag]).unwrap()]; + EventBuilder::new(Kind::Custom(KIND_USER_STATUS), "Older status", tags) + .custom_created_at(Timestamp::from(nostr::Timestamp::now().as_u64() - 100)) + .sign_with_keys(&keys) + .unwrap() + }; + let _ok2 = client.send_event(older).await.expect("send older"); + // Stale write may be rejected or accepted-as-duplicate — either way, + // the older event must NOT replace the newer one. + + // Query — should still have the newer event + let sid = sub_id("stale"); + let filter = Filter::new() + .kind(Kind::Custom(KIND_USER_STATUS)) + .author(keys.public_key()) + .custom_tag(SingleLetterTag::lowercase(Alphabet::D), [d_tag.as_str()]); + client + .subscribe(&sid, vec![filter]) + .await + .expect("subscribe"); + + let events = client + .collect_until_eose(&sid, Duration::from_secs(5)) + .await + .expect("collect"); + + assert_eq!(events.len(), 1, "should have exactly one event"); + assert_eq!( + events[0].id, newer_id, + "surviving event should be the newer one" + ); + assert!( + events[0].content.contains("Newer"), + "content should be from the newer event" + ); + + client.disconnect().await.expect("disconnect"); +} diff --git a/desktop/biome.json b/desktop/biome.json index 8535f3d595..12e778e4a9 100644 --- a/desktop/biome.json +++ b/desktop/biome.json @@ -1,37 +1,3 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.11/schema.json", - "vcs": { - "enabled": true, - "clientKind": "git", - "useIgnoreFile": true - }, - "files": { - "ignoreUnknown": false - }, - "formatter": { - "enabled": true, - "indentStyle": "space" - }, - "linter": { - "enabled": true, - "rules": { - "recommended": true, - "suspicious": { - "noUnknownAtRules": "off" - } - } - }, - "assist": { - "enabled": false - }, - "javascript": { - "formatter": { - "quoteStyle": "double" - } - }, - "css": { - "parser": { - "tailwindDirectives": true - } - } + "extends": ["../biome.json"] } diff --git a/desktop/package.json b/desktop/package.json index 75068b4692..e87e95a9b2 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -3,7 +3,6 @@ "private": true, "version": "0.1.0", "type": "module", - "packageManager": "pnpm@10.33.0", "scripts": { "dev": "vite", "build": "tsc && vite build", @@ -32,14 +31,15 @@ "@radix-ui/react-popover": "^1.1.15", "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", + "@radix-ui/react-switch": "^1.2.6", "@radix-ui/react-tabs": "^1.1.13", "@radix-ui/react-toggle": "^1.1.10", "@radix-ui/react-tooltip": "^1.2.8", "@tanstack/react-query": "^5.90.21", "@tanstack/react-router": "^1.168.10", - "@tauri-apps/api": "^2", + "@tauri-apps/api": "~2.10", "@tauri-apps/plugin-notification": "^2.3.3", - "@tauri-apps/plugin-opener": "^2", + "@tauri-apps/plugin-opener": "~2.5", "@tauri-apps/plugin-process": "^2.3.1", "@tauri-apps/plugin-updater": "^2.10.0", "@tiptap/core": "^3.22.3", @@ -68,12 +68,11 @@ "yaml": "^2.8.3" }, "devDependencies": { - "@biomejs/biome": "^2.4.6", "@noble/hashes": "^2.0.1", "@playwright/test": "^1.58.2", "@tanstack/router-plugin": "^1.167.12", "@tanstack/virtual-file-routes": "^1.161.7", - "@tauri-apps/cli": "^2", + "@tauri-apps/cli": "~2.10", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", diff --git a/desktop/public/sounds/desktop-notification.mp3 b/desktop/public/sounds/desktop-notification.mp3 new file mode 100644 index 0000000000..f6b7e3db6e Binary files /dev/null and b/desktop/public/sounds/desktop-notification.mp3 differ diff --git a/desktop/scripts/build-release-config.mjs b/desktop/scripts/build-release-config.mjs new file mode 100644 index 0000000000..9c642d0f5c --- /dev/null +++ b/desktop/scripts/build-release-config.mjs @@ -0,0 +1,58 @@ +import { writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +// Write a tauri.release.conf.json with release-only overrides. +// +// Tauri's --config flag merges the provided JSON on top of the base +// tauri.conf.json, so this file must contain ONLY the delta fields — +// not a copy of the base config. +// +// For OSS release builds this script emits: +// 1. bundle.macOS.minimumSystemVersion = "10.15" for broad compatibility. +// 2. bundle.createUpdaterArtifacts = true so Tauri produces the .tar.gz +// archive and .sig signature during the build. +// 3. plugins.updater with the public key and endpoint from env vars. +// Both SPROUT_UPDATER_PUBLIC_KEY and SPROUT_UPDATER_ENDPOINT are required - +// the script fails if either is missing (OSS builds always ship with updater). +// +// Apple code signing and notarization happen post-build via +// block/apple-codesign-action in release.yml, so no signingIdentity is +// emitted here and the Tauri build is invoked with --no-sign. + +const outputConfigPath = resolve( + process.cwd(), + "src-tauri/tauri.release.conf.json", +); + +const updaterPubkey = process.env.SPROUT_UPDATER_PUBLIC_KEY; +const updaterEndpoint = process.env.SPROUT_UPDATER_ENDPOINT; + +const missing = []; +if (!updaterPubkey) missing.push("SPROUT_UPDATER_PUBLIC_KEY"); +if (!updaterEndpoint) missing.push("SPROUT_UPDATER_ENDPOINT"); +if (missing.length > 0) { + console.error( + `Error: required environment variable(s) missing: ${missing.join(", ")}`, + ); + process.exit(1); +} + +const releaseConfig = { + bundle: { + macOS: { + minimumSystemVersion: "10.15", + }, + createUpdaterArtifacts: true, + }, + plugins: { + updater: { + pubkey: updaterPubkey, + endpoints: [updaterEndpoint], + }, + }, +}; + +console.log(`Updater enabled -> ${updaterEndpoint}`); + +writeFileSync(outputConfigPath, `${JSON.stringify(releaseConfig, null, 2)}\n`); +console.log(`Wrote ${outputConfigPath}`); diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index 7e4c101841..7ed345442b 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -30,22 +30,27 @@ const rules = [ // Exceptions should stay rare and temporary. Prefer splitting files instead. const overrides = new Map([ - ["src-tauri/src/managed_agents/personas.rs", 830], // built-in persona system prompts + persona pack import/uninstall/list + uninstall safety check + ["src-tauri/src/managed_agents/personas.rs", 900], // built-in persona system prompts (Solo + Kit + Scout) + persona pack import/uninstall/list + uninstall safety check + ["src-tauri/src/managed_agents/teams.rs", 580], // built-in team registry (Kit & Scout) + merge_teams + validate_team_deletion + JSON export/import + tests ["src-tauri/src/managed_agents/persona_card.rs", 970], // PNG/ZIP/MD persona card codec + pack-zip detection + nested root finder + provider/model/namePool fields + 27 unit tests ["src/app/AppShell.tsx", 860], // message edit state + handlers + ChannelPane edit prop threading + scrollback pagination + workflows view + memory-leak safeguards ["src/features/channels/hooks.ts", 550], // canvas query + mutation hooks + DM hide mutation ["src/features/channels/ui/ChannelManagementSheet.tsx", 800], + ["src/features/channels/ui/ChannelPane.tsx", 520], // composer/timeline/sidebar orchestration + anchored agent activity footers + ["src/features/channels/ui/ChannelScreen.tsx", 550], // profile panel state + mutual exclusion wiring + ProfilePanelProvider context + agent typing classification + ["src/features/notifications/hooks.ts", 535], // notification settings + feed notification lifecycle + profile batch resolution + truncated-pubkey guard + badge state ["src/features/messages/hooks.ts", 500], // message query/mutation hooks + optimistic updates ["src/features/messages/ui/MessageComposer.tsx", 700], // media upload handlers (paste, drop, dialog) + channelId reset effect + edit mode (pre-fill, save, cancel, escape) ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav - ["src/features/tokens/ui/TokenSettingsCard.tsx", 800], - ["src/shared/api/relayClientSession.ts", 835], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + ["src/shared/api/relayClientSession.ts", 930], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 720], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests - ["src-tauri/src/commands/agents.rs", 880], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field - ["src-tauri/src/managed_agents/runtime.rs", 700], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload + ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ + ["src-tauri/src/nostr_convert.rs", 870], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + 20 unit tests + ["src-tauri/src/managed_agents/runtime.rs", 740], // KNOWN_AGENT_BINARIES const + process_belongs_to_us FFI (macOS proc_name + Linux /proc/comm) + terminate_process + start/stop/sync lifecycle + pack persona live-read + login shell PATH augmentation + observer endpoint wiring + git credential helper env injection ["src-tauri/src/managed_agents/backend.rs", 530], // provider IPC, validation, discovery, binary resolution + tests ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation @@ -56,18 +61,19 @@ const overrides = new Map([ ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 640], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display - ["src/shared/api/types.ts", 550], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields - ["src-tauri/src/events.rs", 555], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + ["src/shared/api/types.ts", 570], // persona provider/model fields + forum types + workflow type re-exports + ephemeral channel TTL fields + mcpToolsets + sourcePack + UpdateManagedAgentInput edit fields + UserStatus/UserStatusLookup (NIP-38) + RelayMember/RelayMemberRole types + ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders ["src-tauri/src/huddle/kokoro.rs", 980], // Kokoro ONNX TTS engine + three-tier G2P + ARPAbet→IPA + CoreML + synth_chunk() public API + style validation + hyphenated compound splitting + 23 unit tests ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 850], // model download manager for Moonshine STT + Kokoro TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling ["src-tauri/src/huddle/stt.rs", 580], // STT pipeline + PTT edge-detection flush + PTT gating (is_speech AND ptt_active) + barge-in for VAD mode + rubato resampler + earshot VAD + sherpa-onnx transcription ["src-tauri/src/huddle/preprocessing.rs", 670], // TTS text preprocessing pipeline + unified split_sentences + int_to_words 0-999999 + URL trailing punctuation preservation + 23 unit tests - ["src-tauri/src/huddle/relay_api.rs", 510], // audio relay recv task + per-peer frame counting for remote human TTS interrupt + ["src-tauri/src/huddle/relay_api.rs", 520], // audio relay recv task + per-peer frame counting for remote human TTS interrupt + NIP-98 channel member query ["src-tauri/src/huddle/tts.rs", 1030], // TTS pipeline + session warmup + cancel/shutdown handling + apply_fades + 18 unit tests for remote interrupt mechanism - ["src-tauri/src/commands/pairing.rs", 550], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + event parsing helpers + ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test + ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations - ["src/shared/api/tauri.ts", 1110], // +14 lines for 3 NIP-AB pairing command wrappers + ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep ]); async function walkFiles(directory) { diff --git a/desktop/scripts/generate-oss-latest-json.sh b/desktop/scripts/generate-oss-latest-json.sh new file mode 100755 index 0000000000..cedde5dcf0 --- /dev/null +++ b/desktop/scripts/generate-oss-latest-json.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -euo pipefail + +if [[ $# -lt 3 ]]; then + echo "Usage: generate-oss-latest-json.sh " >&2 + exit 1 +fi + +VERSION="$1" +SIG_FILE="$2" +ARCHIVE_URL="$3" + +# Only darwin-aarch64 is included because the workflow builds on ARM64 runners +# only. Supporting Intel Macs (darwin-x86_64) would require a matrix build. +jq -n \ + --arg version "$VERSION" \ + --arg notes "Sprout v$VERSION" \ + --arg pub_date "$(date -u +%Y-%m-%dT%H:%M:%SZ)" \ + --arg signature "$(cat "$SIG_FILE")" \ + --arg url "$ARCHIVE_URL" \ + '{ version: $version, notes: $notes, pub_date: $pub_date, platforms: { "darwin-aarch64": { signature: $signature, url: $url } } }' diff --git a/desktop/scripts/set-version-from-tag.mjs b/desktop/scripts/set-version-from-tag.mjs new file mode 100644 index 0000000000..df2c49b07f --- /dev/null +++ b/desktop/scripts/set-version-from-tag.mjs @@ -0,0 +1,38 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; + +const version = process.argv[2]; + +if (!version) { + console.error("Usage: node scripts/set-version-from-tag.mjs "); + process.exit(1); +} + +if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) { + console.error( + `Invalid version "${version}". Expected semver format (e.g. 1.2.3 or 1.2.3-beta.1)`, + ); + process.exit(1); +} + +const packageJsonPath = resolve(process.cwd(), "package.json"); +const tauriConfigPath = resolve(process.cwd(), "src-tauri/tauri.conf.json"); +const cargoTomlPath = resolve(process.cwd(), "src-tauri/Cargo.toml"); + +const packageJson = JSON.parse(readFileSync(packageJsonPath, "utf8")); +packageJson.version = version; +writeFileSync(packageJsonPath, `${JSON.stringify(packageJson, null, 2)}\n`); +console.log(`Set package.json to ${version}`); + +const tauriConfig = JSON.parse(readFileSync(tauriConfigPath, "utf8")); +tauriConfig.version = version; +writeFileSync(tauriConfigPath, `${JSON.stringify(tauriConfig, null, 2)}\n`); +console.log(`Set tauri.conf.json to ${version}`); + +const cargoToml = readFileSync(cargoTomlPath, "utf8"); +const updatedCargoToml = cargoToml.replace( + /^version = ".*"$/m, + `version = "${version}"`, +); +writeFileSync(cargoTomlPath, updatedCargoToml); +console.log(`Set Cargo.toml to ${version}`); diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index d82dbb2ab9..b6b24ae411 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -879,6 +879,26 @@ version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + [[package]] name = "constant_time_eq" version = "0.3.1" @@ -1071,6 +1091,12 @@ version = "0.8.21" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + [[package]] name = "crypto-common" version = "0.1.7" @@ -1347,6 +1373,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -2115,6 +2150,12 @@ version = "0.12.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8a9ee70c43aaf417c914396645a0fa852624801b24ebb7ae78fe8272889ac888" +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" + [[package]] name = "hashbrown" version = "0.15.5" @@ -2320,7 +2361,7 @@ dependencies = [ "tokio", "tower-service", "tracing", - "windows-registry", + "windows-registry 0.6.1", ] [[package]] @@ -3513,6 +3554,16 @@ dependencies = [ "audiopus_sys", ] +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + [[package]] name = "ordered-stream" version = "0.2.0" @@ -4578,6 +4629,16 @@ dependencies = [ "windowfunctions", ] +[[package]] +name = "rust-ini" +version = "0.21.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "796e8d2b6696392a43bea58116b667fb4c29727dc5abd27d6acf338bb4f688c7" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + [[package]] name = "rustc-hash" version = "2.1.2" @@ -5302,14 +5363,17 @@ dependencies = [ "sherpa-onnx", "sprout-core", "sprout-persona", + "sprout-sdk", "tar", "tauri", "tauri-build", + "tauri-plugin-deep-link", "tauri-plugin-dialog", "tauri-plugin-global-shortcut", "tauri-plugin-notification", "tauri-plugin-opener", "tauri-plugin-process", + "tauri-plugin-single-instance", "tauri-plugin-updater", "tauri-plugin-websocket", "tauri-plugin-window-state", @@ -5352,6 +5416,18 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "sprout-sdk" +version = "0.1.0" +dependencies = [ + "nostr 0.36.0", + "serde", + "serde_json", + "sprout-core", + "thiserror 2.0.18", + "uuid", +] + [[package]] name = "stable_deref_trait" version = "1.2.1" @@ -5856,6 +5932,27 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-deep-link" +version = "2.4.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70ee75bc5627f77bfdf40c913255ebc258117b10ebe2b2239a1a1cf40b0b58aa" +dependencies = [ + "dunce", + "plist", + "rust-ini", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-utils", + "thiserror 2.0.18", + "tracing", + "url", + "windows-registry 0.5.3", + "windows-result 0.3.4", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.0" @@ -5964,6 +6061,22 @@ dependencies = [ "tauri-plugin", ] +[[package]] +name = "tauri-plugin-single-instance" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c8f29386f5e9fdc699182388a33ee80a56de436d91b67459e86afef426282af" +dependencies = [ + "serde", + "serde_json", + "tauri", + "tauri-plugin-deep-link", + "thiserror 2.0.18", + "tracing", + "windows-sys 0.60.2", + "zbus", +] + [[package]] name = "tauri-plugin-updater" version = "2.10.1" @@ -6249,6 +6362,15 @@ dependencies = [ "time-core", ] +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + [[package]] name = "tinystr" version = "0.8.3" @@ -7367,6 +7489,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "windows-registry" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b8a9ed28765efc97bbc954883f4e6796c33a06546ebafacbabee9696967499e" +dependencies = [ + "windows-link 0.1.3", + "windows-result 0.3.4", + "windows-strings 0.4.2", +] + [[package]] name = "windows-registry" version = "0.6.1" diff --git a/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index 1374d45a74..8720843b05 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -27,7 +27,9 @@ windows-sys = { version = "0.61", features = ["Win32_Storage_FileSystem"] } atomic-write-file = "0.3" dirs = "6" tauri = { version = "2", features = [] } +tauri-plugin-deep-link = "2" tauri-plugin-opener = "2" +tauri-plugin-single-instance = { version = "2", features = ["deep-link"] } tauri-plugin-window-state = "2" tauri-plugin-websocket = "2" tauri-plugin-dialog = "2" @@ -42,13 +44,14 @@ futures-util = "0.3" opus = "0.3" serde = { version = "1", features = ["derive"] } serde_json = "1" -nostr = "0.37" +nostr = { version = "0.37", features = ["nip44"] } nostr-compat = { package = "nostr", version = "0.36" } zeroize = "1" reqwest = { version = "0.13", features = ["json", "query", "stream"] } url = "2" sprout-core = { path = "../../crates/sprout-core" } sprout-persona = { path = "../../crates/sprout-persona" } +sprout-sdk = { path = "../../crates/sprout-sdk" } base64 = "0.22" sha2 = "0.11" tar = "0.4" diff --git a/desktop/src-tauri/src/app_state.rs b/desktop/src-tauri/src/app_state.rs index 605e753132..c070c1f44b 100644 --- a/desktop/src-tauri/src/app_state.rs +++ b/desktop/src-tauri/src/app_state.rs @@ -1,7 +1,7 @@ use std::{ collections::HashMap, io::Write, - sync::{atomic::AtomicU16, Mutex}, + sync::{atomic::AtomicU16, Arc, Mutex}, }; use nostr::{Keys, ToBech32}; @@ -13,8 +13,9 @@ use crate::managed_agents::ManagedAgentProcess; pub struct AppState { pub keys: Mutex, pub http_client: reqwest::Client, - pub configured_api_token: Option, - pub session_token: Mutex>, + /// Workspace-provided relay URL override. Set by `apply_workspace` on app + /// init and takes priority over env vars and compile-time defaults. + pub relay_url_override: Mutex>, pub managed_agents_store_lock: Mutex<()>, pub managed_agent_processes: Mutex>, pub huddle_state: Mutex, @@ -29,6 +30,8 @@ pub struct AppState { pub audio_output_device: Mutex>, /// Port of the localhost media streaming proxy (set during setup). pub media_proxy_port: AtomicU16, + /// IOKit power assertion state — prevents idle sleep while agents run. + pub prevent_sleep: Arc>, } pub fn build_app_state() -> AppState { @@ -56,26 +59,23 @@ pub fn build_app_state() -> AppState { ); } - let api_token = match std::env::var("SPROUT_API_TOKEN") { - Ok(token) if !token.trim().is_empty() => Some(token), - Ok(_) | Err(std::env::VarError::NotPresent) => None, - Err(std::env::VarError::NotUnicode(_)) => { - eprintln!("sprout-desktop: SPROUT_API_TOKEN contains invalid UTF-8"); - None - } - }; - AppState { keys: Mutex::new(keys), - http_client: reqwest::Client::new(), - configured_api_token: api_token, - session_token: Mutex::new(None), + http_client: reqwest::Client::builder() + .pool_idle_timeout(std::time::Duration::from_secs(10)) + .pool_max_idle_per_host(1) + .build() + .unwrap_or_else(|_| reqwest::Client::new()), + relay_url_override: Mutex::new(None), managed_agents_store_lock: Mutex::new(()), managed_agent_processes: Mutex::new(HashMap::new()), huddle_state: Mutex::new(HuddleState::default()), app_handle: Mutex::new(None), audio_output_device: Mutex::new(None), media_proxy_port: AtomicU16::new(0), + prevent_sleep: Arc::new(Mutex::new( + crate::prevent_sleep::PreventSleepState::default(), + )), } } @@ -192,7 +192,7 @@ fn load_key_file(path: &std::path::Path) -> Result { /// On Unix, the file is created with mode 0600 (owner read/write only). /// On Windows, default ACLs apply — the app data directory is already /// per-user, so the key is not world-readable in practice. -fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> { +pub(crate) fn save_key_file(path: &std::path::Path, keys: &Keys) -> Result<(), String> { use atomic_write_file::AtomicWriteFile; let nsec = keys diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 156c20248e..9ded778a05 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,4 +1,3 @@ -use reqwest::Method; use tauri::{AppHandle, State}; use crate::{ @@ -8,7 +7,8 @@ use crate::{ DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, }, - relay::{build_authed_request, send_json_request}, + nostr_convert, + relay::query_relay, }; #[tauri::command] @@ -42,6 +42,21 @@ pub fn discover_managed_agent_prereqs( #[tauri::command] pub async fn list_relay_agents(state: State<'_, AppState>) -> Result, String> { - let request = build_authed_request(&state.http_client, Method::GET, "/api/agents", &state)?; - send_json_request(request).await + // Query kind:10100 agent profile events from the relay. + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [10100], + })], + ) + .await?; + + // The convert helper returns `{"agents": [...]}`. Extract and re-deserialize + // into the strongly-typed `Vec` the frontend expects. + let value = nostr_convert::agents_from_events(&events); + let agents = value + .get("agents") + .cloned() + .unwrap_or_else(|| serde_json::json!([])); + serde_json::from_value(agents).map_err(|e| format!("agent parse failed: {e}")) } diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 304b7c05d3..5a96c35b76 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -11,7 +11,7 @@ use crate::{ normalize_agent_args, resolve_command, save_managed_agents, sync_managed_agent_processes, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, }, - relay::{relay_ws_url, sync_managed_agent_profile}, + relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; @@ -154,7 +154,7 @@ pub async fn update_managed_agent( if let Some(relay_url) = input.relay_url { let trimmed = relay_url.trim(); record.relay_url = if trimmed.is_empty() { - relay_ws_url() + relay_ws_url_with_override(&state) } else { trimmed.to_string() }; @@ -184,10 +184,10 @@ pub async fn update_managed_agent( let agent_keys = Keys::parse(&record.private_key_nsec) .map_err(|e| format!("failed to parse agent keys: {e}"))?; let relay_url = record.relay_url.clone(); - let api_token = record.api_token.clone(); let display_name = record.name.clone(); let avatar_url = managed_agent_avatar_url(&record.agent_command); - Some((agent_keys, relay_url, api_token, display_name, avatar_url)) + let auth_tag = record.auth_tag.clone(); + Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) } else { None }; @@ -198,15 +198,14 @@ pub async fn update_managed_agent( // Phase 2: relay profile sync (async, best-effort, outside lock) let profile_sync_error = - if let Some((agent_keys, relay_url, api_token, display_name, avatar_url)) = sync_params { + if let Some((agent_keys, relay_url, display_name, avatar_url, auth_tag)) = sync_params { match sync_managed_agent_profile( &state, &relay_url, &agent_keys, - api_token.as_deref(), - &[], &display_name, avatar_url.as_deref(), + auth_tag.as_deref(), ) .await { diff --git a/desktop/src-tauri/src/commands/agents.rs b/desktop/src-tauri/src/commands/agents.rs index f9a1c62d06..521ae3bb8a 100644 --- a/desktop/src-tauri/src/commands/agents.rs +++ b/desktop/src-tauri/src/commands/agents.rs @@ -1,22 +1,21 @@ use nostr::{Keys, ToBech32}; +use nostr_compat; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - build_managed_agent_summary, default_token_scopes, discover_provider_candidates, - ensure_persona_is_active, find_managed_agent_mut, invoke_provider, load_managed_agents, - load_personas, managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, - mint_token_via_api, normalize_agent_args, provider_deploy, read_log_tail, - resolve_provider_binary, save_managed_agents, start_managed_agent_process, - stop_managed_agent_process, sync_managed_agent_processes, validate_provider_config, - BackendKind, BackendProviderInfo, CreateManagedAgentRequest, CreateManagedAgentResponse, - ManagedAgentLogResponse, ManagedAgentRecord, ManagedAgentSummary, - MintManagedAgentTokenRequest, MintManagedAgentTokenResponse, DEFAULT_ACP_COMMAND, - DEFAULT_AGENT_COMMAND, DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, - DEFAULT_MCP_COMMAND, + build_managed_agent_summary, discover_provider_candidates, ensure_persona_is_active, + find_managed_agent_mut, invoke_provider, load_managed_agents, load_personas, + managed_agent_avatar_url, managed_agent_log_path, managed_agents_base_dir, + normalize_agent_args, provider_deploy, read_log_tail, resolve_provider_binary, + save_managed_agents, start_managed_agent_process, stop_managed_agent_process, + sync_managed_agent_processes, validate_provider_config, BackendKind, BackendProviderInfo, + CreateManagedAgentRequest, CreateManagedAgentResponse, ManagedAgentLogResponse, + ManagedAgentRecord, ManagedAgentSummary, DEFAULT_ACP_COMMAND, DEFAULT_AGENT_COMMAND, + DEFAULT_AGENT_PARALLELISM, DEFAULT_AGENT_TURN_TIMEOUT_SECONDS, DEFAULT_MCP_COMMAND, }, - relay::{relay_ws_url, sync_managed_agent_profile}, + relay::{relay_ws_url_with_override, sync_managed_agent_profile}, util::now_iso, }; @@ -26,7 +25,7 @@ fn build_deploy_payload(record: &ManagedAgentRecord) -> serde_json::Value { "name": &record.name, "relay_url": &record.relay_url, "private_key_nsec": &record.private_key_nsec, - "api_token": &record.api_token, + "auth_tag": &record.auth_tag, "agent_command": &record.agent_command, "agent_args": &record.agent_args, "system_prompt": &record.system_prompt, @@ -152,18 +151,8 @@ pub async fn create_managed_agent( } } - // ── Phase 1: generate keys and collect mint parameters (sync lock) ──────── - // We do NOT mint here — minting is async and must happen outside the lock. - let ( - agent_keys, - private_key_nsec, - pubkey, - resolved_relay_url, - token_scopes, - token_name, - mint_token, - input, - ) = { + // ── Phase 1: generate keys (sync lock) ──────────────────────────────────── + let (agent_keys, private_key_nsec, pubkey, resolved_relay_url, input) = { let _store_guard = state .managed_agents_store_lock .lock() @@ -191,106 +180,38 @@ pub async fn create_managed_agent( .to_bech32() .map_err(|error| format!("failed to encode private key: {error}"))?; - let token_scopes = if input.mint_token { - let requested = input - .token_scopes - .iter() - .map(|scope| scope.trim().to_string()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - if requested.is_empty() { - default_token_scopes() - } else { - requested - } - } else { - Vec::new() - }; - - let token_name = input - .token_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .unwrap_or(name.as_str()) - .to_string(); - let resolved_relay_url = input .relay_url .as_deref() .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string) - .unwrap_or_else(relay_ws_url); + .unwrap_or_else(|| relay_ws_url_with_override(&state)); - let mint_token = input.mint_token; - ( - keys, - private_key_nsec, - pubkey, - resolved_relay_url, - token_scopes, - token_name, - mint_token, - input, - ) + (keys, private_key_nsec, pubkey, resolved_relay_url, input) }; // ── Pre-Phase 2: validate provider config BEFORE any side effects ──────── if let BackendKind::Provider { ref config, ref id } = input.backend { - // Provider agents MUST mint a token so the relay establishes ownership. - // Without ownership the harness ignores !shutdown — the agent becomes - // uncontrollable. Reject early rather than deploy an unstoppable agent. - if !mint_token { - return Err( - "provider-backed agents require a minted token (ownership is established during mint)" - .to_string(), - ); - } - // Enforce minimum scopes for remote agents. The harness needs users:read - // to query its owner (for !shutdown). Without it, the agent is unstoppable. - const REQUIRED_PROVIDER_SCOPES: &[&str] = &[ - "messages:read", - "messages:write", - "channels:read", - "users:read", - ]; - for required in REQUIRED_PROVIDER_SCOPES { - if !token_scopes.iter().any(|s| s == required) { - return Err(format!( - "provider-backed agents require the '{required}' scope" - )); - } - } validate_provider_config(config)?; // Validate via discovered candidates — not raw resolve_command. resolve_provider_binary(id)?; } - // ── Phase 2: mint token via REST API (async, outside lock) ─────────────── - // Pass the desktop user's pubkey as the agent owner so the relay records - // the ownership chain. Only NIP-98 bootstrap mints can set owner_pubkey. - let user_pubkey_hex = { - let keys = state.keys.lock().map_err(|e| e.to_string())?; - keys.public_key().to_hex() + // ── Phase 2: compute NIP-OA auth tag (sync) ────────────────────────────── + // Agents authenticate via the auth tag in their kind:0 profile event. + // No tokens are minted. Fail closed: bad auth tag → don't create agent. + let auth_tag = { + let owner_keys = state.keys.lock().map_err(|e| e.to_string())?; + // Bridge nostr 0.37 → 0.36 (sprout-sdk) via hex round-trip. + let compat_owner = nostr_compat::Keys::parse(&owner_keys.secret_key().to_secret_hex()) + .map_err(|e| format!("failed to bridge owner keys: {e}"))?; + let compat_agent = nostr_compat::PublicKey::from_hex(&agent_keys.public_key().to_hex()) + .map_err(|e| format!("failed to bridge agent pubkey: {e}"))?; + let tag = sprout_sdk::nip_oa::compute_auth_tag(&compat_owner, &compat_agent, "") + .map_err(|e| format!("failed to compute NIP-OA auth tag: {e}"))?; + Some(tag) }; - let api_token: Option = if mint_token { - let token = mint_token_via_api( - &state, - &agent_keys, - &resolved_relay_url, - &token_name, - &token_scopes, - Some(&user_pubkey_hex), - ) - .await?; - Some(token) - } else { - None - }; - - // Agent ownership is set atomically during token mint via owner_pubkey - // in the request body — no separate API call needed. // ── Phase 3: save record and optionally spawn (sync lock) ───────────────── let (agent, spawn_error) = { @@ -361,7 +282,7 @@ pub async fn create_managed_agent( name: name.clone(), persona_id: requested_persona_id.clone(), private_key_nsec: private_key_nsec.clone(), - api_token: api_token.clone(), + auth_tag: auth_tag.clone(), relay_url: resolved_relay_url.clone(), acp_command: input .acp_command @@ -408,9 +329,7 @@ pub async fn create_managed_agent( .map(str::trim) .filter(|value| !value.is_empty()) .map(str::to_string), - // Provider agents don't auto-start with the desktop — they're - // managed externally. Force false to avoid persisting a flag the - // app will never honor. + // Provider agents are managed externally — force false. start_on_app_launch: if input.backend != BackendKind::Local { false } else { @@ -420,13 +339,9 @@ pub async fn create_managed_agent( backend: input.backend.clone(), backend_agent_id: None, provider_binary_path, - // If the persona came from a pack, record the installed pack path - // and the persona's internal name within the pack so the runtime - // can resolve pack-specific config at startup via ACP. - // - // Important: persona_name_in_pack must be the internal `name` slug - // (e.g., "lep"), NOT the display_name (e.g., "Lep"). ACP's - // resolve_persona_by_name() matches on the internal name. + // Pack-backed personas: record path + internal slug so the runtime + // can resolve pack config at startup. Must be the slug (e.g., "lep"), + // NOT the display_name — ACP's resolve_persona_by_name() matches slugs. persona_pack_path: pack_metadata.as_ref().map(|(path, _)| path.clone()), persona_name_in_pack: pack_metadata.as_ref().map(|(_, name)| name.clone()), created_at: now_iso(), @@ -471,10 +386,9 @@ pub async fn create_managed_agent( &state, &resolved_relay_url, &agent_keys, - api_token.as_deref(), - &token_scopes, &name, avatar_url.as_deref(), + auth_tag.as_deref(), ) .await) .err(); @@ -530,7 +444,6 @@ pub async fn create_managed_agent( Ok(CreateManagedAgentResponse { agent: final_agent, private_key_nsec, - api_token, profile_sync_error, spawn_error, }) @@ -702,104 +615,6 @@ pub fn delete_managed_agent( save_managed_agents(&app, &records) } -#[tauri::command] -pub async fn mint_managed_agent_token( - input: MintManagedAgentTokenRequest, - app: AppHandle, - state: State<'_, AppState>, -) -> Result { - // ── Phase 1: load agent record and collect mint parameters (sync lock) ──── - let (agent_keys, relay_url, scopes, token_name) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - - if sync_managed_agent_processes(&mut records, &mut runtimes) { - save_managed_agents(&app, &records)?; - } - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - - let scopes = { - let requested = input - .scopes - .into_iter() - .map(|scope| scope.trim().to_string()) - .filter(|scope| !scope.is_empty()) - .collect::>(); - if requested.is_empty() { - default_token_scopes() - } else { - requested - } - }; - - let token_name = input - .token_name - .as_deref() - .map(str::trim) - .filter(|value| !value.is_empty()) - .map(str::to_string) - .unwrap_or_else(|| format!("{}-token", record.name)); - - // Reconstruct the agent's keypair from the stored nsec so we can sign - // the NIP-98 auth event as the agent (not the desktop user). - let agent_keys = Keys::parse(&record.private_key_nsec) - .map_err(|e| format!("failed to parse agent secret key: {e}"))?; - - (agent_keys, record.relay_url.clone(), scopes, token_name) - }; - - // ── Phase 2: mint token via REST API (async, outside lock) ─────────────── - // Re-minting: do NOT send owner_pubkey. Ownership was established during - // the first mint (create flow). Sending it again would be rejected by the - // relay if the owner is already set to a different pubkey. - let minted_token = - mint_token_via_api(&state, &agent_keys, &relay_url, &token_name, &scopes, None).await?; - - // ── Phase 3: persist new token to agent record (sync lock) ─────────────── - let (agent, api_token) = { - let _store_guard = state - .managed_agents_store_lock - .lock() - .map_err(|error| error.to_string())?; - let mut records = load_managed_agents(&app)?; - let mut runtimes = state - .managed_agent_processes - .lock() - .map_err(|error| error.to_string())?; - - if sync_managed_agent_processes(&mut records, &mut runtimes) { - save_managed_agents(&app, &records)?; - } - let record = find_managed_agent_mut(&mut records, &input.pubkey)?; - record.api_token = Some(minted_token.clone()); - record.updated_at = now_iso(); - record.last_error = None; - let pubkey = record.pubkey.clone(); - - save_managed_agents(&app, &records)?; - - let record = records - .iter() - .find(|record| record.pubkey == pubkey) - .ok_or_else(|| format!("agent {pubkey} not found"))?; - let agent = build_managed_agent_summary(&app, record, &runtimes)?; - - (agent, minted_token) - }; - - Ok(MintManagedAgentTokenResponse { - agent, - token: api_token, - }) -} - #[tauri::command] pub fn get_managed_agent_log( pubkey: String, diff --git a/desktop/src-tauri/src/commands/canvas.rs b/desktop/src-tauri/src/commands/canvas.rs index 71965d0841..1bad5fccb9 100644 --- a/desktop/src-tauri/src/commands/canvas.rs +++ b/desktop/src-tauri/src/commands/canvas.rs @@ -1,20 +1,37 @@ -use reqwest::Method; use tauri::State; use crate::{ app_state::AppState, events, - relay::{api_path, build_authed_request, send_json_request, submit_event}, + relay::{query_relay, submit_event}, }; +/// Read the most recent canvas event (kind:40100) for a channel. #[tauri::command] pub async fn get_canvas( channel_id: String, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["channels", &channel_id, "canvas"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [40100], + "#h": [channel_id], + "limit": 1 + })], + ) + .await?; + + let Some(event) = events.first() else { + return Ok(serde_json::json!({ "content": "" })); + }; + + Ok(serde_json::json!({ + "content": event.content, + "event_id": event.id.to_hex(), + "created_at": event.created_at.as_u64(), + "pubkey": event.pubkey.to_hex(), + })) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/channels.rs b/desktop/src-tauri/src/commands/channels.rs index 2844fd1f56..edd878485e 100644 --- a/desktop/src-tauri/src/commands/channels.rs +++ b/desktop/src-tauri/src/commands/channels.rs @@ -1,19 +1,122 @@ -use reqwest::Method; use tauri::State; use crate::{ app_state::AppState, events, models::{ChannelDetailInfo, ChannelInfo, ChannelMembersResponse}, - relay::{api_path, build_authed_request, send_json_request, submit_event}, + nostr_convert, + relay::{query_relay, submit_event}, }; -// ── Reads (unchanged) ──────────────────────────────────────────────────────── +// ── Reads (pure-nostr via /query) ──────────────────────────────────────────── #[tauri::command] pub async fn get_channels(state: State<'_, AppState>) -> Result, String> { - let request = build_authed_request(&state.http_client, Method::GET, "/api/channels", &state)?; - send_json_request(request).await + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + // Step 1: find all kind:39002 (members) events that mention me, then + // pull the channel ids out of their `d` tags. + let member_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39002], + "#p": [my_pubkey], + "limit": 1000, + })], + ) + .await?; + + let mut channel_ids: Vec = member_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + channel_ids.sort(); + channel_ids.dedup(); + + // Step 2: fetch channel metadata events (kind:39000) for member channels. + // kind:39000 is addressable: exactly one event per `d` tag, so a limit + // equal to the number of ids is both necessary and sufficient. Without + // an explicit limit, multi-value `#d` filters fall through to the relay's + // default LIMIT and can drop results when there are many channels. + let meta_events = if !channel_ids.is_empty() { + query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": channel_ids, + "limit": channel_ids.len(), + })], + ) + .await? + } else { + Vec::new() + }; + + // Step 3: fetch ALL open channel metadata so the channel browser can show + // discoverable channels the user hasn't joined yet. The relay's access + // control allows reading kind:39000 for open channels regardless of membership. + let open_meta_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "limit": 5000, + })], + ) + .await?; + + // Merge: member channels (marked as member) + open channels (not yet joined). + let member_d_tags: std::collections::HashSet = meta_events + .iter() + .filter_map(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .collect(); + + let mut channels = Vec::with_capacity(meta_events.len() + open_meta_events.len()); + for ev in &meta_events { + if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(true)) { + channels.push(info); + } + } + for ev in &open_meta_events { + // Skip channels already included from the member set. + let d_tag = ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }); + if let Some(ref d) = d_tag { + if member_d_tags.contains(d) { + continue; + } + } + if let Ok(info) = nostr_convert::channel_info_from_event(ev, None, Some(false)) { + channels.push(info); + } + } + Ok(channels) } #[tauri::command] @@ -21,9 +124,21 @@ pub async fn get_channel_details( channel_id: String, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["channels", &channel_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(nostr_convert::channel_detail_from_event) + .transpose()? + .ok_or_else(|| "channel not found".to_string()) } #[tauri::command] @@ -31,12 +146,59 @@ pub async fn get_channel_members( channel_id: String, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["channels", &channel_id, "members"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + let mut response = events + .first() + .map(nostr_convert::channel_members_from_event) + .transpose()? + .ok_or_else(|| "channel members not found".to_string())?; + + // Batch-fetch kind:0 profiles to populate display names. + let pubkeys: Vec = response.members.iter().map(|m| m.pubkey.clone()).collect(); + if !pubkeys.is_empty() { + let profile_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + "limit": pubkeys.len() + })], + ) + .await + .unwrap_or_default(); + + // Build pubkey → display_name map from kind:0 events + let mut name_map = std::collections::HashMap::new(); + for ev in &profile_events { + let pk = ev.pubkey.to_hex(); + if let Ok(profile) = nostr_convert::profile_info_from_event(ev) { + if let Some(name) = profile.display_name { + name_map.insert(pk, name); + } + } + } + + // Populate display_name on each member + for member in &mut response.members { + if member.display_name.is_none() { + member.display_name = name_map.get(&member.pubkey).cloned(); + } + } + } + + Ok(response) } -// ── Writes (migrated to signed events via POST /api/events) ────────────────── +// ── Writes (signed events) ────────────────────────────────────────────────── fn parse_channel_uuid(channel_id: &str) -> Result { uuid::Uuid::parse_str(channel_id).map_err(|_| format!("invalid channel UUID: {channel_id}")) @@ -72,11 +234,23 @@ pub async fn create_channel( )?; submit_event(builder, &state).await?; - // Follow-up GET to return the full ChannelInfo the frontend expects. + // Re-fetch the canonical metadata event to return ChannelInfo. let channel_uuid_string = channel_uuid.to_string(); - let path = api_path(&["channels", &channel_uuid_string]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_uuid_string], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(|ev| nostr_convert::channel_info_from_event(ev, None, None)) + .transpose()? + .ok_or_else(|| "channel created but metadata not yet available".to_string()) } #[tauri::command] @@ -90,10 +264,21 @@ pub async fn update_channel( let builder = events::build_update_channel(uuid, name.as_deref(), description.as_deref())?; submit_event(builder, &state).await?; - // Follow-up GET to return the full ChannelDetailInfo. - let path = api_path(&["channels", &channel_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [channel_id], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(nostr_convert::channel_detail_from_event) + .transpose()? + .ok_or_else(|| "channel updated but metadata not yet available".to_string()) } #[tauri::command] diff --git a/desktop/src-tauri/src/commands/dms.rs b/desktop/src-tauri/src/commands/dms.rs index c1d0bd5fdf..dcac491b16 100644 --- a/desktop/src-tauri/src/commands/dms.rs +++ b/desktop/src-tauri/src/commands/dms.rs @@ -1,29 +1,52 @@ -use reqwest::Method; +use serde::Deserialize; use tauri::State; use crate::{ app_state::AppState, - models::{ChannelInfo, OpenDmBody, OpenDmResponse}, - relay::{api_path, build_authed_request, send_empty_request, send_json_request}, + events, + models::ChannelInfo, + nostr_convert, + relay::{parse_command_response, query_relay, submit_event}, }; +#[derive(Deserialize)] +struct OpenDmAck { + channel_id: String, +} + #[tauri::command] pub async fn open_dm( pubkeys: Vec, state: State<'_, AppState>, ) -> Result { - let request = build_authed_request(&state.http_client, Method::POST, "/api/dms", &state)? - .json(&OpenDmBody { pubkeys: &pubkeys }); - let response: OpenDmResponse = send_json_request(request).await?; + // Submit a kind:41010 dm-open event; the relay replies with the channel id + // in its OK message payload. + let builder = events::build_dm_open(&pubkeys)?; + let result = submit_event(builder, &state).await?; + let ack: OpenDmAck = parse_command_response(&result.message)?; - let path = api_path(&["channels", &response.channel_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + // Re-fetch the channel metadata so the frontend gets the same `ChannelInfo` + // shape as `get_channel_details`. + let metadata = query_relay( + &state, + &[serde_json::json!({ + "kinds": [39000], + "#d": [ack.channel_id], + "limit": 1 + })], + ) + .await?; + + metadata + .first() + .map(|ev| nostr_convert::channel_info_from_event(ev, None, None)) + .transpose()? + .ok_or_else(|| "DM channel created but metadata not yet available".to_string()) } #[tauri::command] pub async fn hide_dm(channel_id: String, state: State<'_, AppState>) -> Result<(), String> { - let path = api_path(&["dms", &channel_id, "hide"]); - let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; - send_empty_request(request).await + let builder = events::build_dm_hide(&channel_id)?; + submit_event(builder, &state).await?; + Ok(()) } diff --git a/desktop/src-tauri/src/commands/identity.rs b/desktop/src-tauri/src/commands/identity.rs index f4ca90ce3b..3be94f8fa3 100644 --- a/desktop/src-tauri/src/commands/identity.rs +++ b/desktop/src-tauri/src/commands/identity.rs @@ -1,10 +1,15 @@ -use nostr::{EventBuilder, JsonUtil, Kind, Tag, ToBech32}; +use nostr::{nips::nip44, EventBuilder, JsonUtil, Keys, Kind, Tag, Timestamp, ToBech32}; +use nostr_compat::{ + Event as CompatEvent, JsonUtil as CompatJsonUtil, Keys as CompatKeys, + PublicKey as CompatPublicKey, +}; +use tauri::Manager; use tauri::State; use crate::{ app_state::AppState, models::IdentityInfo, - relay::{relay_api_base_url, relay_ws_url}, + relay::{self, relay_api_base_url_with_override, relay_ws_url_with_override}, }; #[tauri::command] @@ -28,13 +33,18 @@ pub fn get_identity(state: State<'_, AppState>) -> Result } #[tauri::command] -pub fn get_relay_ws_url() -> String { - relay_ws_url() +pub fn get_default_relay_url() -> String { + relay::relay_ws_url() } #[tauri::command] -pub fn get_relay_http_url() -> String { - relay_api_base_url() +pub fn get_relay_ws_url(state: State<'_, AppState>) -> String { + relay_ws_url_with_override(&state) +} + +#[tauri::command] +pub fn get_relay_http_url(state: State<'_, AppState>) -> String { + relay_api_base_url_with_override(&state) } #[tauri::command] @@ -48,6 +58,7 @@ pub fn get_media_proxy_port(state: State<'_, AppState>) -> u16 { pub fn sign_event( kind: u16, content: String, + created_at: Option, tags: Vec>, state: State<'_, AppState>, ) -> Result { @@ -58,14 +69,76 @@ pub fn sign_event( .map(|tag| Tag::parse(tag).map_err(|error| format!("invalid tag: {error}"))) .collect::, _>>()?; - let event = EventBuilder::new(Kind::Custom(kind), content) - .tags(nostr_tags) + let mut builder = EventBuilder::new(Kind::Custom(kind), content).tags(nostr_tags); + if let Some(created_at) = created_at { + builder = builder.custom_created_at(Timestamp::from(created_at)); + } + + let event = builder .sign_with_keys(&keys) .map_err(|error| format!("sign failed: {error}"))?; Ok(event.as_json()) } +#[tauri::command] +pub fn decrypt_observer_event( + event_json: String, + state: State<'_, AppState>, +) -> Result { + let nsec = { + let keys = state.keys.lock().map_err(|error| error.to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|error| format!("encode nsec: {error}"))? + }; + let keys = CompatKeys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?; + let event = + CompatEvent::from_json(event_json).map_err(|error| format!("invalid event: {error}"))?; + + // Defense-in-depth: verify event ID and signature before decrypting. + if !event.verify_id() { + return Err("observer event has invalid ID".into()); + } + if !event.verify_signature() { + return Err("observer event has invalid signature".into()); + } + + sprout_core::observer::decrypt_observer_payload(&keys, &event) + .map_err(|error| format!("decrypt observer event failed: {error}")) +} + +#[tauri::command] +pub fn build_observer_control_event( + agent_pubkey: String, + payload: serde_json::Value, + state: State<'_, AppState>, +) -> Result { + let nsec = { + let keys = state.keys.lock().map_err(|error| error.to_string())?; + keys.secret_key() + .to_bech32() + .map_err(|error| format!("encode nsec: {error}"))? + }; + let keys = CompatKeys::parse(&nsec).map_err(|error| format!("parse nsec: {error}"))?; + let agent_pubkey = CompatPublicKey::from_hex(agent_pubkey.trim()) + .map_err(|error| format!("invalid agent pubkey: {error}"))?; + let agent_pubkey_hex = agent_pubkey.to_hex(); + let encrypted = sprout_core::observer::encrypt_observer_payload(&keys, &agent_pubkey, &payload) + .map_err(|error| format!("encrypt observer control failed: {error}"))?; + let builder = sprout_sdk::build_agent_observer_frame( + &agent_pubkey_hex, + &agent_pubkey_hex, + sprout_core::observer::OBSERVER_FRAME_CONTROL, + &encrypted, + ) + .map_err(|error| format!("build observer control failed: {error}"))?; + let event = builder + .sign_with_keys(&keys) + .map_err(|error| format!("sign observer control failed: {error}"))?; + Ok(event.as_json()) +} + #[tauri::command] pub fn get_nsec(state: State<'_, AppState>) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; @@ -74,6 +147,46 @@ pub fn get_nsec(state: State<'_, AppState>) -> Result { .map_err(|error| format!("encode nsec: {error}")) } +#[tauri::command] +pub fn import_identity( + nsec: String, + app_handle: tauri::AppHandle, + state: State<'_, AppState>, +) -> Result { + let trimmed = nsec.trim(); + let keys = Keys::parse(trimmed).map_err(|e| format!("Invalid private key: {e}"))?; + + // Persist to identity.key + let data_dir = app_handle + .path() + .app_data_dir() + .map_err(|e| format!("app data dir: {e}"))?; + std::fs::create_dir_all(&data_dir).map_err(|e| format!("create app data dir: {e}"))?; + let key_path = data_dir.join("identity.key"); + crate::app_state::save_key_file(&key_path, &keys)?; + + // Update in-memory keys + let pubkey = keys.public_key(); + *state.keys.lock().map_err(|e| e.to_string())? = keys; + + let pubkey_hex = pubkey.to_hex(); + let bech32 = pubkey + .to_bech32() + .map_err(|error| format!("bech32 encode failed: {error}"))?; + let display_name = if bech32.len() > 16 { + format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + } else { + bech32 + }; + + eprintln!("sprout-desktop: imported identity pubkey {}", pubkey_hex); + + Ok(IdentityInfo { + pubkey: pubkey_hex, + display_name, + }) +} + #[tauri::command] pub fn create_auth_event( challenge: String, @@ -82,20 +195,13 @@ pub fn create_auth_event( ) -> Result { let keys = state.keys.lock().map_err(|error| error.to_string())?; - let mut tags = vec![ + let tags = vec![ Tag::parse(vec!["relay", &relay_url]) .map_err(|error| format!("relay tag failed: {error}"))?, Tag::parse(vec!["challenge", &challenge]) .map_err(|error| format!("challenge tag failed: {error}"))?, ]; - if let Some(token) = state.configured_api_token.as_deref() { - tags.push( - Tag::parse(vec!["auth_token", token]) - .map_err(|error| format!("auth token tag failed: {error}"))?, - ); - } - let event = EventBuilder::new(Kind::Custom(22242), "") .tags(tags) .sign_with_keys(&keys) @@ -103,3 +209,28 @@ pub fn create_auth_event( Ok(event.as_json()) } + +#[tauri::command] +pub fn nip44_encrypt_to_self( + plaintext: String, + state: State<'_, AppState>, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + nip44::encrypt( + keys.secret_key(), + &keys.public_key(), + &plaintext, + nip44::Version::V2, + ) + .map_err(|e| format!("nip44 encrypt failed: {e}")) +} + +#[tauri::command] +pub fn nip44_decrypt_from_self( + ciphertext: String, + state: State<'_, AppState>, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + nip44::decrypt(keys.secret_key(), &keys.public_key(), &ciphertext) + .map_err(|e| format!("nip44 decrypt failed: {e}")) +} diff --git a/desktop/src-tauri/src/commands/media.rs b/desktop/src-tauri/src/commands/media.rs index 9dd53d4609..dcb1a94d95 100644 --- a/desktop/src-tauri/src/commands/media.rs +++ b/desktop/src-tauri/src/commands/media.rs @@ -5,7 +5,7 @@ use sha2::{Digest, Sha256}; use tauri::State; use crate::app_state::AppState; -use crate::relay::relay_api_base_url; +use crate::relay::relay_api_base_url_with_override; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct BlobDescriptor { @@ -123,6 +123,7 @@ fn sign_blossom_upload_auth( keys: &Keys, sha256: &str, expiry_secs: u64, + base_url: &str, ) -> Result { let now = Timestamp::now().as_u64(); let mut tags = vec![ @@ -131,7 +132,6 @@ fn sign_blossom_upload_auth( Tag::parse(vec!["expiration", &(now + expiry_secs).to_string()]) .map_err(|e| e.to_string())?, ]; - let base_url = relay_api_base_url(); if let Some(domain) = extract_server_authority(&base_url) { tags.push(Tag::parse(vec!["server".to_string(), domain]).map_err(|e| e.to_string())?); } @@ -161,32 +161,23 @@ async fn do_upload( } else { 300 }; + let base_url = relay_api_base_url_with_override(state); let auth_event = { let keys = state.keys.lock().map_err(|e| e.to_string())?; - sign_blossom_upload_auth(&keys, &sha256, expiry_secs)? + sign_blossom_upload_auth(&keys, &sha256, expiry_secs, &base_url)? }; let auth_header = format!( "Nostr {}", URL_SAFE_NO_PAD.encode(auth_event.as_json().as_bytes()) ); - - let base_url = relay_api_base_url(); - let mut req = state + let req = state .http_client .put(format!("{base_url}/media/upload")) .header("Authorization", &auth_header) .header("Content-Type", mime) .header("X-SHA-256", &sha256); - if let Some(ref token) = state.configured_api_token { - req = req.header("X-Auth-Token", token.as_str()); - } else if let Ok(guard) = state.session_token.lock() { - if let Some(ref token) = *guard { - req = req.header("X-Auth-Token", token.as_str()); - } - } - let resp = req .body(body) .send() diff --git a/desktop/src-tauri/src/commands/messages.rs b/desktop/src-tauri/src/commands/messages.rs index 967bf87c8a..eef7579f53 100644 --- a/desktop/src-tauri/src/commands/messages.rs +++ b/desktop/src-tauri/src/commands/messages.rs @@ -1,18 +1,19 @@ use nostr::EventId; -use reqwest::Method; use tauri::State; use crate::{ app_state::AppState, events, models::{ - FeedResponse, ForumPostsResponse, ForumThreadResponse, GetFeedQuery, GetForumPostsQuery, - GetForumThreadQuery, SearchQueryParams, SearchResponse, SendChannelMessageResponse, + FeedItemInfo, FeedMeta, FeedResponse, FeedSections, ForumMessageInfo, ForumPostsResponse, + ForumThreadReplyInfo, ForumThreadResponse, SearchResponse, SendChannelMessageResponse, + ThreadSummary, }, - relay::{api_path, build_authed_request, relay_error_message, send_json_request, submit_event}, + nostr_convert, + relay::{query_relay, submit_event}, }; -// ── Reads (unchanged) ──────────────────────────────────────────────────────── +// ── Reads (pure-nostr) ────────────────────────────────────────────────────── #[tauri::command] pub async fn get_feed( @@ -21,26 +22,104 @@ pub async fn get_feed( types: Option, state: State<'_, AppState>, ) -> Result { - let request = build_authed_request(&state.http_client, Method::GET, "/api/feed", &state)? - .query(&GetFeedQuery { - since, - limit, - types: types.as_deref(), - }); + let cap = limit.unwrap_or(50).min(100); - send_json_request(request).await + // Parse types filter — if absent, run all sub-queries. + // Comma-separated: e.g. "mentions,needs_action". + let want_mentions = types + .as_deref() + .map(|t| t.split(',').any(|s| s.trim() == "mentions")) + .unwrap_or(true); + let want_needs_action = types + .as_deref() + .map(|t| t.split(',').any(|s| s.trim() == "needs_action")) + .unwrap_or(true); + + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + // Mentions: messages that reference me via #p. + let mut mention_filter = serde_json::json!({ + "kinds": [9, 40002, 1, 45001, 45003], + "#p": [my_pubkey], + "limit": cap, + }); + if let Some(s) = since { + mention_filter["since"] = serde_json::json!(s); + } + // Needs-action: workflow approval-request events sent to me. + let mut approval_filter = serde_json::json!({ + "kinds": [46010, 46011, 46012], + "#p": [my_pubkey], + "limit": 20, + }); + if let Some(s) = since { + approval_filter["since"] = serde_json::json!(s); + } + + let mention_events = if want_mentions { + query_relay(&state, &[mention_filter]) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + let approval_events = if want_needs_action { + query_relay(&state, &[approval_filter]) + .await + .unwrap_or_default() + } else { + Vec::new() + }; + + let mentions: Vec = mention_events + .iter() + .map(|ev| feed_item_from_event(ev, "mentions")) + .collect(); + let needs_action: Vec = approval_events + .iter() + .map(|ev| feed_item_from_event(ev, "needs_action")) + .collect(); + + let total = (mentions.len() + needs_action.len()) as u64; + Ok(FeedResponse { + feed: FeedSections { + mentions, + needs_action, + activity: Vec::new(), + agent_activity: Vec::new(), + }, + meta: FeedMeta { + since: since.unwrap_or(0), + total, + generated_at: chrono::Utc::now().timestamp(), + }, + }) } #[tauri::command] pub async fn search_messages( q: String, limit: Option, + channel_id: Option, state: State<'_, AppState>, ) -> Result { - let request = build_authed_request(&state.http_client, Method::GET, "/api/search", &state)? - .query(&SearchQueryParams { q: q.trim(), limit }); + let cap = limit.unwrap_or(20).min(100); + let mut filter = serde_json::Map::new(); + filter.insert( + "kinds".to_string(), + serde_json::json!([9, 40002, 45001, 45003]), + ); + filter.insert("search".to_string(), serde_json::json!(q.trim())); + filter.insert("limit".to_string(), serde_json::json!(cap)); + if let Some(cid) = channel_id { + filter.insert("#h".to_string(), serde_json::json!([cid])); + } - send_json_request(request).await + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + Ok(nostr_convert::search_response_from_events(&events)) } #[tauri::command] @@ -50,16 +129,26 @@ pub async fn get_forum_posts( before: Option, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["channels", &channel_id, "messages"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query( - &GetForumPostsQuery { - limit, - before, - with_threads: true, - }, - ); + let cap = limit.unwrap_or(20).min(100); + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([45001])); + filter.insert("#h".to_string(), serde_json::json!([channel_id.clone()])); + filter.insert("limit".to_string(), serde_json::json!(cap)); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } - send_json_request(request).await + let events = query_relay(&state, &[serde_json::Value::Object(filter)]).await?; + let messages: Vec = events + .iter() + .map(|ev| forum_message_from_event(ev, &channel_id)) + .collect(); + + let next_cursor = messages.last().map(|m| m.created_at); + Ok(ForumPostsResponse { + messages, + next_cursor, + }) } #[tauri::command] @@ -70,36 +159,63 @@ pub async fn get_forum_thread( cursor: Option, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["channels", &channel_id, "threads", &event_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)? - .query(&GetForumThreadQuery { limit, cursor }); + let _ = (limit, cursor); + // Two filters: the root event itself, plus any reply (kinds 9/45003) + // that references it via #e. + let events = query_relay( + &state, + &[ + serde_json::json!({ "ids": [event_id.clone()], "kinds": [9, 40002, 45001, 45003] }), + serde_json::json!({ + "kinds": [9, 45003], + "#e": [event_id.clone()], + "#h": [channel_id.clone()], + }), + ], + ) + .await?; - send_json_request(request).await + let mut root: Option = None; + let mut replies: Vec = Vec::new(); + for ev in &events { + if ev.id.to_hex() == event_id { + root = Some(forum_message_from_event(ev, &channel_id)); + } else { + replies.push(forum_reply_from_event(ev, &channel_id, &event_id)); + } + } + let total_replies = replies.len() as u32; + + let root = root.ok_or_else(|| "forum thread root event not found".to_string())?; + Ok(ForumThreadResponse { + root, + replies, + total_replies, + next_cursor: None, + }) } #[tauri::command] pub async fn get_event(event_id: String, state: State<'_, AppState>) -> Result { - let path = api_path(&["events", &event_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - let response = request - .send() - .await - .map_err(|error| format!("request failed: {error}"))?; + let events = query_relay( + &state, + &[serde_json::json!({ + "ids": [event_id], + "kinds": [0, 1, 3, 5, 7, 9, 30078, 40002, 40003, 40008, 40099, 40100, 45001, 45003], + "limit": 1 + })], + ) + .await?; - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } - - response - .text() - .await - .map_err(|error| format!("parse failed: {error}")) + let ev = events + .first() + .ok_or_else(|| "event not found".to_string())?; + serde_json::to_string(ev).map_err(|e| format!("serialize event: {e}")) } -// ── Writes (migrated to signed events via POST /api/events) ────────────────── +// ── Writes ────────────────────────────────────────────────────────────────── /// Fetch a parent event and extract the thread root from its NIP-10 e-tags. -/// Same logic as MCP's `resolve_thread_ref`. async fn resolve_thread_ref( parent_event_id: &str, state: &AppState, @@ -107,41 +223,33 @@ async fn resolve_thread_ref( let parent_eid = EventId::from_hex(parent_event_id).map_err(|e| format!("invalid parent event ID: {e}"))?; - let path = api_path(&["events", parent_event_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, state)?; - let response = request - .send() - .await - .map_err(|e| format!("failed to fetch parent event: {e}"))?; + let evs = query_relay( + state, + &[serde_json::json!({ + "ids": [parent_event_id], + "kinds": [9, 40002, 45001, 45003], + "limit": 1 + })], + ) + .await?; - if !response.status().is_success() { - return Err(relay_error_message(response).await); - } + let parent = evs + .first() + .ok_or_else(|| "parent event not found".to_string())?; - let event_json: serde_json::Value = response - .json() - .await - .map_err(|e| format!("failed to parse parent event: {e}"))?; - - // Walk tags looking for NIP-10 root/reply markers — same as MCP's find_root_from_tags. - let root_hex = event_json - .get("tags") - .and_then(|t| t.as_array()) - .and_then(|tags| { - let mut root = None; - let mut reply = None; - for tag in tags { - let parts = tag.as_array()?; - if parts.len() >= 4 && parts[0].as_str() == Some("e") { - match parts[3].as_str() { - Some("root") => root = parts[1].as_str().map(|s| s.to_string()), - Some("reply") => reply = parts[1].as_str().map(|s| s.to_string()), - _ => {} - } - } + // Walk tags looking for NIP-10 root/reply markers. + let (mut root, mut reply) = (None, None); + for tag in parent.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 4 && s[0] == "e" { + match s[3].as_str() { + "root" => root = Some(s[1].clone()), + "reply" => reply = Some(s[1].clone()), + _ => {} } - root.or(reply) - }); + } + } + let root_hex = root.or(reply); let root_eid = match root_hex { Some(hex) if hex != parent_event_id => { @@ -173,7 +281,6 @@ pub async fn send_channel_message( let media = media_tags.unwrap_or_default(); let kind_num = kind.unwrap_or(sprout_core::kind::KIND_STREAM_MESSAGE); - // Track the resolved thread ref so we can return accurate metadata. let mut resolved_root: Option = None; let builder = match kind_num { @@ -195,7 +302,6 @@ pub async fn send_channel_message( )? } _ => { - // Stream message (kind 9) — with optional thread ref for replies. let thread_ref = match parent_event_id.as_deref() { Some(pid) => { let tr = resolve_thread_ref(pid, &state).await?; @@ -216,7 +322,6 @@ pub async fn send_channel_message( let result = submit_event(builder, &state).await?; - // Derive depth: 0 = top-level, 1 = direct reply, 2+ = nested. let depth = match (&parent_event_id, &resolved_root) { (None, _) => 0, (Some(pid), Some(root)) if pid == root => 1, @@ -251,39 +356,30 @@ pub async fn remove_reaction( emoji: String, state: State<'_, AppState>, ) -> Result<(), String> { - // Fetch reactions to find our reaction event ID — same pattern as MCP. - let path = api_path(&["messages", event_id.trim(), "reactions"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - let reactions: serde_json::Value = send_json_request(request).await?; + // Find our own kind:7 reaction event referencing the target. + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + let target = event_id.trim(); + let trimmed_emoji = emoji.trim(); - let my_pubkey = state - .keys - .lock() - .map_err(|e| e.to_string())? - .public_key() - .to_hex(); + let reactions = query_relay( + &state, + &[serde_json::json!({ + "kinds": [7], + "#e": [target], + "authors": [my_pubkey], + })], + ) + .await?; - let reaction_event_id_hex = reactions - .get("reactions") - .and_then(|r| r.as_array()) - .and_then(|groups| { - groups.iter().find_map(|group| { - if group.get("emoji")?.as_str()? != emoji.trim() { - return None; - } - group.get("users")?.as_array()?.iter().find_map(|user| { - if user.get("pubkey")?.as_str()? != my_pubkey { - return None; - } - user.get("reaction_event_id")?.as_str().map(String::from) - }) - }) - }) + let reaction_event = reactions + .iter() + .find(|ev| ev.content.trim() == trimmed_emoji) .ok_or("could not find your reaction event for this emoji")?; - let reaction_eid = EventId::from_hex(&reaction_event_id_hex) - .map_err(|e| format!("invalid reaction event ID: {e}"))?; - let builder = events::build_remove_reaction(reaction_eid)?; + let builder = events::build_remove_reaction(reaction_event.id)?; submit_event(builder, &state).await?; Ok(()) } @@ -314,3 +410,98 @@ pub async fn delete_message(event_id: String, state: State<'_, AppState>) -> Res submit_event(builder, &state).await?; Ok(()) } + +// ── Local helpers ─────────────────────────────────────────────────────────── + +fn channel_id_from_tags(ev: &nostr::Event) -> Option { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) +} + +fn tags_to_vec(ev: &nostr::Event) -> Vec> { + ev.tags.iter().map(|t| t.as_slice().to_vec()).collect() +} + +fn feed_item_from_event(ev: &nostr::Event, category: &str) -> FeedItemInfo { + let channel_id = channel_id_from_tags(ev); + FeedItemInfo { + id: ev.id.to_hex(), + kind: ev.kind.as_u16() as u32, + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + created_at: ev.created_at.as_u64(), + channel_id, + channel_name: String::new(), + channel_type: None, + tags: tags_to_vec(ev), + category: category.to_string(), + } +} + +fn forum_message_from_event(ev: &nostr::Event, channel_id: &str) -> ForumMessageInfo { + ForumMessageInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_u64() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + thread_summary: Some(ThreadSummary { + reply_count: 0, + descendant_count: 0, + last_reply_at: None, + participants: Vec::new(), + }), + reactions: serde_json::Value::Null, + } +} + +fn forum_reply_from_event( + ev: &nostr::Event, + channel_id: &str, + root_event_id: &str, +) -> ForumThreadReplyInfo { + // Walk e-tags for NIP-10 parent/root markers. + let (mut parent_id, mut explicit_root) = (None, None); + for t in ev.tags.iter() { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "e" { + match s.get(3).map(|x| x.as_str()) { + Some("root") => explicit_root = Some(s[1].clone()), + Some("reply") => parent_id = Some(s[1].clone()), + _ => { + if parent_id.is_none() { + parent_id = Some(s[1].clone()); + } + } + } + } + } + let parent = parent_id + .clone() + .unwrap_or_else(|| root_event_id.to_string()); + let root = explicit_root.unwrap_or_else(|| root_event_id.to_string()); + let depth = if parent == root { 1 } else { 2 }; + + ForumThreadReplyInfo { + event_id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + created_at: ev.created_at.as_u64() as i64, + channel_id: channel_id.to_string(), + tags: tags_to_vec(ev), + parent_event_id: Some(parent), + root_event_id: Some(root), + depth, + broadcast: false, + reactions: serde_json::Value::Null, + } +} diff --git a/desktop/src-tauri/src/commands/mod.rs b/desktop/src-tauri/src/commands/mod.rs index fa1414708e..511b61d5d1 100644 --- a/desktop/src-tauri/src/commands/mod.rs +++ b/desktop/src-tauri/src/commands/mod.rs @@ -11,11 +11,13 @@ mod media; mod messages; pub mod pairing; mod personas; +mod prevent_sleep; mod profile; +mod relay_members; mod social; mod teams; -pub mod tokens; mod workflows; +mod workspace; pub use agent_discovery::*; pub use agent_models::*; @@ -29,8 +31,10 @@ pub use media::*; pub use messages::*; pub use pairing::*; pub use personas::*; +pub use prevent_sleep::*; pub use profile::*; +pub use relay_members::*; pub use social::*; pub use teams::*; -pub use tokens::*; pub use workflows::*; +pub use workspace::*; diff --git a/desktop/src-tauri/src/commands/pairing.rs b/desktop/src-tauri/src/commands/pairing.rs index 96d1ebe2ab..3e12938040 100644 --- a/desktop/src-tauri/src/commands/pairing.rs +++ b/desktop/src-tauri/src/commands/pairing.rs @@ -15,9 +15,7 @@ use tokio_util::sync::CancellationToken; use zeroize::Zeroizing; use crate::app_state::AppState; -use crate::relay::{relay_api_base_url, relay_ws_url}; - -use super::tokens::{mint_token_internal_with_auth_mode, MintTokenAuthMode}; +use crate::relay::{relay_api_base_url_with_override, relay_ws_url_with_override}; #[derive(Serialize, Clone)] struct PairingSasPayload { @@ -62,29 +60,11 @@ impl PairingHandle { } } -const MOBILE_SCOPES: &[&str] = &[ - "messages:read", - "messages:write", - "channels:read", - "channels:write", - "users:read", - "files:read", - "files:write", -]; -const EXPIRES_IN_DAYS: u32 = 90; - -fn mobile_pairing_mint_auth_mode(state: &AppState) -> MintTokenAuthMode { - if state.configured_api_token.is_some() { - MintTokenAuthMode::BootstrapNip98 - } else { - MintTokenAuthMode::Auto - } -} - /// Start a NIP-AB pairing session as the source device. /// -/// Mints a token, creates a `PairingSession`, connects to the relay, -/// and returns the `nostrpair://` QR URI for the frontend to display. +/// Creates a `PairingSession`, connects to the relay, and returns the +/// `nostrpair://` QR URI for the frontend to display. The mobile peer will +/// receive the desktop's nsec (NIP-OA auth — no token minting needed). #[tauri::command] pub async fn start_pairing( app: AppHandle, @@ -96,18 +76,6 @@ pub async fn start_pairing( } pairing.clear(); - let scopes: Vec = MOBILE_SCOPES.iter().map(|s| s.to_string()).collect(); - let token_name = format!("mobile-pairing-{}", chrono::Utc::now().timestamp()); - let mint_result = mint_token_internal_with_auth_mode( - &state, - &token_name, - &scopes, - None, - Some(EXPIRES_IN_DAYS), - mobile_pairing_mint_auth_mode(&state), - ) - .await?; - let (nsec, pubkey_hex) = { let keys = state.keys.lock().map_err(|e| e.to_string())?; let nsec = keys @@ -118,15 +86,25 @@ pub async fn start_pairing( (nsec, pubkey) }; - let ws_url = relay_ws_url(); - let http_url = relay_api_base_url(); + let ws_url = relay_ws_url_with_override(&state); + let http_url = relay_api_base_url_with_override(&state); - let (session, qr_payload) = PairingSession::new_source(ws_url.clone()); + // Detect NIP-43: if the relay requires auth, the target device should + // connect to the /pair sidecar instead of the main relay. + let qr_relay_url = if probe_relay_requires_auth(&ws_url).await { + let mut url = url::Url::parse(&ws_url).map_err(|e| format!("invalid relay URL: {e}"))?; + let path = url.path().trim_end_matches('/').to_string(); + url.set_path(&format!("{path}/pair")); + url.to_string() + } else { + ws_url.clone() + }; + + let (session, qr_payload) = PairingSession::new_source(qr_relay_url); let qr_uri = encode_qr(&qr_payload); let payload_json = serde_json::json!({ "relayUrl": http_url, - "token": mint_result.token, "pubkey": pubkey_hex, "nsec": nsec, }); @@ -444,6 +422,49 @@ fn parse_relay_event(text: &str, sub_id: &str) -> Option { serde_json::from_value(arr[2].clone()).ok() } +/// Check the relay's NIP-11 information document to determine if auth is +/// required (indicating NIP-43 access control). Returns `true` if the relay +/// advertises `limitation.auth_required: true`, `false` otherwise. +/// +/// Converts the WebSocket URL to HTTP(S) and fetches `GET /` with +/// `Accept: application/nostr+json` per NIP-11. +async fn probe_relay_requires_auth(relay_url: &str) -> bool { + // Convert ws(s):// to http(s):// for the NIP-11 fetch. + let http_url = if let Some(rest) = relay_url.strip_prefix("wss://") { + format!("https://{rest}") + } else if let Some(rest) = relay_url.strip_prefix("ws://") { + format!("http://{rest}") + } else { + return false; + }; + + let client = reqwest::Client::builder() + .timeout(Duration::from_secs(5)) + .build() + .unwrap_or_default(); + + let resp = match client + .get(&http_url) + .header("Accept", "application/nostr+json") + .send() + .await + { + Ok(r) => r, + Err(_) => return false, // can't reach relay — assume open + }; + + let json: serde_json::Value = match resp.json().await { + Ok(v) => v, + Err(_) => return false, + }; + + // Check limitation.auth_required per NIP-11. + json.get("limitation") + .and_then(|l| l.get("auth_required")) + .and_then(|v| v.as_bool()) + .unwrap_or(false) +} + fn parse_auth_challenge(text: &str) -> Option { let arr: serde_json::Value = serde_json::from_str(text).ok()?; let arr = arr.as_array()?; @@ -481,37 +502,3 @@ where .await .map_err(|_| "timeout waiting for EOSE".to_string())? } - -#[cfg(test)] -mod tests { - use super::MOBILE_SCOPES; - use super::*; - use crate::app_state::build_app_state; - - #[test] - fn mobile_pairing_token_includes_file_write_scope() { - assert!(MOBILE_SCOPES.contains(&"files:write")); - } - - #[test] - fn mobile_pairing_uses_bootstrap_mint_when_configured_token_is_present() { - let mut state = build_app_state(); - state.configured_api_token = Some("desktop-token".to_string()); - - assert_eq!( - mobile_pairing_mint_auth_mode(&state), - MintTokenAuthMode::BootstrapNip98 - ); - } - - #[test] - fn mobile_pairing_keeps_auto_mint_without_configured_token() { - let mut state = build_app_state(); - state.configured_api_token = None; - - assert_eq!( - mobile_pairing_mint_auth_mode(&state), - MintTokenAuthMode::Auto - ); - } -} diff --git a/desktop/src-tauri/src/commands/prevent_sleep.rs b/desktop/src-tauri/src/commands/prevent_sleep.rs new file mode 100644 index 0000000000..8e04d7f1fb --- /dev/null +++ b/desktop/src-tauri/src/commands/prevent_sleep.rs @@ -0,0 +1,15 @@ +use crate::app_state::AppState; + +#[tauri::command] +pub fn set_prevent_sleep_active( + active: bool, + state: tauri::State<'_, AppState>, + app_handle: tauri::AppHandle, +) -> Result<(), String> { + if active { + crate::prevent_sleep::acquire(&state.prevent_sleep, &app_handle) + } else { + crate::prevent_sleep::release(&state.prevent_sleep); + Ok(()) + } +} diff --git a/desktop/src-tauri/src/commands/profile.rs b/desktop/src-tauri/src/commands/profile.rs index fc06c186c1..ff3d4e3f6b 100644 --- a/desktop/src-tauri/src/commands/profile.rs +++ b/desktop/src-tauri/src/commands/profile.rs @@ -1,6 +1,6 @@ use std::collections::HashMap; -use reqwest::Method; +use serde_json::Value; use sprout_core::PresenceStatus; use tauri::State; @@ -8,21 +8,31 @@ use crate::{ app_state::AppState, events, models::{ - GetUserNotesQuery, GetUsersBatchBody, ProfileInfo, SearchUsersResponse, SetPresenceBody, - SetPresenceResponse, UserNotesResponse, UsersBatchResponse, + ProfileInfo, SearchUsersResponse, SetPresenceResponse, UserNotesResponse, + UsersBatchResponse, }, - relay::{api_path, build_authed_request, send_json_request, submit_event}, + nostr_convert, + relay::{query_relay, submit_event}, }; #[tauri::command] pub async fn get_profile(state: State<'_, AppState>) -> Result { - let request = build_authed_request( - &state.http_client, - Method::GET, - "/api/users/me/profile", + let my_pubkey = current_pubkey_hex(&state)?; + let events = query_relay( &state, - )?; - send_json_request(request).await + &[serde_json::json!({ + "kinds": [0], + "authors": [my_pubkey], + "limit": 1 + })], + ) + .await?; + + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) } #[tauri::command] @@ -33,59 +43,85 @@ pub async fn update_profile( nip05_handle: Option, state: State<'_, AppState>, ) -> Result { - // Read-merge-write: kind 0 is a full profile snapshot, so we must fetch - // the current profile, merge the caller's changes, then sign the complete - // profile as a Nostr event. Same pattern as MCP's set_profile. - let current: serde_json::Value = { - let request = build_authed_request( - &state.http_client, - Method::GET, - "/api/users/me/profile", - &state, - )?; - send_json_request(request).await.unwrap_or_default() - }; + // Read-merge-write: kind 0 is a full profile snapshot. + let my_pubkey = current_pubkey_hex(&state)?; + let prior_events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [my_pubkey], + "limit": 1 + })], + ) + .await?; + + // Pull the current content as a JSON object so we can merge with + // the caller's overrides. + let current: Value = prior_events + .first() + .and_then(|ev| serde_json::from_str::(&ev.content).ok()) + .unwrap_or(Value::Null); let dn = display_name .as_deref() - .or_else(|| current.get("display_name").and_then(|v| v.as_str())); - let name = current.get("name").and_then(|v| v.as_str()); + .or_else(|| current.get("display_name").and_then(Value::as_str)); + let name = current.get("name").and_then(Value::as_str); let picture = avatar_url .as_deref() - .or_else(|| current.get("avatar_url").and_then(|v| v.as_str())); + .or_else(|| current.get("picture").and_then(Value::as_str)); let ab = about .as_deref() - .or_else(|| current.get("about").and_then(|v| v.as_str())); + .or_else(|| current.get("about").and_then(Value::as_str)); let nip05 = nip05_handle .as_deref() - .or_else(|| current.get("nip05_handle").and_then(|v| v.as_str())); + .or_else(|| current.get("nip05").and_then(Value::as_str)); let builder = events::build_profile(dn, name, picture, ab, nip05)?; submit_event(builder, &state).await?; - // Re-fetch to return the canonical profile the frontend expects. - let request = build_authed_request( - &state.http_client, - Method::GET, - "/api/users/me/profile", + // Re-fetch to return canonical profile. + let events = query_relay( &state, - )?; - send_json_request(request).await -} + &[serde_json::json!({ + "kinds": [0], + "authors": [current_pubkey_hex(&state)?], + "limit": 1 + })], + ) + .await?; -// ── Unchanged reads below ──────────────────────────────────────────────────── + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| empty_profile_info(¤t_pubkey_hex_unwrap(&state)))) +} #[tauri::command] pub async fn get_user_profile( pubkey: Option, state: State<'_, AppState>, ) -> Result { - let path = match pubkey { - Some(pubkey) => api_path(&["users", &pubkey, "profile"]), - None => "/api/users/me/profile".to_string(), + let target = match pubkey { + Some(pk) => pk, + None => current_pubkey_hex(&state)?, }; - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": [target.clone()], + "limit": 1 + })], + ) + .await?; + + Ok(events + .first() + .map(nostr_convert::profile_info_from_event) + .transpose()? + .unwrap_or_else(|| empty_profile_info(&target))) } #[tauri::command] @@ -93,13 +129,22 @@ pub async fn get_users_batch( pubkeys: Vec, state: State<'_, AppState>, ) -> Result { - let request = - build_authed_request(&state.http_client, Method::POST, "/api/users/batch", &state)?.json( - &GetUsersBatchBody { - pubkeys: pubkeys.as_slice(), - }, - ); - send_json_request(request).await + if pubkeys.is_empty() { + return Ok(UsersBatchResponse { + profiles: HashMap::new(), + missing: Vec::new(), + }); + } + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "authors": pubkeys, + })], + ) + .await?; + + Ok(nostr_convert::users_batch_from_events(&events, &pubkeys)) } #[tauri::command] @@ -110,16 +155,20 @@ pub async fn get_user_notes( before_id: Option, state: State<'_, AppState>, ) -> Result { - let path = format!("/api/users/{pubkey}/notes"); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?.query( - &GetUserNotesQuery { - limit, - before, - before_id: before_id.as_deref(), - }, + let _ = before_id; // pure-nostr filter does not use the id-based cursor + let mut filter = serde_json::Map::new(); + filter.insert("kinds".to_string(), serde_json::json!([1])); + filter.insert("authors".to_string(), serde_json::json!([pubkey])); + filter.insert( + "limit".to_string(), + serde_json::json!(limit.unwrap_or(20).min(100)), ); + if let Some(t) = before { + filter.insert("until".to_string(), serde_json::json!(t)); + } - send_json_request(request).await + let events = query_relay(&state, &[Value::Object(filter)]).await?; + Ok(nostr_convert::user_notes_from_events(&events)) } #[tauri::command] @@ -128,13 +177,51 @@ pub async fn search_users( limit: Option, state: State<'_, AppState>, ) -> Result { - let limit = limit.unwrap_or(8); - let limit_param = limit.to_string(); - let request = - build_authed_request(&state.http_client, Method::GET, "/api/users/search", &state)? - .query(&[("q", query.as_str()), ("limit", limit_param.as_str())]); + let q = query.trim().to_lowercase(); + let max = limit.unwrap_or(8).min(50) as usize; - send_json_request(request).await + if q.is_empty() { + return Ok(SearchUsersResponse { users: Vec::new() }); + } + + // Fetch all kind:0 profiles and filter client-side. The old REST endpoint + // used a DB ILIKE query; this is equivalent for small-to-medium relays. + // NIP-50 search doesn't work well for user lookup because Typesense indexes + // raw JSON content and short names don't tokenize at JSON boundaries. + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [0], + "limit": 2000, + })], + ) + .await?; + + let mut users = Vec::new(); + for ev in &events { + let pubkey_hex = ev.pubkey.to_hex(); + if let Ok(v) = serde_json::from_str::(&ev.content) { + let display_name = v + .get("display_name") + .and_then(|v| v.as_str()) + .or_else(|| v.get("name").and_then(|v| v.as_str())) + .unwrap_or(""); + let nip05 = v.get("nip05").and_then(|v| v.as_str()).unwrap_or(""); + + let matches = display_name.to_lowercase().contains(&q) + || nip05.to_lowercase().contains(&q) + || pubkey_hex.starts_with(&q); + + if matches { + users.push(nostr_convert::user_search_result_from_event(ev)); + if users.len() >= max { + break; + } + } + } + } + + Ok(SearchUsersResponse { users }) } #[tauri::command] @@ -146,9 +233,54 @@ pub async fn get_presence( return Ok(HashMap::new()); } - let request = build_authed_request(&state.http_client, Method::GET, "/api/presence", &state)? - .query(&[("pubkeys", pubkeys.join(","))]); - send_json_request(request).await + // Presence is published as kind:20001 ephemeral events. Query the most + // recent per author. Some relays don't retain ephemeral events — we + // best-effort and return what we get. + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [20001], + "authors": pubkeys, + })], + ) + .await + .unwrap_or_default(); + + let mut latest: HashMap = HashMap::new(); + for ev in &events { + // Relay-synthesized presence events use a p-tag to identify the subject. + // Self-signed presence events (live WS) use the event author directly. + let pk = ev + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "p" { + Some(s[1].clone()) + } else { + None + } + }) + .unwrap_or_else(|| ev.pubkey.to_hex()); + let ts = ev.created_at.as_u64(); + let status = match ev.content.trim() { + "online" => PresenceStatus::Online, + "away" => PresenceStatus::Away, + "offline" => PresenceStatus::Offline, + _ => continue, + }; + match latest.get(&pk) { + Some((prev_ts, _)) if *prev_ts >= ts => {} + _ => { + latest.insert(pk, (ts, status)); + } + } + } + + Ok(latest + .into_iter() + .map(|(pk, (_, status))| (pk, status)) + .collect()) } #[tauri::command] @@ -156,7 +288,35 @@ pub async fn set_presence( status: PresenceStatus, state: State<'_, AppState>, ) -> Result { - let request = build_authed_request(&state.http_client, Method::PUT, "/api/presence", &state)? - .json(&SetPresenceBody { status }); - send_json_request(request).await + let status_str = match status { + PresenceStatus::Online => "online", + PresenceStatus::Away => "away", + PresenceStatus::Offline => "offline", + }; + let builder = events::build_presence(status_str)?; + submit_event(builder, &state).await?; + + Ok(SetPresenceResponse { + status, + ttl_seconds: 60, + }) +} + +fn current_pubkey_hex(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn current_pubkey_hex_unwrap(state: &AppState) -> String { + current_pubkey_hex(state).unwrap_or_default() +} + +fn empty_profile_info(pubkey: &str) -> ProfileInfo { + ProfileInfo { + pubkey: pubkey.to_string(), + display_name: None, + avatar_url: None, + about: None, + nip05_handle: None, + } } diff --git a/desktop/src-tauri/src/commands/relay_members.rs b/desktop/src-tauri/src/commands/relay_members.rs new file mode 100644 index 0000000000..4e8f935069 --- /dev/null +++ b/desktop/src-tauri/src/commands/relay_members.rs @@ -0,0 +1,92 @@ +use tauri::State; + +use crate::{ + app_state::AppState, + events, nostr_convert, + relay::{query_relay, submit_event}, +}; + +#[tauri::command] +pub async fn list_relay_members(state: State<'_, AppState>) -> Result { + // kind:13534 is a single replaceable event on the relay carrying all members. + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [13534], + "limit": 1 + })], + ) + .await?; + + Ok(events + .first() + .map(nostr_convert::relay_members_from_event) + .unwrap_or_else(|| serde_json::json!({ "members": [] }))) +} + +#[tauri::command] +pub async fn get_my_relay_membership( + state: State<'_, AppState>, +) -> Result { + let my_pubkey = { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + keys.public_key().to_hex() + }; + + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [13534], + "limit": 1 + })], + ) + .await?; + + let Some(event) = events.first() else { + return Ok(serde_json::json!({ "member": null })); + }; + + let members_value = nostr_convert::relay_members_from_event(event); + let me = members_value + .get("members") + .and_then(|m| m.as_array()) + .and_then(|arr| { + arr.iter() + .find(|m| m.get("pubkey").and_then(|p| p.as_str()) == Some(my_pubkey.as_str())) + .cloned() + }); + + Ok(serde_json::json!({ "member": me })) +} + +#[tauri::command] +pub async fn add_relay_member( + target_pubkey: String, + role: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_relay_admin_add(&target_pubkey, &role)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn remove_relay_member( + target_pubkey: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_relay_admin_remove(&target_pubkey)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} + +#[tauri::command] +pub async fn change_relay_member_role( + target_pubkey: String, + new_role: String, + state: State<'_, AppState>, +) -> Result { + let builder = events::build_relay_admin_change_role(&target_pubkey, &new_role)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::to_value(result).map_err(|e| e.to_string())?) +} diff --git a/desktop/src-tauri/src/commands/social.rs b/desktop/src-tauri/src/commands/social.rs index 1ab49fb3fc..6baed6fbba 100644 --- a/desktop/src-tauri/src/commands/social.rs +++ b/desktop/src-tauri/src/commands/social.rs @@ -1,12 +1,12 @@ use nostr::EventId; -use reqwest::Method; use tauri::State; use crate::{ app_state::AppState, events, - models::{ContactEntry, ContactListResponse, UserNotesResponse}, - relay::{api_path, build_authed_request, send_json_request, submit_event, SubmitEventResponse}, + models::{ContactEntry, ContactListResponse, UserNoteInfo, UserNotesResponse}, + nostr_convert, + relay::{query_relay, submit_event, SubmitEventResponse}, }; /// Publish a global kind:1 text note (NIP-01). @@ -14,13 +14,17 @@ use crate::{ pub async fn publish_note( content: String, reply_to: Option, + mention_pubkeys: Option>, + media_tags: Option>>, state: State<'_, AppState>, ) -> Result { let reply_id = reply_to .map(|hex| EventId::from_hex(&hex).map_err(|e| format!("invalid reply_to event id: {e}"))) .transpose()?; - - let builder = events::build_note(&content, reply_id)?; + let mentions = mention_pubkeys.unwrap_or_default(); + let mention_refs: Vec<&str> = mentions.iter().map(|s| s.as_str()).collect(); + let media = media_tags.unwrap_or_default(); + let builder = events::build_note(&content, reply_id, &mention_refs, &media)?; submit_event(builder, &state).await } @@ -30,9 +34,21 @@ pub async fn get_contact_list( pubkey: String, state: State<'_, AppState>, ) -> Result { - let path = api_path(&["users", &pubkey, "contact-list"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [3], + "authors": [pubkey], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(nostr_convert::contact_list_from_event) + .transpose()? + .ok_or_else(|| "contact list not found".to_string()) } /// Replace the full contact list (kind:3, NIP-02). Read-before-write required @@ -57,17 +73,22 @@ pub async fn set_contact_list( submit_event(builder, &state).await } -/// Maximum number of pubkeys per timeline request to prevent unbounded -/// sequential HTTP requests. +/// Maximum number of pubkeys per timeline request to keep filter size bounded. const MAX_TIMELINE_PUBKEYS: usize = 100; -/// Fetch notes for multiple pubkeys sequentially and return a merged, sorted timeline. +/// Fetch notes for multiple pubkeys with a single multi-author query. #[tauri::command] pub async fn get_notes_timeline( pubkeys: Vec, limit_per_user: Option, state: State<'_, AppState>, ) -> Result { + if pubkeys.is_empty() { + return Ok(UserNotesResponse { + notes: Vec::new(), + next_cursor: None, + }); + } if pubkeys.len() > MAX_TIMELINE_PUBKEYS { return Err(format!( "too many pubkeys (max {MAX_TIMELINE_PUBKEYS}, got {})", @@ -75,34 +96,38 @@ pub async fn get_notes_timeline( )); } - let per_user = limit_per_user.unwrap_or(10).min(50); - let mut all_notes = Vec::new(); - let mut errors = 0u32; + // One filter for all authors: `limit` here is the total cap. We use + // `limit_per_user * pubkeys.len()` as a rough approximation, capped at 200 + // to match the prior implementation's behavior. + let per_user = limit_per_user.unwrap_or(10).min(50) as usize; + let cap: usize = (per_user * pubkeys.len()).min(200); - for pk in &pubkeys { - let path = api_path(&["users", pk, "notes"]); - let req = build_authed_request(&state.http_client, Method::GET, &path, &state) - .map(|r| r.query(&[("limit", per_user.to_string())])); - match req { - Ok(r) => match send_json_request::(r).await { - Ok(resp) => all_notes.extend(resp.notes), - Err(_) => errors += 1, - }, - Err(_) => errors += 1, - } - } + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [1], + "authors": pubkeys, + "limit": cap, + })], + ) + .await?; - // If all requests failed, surface the error instead of returning empty. - if errors > 0 && all_notes.is_empty() && !pubkeys.is_empty() { - return Err("failed to fetch notes from any user".into()); - } + let mut notes: Vec = events + .iter() + .map(|ev| UserNoteInfo { + id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + created_at: ev.created_at.as_u64() as i64, + content: ev.content.clone(), + }) + .collect(); // Sort newest-first. - all_notes.sort_by(|a, b| b.created_at.cmp(&a.created_at)); - all_notes.truncate(200); + notes.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + notes.truncate(200); Ok(UserNotesResponse { - notes: all_notes, + notes, next_cursor: None, }) } diff --git a/desktop/src-tauri/src/commands/teams.rs b/desktop/src-tauri/src/commands/teams.rs index e4c264b6ef..ff36f49a46 100644 --- a/desktop/src-tauri/src/commands/teams.rs +++ b/desktop/src-tauri/src/commands/teams.rs @@ -6,8 +6,8 @@ use crate::{ app_state::AppState, managed_agents::{ encode_team_json, ensure_persona_ids_are_active, load_personas, load_teams, - parse_team_json, save_teams, CreateTeamRequest, ParsedTeamPreview, TeamRecord, - UpdateTeamRequest, + parse_team_json, save_teams, validate_team_deletion, CreateTeamRequest, ParsedTeamPreview, + TeamRecord, UpdateTeamRequest, }, util::now_iso, }; @@ -58,6 +58,7 @@ pub fn create_team( name, description, persona_ids: input.persona_ids, + is_builtin: false, created_at: now.clone(), updated_at: now, }; @@ -104,11 +105,12 @@ pub fn delete_team(id: String, app: AppHandle, state: State<'_, AppState>) -> Re .lock() .map_err(|error| error.to_string())?; let mut teams = load_teams(&app)?; - let original_len = teams.len(); + let team = teams + .iter() + .find(|record| record.id == id) + .ok_or_else(|| format!("team {id} not found"))?; + validate_team_deletion(team)?; teams.retain(|record| record.id != id); - if teams.len() == original_len { - return Err(format!("team {id} not found")); - } save_teams(&app, &teams) } diff --git a/desktop/src-tauri/src/commands/tokens.rs b/desktop/src-tauri/src/commands/tokens.rs deleted file mode 100644 index 3ff58635ce..0000000000 --- a/desktop/src-tauri/src/commands/tokens.rs +++ /dev/null @@ -1,207 +0,0 @@ -use reqwest::Method; -use tauri::State; - -use crate::{ - app_state::AppState, - models::{ListTokensResponse, MintTokenBody, MintTokenResponse, RevokeAllTokensResponse}, - relay::{ - api_path, build_authed_request, build_nip98_auth_header, build_token_management_request, - relay_api_base_url, send_empty_request, send_json_request, - }, -}; - -#[derive(Clone, Copy, Debug, Eq, PartialEq)] -pub(crate) enum MintTokenAuthMode { - Auto, - BootstrapNip98, -} - -impl MintTokenAuthMode { - fn uses_configured_bearer_token(self, state: &AppState) -> bool { - matches!(self, Self::Auto) && state.configured_api_token.is_some() - } - - fn should_store_minted_session_token(self, state: &AppState) -> bool { - matches!(self, Self::Auto) && state.configured_api_token.is_none() - } -} - -#[tauri::command] -pub async fn list_tokens(state: State<'_, AppState>) -> Result { - let request = - build_token_management_request(&state.http_client, Method::GET, "/api/tokens", &state)?; - send_json_request(request).await -} - -fn build_mint_token_request( - state: &AppState, - body: &MintTokenBody<'_>, - auth_mode: MintTokenAuthMode, -) -> Result { - if auth_mode.uses_configured_bearer_token(state) { - return Ok( - build_authed_request(&state.http_client, Method::POST, "/api/tokens", state)? - .json(body), - ); - } - - let url = format!("{}{}", relay_api_base_url(), "/api/tokens"); - let body_bytes = - serde_json::to_vec(body).map_err(|error| format!("serialize failed: {error}"))?; - let auth_header = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; - - Ok(state - .http_client - .request(Method::POST, url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes)) -} - -/// Internal token minting logic, callable from other modules (e.g. pairing). -pub async fn mint_token_internal( - state: &AppState, - name: &str, - scopes: &[String], - channel_ids: Option<&[String]>, - expires_in_days: Option, -) -> Result { - mint_token_internal_with_auth_mode( - state, - name, - scopes, - channel_ids, - expires_in_days, - MintTokenAuthMode::Auto, - ) - .await -} - -pub async fn mint_token_internal_with_auth_mode( - state: &AppState, - name: &str, - scopes: &[String], - channel_ids: Option<&[String]>, - expires_in_days: Option, - auth_mode: MintTokenAuthMode, -) -> Result { - let body = MintTokenBody { - name, - scopes, - channel_ids, - expires_in_days, - owner_pubkey: None, - }; - let request = build_mint_token_request(state, &body, auth_mode)?; - let response: MintTokenResponse = send_json_request(request).await?; - - if auth_mode.should_store_minted_session_token(state) { - let mut token = state - .session_token - .lock() - .map_err(|error| error.to_string())?; - *token = Some(response.token.clone()); - } - - Ok(response) -} - -#[tauri::command] -pub async fn mint_token( - name: String, - scopes: Vec, - channel_ids: Option>, - expires_in_days: Option, - state: State<'_, AppState>, -) -> Result { - mint_token_internal( - &state, - &name, - &scopes, - channel_ids.as_deref(), - expires_in_days, - ) - .await -} - -#[tauri::command] -pub async fn revoke_token(token_id: String, state: State<'_, AppState>) -> Result<(), String> { - let path = api_path(&["tokens", &token_id]); - let request = - build_token_management_request(&state.http_client, Method::DELETE, &path, &state)?; - send_empty_request(request).await -} - -#[tauri::command] -pub async fn revoke_all_tokens( - state: State<'_, AppState>, -) -> Result { - let request = - build_token_management_request(&state.http_client, Method::DELETE, "/api/tokens", &state)?; - send_json_request(request).await -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::app_state::build_app_state; - - fn test_body<'a>(scopes: &'a [String]) -> MintTokenBody<'a> { - MintTokenBody { - name: "test-token", - scopes, - channel_ids: None, - expires_in_days: Some(7), - owner_pubkey: None, - } - } - - #[test] - fn auto_mint_uses_configured_bearer_when_present() { - let mut state = build_app_state(); - state.configured_api_token = Some("desktop-token".to_string()); - let scopes = vec!["messages:read".to_string()]; - - let request = - build_mint_token_request(&state, &test_body(&scopes), MintTokenAuthMode::Auto) - .expect("request should build") - .build() - .expect("request should finalize"); - - assert_eq!( - request - .headers() - .get("Authorization") - .expect("auth header") - .to_str() - .expect("auth header should be valid utf-8"), - "Bearer desktop-token" - ); - } - - #[test] - fn bootstrap_mint_ignores_configured_bearer_token() { - let mut state = build_app_state(); - state.configured_api_token = Some("desktop-token".to_string()); - let scopes = vec!["messages:read".to_string(), "files:write".to_string()]; - - let request = build_mint_token_request( - &state, - &test_body(&scopes), - MintTokenAuthMode::BootstrapNip98, - ) - .expect("request should build") - .build() - .expect("request should finalize"); - - let auth_header = request - .headers() - .get("Authorization") - .expect("auth header") - .to_str() - .expect("auth header should be valid utf-8"); - - assert!(auth_header.starts_with("Nostr ")); - assert_ne!(auth_header, "Bearer desktop-token"); - } -} diff --git a/desktop/src-tauri/src/commands/workflows.rs b/desktop/src-tauri/src/commands/workflows.rs index c7937e2632..e79bd0a21a 100644 --- a/desktop/src-tauri/src/commands/workflows.rs +++ b/desktop/src-tauri/src/commands/workflows.rs @@ -1,10 +1,10 @@ -use reqwest::Method; -use serde::Serialize; +use serde_json::Value; use tauri::State; use crate::{ app_state::AppState, - relay::{api_path, build_authed_request, send_empty_request, send_json_request}, + events, + relay::{parse_command_response, query_relay, submit_event}, }; // ── Reads ─────────────────────────────────────────────────────────────────── @@ -13,20 +13,39 @@ use crate::{ pub async fn get_channel_workflows( channel_id: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["channels", &channel_id, "workflows"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await +) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [30620], + "#h": [channel_id], + })], + ) + .await?; + + let workflows: Vec = events.iter().map(workflow_from_event).collect(); + Ok(serde_json::json!({ "workflows": workflows })) } #[tauri::command] pub async fn get_workflow( workflow_id: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["workflows", &workflow_id]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await +) -> Result { + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [30620], + "#d": [workflow_id], + "limit": 1 + })], + ) + .await?; + + events + .first() + .map(workflow_from_event) + .ok_or_else(|| "workflow not found".to_string()) } #[tauri::command] @@ -34,37 +53,57 @@ pub async fn get_workflow_runs( workflow_id: String, limit: Option, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["workflows", &workflow_id, "runs"]); - let mut request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - if let Some(limit) = limit { - request = request.query(&[("limit", limit.to_string())]); - } - send_json_request(request).await +) -> Result { + let cap = limit.unwrap_or(50).min(200); + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [46001, 46002, 46003, 46004, 46005, 46006, 46007, 46010, 46011, 46012], + "#d": [workflow_id], + "limit": cap, + })], + ) + .await?; + + let runs: Vec = events + .iter() + .map(|ev| { + serde_json::json!({ + "event_id": ev.id.to_hex(), + "kind": ev.kind.as_u16(), + "pubkey": ev.pubkey.to_hex(), + "created_at": ev.created_at.as_u64(), + "content": ev.content, + "tags": ev.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + }) + }) + .collect(); + Ok(serde_json::json!({ "runs": runs })) } // ── Writes ────────────────────────────────────────────────────────────────── -#[derive(Serialize)] -struct CreateWorkflowBody { - yaml_definition: String, -} - #[tauri::command] pub async fn create_workflow( channel_id: String, yaml_definition: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["channels", &channel_id, "workflows"]); - let request = build_authed_request(&state.http_client, Method::POST, &path, &state)? - .json(&CreateWorkflowBody { yaml_definition }); - send_json_request(request).await -} +) -> Result { + let workflow_id = uuid::Uuid::new_v4().to_string(); + let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?; + let result = submit_event(builder, &state).await?; -#[derive(Serialize)] -struct UpdateWorkflowBody { - yaml_definition: String, + // The relay returns webhook_secret in the OK response message for new workflows. + let mut response = serde_json::json!({ + "workflow_id": workflow_id, + "event_id": result.event_id, + }); + if let Ok(cmd_resp) = parse_command_response::(&result.message) { + if let Some(secret) = cmd_resp.get("webhook_secret") { + response["webhook_secret"] = secret.clone(); + } + } + Ok(response) } #[tauri::command] @@ -72,11 +111,39 @@ pub async fn update_workflow( workflow_id: String, yaml_definition: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["workflows", &workflow_id]); - let request = build_authed_request(&state.http_client, Method::PUT, &path, &state)? - .json(&UpdateWorkflowBody { yaml_definition }); - send_json_request(request).await +) -> Result { + // Find the channel id from the existing workflow event so the new event + // carries the same `h` tag — kind:30620 is replaceable by (pubkey, d-tag). + let prior = query_relay( + &state, + &[serde_json::json!({ + "kinds": [30620], + "#d": [workflow_id.clone()], + "limit": 1 + })], + ) + .await?; + + let channel_id = prior + .first() + .and_then(|ev| { + ev.tags.iter().find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) + }) + .ok_or_else(|| "workflow not found".to_string())?; + + let builder = events::build_workflow_definition(&workflow_id, &channel_id, &yaml_definition)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ + "workflow_id": workflow_id, + "event_id": result.event_id, + })) } #[tauri::command] @@ -84,19 +151,19 @@ pub async fn delete_workflow( workflow_id: String, state: State<'_, AppState>, ) -> Result<(), String> { - let path = api_path(&["workflows", &workflow_id]); - let request = build_authed_request(&state.http_client, Method::DELETE, &path, &state)?; - send_empty_request(request).await + let builder = events::build_workflow_delete(&workflow_id, ¤t_pubkey_hex(&state)?)?; + submit_event(builder, &state).await?; + Ok(()) } #[tauri::command] pub async fn trigger_workflow( workflow_id: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["workflows", &workflow_id, "trigger"]); - let request = build_authed_request(&state.http_client, Method::POST, &path, &state)?; - send_json_request(request).await +) -> Result { + let builder = events::build_workflow_trigger(&workflow_id)?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ "event_id": result.event_id })) } // ── Approvals ─────────────────────────────────────────────────────────────── @@ -106,16 +173,31 @@ pub async fn get_run_approvals( workflow_id: String, run_id: String, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["workflows", &workflow_id, "runs", &run_id, "approvals"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, &state)?; - send_json_request(request).await -} - -#[derive(Serialize)] -struct ApprovalBody { - #[serde(skip_serializing_if = "Option::is_none")] - note: Option, +) -> Result { + let _ = run_id; + // Approval-request events for a workflow are kinds 46010/46011/46012. + let events = query_relay( + &state, + &[serde_json::json!({ + "kinds": [46010, 46011, 46012], + "#d": [workflow_id], + })], + ) + .await?; + let approvals: Vec = events + .iter() + .map(|ev| { + serde_json::json!({ + "event_id": ev.id.to_hex(), + "kind": ev.kind.as_u16(), + "pubkey": ev.pubkey.to_hex(), + "created_at": ev.created_at.as_u64(), + "content": ev.content, + "tags": ev.tags.iter().map(|t| t.as_slice().to_vec()).collect::>(), + }) + }) + .collect(); + Ok(serde_json::json!({ "approvals": approvals })) } #[tauri::command] @@ -123,11 +205,10 @@ pub async fn grant_approval( token: String, note: Option, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["approvals", "by-hash", &token, "grant"]); - let request = build_authed_request(&state.http_client, Method::POST, &path, &state)? - .json(&ApprovalBody { note }); - send_json_request(request).await +) -> Result { + let builder = events::build_approval_grant(&token, note.as_deref())?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ "event_id": result.event_id })) } #[tauri::command] @@ -135,9 +216,48 @@ pub async fn deny_approval( token: String, note: Option, state: State<'_, AppState>, -) -> Result { - let path = api_path(&["approvals", "by-hash", &token, "deny"]); - let request = build_authed_request(&state.http_client, Method::POST, &path, &state)? - .json(&ApprovalBody { note }); - send_json_request(request).await +) -> Result { + let builder = events::build_approval_deny(&token, note.as_deref())?; + let result = submit_event(builder, &state).await?; + Ok(serde_json::json!({ "event_id": result.event_id })) +} + +fn current_pubkey_hex(state: &AppState) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + Ok(keys.public_key().to_hex()) +} + +fn workflow_from_event(ev: &nostr::Event) -> Value { + let workflow_id = ev + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "d" { + Some(s[1].clone()) + } else { + None + } + }) + .unwrap_or_default(); + let channel_id = ev + .tags + .iter() + .find_map(|t| { + let s = t.as_slice(); + if s.len() >= 2 && s[0] == "h" { + Some(s[1].clone()) + } else { + None + } + }) + .unwrap_or_default(); + serde_json::json!({ + "workflow_id": workflow_id, + "channel_id": channel_id, + "yaml_definition": ev.content, + "event_id": ev.id.to_hex(), + "pubkey": ev.pubkey.to_hex(), + "created_at": ev.created_at.as_u64(), + }) } diff --git a/desktop/src-tauri/src/commands/workspace.rs b/desktop/src-tauri/src/commands/workspace.rs new file mode 100644 index 0000000000..e75d1801d6 --- /dev/null +++ b/desktop/src-tauri/src/commands/workspace.rs @@ -0,0 +1,55 @@ +use nostr::Keys; +use serde::Serialize; +use tauri::State; + +use crate::app_state::AppState; +use crate::relay; + +#[derive(Serialize)] +pub struct ActiveWorkspaceInfo { + relay_url: String, + pubkey: String, +} + +/// Returns the current active workspace info (relay URL + pubkey). +#[tauri::command] +pub fn get_active_workspace(state: State<'_, AppState>) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let relay_url = relay::relay_ws_url_with_override(&state); + Ok(ActiveWorkspaceInfo { + relay_url, + pubkey: keys.public_key().to_hex(), + }) +} + +/// Apply a workspace's configuration to the backend session. +/// +/// Called by the frontend on app init (after reload) to configure the +/// Tauri backend with the selected workspace's relay URL and keys. +#[tauri::command] +pub fn apply_workspace( + relay_url: String, + nsec: Option, + state: State<'_, AppState>, +) -> Result<(), String> { + // ── Validate before mutating ────────────────────────────────────────── + let parsed_keys = match nsec.as_deref().map(str::trim).filter(|s| !s.is_empty()) { + Some(nsec_trimmed) => { + Some(Keys::parse(nsec_trimmed).map_err(|e| format!("invalid nsec: {e}"))?) + } + None => None, + }; + + // ── Apply all state changes (nothing below can fail) ────────────────── + { + let mut override_guard = state.relay_url_override.lock().map_err(|e| e.to_string())?; + *override_guard = Some(relay_url); + } + + if let Some(keys) = parsed_keys { + let mut keys_guard = state.keys.lock().map_err(|e| e.to_string())?; + *keys_guard = keys; + } + + Ok(()) +} diff --git a/desktop/src-tauri/src/events.rs b/desktop/src-tauri/src/events.rs index efcddb1e52..f564d243bd 100644 --- a/desktop/src-tauri/src/events.rs +++ b/desktop/src-tauri/src/events.rs @@ -66,6 +66,7 @@ fn mention_tags(mentions: &[&str]) -> Result, String> { let mut seen = std::collections::HashSet::new(); let mut tags = Vec::new(); for &hex in mentions { + check_pubkey(hex)?; let lower = hex.to_ascii_lowercase(); if seen.insert(lower.clone()) { tags.push(tag(vec!["p", &lower])?); @@ -428,15 +429,66 @@ pub fn build_huddle_guidelines( pub fn build_note( content: &str, reply_to_event_id: Option, + mentions: &[&str], + media_tags: &[Vec], ) -> Result { check_content(content)?; let mut tags = Vec::new(); if let Some(parent) = reply_to_event_id { tags.push(tag(vec!["e", &parent.to_hex(), "", "reply"])?); } + tags.extend(mention_tags(mentions)?); + imeta_tags(media_tags, &mut tags)?; Ok(EventBuilder::new(Kind::TextNote, content).tags(tags)) } +// ── Relay admin (NIP-43) ──────────────────────────────────────────────────── + +/// Allowed relay member roles for NIP-43 admin commands. +const VALID_RELAY_ROLES: &[&str] = &["owner", "admin", "member"]; + +fn check_relay_role(role: &str) -> Result<(), String> { + if !VALID_RELAY_ROLES.contains(&role) { + return Err(format!( + "invalid relay role \"{role}\" (expected one of: {})", + VALID_RELAY_ROLES.join(", ") + )); + } + Ok(()) +} + +/// Kind 9030 — add a pubkey to the relay member list. +pub fn build_relay_admin_add(target_pubkey: &str, role: &str) -> Result { + check_pubkey(target_pubkey)?; + check_relay_role(role)?; + let tags = vec![ + tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, + tag(vec!["role", role])?, + ]; + Ok(EventBuilder::new(Kind::Custom(9030), "").tags(tags)) +} + +/// Kind 9031 — remove a pubkey from the relay member list. +pub fn build_relay_admin_remove(target_pubkey: &str) -> Result { + check_pubkey(target_pubkey)?; + let tags = vec![tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?]; + Ok(EventBuilder::new(Kind::Custom(9031), "").tags(tags)) +} + +/// Kind 9032 — change the role of an existing relay member. +pub fn build_relay_admin_change_role( + target_pubkey: &str, + new_role: &str, +) -> Result { + check_pubkey(target_pubkey)?; + check_relay_role(new_role)?; + let tags = vec![ + tag(vec!["p", &target_pubkey.to_ascii_lowercase()])?, + tag(vec!["role", new_role])?, + ]; + Ok(EventBuilder::new(Kind::Custom(9032), "").tags(tags)) +} + /// Maximum contacts per contact list event. const MAX_CONTACTS: usize = 10_000; @@ -467,41 +519,77 @@ pub fn build_contact_list( Ok(EventBuilder::new(Kind::ContactList, "").tags(tags)) } -// ── Transport ──────────────────────────────────────────────────────────────── - -/// Post a pre-signed event to the relay. +/// Kind 41010 — open (or surface) a DM channel with the given participants. /// -/// Standalone helper for async tasks that don't have access to `&AppState`. -/// The caller pre-captures `http_client`, `api_token`, and `pubkey_hex` at -/// spawn time and passes them here. -/// -/// Returns `Err` on transport failure OR non-2xx HTTP status. -pub async fn post_event_raw( - http_client: &reqwest::Client, - api_token: Option<&str>, - pubkey_hex: &str, - event_json: String, -) -> Result<(), String> { - let url = format!("{}/api/events", crate::relay::relay_api_base_url()); - let req = match api_token { - Some(token) => http_client - .post(&url) - .header("Authorization", format!("Bearer {token}")), - None => http_client.post(&url).header("X-Pubkey", pubkey_hex), - }; - let response = req - .header("Content-Type", "application/json") - .body(event_json) - .send() - .await - .map_err(|e| format!("event POST failed: {e}"))?; - - if !response.status().is_success() { - return Err(format!( - "event POST HTTP {}: {}", - response.status().as_u16(), - response.status().canonical_reason().unwrap_or("unknown"), - )); +/// Each pubkey is added as a `p` tag. The relay derives the canonical +/// channel id and replies via OK message with `response:{channel_id}`. +pub fn build_dm_open(pubkeys: &[String]) -> Result { + if pubkeys.is_empty() { + return Err("dm_open requires at least one pubkey".into()); } - Ok(()) + let mut tags: Vec = Vec::with_capacity(pubkeys.len()); + for pk in pubkeys { + check_pubkey(pk)?; + tags.push(tag(vec!["p", &pk.to_ascii_lowercase()])?); + } + Ok(EventBuilder::new(Kind::Custom(41010), "").tags(tags)) } + +/// Kind 41012 — hide a DM channel from the user's listing. +pub fn build_dm_hide(channel_id: &str) -> Result { + let tags = vec![tag(vec!["h", channel_id])?]; + Ok(EventBuilder::new(Kind::Custom(41012), "").tags(tags)) +} + +/// Kind 20001 — ephemeral presence broadcast (`online` / `away` / `offline`). +pub fn build_presence(status: &str) -> Result { + match status { + "online" | "away" | "offline" => {} + other => return Err(format!("invalid presence status: {other}")), + }; + Ok(EventBuilder::new(Kind::Custom(20001), status.to_string())) +} + +/// Kind 30620 — replaceable workflow definition. +/// +/// The `d` tag carries the workflow id; `h` tag carries the channel id; the +/// content is the YAML definition. Same (pubkey, d) replaces the prior version. +pub fn build_workflow_definition( + workflow_id: &str, + channel_id: &str, + yaml_definition: &str, +) -> Result { + check_content(yaml_definition)?; + let tags = vec![tag(vec!["d", workflow_id])?, tag(vec!["h", channel_id])?]; + Ok(EventBuilder::new(Kind::Custom(30620), yaml_definition.to_string()).tags(tags)) +} + +/// Kind 5 — NIP-09 deletion targeting a kind:30620 workflow definition. +pub fn build_workflow_delete( + workflow_id: &str, + owner_pubkey_hex: &str, +) -> Result { + let coord = format!("30620:{owner_pubkey_hex}:{workflow_id}"); + let tags = vec![tag(vec!["a", &coord])?]; + Ok(EventBuilder::new(Kind::Custom(5), "").tags(tags)) +} + +/// Kind 46020 — trigger a workflow run by id. +pub fn build_workflow_trigger(workflow_id: &str) -> Result { + let tags = vec![tag(vec!["d", workflow_id])?]; + Ok(EventBuilder::new(Kind::Custom(46020), "").tags(tags)) +} + +/// Kind 46030 — grant an approval token (with optional note). +pub fn build_approval_grant(token: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["t", token])?]; + Ok(EventBuilder::new(Kind::Custom(46030), note.unwrap_or("")).tags(tags)) +} + +/// Kind 46031 — deny an approval token (with optional note). +pub fn build_approval_deny(token: &str, note: Option<&str>) -> Result { + let tags = vec![tag(vec!["t", token])?]; + Ok(EventBuilder::new(Kind::Custom(46031), note.unwrap_or("")).tags(tags)) +} + +// ── Transport ──────────────────────────────────────────────────────────────── diff --git a/desktop/src-tauri/src/huddle/pipeline.rs b/desktop/src-tauri/src/huddle/pipeline.rs index 586900240b..7c12455ae3 100644 --- a/desktop/src-tauri/src/huddle/pipeline.rs +++ b/desktop/src-tauri/src/huddle/pipeline.rs @@ -247,7 +247,7 @@ pub(crate) fn spawn_transcription_task( Ok(k) => k.clone(), Err(_) => return, }; - let configured_api_token = state.configured_api_token.clone(); + let relay_base_url = crate::relay::relay_api_base_url_with_override(state); tauri::async_runtime::spawn(async move { // recv().await yields (not blocks) until text arrives or sender is dropped. @@ -284,15 +284,40 @@ pub(crate) fn spawn_transcription_task( continue; } }; - let event_json = event.as_json(); - let api_token_ref = configured_api_token.as_deref(); - let pubkey_hex = keys.public_key().to_hex(); + let body_bytes = event.as_json().into_bytes(); + let url = format!("{relay_base_url}/events"); + let auth_header = match crate::relay::build_nip98_auth_header_for_keys( + &keys, + &reqwest::Method::POST, + &url, + &body_bytes, + ) { + Ok(h) => h, + Err(e) => { + eprintln!("sprout-desktop: STT NIP-98 auth: {e}"); + continue; + } + }; - if let Err(e) = - crate::events::post_event_raw(&http_client, api_token_ref, &pubkey_hex, event_json) - .await - { - eprintln!("sprout-desktop: STT kind:9 post failed: {e}"); + let response = http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes) + .send() + .await; + + match response { + Ok(resp) if resp.status().is_success() => {} + Ok(resp) => { + eprintln!( + "sprout-desktop: STT kind:9 post failed: HTTP {}", + resp.status() + ); + } + Err(e) => { + eprintln!("sprout-desktop: STT kind:9 post failed: {e}"); + } } } }); diff --git a/desktop/src-tauri/src/huddle/relay_api.rs b/desktop/src-tauri/src/huddle/relay_api.rs index 241729eb8a..5b92313fa6 100644 --- a/desktop/src-tauri/src/huddle/relay_api.rs +++ b/desktop/src-tauri/src/huddle/relay_api.rs @@ -11,8 +11,6 @@ //! ``` use futures_util::{SinkExt, StreamExt}; -use reqwest::Method; -use serde::Deserialize; use std::sync::{ atomic::{AtomicBool, Ordering}, Arc, @@ -22,7 +20,7 @@ use tokio_util::sync::CancellationToken; use uuid::Uuid; use crate::app_state::AppState; -use crate::relay::{api_path, build_authed_request, send_json_request}; +use crate::relay::query_relay; /// Maximum number of agents that can be invited to a single huddle. pub(crate) const MAX_HUDDLE_AGENTS: usize = 20; @@ -57,7 +55,7 @@ pub(crate) async fn connect_audio_relay( ) -> Result<(CancellationToken, tokio::sync::mpsc::Sender>), String> { use nostr::JsonUtil; - let relay_url = crate::relay::relay_ws_url(); + let relay_url = crate::relay::relay_ws_url_with_override(state); let ws_url = format!("{relay_url}/huddle/{channel_id}/audio"); let keys = state.keys.lock().map_err(|e| e.to_string())?.clone(); @@ -452,32 +450,44 @@ async fn audio_relay_pipeline( } /// Fetch channel members with roles from the relay. Returns (pubkey, role) tuples. +/// +/// Queries kind:39002 (NIP-29 members) by `#d` channel id and extracts +/// `["p", pubkey, relay_url?, role?]` tags from the most recent event. pub(crate) async fn fetch_channel_members_with_roles( channel_id: &str, state: &AppState, ) -> Result)>, String> { - #[derive(Deserialize)] - struct Member { - pubkey: String, - role: Option, - } - #[derive(Deserialize)] - struct MembersResponse { - members: Vec, - } + let filter = serde_json::json!({ + "kinds": [39002], + "#d": [channel_id], + "limit": 1, + }); + let events = query_relay(state, std::slice::from_ref(&filter)) + .await + .map_err(|e| { + eprintln!("sprout-desktop: fetch channel members failed: {e}"); + e + })?; - let path = api_path(&["channels", channel_id, "members"]); - let request = build_authed_request(&state.http_client, Method::GET, &path, state)?; - let resp: MembersResponse = send_json_request(request).await.map_err(|e| { - eprintln!("sprout-desktop: fetch channel members failed: {e}"); - e - })?; + let Some(event) = events.first() else { + return Ok(Vec::new()); + }; - Ok(resp - .members - .into_iter() - .map(|m| (m.pubkey, m.role)) - .collect()) + let mut seen = std::collections::BTreeSet::new(); + let mut members = Vec::new(); + for tag in event.tags.iter() { + let slice = tag.as_slice(); + if slice.first().map(String::as_str) != Some("p") { + continue; + } + let Some(pubkey) = slice.get(1) else { continue }; + if pubkey.is_empty() || !seen.insert(pubkey.clone()) { + continue; + } + let role = slice.get(3).filter(|s| !s.is_empty()).cloned(); + members.push((pubkey.clone(), role)); + } + Ok(members) } /// Fetch channel members, optionally filtered by role (e.g., "bot" for agents). diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 1ecc9b298a..34aa644366 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -6,6 +6,8 @@ mod managed_agents; mod media_proxy; mod migration; mod models; +pub mod nostr_convert; +mod prevent_sleep; mod relay; mod util; @@ -31,6 +33,7 @@ use std::sync::{ }; use tauri::{Emitter, Manager, RunEvent}; use tauri_plugin_window_state::StateFlags; +use url::Url; fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { let state = app.state::(); @@ -151,9 +154,76 @@ fn shutdown_managed_agents(app: &tauri::AppHandle) -> Result<(), String> { Ok(()) } +/// Handle an incoming `sprout://` deep link URL. +/// +/// Currently supports: +/// - `sprout://connect?relay=` — emits `deep-link-connect` to the frontend +fn handle_deep_link_url(app: &tauri::AppHandle, url_str: &str) { + let url = match Url::parse(url_str) { + Ok(u) => u, + Err(e) => { + eprintln!("sprout-desktop: invalid deep link URL {url_str:?}: {e}"); + return; + } + }; + + if url.scheme() != "sprout" { + eprintln!("sprout-desktop: ignoring non-sprout deep link: {url_str}"); + return; + } + + match url.host_str() { + Some("connect") => { + let relay = url + .query_pairs() + .find(|(k, _)| k == "relay") + .map(|(_, v)| v.into_owned()); + let Some(relay_url) = relay else { + eprintln!("sprout-desktop: connect deep link missing relay param: {url_str}"); + return; + }; + // Validate the relay URL is ws:// or wss:// + match Url::parse(&relay_url) { + Ok(parsed) if parsed.scheme() == "ws" || parsed.scheme() == "wss" => {} + Ok(parsed) => { + eprintln!( + "sprout-desktop: rejecting non-websocket relay URL scheme {:?}: {relay_url}", + parsed.scheme() + ); + return; + } + Err(e) => { + eprintln!("sprout-desktop: invalid relay URL {relay_url:?}: {e}"); + return; + } + } + let _ = app.emit("deep-link-connect", relay_url); + } + Some(action) => { + eprintln!("sprout-desktop: unknown deep link action: {action}"); + } + None => { + eprintln!("sprout-desktop: deep link missing action: {url_str}"); + } + } +} + #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { let builder = tauri::Builder::default() + .plugin(tauri_plugin_single_instance::init(|app, argv, _cwd| { + // Focus the existing window when a duplicate instance launches. + if let Some(w) = app.get_webview_window("main") { + let _ = w.set_focus(); + } + // Forward any deep link URLs from the duplicate launch. + for arg in &argv { + if arg.starts_with("sprout://") { + handle_deep_link_url(app, arg); + } + } + })) + .plugin(tauri_plugin_deep_link::init()) .plugin(tauri_plugin_notification::init()) .plugin(tauri_plugin_opener::init()) .plugin( @@ -308,7 +378,7 @@ pub fn run() { let proxy_client = state.http_client.clone(); let proxy_handle = app_handle.clone(); tauri::async_runtime::spawn(async move { - let port = media_proxy::spawn_media_proxy(proxy_client).await; + let port = media_proxy::spawn_media_proxy(proxy_client, proxy_handle.clone()).await; let state = proxy_handle.state::(); state .media_proxy_port @@ -341,6 +411,20 @@ pub fn run() { } } + // Handle deep link URLs received while the app is running (macOS) + // and on cold start. The single-instance plugin handles forwarding + // from duplicate launches on Windows/Linux. + #[cfg(desktop)] + { + use tauri_plugin_deep_link::DeepLinkExt; + let dl_handle = app.handle().clone(); + app.deep_link().on_open_url(move |event| { + for url in event.urls() { + handle_deep_link_url(&dl_handle, url.as_str()); + } + }); + } + // Keep launch-time agent restoration off the synchronous setup path // so the frontend can mount and reveal the window promptly. tauri::async_runtime::spawn_blocking(move || { @@ -356,6 +440,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ get_identity, get_nsec, + import_identity, get_profile, update_profile, get_user_profile, @@ -364,13 +449,18 @@ pub fn run() { search_users, get_presence, set_presence, + get_default_relay_url, get_relay_ws_url, get_relay_http_url, get_media_proxy_port, discover_acp_providers, discover_managed_agent_prereqs, sign_event, + decrypt_observer_event, + build_observer_control_event, create_auth_event, + nip44_encrypt_to_self, + nip44_decrypt_from_self, get_channels, create_channel, open_dm, @@ -403,10 +493,11 @@ pub fn run() { upload_media, pick_and_upload_media, upload_media_bytes, - list_tokens, - mint_token, - revoke_token, - revoke_all_tokens, + list_relay_members, + get_my_relay_membership, + add_relay_member, + remove_relay_member, + change_relay_member_role, list_relay_agents, list_managed_agents, create_managed_agent, @@ -414,7 +505,6 @@ pub fn run() { stop_managed_agent, set_managed_agent_start_on_app_launch, delete_managed_agent, - mint_managed_agent_token, get_managed_agent_log, get_agent_models, update_managed_agent, @@ -473,6 +563,9 @@ pub fn run() { start_pairing, confirm_pairing_sas, cancel_pairing, + apply_workspace, + get_active_workspace, + set_prevent_sleep_active, ]) .build(tauri::generate_context!()) .expect("error while building tauri application"); @@ -482,6 +575,7 @@ pub fn run() { RunEvent::ExitRequested { .. } | RunEvent::Exit => { shutdown_started.store(true, Ordering::SeqCst); if !shutdown_done.swap(true, Ordering::SeqCst) { + prevent_sleep::release(&app_handle.state::().prevent_sleep); if let Err(error) = shutdown_managed_agents(app_handle) { eprintln!("sprout-desktop: failed to stop managed agents: {error}"); } @@ -495,7 +589,7 @@ pub fn run() { mod tests { use serde_json::json; - use crate::{models::ChannelInfo, util::percent_encode}; + use crate::models::ChannelInfo; #[test] fn channel_info_defaults_is_member_for_legacy_payloads() { @@ -517,18 +611,4 @@ mod tests { assert!(channel.is_member); } - - #[test] - fn percent_encode_leaves_unreserved_chars() { - assert_eq!( - percent_encode("ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~"), - "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-_.~" - ); - } - - #[test] - fn percent_encode_escapes_unicode_and_reserved_chars() { - assert_eq!(percent_encode("👍"), "%F0%9F%91%8D"); - assert_eq!(percent_encode("a/b?c"), "a%2Fb%3Fc"); - } } diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 4479015f09..06a2321879 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -1,18 +1,9 @@ use std::path::{Path, PathBuf}; use std::process::Command; -use base64::{engine::general_purpose::STANDARD as BASE64, Engine as _}; -use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag}; -use reqwest::Method; -use sha2::{Digest, Sha256}; use tauri::AppHandle; -use crate::{ - app_state::AppState, - managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}, - models::MintTokenBody, - relay::{relay_http_base_url, send_json_request}, -}; +use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}; struct KnownAcpProvider { id: &'static str, @@ -361,69 +352,6 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { Some(provider.avatar_url.to_string()) } -pub fn default_token_scopes() -> Vec { - vec![ - "messages:read".to_string(), - "messages:write".to_string(), - "channels:read".to_string(), - "users:read".to_string(), - "users:write".to_string(), - ] -} - -/// Mint an API token for `agent_keys` by signing a NIP-98 auth event with the -/// agent's own keypair and posting to `POST /api/tokens` on the relay. -/// -/// The relay mints the token for the signing pubkey, so using the agent's keys -/// here ensures the token is bound to the agent's identity — not the desktop -/// user's. No `sprout-admin` binary or database access is required. -pub async fn mint_token_via_api( - state: &AppState, - agent_keys: &Keys, - relay_url: &str, - name: &str, - scopes: &[String], - owner_pubkey: Option<&str>, -) -> Result { - let http_base = relay_http_base_url(relay_url); - let url = format!("{http_base}/api/tokens"); - - let body = MintTokenBody { - name, - scopes, - channel_ids: None, - expires_in_days: None, - owner_pubkey, - }; - let body_bytes = - serde_json::to_vec(&body).map_err(|e| format!("serialize mint body failed: {e}"))?; - - // Build NIP-98 auth header signed by the AGENT's keys (not the desktop user's). - let payload_hash = hex::encode(Sha256::digest(&body_bytes)); - let tags = vec![ - Tag::parse(vec!["u", &url]).map_err(|e| format!("url tag failed: {e}"))?, - Tag::parse(vec!["method", "POST"]).map_err(|e| format!("method tag failed: {e}"))?, - Tag::parse(vec!["payload", &payload_hash]) - .map_err(|e| format!("payload tag failed: {e}"))?, - ]; - let event = EventBuilder::new(Kind::HttpAuth, "") - .tags(tags) - .sign_with_keys(agent_keys) - .map_err(|e| format!("sign failed: {e}"))?; - let auth_header = format!("Nostr {}", BASE64.encode(event.as_json().as_bytes())); - - let request = state - .http_client - .request(Method::POST, &url) - .header("Authorization", auth_header) - .header("Content-Type", "application/json") - .body(body_bytes); - - let response: crate::models::MintTokenResponse = send_json_request(request).await?; - - Ok(response.token) -} - #[cfg(test)] mod tests { use super::{ diff --git a/desktop/src-tauri/src/managed_agents/persona_avatars.rs b/desktop/src-tauri/src/managed_agents/persona_avatars.rs index 905492c7a8..23b000e79c 100644 --- a/desktop/src-tauri/src/managed_agents/persona_avatars.rs +++ b/desktop/src-tauri/src/managed_agents/persona_avatars.rs @@ -2,7 +2,7 @@ pub const SOLO_AVATAR_DATA_URI: &str = "data:image/png;base64, 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"; -pub const RALPH_AVATAR_DATA_URI: &str = "data:image/png;base64, +pub const KIT_AVATAR_DATA_URI: &str = "data:image/png;base64, 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"; pub const SCOUT_AVATAR_DATA_URI: &str = "data:image/png;base64, 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"; diff --git a/desktop/src-tauri/src/managed_agents/personas.rs b/desktop/src-tauri/src/managed_agents/personas.rs index c730f04827..18892f069c 100644 --- a/desktop/src-tauri/src/managed_agents/personas.rs +++ b/desktop/src-tauri/src/managed_agents/personas.rs @@ -20,221 +20,121 @@ const SOLO_AVATAR: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAAC const SCOUT_AVATAR: &str = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAIAAAACACAYAAADDPmHLAAAAAXNSR0IArs4c6QAAAERlWElmTU0AKgAAAAgAAYdpAAQAAAABAAAAGgAAAAAAA6ABAAMAAAABAAEAAKACAAQAAAABAAAAgKADAAQAAAABAAAAgAAAAABIjgR3AABAAElEQVR4AUy9V4ylaXrf99TJuU7l0DlMT9pJG7jL3WUmLVIGLRu2INsXtgHfyTeGb32z0JVgGLqwLwTZlmFAli0ThkFKlARK5pJLLtOGmd2Z2Znp6enclePJuY5/v7dmCFd3dVc45/ve94n/J73fwtqbq/OFhXksLGQiy+d8Po9yJR+T8Symkxk/Xwj+iWx2IXL5XEQmeE3wz0Xw8sjzmeMz/BnX4WX8PBM5frjSKEazmY9yMcuvavFwZx7FYj69eDIY8rp58DcyuVlUatkol7NxMb2IaW8ai0vVyBXyMepPolbPx91XNmMyGsX5UT+KmYUo5rJR4ub+Xy7koujXhWwU+L7AvYusNZtfiMVGKW689pUo1u/FxcUCW8nGpHccL97713ER2bj6zq9HvrLI15n0u+moGw++97vRWNmM4nIzfvrdP471G1fj1W//UpzsPov3/uhPonmzFoVGNp49a0WrM47ReB7jyQWf05hezIMvY8bnZBLRZy+j4TwuINqMnV9Atyk/X8hkYzieRI/f+ztpnGP9v/H6jRgeDOL0fMDPMzHmQmfco1wqRK1SjB50G3PhwXACbSsxGk1igbVPZ9Ae4uehWQb6XFxcxOHJeZyctqDxQkynsxjxvmqlFHloM2Gto9E0srX1yne4e/qThXEyfArzS+WCHE18LfN1qZTnjVkYy00gsHKhsPiHL9NNL6+SicV6IVhDDMYXCMdCFGDO5moeohSjO5pzHQRthnBJ9qzvjSgUZCCMhWkXUDDHvYrFHHIG4dhMjs1VWMN4MI4sG1LA8rzRteQUOK5TyPI9nznkNMv/7sc1FnLzqDTWuVchJu3d2PvZ99L9t17/ZhRqi6yeD6WZFWXzRe6dib3PPoxSrZgId7R7wPoiXnz6EAZ0otDMRvtsEP3uNCYQdjadw4CLSybDyxlrlo5j9jocXv6cJSeaXSAFvt73DWGAAhP8UqZl2fNWsxoZBGY0niYhykCgxUaFvcyj3eknAVJevMdivcw9pp8vHzqjCNKr0x3E3u5JdDo9JY5rTbh+JjHfF6vcpVIpmgh4trZV/U7Scn7BdRMzXekM5pWraKuMRrr8nWv94rXSK7Gfn83VZF6HnvMNDGSFd64vxmCAlMlMmJTNzuPe9Xq8OJimzXqxC1TBjWW5X57fKwAZGJ1Fogv8TMGRMHyJ1iCtMojPHDeXuQVeWyoWYtmNIADYD97DtfL5JAC8JG18OhkiPLkYnp/E7gd/HtlCOTZf/0aUFpfTHtyTloFX84lVK2ZiNDiJo+fPowpDOme9OHiyG91uO2qb1SSxPbRyBtP9vGDP2WwOehWiWMry/hxMXoh2m9fAny/oKuOm0EPmjbF0Ez4X2J+/zyLBWfa7VC5F5SIT/cEkFperXBOlgfGj0ThJ0OxzTfe9NQR0wvVkrpa11x1dav1xCysx5vpTqAWNSsVED/kn4wtYiWqjGo1GDQHYrH7HTfuhqVCCEtEh7mQ04yZ5FshllFC0fwEJniPZc+0ZF7xI7sN383sWojuZIOFLmN7FhlYkkEDsIUReW8KKTOZxtjuOHK/J8XXhAlM+x1z7Ev7RYjSCjc/Q9lE/1moVzFouRlgMtVmTr5Ync4dgKTDb2+tx594rMK2LUI2j1mjE8pouY8heWABM6h0eRPdwNxa3b8fGa1+PfLkcF1zTD01oLOQQYizGxST6rSeRKaKhg1F0js543UL0O6PI4aKWt1ewCtourFWpHNV6IzY2lmJjsxgN3J2WoD+cRut8HIO+a3brCCUMklmTES6AHyaL8QWtEXAtXoZ9zaFHBRextIqg8XF63OH+lxYlk5RF+cvievpYkHE0F2EiNNs/OI2D4zOUbhQTpM77lnAbBa7Ll3xdxrWXEJRC1GH8oD+IYqUcGEt/jfnky61yM/YH5zD1ctFzmNntIon48gHCMGbx9SjG1kINkwvREIheZhRnF93oXSDtXGu5yGsxb4en/Xj1bhNfNY46mjHA7Owc9eLOtcU4fATjIHYGA6PPLqKx84VxdNA6rHUUs8WoZkpozyTyEP/6+kp8enoMH9UePDcCwZ41NkkoDg4O49a9l+OVt74eDz/6i3TdrZuvYlmycfL0s0vCYxmuvvxarNx+h/flEJQR70Xq/FCI09daAS6qQCA5y1ur0do7i2FnEAW0etSbxGQwj5Wtq7GGyUlam4HRw5N4vnMSL/Z6cdaeJEwwGM4S47K8rrZYwkoGFmGYTD+gy4UnZVHZUGYoh+AjYDHvx1kXob7SiGfPT5P1jAWsDK+5QDi0dCfgA93h0UmLa/Yv/TtmXnwhfbQG4rmpFgbLtLLU4OtpVKoVBIztQWMV/UIBTFTkZ6uFWroQl1FZ06tcXA4GdTAtFUyb33dh1H6mG+2FUbII18pL8U7jerzV2IxmvhRf2b4Sr27djNFFjRug2UhrFq4uAuRaHUQ7f4FpExTqu73mJZ6Q6fl5KRamaPhCITGvWWrgCnKxXa3G7cXFSPCRDar19QbSy9pc62g4jL1nj/BpK3H1+t2YgRP2738Yvf1DNADABljKLRZjrnRJAdXjrz/82aWGyXuFQoB2gcAfPnyRNH35Ovjhc2xy8uQ4chCujvktZtvROn4SDx68iBcvetFH4yWdhNXUZ7BQanIJKzoEtAm6ZJKvSa6UxaPcsdrIx5s38uyzH7U5QgJYm4IBVDLdoJ/iJi2w13DPefauGz0770a3N0RZVNApQBGLCdMzSEpjsQ7zm8l9Vvw5pl9rqrWRAsg9au8/bLgIoXfH52mjapa+ww9vPMLkDzBrtQqaDGEGmWkMFyZxNutHY1qMtRwMWlmKL9e2YogJ3QOtzzNlTBJ+cdLE7E0i15wimbM4741iaaUCiOL6qrG3YQ0ZtLWRqyGPaA4bG8cUkJePJv4rIX75Nh5jmrNsFDGtYc7QghnEuuACe893orLwg5gAfHrHPdxLKzZu34vm1nYMukdx8OJDzF4bn801coVLAnhr7o0uQMABa0Frxq0YnB3E3kePYOgotl65DVCswMRK7H36ApNMlPCj+3HrS4sxXWjF/uEweh2YwprTVrjeFPcmgzfWG5FDcfqYav1z0kj2fAmefXU2vnKvEVur0/j04WHEsBy3G9txBGZJWAirkbiNnuryZHy728F9TIkuRtAJzIOQeWdNO2EFwoLZxwIICFX2AtZ1MBwB6ovJOhgBzECiOS0YypdTCrQCR6NOTPDBmo56lKKPL1Xlkv/yYvg2w8KqISLSpq8X/HVxAUPMfx8tfGdxI67gE3uHfa45i6NzwrsZAGWyEJ3jiMZ6Jjo1cEVlGp1ejxAH7YcDeQQwIXqEOs9Gz7PnCdVrcerV5eQi5NgI/8ZL8KfzaA1bUWJdBbRCH6h1+bTzSWxtL8fWvauxdeuNKIP8XUeuWIagORh1yD4AoblLKyCBZD9hB8qEdo4x4S8exfOffcJNSnHlzVcJEREy9r50dTMK+sz7T2MHK3BOiNXA7y9UhKyYcV6jNMlcBXiJMHZ9o4657mChUBgUR1L7WkFgHYb88jurUa0O4wOYX7yoxo2VragVqtEiujDa0stK/zLhn7ji2c5RtNod+EyEAH7Jw8QqEVoZ4CgeOTg+j5WVOvy5BIZaAS3F4lI9hY6uS1yTBICfF7EI2cpG+TsuegyhFjRZ2XKs5+vRmqARLMIwy9+LHMf48TIIWUDoRwM/nVEsQfCiuBMIKILJl/FP5wIRY2L0E6JP0YJBGxqxiNW1Yrx40ocggj58pAzSz/E6F6zpS+Em3DYcqldL5AIKcdzvRqc9iCwgTFOWIXTM86mWlcApZUz91s1NQr4yWgBOgYksgQ/WX65FsdxAEPIQVROoJvIrlq8wz7AOOx9/SKj3WdTXr8Std74SeRD4eAwd/EBbqqxhYwN6zIZxsDOM00M1exKzpAy8hmuhGxAY7V9bAsmTt8BHn56NUj5ACdD/s834za+/FDc3sgDiYXTJdVyrbcYKLk/haHWG7JdoAItrbuHZi5N4+OgA5g9SLO9ypJMRUKNOiEjcW4NGCurqSpPcwgiBKKR9igkqCMhgwHvhgTzRHQzIJ4yw1JcWwP1BdOPrZobkAiu8SKGdIcqlOZPPmpZenyTN4iWo20BQmpj6s+jFeXQTsz87PI/XX1qJ25NSPHjaS6ZrShBtDmkBLTjfx9qUNUHJvpHEKbKRfBKE8fhSOquYMSU7A2FNlnz66DDu3VmNJqFLG/8+xnEOcQPFJTQQbLGAEOhvNY2i4DKuodc5wGINolRZxmKAR/C95gESExD2GWYWVvF3Fqd7z+L5xw+Shbn2xldj8+6XcIWF6LX3YoG435xFFgEtZtjp7CTqayS5tnPgDgTgjGu0p1Eiz5GpGs/Po4Z2KwxPXpyTN7hMuMg01J/kVQ5rF/HSlXV0ZRqn2WmUF7qxVV/GvQIy2cOMzx/+6AnrJQo47+NCuAauyz0UcV9qdh3Gy/QqVmnA70uA73odukIH8zVJaXGX52d9XCa5kwxCAm45OTlT4hEw4DwCJCxyrdIlSuCALMTtzQFraL8/1wQl1Oo3fo8mjEG4pVImns7OYpwjIzUvx0Z2NSblUeyDC3ZB+6/eWYcAajnXhvmoa2TYRCVfQXswwxpDBK6ARirNFZErxNO0NUl8rGFCh0hor58FUE7jhbFt+SKqi2VcQjUBvdZeO6aDIqCyQqYvExMINAQnoEQQknAI3z8a9mJx5RqWoso+XM0M/LEbz598yOYzMWtB5KOjqC4vxc1XX4pKk8ilTzSSL7N3rAw4RASdnY/AEufRbo2JjLCEKEGhQR5jUogh2bzOHi6zSk5jEbdE1HOGFg+xmCnRI+1guuh8a4kQ7GSQsGie68uErdpy8uf1ZjmGJ2T5CNH6vUGcJ3ONUGeGMcXCsstYay4S+lV5Xw7wN5KX0GiIOQc4Ixi6NYXhtNUimOa2WOsyAtlp9wDZxVheXUmW3nsc7Z1EtrpW+Y4mXVO+jPk3Lj8PbohoaOmVKMFRbsz/PfwY2uamaiRWZpiTzsUoujk0F796F+LVFqrx9LQXVzeM47kW7xF1LmCeipjeWoVMF6vUtghiyixWYFJmQ7qBIqa9gaRu4z9N9FzZaka/MI2dQSsuIILrWSjg8zDzhoNzrMoE/38BsVlhkv4qPjPF1QjrdAaiRttNNnXPj+Lw2f148MH7cfTsKHonuCzUbPvll2Lt5pV0vRnYZzrpA1z7CKPJFBgbWBsin8mwj2WZQXhCXoUMez8CDBfrrAO3NGqxli4uz5wNhqEIjfopo6fgRaysNaKJae+fD+P61kqsICgzIocGUY4h8WePD+Jnn+ziAsBVCNxwzvpQsBnKaBp+qbQY1whNdYm6CjU0lzM24gsVla9SZhSF0r0ZGfQx9b6m1qjHAqanNWqBFQ5xJ92UFOLS6e1J24to4xytnGAiJaa/86L67cWLQiyW6/Fs1o4F4tUurmB1uRRDgKFSdjYZxQ9P96KBSZoTGTzHPF7bJJY9Mg2JNVHIEIS0YIRljlbNEuI3bAJg8r03VIrLaErK6YP0a2jFaHiOQGbIT1yuVlOopOdgdImEU573j9GG+Smfg7No7QLQlspsGhzA+zpsdgEGqVUzzGGlWo/NKzejCjiqEb2YJDHnoNl0DeIDFphyBTMAYiU3BL+cxTFJma7CJvGhUHN1MYaHuAVCvIsi1gyMMO5fxOgMIYKBI9aZxzx7Xdc7b49IEGFJWsM4xFV+7ZWtaJ224/GL4/jkwV6c8/OBSQEEtwTm6qkgREFNsICJqvLI1DTrgo5FPjX/LdK+ujwzi3mwwAAX2ST8a5u7QFHWUTiF6RAA3Bl2EBzqAYTu64LaHIqSOMziJC2XZpUsFlOpA1AY3Ky/Qz9T6FDAbE5UYb6fkMkzK4eBSJqbA2mftnoAEcAhAAzWRwEt0I3ozxbQaLHFAu8xC+fP9dtjiikj3nuBqc+Aqg1hjDhqNfwwYV6HrxcQjDnhi6R3PfLKtY1gbK6ci2oN98VvxAngdtzMIf6uhSZSEIIgGfIPRTJ1a2s3YnXjCq8whQuuKeFOWMsc65RBU8wDXGA1XIsWLpfl3hcdEi5dBJF38TPpIlWqZBwrRD/jhWESzs0rG+QPCJkRtOVry/Hpx0+j85xiDGEhl4nZCQzmfVrbZySO2t1xvPuTxwjAabIo5jXqhG0W5oriFhJHG2vNmBE+twnxgAuYc7AGBPA1PcJUFeVCumJ2TOscYdVq4INKtQz+6Mf59DwGs0H0EMRhH+Fnv1fYv3ScIvSXGMDv/KhToULC5zBkASSr3MKfxPgxpqw7Hca8eJnIKAIwugCcFXyhFSu1+PbdZXzzetx//CzWXgIgcUMJlQAm/3tXs4wj4tIhjFWhM/js7AIg0E1wM4lzSu5dTc3mGnGALx9aQWHDM0BTDqFYAKAlJnA9TeGQVLPvMyQcsI7VzSUiEcWBnAX+8e6dW1gswByJqaXVLdaL6QZwlrBoOTDJDDOugLGc9L3SNRy2oUMP0EWIe3bJ/Mv4XoG+xCpZ1pFFaPII1yqp5+1bV8gWDuP+Dx9EHV+/+fJqvMAETxCcDBvf3FzGxc7jdLcdB6fd+Kf/9l0AKuBtmdANtwJOiyoab9a0SgQyjSZev0SENgyB8Rw3UGKdJyiZ0Yyuc4wLVB3r5CkOTzpkInFdzw4J96DfsrhkDOMR5gkRXnU11lfWEBNyO9QKVKLkAjRRfpplStkyNDuhFEgCPc1XpJi7TqXMTCBB3aUKovkmiEoQe84Ln2PK7ty6G6X6duweHMQb9xbj4a6hJNq5AJDCDOQw7yOAqKGI1bsyEm9uoQFjS4SYVQSrTFnUCODx0XkcZNFG3QcMNsypNetBnQT/ewkkXfcYDTNhleM1PYBTmw2vLG2ypgnmvZcIVC4vIkMANhhvzF4iJKzUl/gZ74OJavwYbS5gTovFaqoKZnpYt3E7JV2m0CTdBwaZX28PTrEaGfION2IDq1giz86GEDnwCV+f76P5vM50MeYzCVh+qRJ1BRYQqPadEOEUSBZdu7YSbwHUnjw5xHoMY2UZc0/ohvohIIU4xyJlZggo965Bq9GkmIpsUwS5Ah5QOU5A+7qDBgBaNzHG/xexZiMwy8AoBRC8vLREhPK5xYNu1hRyBdCjEm2WyooblvCSuZi59CHaMSGEhpZgVnGOb9I/shEBRw8JtGBk6nOEL3z6/DEuYDv2juooPAvEPE8h0EJK+mBVqCdUqjW0gjAMzZ5A+AElU0SNcAtUTEq4Rjx/4+pS7PX7CNXupbbDXM2emx1hNTQnU9atGONVIAh9DAhik+jBq010X6y1CpOnmh3voP9k7QUYXKkvonEKrjbK1C1gC4JOEBAuCSPOEQjwzqSLllm5Y7lozgR6lPC9Wo/G0iqav4G1wNS2ztP6ShRntm9fjef3n8YMGD7n3ro6M3KmaC/MknINBToroOX+Ckkd11Uj9F0EAGvV8mi8OCBH5DMl1OyBLa4urgDqKKRxrQK5Dyut50QbRj8NoqO1LfsaSLm3+vHk6T4KTZqYHMOc6uKANVo2T1EdG9TFd87b/AzSWMo0oThil9nPiZUIwy/9X9A1k8q8JqdgcAG1UXDHdTDn3BbB8RU9/N9K8zSh3C4CQdGNLBbv5zolpLOMFTk+PUwCkIcIJesFABw1+pzQZEyRptujIEIGbePKUlQR0HOYkzfM4TUdqmAT/PqloF5aBpckY1okZYrlKaZ2gzU04/y8hYDh31mn93fNZRJC9cYyRAf+sC8B10yNSYw35d2NLMmfKehfqHnBvc05zA1jwWJj8MgmbmTr5j3ei4ZjhitZQrshoR1muVrH9N/Zjmf3n5Bf6CRGS6catXs/+5h+6/EsmTI29MACYByJEJpR4nW7+8RgCInvMdSrkXjagfljzJ4NHUuLjWgBeM9IClkqvnqlieujUIUl7pkjwbJUwAmrKw3cYZ4orJXwmbmDSrmCAHBjProdXCIMy0gDJVIAYkr1AnPKvmG67OQj+TsWQGSg1lszSAIgQTH75qxNU9qAMeFnArxdEO7NTZi7eJVFEuJwVzdz496VWFxfRAPJcmGKRN9aIPPXpiWV9jqFolvX1ul2qcazs/Po2MzANZfXKAbpPiCES5OhEmmT8qzh4ATmjmDO3mEnnu0fJ9PZXKLZg42P2NOA341dH/EWiCJlwTShbDWBPTGM+/OPwpEDQasndtz0+ZwhccXqIv0BpGuXNgCASwnPsDkEI49wAzRZ3xjBGZNHWQSHjAjn1Arj8O0rKwiHBSyLOmgif6qY7/WNRVLWpcjCtFPMuKZchTT373t7036Qa4sslv4Z+fS/+ORBfPrkIF599Sqh8iL5CVwTjNeCoa3QBYsKOHQ/NRi+urgKbQuxubyBoFyGjP0u1+T+5iBIkWvzpZOxJlJSJ7ECoCiimfpXTYYXG+BPJY5ghHA9fW1zQhIGiK92Jc5wrQEgstU6izXy0oI1b1aFCBubK4Cky3q1vrgNMDvpn8YxLVpnrXacgrRftA7iycFuEkTjY2+hn1tGouFPykEkI8Q34ojbt7YQJip9/NDGDPPgewctXNFBSsNivWPIz8CwyXKctjqxs7cXz/Z24uDkGG0DwbNPmZ1y56SlszB/Qlg74HeWs820oeqxuHoFRi9xLVAQ+7VqOEfADE8FhBkEx6zb2clp5GFqtUaK2wgGmlh8wdggDJp2La4e8LJqaHvXFAVbxSJUyQ3owgzRrMdkqrhY2s9q4ILuvA0gHsVLd67yfpJeMNprq9UqgzxU2UwMGec3avW4vX2dvAHuik+VdIz7FBgr7LoScNPl5lOligvJ8Bp59UIPl8AmrfyZF+iaWSNuX8X3laaUNwnydA2prItED/Tt5UtT6zXMiY8gnsQ1tlfyc2x8ET/19P5jfo7UUkjKVeAQpVbjXJJvvIZ7UmbdOaHqZwMDGGIV8zhE88f4s1K9GgtIrnntJui5CCjyf0M2wy/vpys7okzagPjIDB/+K8lhM9qZRfMV7j7RiNHNCtk1Ndna+Rwu6QWnuA5lWveGasfa9q0ogV26NF2IQcQBaqrKITYxW+qaumdtED3REnRtrBGPEwUsUaCq0F11GfqyR2ghw4b0F7RImk3FGNyn1SE5g6W0le7J6UE07hD1kFU+IR18ckyji7k9rKfKZmFMMOoaBbBKQlJWtjpCcHxNj3VUEMJFADDUJy3ciVOKWH1crHhvfkDG03emTB3k4Rr8AtSISSmT8asOqCTlSE8iBH0IZziWZ+G36ivxoE+uXebyHjV/AlFRB9zJpTVZBNS0QOPQJTUvFhAWa/+Cl1kGZpLenAHIFCBvPMzQ5MC1zSXYzLm1UYlOUaRe5LMc+08P0/qWAFkXmnw0aWmlyqa65MSRHD6egg9SHA8RdBFTmJMEIP3j+mAyC8pjCtUGBeCsDdBjCU2qmK5V368PhK7JDVxgfxfXatFc34bBMJafDwmhhLgL+H/vMAJgtbEsHT6HEB2O8CoiMELBPBnHvNrG/ZLFZBcKn5qqwORxLxeAtBNCZusZEwRnDXeRA7kPCeE+fnQUO8/ICAKiv3H3JmFpNU4BfrpsGc9l2Q9fs7AF6HJ+dHJZMwEIPn92EPdeuUEImonnT3aiDa6yzuCH0Y+Cmxv1zK9TNiT8Ml4t4YuKFa6KVCrFFojwUqR+8YmEOW02v40mLGOWP8MnnYyQYImKj0yJIQLLKsmcWq1JUmIa62srqXv37OA4LVITaYpyQmgjwXUPc5tA0LIRGpw3BY2gdUnFFhaXoozpn6BxbtjQyCxhlnRqlZBK22UzRAGLoLldbNbilMyarkBGS3CJzV8IxZ0QsC8Eo4YlEVi26B84x1zmMOHlEu9DkLMQKWFhBKWyTMaMgpVmfoFA3dBJxmkhtGxT6HEM0VsknVLvpBLim7lpjgyeJr9/0sXyrUj2VPQyekrrwDWZTnaNJwDgN29fi8r+CZasF+UxBTcUwd4HcwFWHI3CfK+ZP/8H713uEYGzUHfUsloID9n3Bdc9OWrHR6yvh8+X5kZyCr70SJ/QL6MPM0xKqVqYN9FnsdlCmRcCnkYkOboUPUZko6zdWyd4MsUM4gLeWduOX8A0rhZhBr8TiWuSlgnjutNy+n5tcxXC0GfA4pPJJHNXpfvI6pTFIO9RXajEenk1GkXy//ypQPAMFaQW6cyB2SvQ7qrgEQmfsT6rXZZK7d5dXiW/TvNkAW3UVRSIlZVsGaUifpEwUtoEPe7NGNnvTZ6YNuXFhEsS6VI7tGzqsCC3UGGtYAJDxAT4EEQjFn2p2jRFC+0erpFWXgAMSs8smASCJndSWa6l5JC1Cu9doUybh86+r4/vP6NxdL1Wi1//6uvRJXfQI5kzB1QTPOP2sMadeXzp6vV4+doWSoKFZV0mcRSm1FaHUAuCe7jHJ8+Oo4ULevToBdFSB3pfoBDn8JScCYz3/l5AayEGsMeDBlvMqU2eNFlkWBiCnTJHhhaTJSxBDe0h1p/zu1ZxSPkX7UMmRxDxGTl6Gxtexjx+fHZGZrAFI2AoQOmwI6EMoej1w/Q0QOSmU1vnVKV0bGygkiUZxKLs/LEIVC7ii4mJcOtUB6nFw9RjNKtCBivNJPBaBewLYTWCsM99hMuybGzf3uYWZVWAphbD5FQFX21btEyvwOgU/nENMQPyisCAxCF3lZAp2zW9Tb0AzbugTUshUHglXIp4+N/3+7U4QFdlJGHt3aRPn9cO6fwpwJRpQuS4gdVmnO2fRQfGNlibEYVNs5rtEWsYsYgE+Efcd2wiqgDYIwE2P4z7pLPHg0zcpSXNHITVPyOZBw/pcEJYbpNAOiJqeAHoPW8RXiIYWiEZnAe0KiDSQaUegsekud9LP+SU//m9Wp1M5hCtqSAImJoLkipDGDkj7s1RzZuUCP9YwCJmaYzffjg+iRVCjBvU2pdAzT0ihOWVRQpGNB6A6gfTOhv73LQdntA/j2mHUZ1nO4mRDnyopSUEocj/9v0JiuoFLAkCsI1Gt2lPy+LvoSTxPH0FcoMFYzTSe42l1XBsVwp73JRl0iUyhR1eL+MS5sA0WmeX2WICX6dwV8lHZKHJkGzjCLdxxp79nUDNhNb6S9u4nxqmF1DIexXm1L/IaxRC3UAlIXYXxqI0r7jREY0wZg3zmOkhkYQCsHF9I3bp5snhDprURkrgoItzKqq2ta0DLGFcm5yF9yaIRAB6sQc4PKHd7Hp9MwmLYWiDtPsBWcQ24aXFqxYY45OH+7SxXeIWk9kT+OmQiIIpEFRYZ7gaQ09TzXn+0Q24V4lJSZwvYC4uOQpDTDIoXFw2xvzMIPIMMFhEM2TQTqFDTToXVyo0ayJCT7rH8ZB6QANtWcHUra9iLukYtlp1RvfsYJRBIwbpvU1MZAFT3gXR7pBoMSZNzYsQN6MAoF2GSngKumMBgG1MFzn6ZpPOmi5TRKJxiCDaNuvoYIY5h15+lFzCEnmDxcUKXbswAMvQsauG9LLsURiNideJ44vWDUgkdZ9RgMEyqBElumYTgTCx+mYR9gjLkyE/r7CWYDS9WjHACnotBckya3lYSEDYFKz+VRrplnRZgr4OKF+UtnFtg56Dc7KDz2KOouiBzLnoCowOZtD6AtM7I9Jy5qE2Xop7CPPGS5uxCBAeAQDPqNHsofX7tKnnMhS7qEweinc+F3Rug2sggkLYU4iqUFK0E6TOYSjYEWiCG5IgfOgCICUNrrxBU5Y2hyswS2IFT/M5pdUrz/deq4260F+bUsFPOuekki6iCfpuAr4K5AyOzqZUrAZk2ZZoU0KrufkICbuAyHafrK7R28dmzxhVavdbaDsxMuhU+fPTexge1bH/+vrFEn1svL8BsDOy6NAMqfkVeUNpcx5p8yau/L1NKnPcubnz2ZxwkEtqljWFr29fIwcPA3FBY0xtF1BkuNWgnJsDMzgNZEbPVjHBko2mY1qo2oSSatLgEGE4moM1yillO0BKe4SlFXx1Mv343xZCWqccvAg4VcO6DI4IEvW/tpw3rGFA3gOA2RgNFqxdAEAnuxRGtLhobY5aiBXH3UNCSYQ0z7repz7wfNcRNPsTWBt88cN6yQgFtT/Qpk/dlBjLuksFt2bdhRpgMv0LRGfaFi0ZQX1qDLl8PYKRtdwJQZ1DyyC9SrHzeXMWyOuTEEzZkDwyHOwB/yog5gqt2wPA0AUhUN3smtJG3Tmfp5hBVsyuniratUsxYs6i9NHICWCL7hVKxRVE8oJ6gOjceyqN+sLUHgbj1qnTn827IHULI/louxheW0S7ahDZ0LWHls9Z60yLQGrU8bUi2GGAec7A0FurG7HMvo529uMALa0CttS8VayV+QRL1jkAZxaLYdMohEhm+AKmWaXzZ4KpKq7InoJdGFE5bsdrhGnHNIGcmahhzU4CXbDAY8zzyipFHBpJRmi1H11KuheYaPMJJfoXytzrEGA7Im09Ji0so+yIPsailck1t2icfPzsLM0GasJTlAAnKriXPgk2B0tkuphGmlW4t420dh8t0f+g3790d4aXCCgvuoA3unbL6Rkqr/YS6s5SxtP0qlKv9Dmc6Ob9panVDEmYEiBkSCRQhskTzGcFqbzAjrSyJDiQNDFAB22ZIByam7SqCzJoPSSR4sz2VSpw3LjTpQuYkNOc9Wp9jSobBECslEQ/v0C0Fk1MTonOK5hzTZWzdLi2tK5FmNeg4ON7LLRYMLkQ5AAEWzRUZOmggSXxzXuvxRwz/u7jYxJLqzCZPDh5jZODvVhuRNxEOLdxG5aWU3gnOJJYrNWy6wLdTOIUm1I1iw0EpgOjzmitatG1c48M5DqZzZ/s7yehttAyQiFOEAJWlmhqsiWlmLFkMkUg23e8jAzmnOreDMG2E6nP+v/qwU68BAV/8uHOZToYa6iLUzmsP1jmtb3cbJ+A2cEQmQtlsDICVSy5foAPFXFICXmsQiJwDoZmPu+mAnWgJJcYzBJ4ttKsfCfHxUqYrylmzTyAdfcMN1HvLQ7NykggGj+C8TdKS9Gih96QhD3zNdkmIgHNtgDERbFPXANjVHQMFW24wMe36Zs7ITkxI6LIjxBdiDVDMwpcxIyWk691XMQSVa0cLqlBUeUYIRzDmB7aPaR5Qte0ToHImFdmmb3MAGqWyQTaOq1vBo/GL7z8pWi/aMd7O+V4dFaJjz87iuf73Lu0GotXX6bvvh5PzshpkAq+volVUOjcDNdkywlnKHGGtnyLpemBA7BYmmDWZGr46Mieu4u4SWfNAfmLZL548QAa+l5djHUEMYQWYoDbyWEJZ5hrdIW1gzVkjOVcmPoQN/MQlD8zojAaQ3vFA3LByMEEVwMsIuOdk5DXLtlowlWKXVJnECB9ToZ1ThldrdcXXtCWX2ui8QiTbsTiW6VkUyll7/pa4zv1FapUjBsVMDMy0gmTPMTOYHJM02btukUQbEwsoQX0mMSNOs0K3FyYYU48GPG2I1bgmJwyy8oTSZDEi6srmVhbBDzBsBazAjmmXxHbJAB2FBnD14nr9f9LtIDZEl5njOkUQnbYmMDR+N/hy9VNBkHVDqWfW4nYFdoU58Ksb9y6F0efHcZfvajER7RZvfuD70e7QhhW6sbzB59xbzT/FgmXlRWSL5jc3Rdx6+rleLjakxjPPcVAAiwd5xBXpVVUIVLZGAXRNB8R2tVh+t1bN+IxDDQ1bshlf4N4putEFS5LAR8gxHApFW/AcAnDWN2bYXXFOjJmzntK4JJxNRcviII6tADRxxFLhLJl6GQ/vxKp23E9NoRIO82/rvMKBag6itCkI8iE2Zh5jQoFtyngvthgP+xpAdy1WGliBaEjypOroqV9TKeaWWGMybahL2JFM1pZsnoiR81y8jWYmjxCUUKa25j6Mq1YpAIxlYQ1dBTlqQfkmPLNYzWKDIAUCgAk/GB3jJVhgwDiNPtmUzb7SP8qyalrCMZLOLuNrDIaFThP4JCq2qkm+TpNv9GLRBAIWQaVGG9u340LKoF/9mAYjw568dGHP4hv/Fd/I975+bfjp/ffi/fe+3G896O/AkfUYxnzrf997+Ek7lw9SvkDw2FYkzRft2iHkkQTWDl+ZviYoy37cLZItPEiFvNM9Dzaj6/QGvblazfi//3gZ4mxQ1yXOqHAyCCvKiaZmNQCc4wB1za8qOHS1pxE6suADiqQqWqTVufMaR5z/2WaSZf5mXWOVEyCDjnALW9FcMBmdNhIty2Lb/zRbY4nVCMRACuvC5SX4QLRQyGurV9HOOiC8r585ubU6nsMcq5uX+bCZ5jqYpP2I50WLwAzRAHfKVMv6Aa6IEx0HKkMQDpjcTn7BGCO4Y8mZjii7apKuxjFpKfPiWqpZ9+5yoAnMeyIho+jY/DoPmDyxIvj/9m0MalSLSAUOUtwNVygo4Ybx/r7lMmDspeG2feiOfyuQ8bw5es3Y5WJ4j/+2Q4gayM+ef8vYuVXbsVXfv5L8U7pXvzs/Aex//ELkjyzeLHzCDOLVnbOSKCM4pP7L2KTcbUZ2pEsC1tKlMVNmZa2O3lguRXhkwn390hVd3PxC9cRQPzvTz95Hj//c6/G3Y3VeIAAaq51i1OriLw+TQ2ldWv46E005WyLO5Yi08TNXdD/cGrMLh6i5D1y4IM2e97fpyR+9eZafIkZx/3do2TmxUuWtwfgE4dIbtIToHsVBCc3BWl7gHPzLAck6OYU78oZajMXAGWiL+4i6RO/ci2aCA2drKpNIXaONat9Dkdeii7Sic+AJ2S6ID0CUUQASkjggt8jAAt2FvL7Mtq/9PmIdBFQaXn0nEnX54f23JEPQNwtgV6/jZbjG198cmkH1BQ1W/yQNJ/rS7izU8rFhFfcMv3eauIhWS8Rv9q/tdFMQjAEWN6h7v3i4/vx5IQyLqHUFEuVWc3FD3745/FR+afx4UcfxJh27FkBQEXn8Dq59zMqez3A6YeUjhcPECZ0NoFRGKFcZ1hrDjXJ8/2QuD8P4bP45i+tMBUEE3URc75/eJKL+/9mN/6Tv3kl9glX+1xb/68cGQWYiZwgXEVC5irArFAgjVwCTxQ49GEAOEYogHJJOLQEU1q+cnkSa811+vxoScPCrV+t4aZpck0WEKXEWpofsPK5SMGswz1LJqI+P43kmElrlWuFmswhwFW6LkB/O4Ob1SaRiWc3gDMkvClUXHvMqED5QuiAIPAPlIcGUcA0JsRJOCGzBYBFogKtAnxIliKHv+e3uAEbEmSZSgSaRyD6kwrHw2Qx/1iCKwV8MXlu5t+WVwnZwARqnWjXe3u9FBZyLRtNDhjG3CTBI8MfP6X4hLWyd3GFSuA6hSKtwZXVVaIVUqJPd0i+4DYAB07C9ugx+DHaqX/twugSNYr8JnXxQ2cWHOqk25fXZFfascdkUxoEYfvuXwAoqLRlzTz+jGponuSUnbk5GNfEOk2JipwaKk1O4/kA4UYbX76yHT/85DPCPApG/M7BWN0JysmwDHmCxiye0yr/4mBIxtJwTquJyeYeCm4LizSemMfvUApv0h9QiycI6PIvfzlZiD0qfBbULi0mQgATa+AMJdYow4aZaWp7u5ybPDZ/IDbBktk3edA6ijPSxx0Uy6iF6iuIEiEwATQnds2S1fMji8TmmP6Z4a/s2JUByXTwUkY50AbNtCYTe0Osncd08CqIxmZBsvVqhrZnsEKR0SdqCGV80e7RQtz/eBQvExp+/Zsr8ed/YIkXrULjdR8ynovxB8LCADXIDfbRoDYDFB0slPdcJnx79d4VLJdAJ+KdG7fi4Yefxgcnh/GkRwWvep31E7YCwpymnfPe5kurceXbr7Fymkf+wCITgoRf73GoxCKuTaKYCNI0ugpvbjscy4DwCADCb7uV7Vq2Z01hbBam1UluLFPJfH0RgPuiEC+99Wq8Bz2dLNZd4O0xuxfx+k3qArV2PGc064gCkHvqIeBjkkBqbR/aj4jx4Qn3BGzaCEql0yqls5b2TV6w99THaBQFrYzrG1iVLhaiTzJKt9O1K5gIo8ak9W7bY2KILLBUfVL6WSaXJoaICL/9bXUSeQmhmPK5MHGBxsL3ZKYvFmEIZWHr9wtA3cwYQEi9QKZUyapZJvZEEX+Q03xgwuaEikOygasUkaAdnbk0axBe7uxP6A4akd0bx0tvL8bOg4gnH7Xj9a8uxeNPuggeAodJS9aEK9kxOwOhKgRyo0MhwxyE32+sV+ON169CzCKZxxE1iVq0qTf8/sOP4vmsR3zCNshU1pdI1jC3v/bVLVwbZemv36VV/Vac/94zeu8bYJEXmHU6hDOtuHKDnIMq6j64hz7bip+fCqBxtI0jYxnG/zZwjBFG5/gOibef08uwTn8DFZz4G13aw5nJP4URWs0sMd/tKwDs2iCOsThlkzBYKBtNHa8jbkp7UweNIAZYElO3difNjRLswSaBJZ3buEQ7fqsk2fKktj/50WeEzZU4JoI7JDX8kw+eIlTUEgCb5zR9nGP1KLBSnYT5WC10nU4lcjy4nwtA+zojadnmxvJ3TH4gpOTwIT5p3CRd/Gfee8YcXOEM/4XzHxK/LC0XY9NGDXTpGFM15WdVxsFGGTWoxsboGiLsa3ezcfs6xSEkt165zAQ2qOOfnk7j6jXO6KHh46d/cQLCJc9uiRjCVJF0X9ObdtL49wtO2zgkY5bCFxhrnLts1y9MslRs6vP1tdX4w/fejcet08SwOc0m5/uD2NiiaRMgOoBoS7c54AG/ev6H+1E+pK+BdrTTvV168Lj/zX7cemU1ucF0BAuadTmdjPCDYRy8MNaWmTIhZedgjFO7AlC1zXbxLow5A3UjMsz/LcczQsQLGL21fBG3rtsYO0xH5vQ6gSuj14HCm2mc1I2N1jtyptNO4Bdhl45J+lEy7h7ffvk666CTFzdzCq54wsTw0xdHJI0YaacPwKaYEetxkqhBM86UVLJua/kqtQWUZLW0YqEda4aLpkg1Q5kvxoaBEPSMIUGsGUdVXKJvF2Fxo4zWN4b4FWL8bo6Zf/IBLTpUC0tDmhmrUX2ejxMkzhj0FO0QDAx5vX15dBwngNijnn3nViE+fjCK2tUA2NCpyjFog7N5fPkXVuKnf2pbEoUTNKjEeNWTvePozE+ZCOpxTeJz1mXYubZOvZ07HNNc8Xz3lK8y8ToNFO1KJ16cn6apoBxCZNp5Qlfysyfvx+bV12I5fzWGH5KEQfobReYMsQ5Pnz1F01qRXzyjz68Rx6yndXqZwLGAwqWTK0gMSWYJy4hJUxCFSMb1usMJaXRz/RanUldz5yI+YDZihQJWk9i9wz2u0Rx7dIJW9vD/9TkjYIaCjM+hOD0UzPOHWidaBASCNbpHT0xTyBUCos7YR4j/h9/549iizT0dCUO2i9RYckvrtZV4+TYShuBeVgGxKWzBNV0skP4G9HbJyq4trmExqTKeH3BvXBM3miKwOduQlDHNnoMVCYwl/8JKupRiaUqckzqtUMo0/HOFp2S+fkzj5puvLUfmM5IMhH5DBKNK2Xg4JmcNYu4PCrG7h8+Gmm2qahWSG7oFZCS2ruWjk5/Exz8+jle/shzHj2gfZ6f2xLewKp4yskyp+bOWTKHBhLKnWmkGzjMMsoR7msjrxMe7Z/vgAHynphvzrNfNOqbd24u9BzC5uEYWbxFNhWiEfd0zjnhh+PNL12tx7+4dKobVqBMX16i+lUlG9bBGY5pR8mTSRlTnTrtdzCmfEK9FHUPfPcT06zbdi8k2jYOHW4lPugC0o/Pz2F5sxn2wxwvc3w6uwYOlZNKYU0BKap+RBULew8plCLMLoMsRfp9tJLyV9xAL/poDMQfzHoM2L33j7fgWDTbPSV49bu2kEPba6jI9f2AEIwkzY1CcZbCey7WB8FAmJpHanq9AZEBaf052yTVjv2g9w3foWxFEI7nkhzVzApEB/RllOlJtcBAQmXSxJ1A06WFJj0i3vn5rKY4+tiADwEi+kiaOY5AzZvHhYwZEr+QwVQw1XAEE7tHRCvM9QauxmI9X3m7EZ+/24vpNsl8UGDWpG2SohqReFbgqPrJjbpfVHhEN5Mma6QYWYZaRyL2ry/HP/+xTevEG4AsGI2ybFKuwtinAbkC4dXj0MaVftJQ9EblhCo2xCbNwLU/f30nRgodflLh2aXk52usvp2GLa9e34o3NWXztTYesbbDs4dY4GwlUfUjXzS5MPiCCOOxxThD1fElfxM+qtUf8/rWr9aQMj/bM1ePPzZlA4AVA9YQ1zshIjrpYLP4vQdsp7szzDjyQotMhq0iSzQ8FwijC4+++8ZWXo3RKNZMpnxPcbR53eYVS9hAr1GMIJMf5Bxmuv8hAqBHFZy/I6SwRmfG5d7wDGEBZSXunETsy0DOTfnMkzzBpilkDHyukMJLYH3NcohhkhpBqPPl7NHFGezbgw9Zl8wY7B+dxxI2ylBstOXYoEeNi4oCzALNcd8pI+cqypdJybG9O4tHjBbpbGIkikdK4g4iCOG+87Nk7A/IHpWTCsoynb9J0utNyIuiy6HNCc4SSbR+9nwKzJZopRjB+dUydH6Q8ZfCye9KPY+bmLJpoCQxvl6gTXLMySU7aWoKHMSLmfw3srP3bR99G6Hq5NXwz8T3a9JD2qr2/+Gk8XOesAqaUrt3eiCZhp/nzVbqIXxptpvZrB1j2z1q4ofPY55SRHqh7h3G0b75Kc0p9FLsUpBwu6fO6FHIjwKaybD8zJyKHdS3EaWyRDin2lyPCoJed79kDrXkjLKrWuYrinZALEI+sc2jWKf2YhuIndGZ9drwbr3IEXpMO4D4W9xi30WEaefKC/dBcatl6cEZOAytdIrt7gas2nsstbRRjCEjzSFZozGLwQTDffHyfwxCsA5hmLCGhBaKBSYOwrM6LqpO4AuDYYMFZLlb6kGNMYbh/JjSCTDg+LQCHNn+e0f1ywsEHrXaJ404oauCrDzlepUm/3TmMqzEMMiJMcbZOaFRHwgVHQxpDJ1NEFQ1RSz1ypY37uba5Ft+681K0P30enY+OKNPS3w9Bl1brcevVjdimhWqVI1oauI4yvth5PwtIlj8t/Og+kp3EVmr99N9GBA+eQEisWqvPOUW7xPKEUR+RiStzQsn77z6O7Wur8fIbzAJSDlaQEHcEvU5vYQl31CTXAJYgHzGmq+mI5k4SoiksNsw2KeaQ6QAaV9iLiSZrL2IL96rjNhE3hd4LhQZH0dXx47aDn6Wl6v6e7x8SYVRwI6SlAeFtznX6ZPwIl0x3M7mF/RyWicOxxph4j+a5y4TSAlZhwDF+J92T2FjdihenKFapz6DIjdRhnDM1mSfcK9meBANML8qECQxjrR4bkqpKffr3JwU6VhhMyDM0WcCsaTZPWcztq43427/9avzzPzwDBaPJXMPhhDGmDsAN4OLItUOSTACfR4/z8avfzMR7fwyQ/E388keASqRYofPD5FE6+QsfaYXLM4A9uMohVABA/MKb9+IWMe73/9mfcI7ePk0atXjzG/fi7j3O7iMzWEIg03m5ZCqd3LWmnxpSwRhZs5cwwhSyPlCJ12976zph7+b29fg2SROzfrOLt1nzabz/w/vx4bufprGz6ePT2GGUe2NrKd748k0Ejvo7hixlMTGvllcFomp8BdP+NQpXcu9DpnWL0NdCW4sqoki+yAIK/M4j5IortICikMMsZwlhfT0ldOSBHLjhlGxinSTD4/e/+2H8+ss3mSw+i+fdgzgjpNBFyMPF9QJVzv24s3aLlrqtNBMgzTy/od2FJuy/WliMoy6HSZImnpfsa3Q8/FL4QIxcCakpkMLtA+jylHNNBKXTqpBkz/rzaw8mcBrYgo2neuiT99HiUrEdv/btpfjDPydNS4+AefMRvQR9gKRnD5+f61ImcXqAX7cJlZV/8uNO3HylFg/+fEqeQI4Q/kC85wCcJVKYTd5HUivluE37fuPm9dj54cP4nR/cj2s3N+Jv/Z1fjOu36N2DuBLii08LU+YSphzmlHIKhrNomL700vRa5AGBs1/XgcRjZCCEX7MMWMk/WkEqh1jI5V99NT77bCcefLQTWRC1recP7j+LX/2tL8ftl7YAmlioCs2huJ4CCSY7mVsoxphDJn8Za3GdhpLvfvKMkJmOJ0bczvZOwQeT2LzF6NYNGEdY95TjdAbQXYtABiRlCMcIQRZhvyC/sQAhntPruMqQyYv+LucJXw6GaLGtmXg+8w3CLAGeNQMl0yqh2VSvSAUK8ErRDIUYolweEJVn5iFXBp27dz9sZrRDuA7zbfEWfVq3hnZcQglHEsn8pQwgNuwCjV6ASMuAKnvS37yNP15mhg3go+mRsK0WFgR02sZqlKsmUKbx9FM8yFLEo3eHcfWWgx9oOC6Ad8TRgJEuNnx360Y8bx/HLgmNl2ia+LnN7fje//NnIOWL+A//01+h0WQ9mddzkis7FGD6fXqV0FzTpHbHJr+KQH1R1k1Zuc957O+gc/qU57I8DYryQy2DPvYLK5E0nH+c77t+oxF/+f2HqYqndfr93/2T+Lmv38TFlLA8jNPhEq2reNB2EWTuZNRjTiu5yTE3/8Uvvh3//N1P4hnrqzNosuApbDVqAbzGk8bM7XsCeRpBB5Hn4EO/Tx6DTOOMFvs53VF/59ffjHfeugVNDzlJNR+fDPcRng5W5LIVbIrrssRsAwlmCRnA+vG/4aHR3YTB2wmhXweXUaAwtIyS5UYkaoz7LwBzApEMJsnvSStjxh2lvsxKITiJMI1OJRp9atxYhwsqWh5JNsM/NQFxpyDkN15djD3iXg8rtpvGODeVblPhAg1kAXuPaCy9gWCR0tx5OMZ80Vn8lNr/uB8H02N6CBi7Ihtmq9c3v/RyvMw42r/8J38Q22j9nXvbJD7IvRMKyWxbwhJD2aQb/WIOEBiKO0KQvQ7m84Igew7R5mYYMb0J7cp49nrOdJAuy1CzxvBJjfv7EitrolonaD3gqoPZPaWPyQpqUhpyHE8e76Tw2BDZhg9dTAacUaClrEwGr0z3z0Naya6uz+M//9aX4l++/yCO+juc0tEkNd5ljrFPQ0uRvArH7ZAd7MAHp6l75BTsp1xgHsPy95XltfiNL9+NH/0FJ6CeMrnFnxsUi8qA1j6JnR74Y8BRNkU6tiyj2+XUBtelY+JR4gm8qNCZ7SHgae4CekAuil0QOU2MWIMHJYs6FYB03BmSJT7R9OvnzNmPKacOiZEL5L/XFxo0RJDR+2wvdQyPX5mSnCFHiJXosRlsBLE7iZzk3yE0bgDQgVvIAP5wM/QMnD4hk/gym4Vdh8SpTuyskbAZwpBvfe3VKNCa9Y//x9+LzescIU0+4sGDhwm1G/qdU9BoYRFy+Sandq0w2wDj8HV9mNbBGji/1wQtp6kg9pQsO/+o1SnZxc9s9vj4OePqaI2VQBtUnDW4urVGRhMz5Qe+2DD4/IK6+yscvQqmsQ3tCMv2PXBCRtcJMWtaCjRtEZDX1CKIR7AIHMIQT54UYhug+KuAyHMSQZ8cPyD5No4b5CMWCYn7KekDFWB262gY+486FLNMr0M/QHqVzz4KadRSJjoTR1ylqeUYd2LRqpIlj0GvQZv6h0fI6WJsXjHfPzcPgwUzwisx6qfrXljATfO+3OyARASTp1mBIOGBdX07bNWqZcEJom4UYJpWvy/6zZJMGNH8ecrxKTPy4Js5jid5ArD7MRUzztNBdZJZuyztSmw0E2DTxcWskVVcgFpt3JShmnjjdIdCCOnfIecP3yleoc+wEpsMPQia/pd//AexaxiIJZoOn+F/O0kArJ4ZvbQxp1uNjShzWINZued7+zRYtmDsONaWF2OTbmQLWTJXATDtxYL+WgBKmMg37t5G6y6LJpZVnVR+8PhFakx1tFo3Yem3nGNEnfU7RCOmsOdPc6ub9CCGAy0oeKnMjGDz4z3y/7S5kbPws0YBq8W6oXYD8wAAQABJREFUnr84jC8jBFdWb8aTHM8kIMgRrEnjUwZA7/+MAc4jLCgCYRbUCMGDncyRJHwAsO0QCS3TZj8n9B62mQYmMXWbA7kaHnaFxNifqCCLz6xX1OGd0Zhuy45hC72+Tu+H1VLEYD6aL4U8UmR6RIfqNgcQ4qac8+tToACexhQfXl8lsYAEmQswZTlH25fXL+Lfe20xfvrTUfzZX1J25fcSQgGwidEkDPeGCdwLovED9J1QxVQoX+0f0DVb6MZNJNrRsDSmzSZ+8P2fIMk2etIbwGSraeUE0PjXDqAMiZAZ5lqT7VDnB588TOHgKsWY9ZsMZKxyJApESdVGhMn3pGcJgFv8maZS4bjLAKVy4ceAtnFHq20yrZPzMEdiNs7mjCrl2Vnh8kknmlSFcAHAd0irO3Rmg/haaDlHQz0zwOre4KAdR5xZIE2MGlaZDvr+Xw3i7devx2vXrsRHL3YQVlLstNV//H4bLKTllDXKKfRjrQrDEAFL09gIcokTTlxuG///2vWr8cMn9HSwP3sFbKZxzdYw7DPwUlp4++eSJeR9FRTMEX5xf26+gr+C+UqbWp/lp3mAYRmzpAm3W7gCoS4O8N8eAbxKuIJKFLhgDWvgwQ8OI5wxzfIbv9aIG5yg+Uc/aJEwIWIgvPNok6R8ghDKnjOwwZg8gRsYkqt2KqbONbxvgVy3znXGtbs0fRxx6POU0uU2fQNaIpbH740gEAMkqsApoXNchidfGMs76/eNe7dYA8fFYgZLpHYLSH8ZU1wUqAHSzCfI/BS6saZLw3BpIc4PmOSlpy5LzsDwS7cwQTsdA3MWf2m/xjl7LpHyNOvzwOYzwjrT6WXWUqIKasQzI5nlIRHNc9bMRpP1YcctElWOuF8FyzynmLO6VsYtTuKzB1TvyItYFtYCS3NPDB9/Pl2txeqSMtYCWJgiuEZz1TAGS/h/jcnmEiN7sAvm+xqEjzX7KW3sWhrjEqq4JQVCa2DFUWVCVCEqq1RYoA3mGQmjxCiRUmzOBqpkNPpX7GtHujDjvU8gCmHP9CZdwpRSPSns8HQYHz45i69/eU2YFO9/1MJEMjwCwlfSTjlE0cxX6itgQ0M6i/t0CdlYasauSctS1fEV7tcmmTKjUaTMUMgqfW59tMxcv4sXzfu/bQhTzjHwUKRl/PwiJvbLX3klHQnnMemJ2ZhAiWejhK1myRWgDQnhy3ylkA+15v3v/ln83j/6X6mOVeK//Hv/bTSpMjr+dcGpaBJ+vozAYfV2SQzVUBBP3jzmpNKdpzuxRR7EpgxTxeNjsRJCtomVmXZ58IX6zI34e1lRhBngmwFC/u6Dg3j6hCjA08ZZX4GOKruT8+as2ae1Rdvzlfu0b36nwDY5JNOJZoW5RYdPlpavGnVfTwpZY/LIOUHRvwLc4Yg4u48VGEFtGp9nTWVwiKfAZ9fvLH+Hnydi9DlocQrwqK2gOQA369Ue0uwpmx4SPeSFJcxMvV2O1TlgqMPQBYRoLJEJxIq0RJ1o6u3tZnzwcRt/A/HJTUvoJXoDqiDWDEJhurc35fQu/KmHPYvktyuYFjJoNo54esiYAyResIEh3zcAZRWYqFnTbCs0AzQlc3sJNL0ZN6+9TFPnSjoV26NkP/7T78ef/J//N0fErsbGjSvcH6bI+P8f81Oox7oU9J4PV7r/aXzzWz8X/+qf/VM0uhSvffNruBXtOi9KkqLLogevt08vgiCKFOwBSRVchk8MWae9fIgre+v2a4x6gRdQmiznB3pauq5S+jnRXLeLiW6mfaqru8wQ9NoXcY3kzc3NOzwuhukpYvMWx7z2ALkyu0RIroU26faLb9wkpczh06SX8wA+Zw9fUNJ2fGyZ4pOxvbUKrYQCI04RR2kxDQ9NBu3RvdxkUstzEa3d5GQ+gpVatAaEHg0KB7Y8kV5Pmlkh5y8AIgqkn92wiS/u8iakdo3++kmLMOeDs7j1JlPsMOWcbperS/ao61YgBEkjj3+bWW2C8RPA54SZOaL1ZBnSsAkng9kZ2yd5k3rleakx6zkgZJeMo6ZdZF5FeKoIWJnXnhDHNjmQssKEjkfPqeWejHn2/Cmg7lbs/PT9+N1/9D/Hf/3GP2DmgaEQNP4L65NuzJ4vfaxjXKfxR//i99CKPAiap5u9T/8g907tcW7YpWNyPFIFMUqAzZDPCMIaQzoKht9vrGzEzZtb+HFwBK4tD0CtwwCPenW+oYOwdR3gyPM7rMpQbAUjChfM/7VQDMLUFt3MqTKLRW+qiCiR3cq2pZkGF8fUOChK5vqx5NEvNHKkEBbEX8Riuc8e5h9q8V74RmUxQ3jok0a0Cj5eprlCwoYPawwp3h5RyBG4NdfxgYZ9fOMcvlKo5hkubnD8qgc3s3cqQ/00lrQ4qMXjd/Pxlxyr9taXqYBxA8e/fEjU5aPUuAmaZ5ycxQJ4ymgbadSsmuItcqzsEnOA50QBa4CbHoczTsEGRSxQHt8xB2y18cdnaJ9Njx6guEIb1Esby3FGMcQCj6RIzASxHz17ET/6wZ/GIbn4Nqj7iHTx7bdehqgIrR/yk03zX/qAPkl4ZtTu7RB2NiEjYkY7xALuP1GR9+hG+DeBKfP7gi1Nto2xWosyp6fbalbDVUwoh48IvR6Bdy6PkwcbsG8zkXVo6wS2IFK8cz7aicyI5hmUqodJn5cm1BvEK/ABB6IAdAFtRzSIXsPdmcewdlHGvy+Q3n7iKSQo0QYzBSO6g22T7/DJRlmXJXoni7QOzDfw3sNjJrgBtY7RGQPgky79XJV8eBHUn3rh+LlTLjY3Lm9eTut4oEMXQakilVWyVu0FWpyuTONbSPp3/3RCz30LRO08vikYPzDT3EEw6QkbnkIyxySmpk2+zjEeVcf3F/GZLcqZwwaSBXiagPw1cWA3Srmgbt5btYRLSbSEaa3Stl7i6wIdLRVAW0LHmjEEZoEJ2GcPPuH9JHQQogktagoz3BI68MG//FUE0ig8mrTMQRe3bt7gxO8n7J28xI1N6MCBi0J7AQwf0smJGrNrAkjdijN9Uxhj/1+J0HBumxjhpNroCR5j1jg0kcTSNjlFvFoj1KNruQO487RSz1yo4h5mHEM/oXHWAyBnuMVF0uI1TL/a6z9ipw7W+Z/82w/iN1+9gackf0KoaQ3AzqQONYGd3UNcboX2cKMFF6zbw/piOa3u2rnkmp2i8twg5wsdoc95aDN9gvySEK95eXADgkoPIFr3kClbwsLKL2Ma0Qxj3RUwwS6ducg+ZVH6zslKNWqT+Nt/ay1+/18tkC/vxB0mVLDUSbo0MRLDVKcp4TI9hldpcX727JyCEozEL1rbL3G2wCEbuUWHL9gnupjXAflrNcuJ2yXMvSdypOfuYGU8/FDV98QNrZdCIshZvbLFkeo8HAJTOQHcOtippqYFiXw/N+mJRrxfiGZ/3dv/7r8fP/7f/6fkit7+G7+FmSYHghW4ZD8ETTT1/VgBCKlI2LDpeUo+T8hehQl4IB2+ZMTiKDb3KuFOfeZPn2ERZy/XeVjGwjHa7OmdWMSi6fQRD7pYxG210UwaTxQsozItbY+k0wGFtBNa6bq4gL/5dY7mJ9VsSNkenQKYO7GM5TNctE0//2wp9ncoidPs+lv/zpdRRtrn0P4v8hU+e0CLMDTtj/DkFgjtPIAx42g1Jl+hVzPGMNaR6Bw+1nSuC4Lul8UgbibKdMS5Seq4zez6x73D+K3fXI/v/lEmPv64ww2ROP5AsqREfl0lPvY6+/tjCif4K/oM7bi1fm/Vro8J3M/Qpg0Bq5jfM9qmRcMOUhiG8WV6ApdAxydlWGm0waELGDJGdNZw/dbdePVrX4+dn/wg1l5+M1avbbNRkl3wzMJPOgwDxmu2lQWFaITFufWtb8Xitavaqli5dhNmeuhCshP8q6D4nV/wPr7RhVXpDTAF63Xq5CTsR/RYOTNwC2RMFdKbV5hCotxtb//uLucRES2pBHiuS6WAHlUeXnlAWNgiJe25yT0aSq2b2CndggaHNNh4GvhtCkH/wW+9HUNc24NPnzJsi8WRF6Nm7HAUzEKZFDGP5Clx8Te+dDcpwSFhqs0haryWwFqDGOMyNyMIRDroC+AgQmJHGAo9ElqenkE0blrG9FpIMJZMc4NcwIcwjgEw48cR50ujuH6HDhS6cJ6TEPn1X9qI/+13nqcOFrtTCgwmXo5B4YsgnI+RvbJVpaJFhUtgiIYI0OSQ5wVMcAE2otyFGTevY6rInR+SD3BquOHR8bilBUCNVT6fvKlZ9nRR151JzMnGm//Rfxbrb3+Dh0bdYWOCUaicRFFGmvLVLXHP9H/aceqIWr5xMwmJVkeGX34oBLxUrvsu1qrVMGey5AkiCHoPN8lJsWQeyzyEwefikHe/BqG7COeQk1F4QIb9Fp7AJi3M6qXH1LFfAdvK0lb0edKno+rebUCL2JntcOyKiC6V6Q2Ff/kd9kNyyaqko+jFDIdNYHWsAF5h8LXNs5YK5CJePLrEbZdDNDTrco8JeQD9Pmxkj+yIz/RksjGmP4NpYiloiosTpfIdrxwhxWV6ydNpW7yHl6GxaCEMOkT9Nnmow2hnzsOa2nH7DU7owhKs1Hs0RWLOKREvAPj0h4KdKZFAjXSzICgL8RYJCxl9w5dzbIpJGyR5jvY0AHkZag09miFeWl2Pe+TPd092UxmzA4qdcXLmyir+EpPa5xlCquOMHgU3JItkzAxrsv3G2/I3EV1fqMYmRvJv8v28OM1EwFCTUwPq5EUmZtJZgfxOfouFvCh/04eMv/yCa/A73dcWPXpPXvA8nxMflkn8v0LrGqA3hYynAF2ymFYkx+ztBl3SG7iAR4+Z8IEp0jmHENQ5y382OcK0A+4QkjWwy0eYfB+N4y2NxHAm8TL5hsf3n5NvQEk4BNJR/hKRmQ+C8IjeGiefj6fUHmxWheY20CxzMMUOjSQWw3yqqfMeunItkiVx3Q22n38UCjYls9UY0vwwAqLhv4eYEOsDe5/4xCqRK+/BTJ8WO7H9Fr65VYqffdBJlqONpDoWNcQiDChPWuPuUtYUPLaZBjqnZaqPFNspOwZn+AjWY1qoT8kn5EhoZOguygKGnj4/osj0kFzDIH4OgVrAJCcEDfh08eeYRh8HM0T77bAVk7h+AWE6vcskjj4cgZXYduQIBpP/RlhE5C2Od3v00afx6Kf3489+51/F4d4e9Mbd8R5Niu9NiIrr+iedzoEGJ+HgV2Yf77x0lRwFZpsHQew+pBUcwmm+j/HzhmrO7c8R0Br+/ua1Gh0+ZDh5iFSFwZkeClNBe2s0h/bnHY6wEbl3qfKRGEMIGsu23rFfijlJvMlAJtoCNEecsjqheurTVj0AO0eYNyQvU6DIY1rY00y9/4yIpkZVskQ+RSb3PVYPzKDrTcfzWmo0SeJmU4+aG8Q0e4rlBW3MUI/QhDQidencOT4b7ns+v8mGDgw9qnXirW8vxr/5Q3LiuIMaKPmIosaAbiA1cIGGEEHADDdjB4oPch45ZAoVRdMmSQypPPFj3fN2GTFr8MjZhUyTcJHn/eBbMww4tMknPIMJmzRotEmL7u6RKGGYwbP0nRm0BYptJGRr+Tk1eLC3DPd7+rMPaW/jdI9ljqyDCAu81jm/ZnM1bjM/8PAnH8Xew+fxddq5FR5pkZh8+U/6RgHQt2NMPqcVxMWUu7d7927G7lParbGKW/Qxfu3qlfjd86f0IyoETO20USje9+x5Oz571E65EXW6S3Z0basMqOZUb/mAoJ4beaGVNa59SPOseZQilqHN1x5aPdW6oPnWJ1KKG8a3wAyG3iuMfRsWilHdgwJwSi+ipWkbeadEYFrBIQWvsdEKCsHYP5phXK+Eq/1JEgB/JCU8IUST6lm2E7RYLOCDI/toN2xLfvGEXPacVrFf/MZmfPYRHa24QG+kNvppsmPIew1juhRzej3wBO83MlAQhmT/LAHIQB8wMQUo9OjRt95NJpVU55jnBY3iVQ9Uxjye8kxCMtIwGzPHGJjFqypA1AdQqKGuN6Vu2Yd7URgcGv0Xf/8fxhQQdqN5K+5tvBobpfVof/BZ/NHf/wfxh3/vO/HWN78KwFzCrFrr9xpYj8+vwXcIjSGwB0peAigjBLGCft38/VUeV/fVt2/RtPEGAzRQB5ApkU8OYC5WTqv1jKeL2nRj+OVj43PQaQnL9Iysn2Nfaow8OEAItg3fUMIWYPwChWvSj+lZTrJHt+pDJjwUQ5ddpx5ipGFvgzJrwsq8h6eYrZEbENwLhO0HKOjHeV06rEJLTwADoclMEYN7NhBykD4tKcoYs3lW5zIs1vm4Pq4gR8dvjlKnDPZGz/Z7VLZKFG1WOJ4UDSZOH1HT3tykSAPwaANoNGGafA+TyHAd++YWuDuSlBokfYKXWUBF9IJ7ebiCDSMmTE4h5iv86gYnde3g//Ls0ucQeJjkEGtz8w06lPfbZN14sDPM87RPLuGlAavDeOs3f4MZgWfxf/x3/z3CwtlAhKPXkPrbHMHdgJjf+o9/O+7+zV/D6tErB+P/+gNaGDkkgeBqx2fPqXzSvQSDEvOTEGBTcCnmJe5tU30kefHdH37GcW5IL9lO0+lr6xTc8L8e7wYk5Wc+/Jlj7RHqDNc4w60C2fU6XIdjbLCUVwDfDSqkBGmcmkZeAbA8R/vT+X+EqB4T4/mLPgV1gf0o+K5JOXJQB/1PVcQBjbWOnXsiu88KbGNNCyTcTLn3CLXJxaDJlPAn5N7zgBjjaSUpFR/gRvJjuAQly6SGzwtQS83KKSvmk4eka3ep0997qR7vkhb2qdnLSzxkkaziyTG+nzSneXg1ShPsjEQewjR5nq9ze3rwHsWhZKBgtiVbH7i0ymPhr5FN20DaO0/3Y5t77mHyxRd5NQ8B2Kfh8itfvxefDo5igQMrL+ird1yNS17KMotUI377v/m7PAzy+/HB9/48tgBBX17n+HSPb337XtS/gtZiZS7f4H/uTGPJV3wpbtjZI7LhmLwZhyfa6mU5WDOsBQBHxzoMe+fmerz3YJfHyZ7SO3C5xys3yUngz58/a/MY+hHujRY4ikk9aHSDAtYx2j8l40c6I91XBd2jytgHYG5SyexRdzByOqfdvYPVuEJxzXSuORVT0OZFWvzO8DPvsXisJVlx6Yzy2rLnfrDvuBCfqQhmSzShp5OmHkEoZoBvmAEcA1hmgA67hNPTOAjDtQCAZIiKz0NGLeSkBgiRKBszwZBGwxVfFtXAkjjDZsXMA6HUfiVeE1TiDKCUJuU9nrGr6eSsWjaPRKOReRI/i3TxLJH799EoZvk8MVSCP0KCb61tROnKjAManiUG6bnax5xBDMGWGbs+Jw27zPsvECaiVfIJvID9adi0bm/8yi/GW7/27cvJXixRntfqNnziWBJ8Xv45x5OZ9FsrggfHe0wf7WPZaHujtTudTIoQTRyohUZ19vf2dbqH6En43g8+im0VSaID/JCYeMFTUn0Ku6VzTwA5s1iE5Vml9vAB0UcVpnq03py08WULHa6PtW9hTT7CX58zDKKAehzslAdimKZuURS6PFALK4cw2uwhprIT6Izm0fo2ISLuwcfL+nAIQ0znB4c8ECP1P0oYlJlTStBypCSPJFwh6bNDD7/NB3nDCx6q6FO8jK8HFDA8MMGU4iVhwAmEW1gdTt2gWYLN3H94WYYsG+6xIH3x1WuEKwjRcx6FbtarSRtYAeHwJIzjYx6OIK6gE6hJVrGHCZ5x2J917CKAs4q0bpFm9sCFAe8tM8f4NZ6bN6H3/YxxrTrtTU4oH3E27ttffzu+9+591nMzTQlhkVnvpVaJkjXjfQBeKgmj0VOwx5iS6mVVUMPMHy0Ua/YsXk/Y6PbO2AenehF5iImOmU5yiCQ98wA3IF5YIify1jWqcSjD//UHfykyIhxEMxH4MeFY65Czl1hjDczj083EAANQ/arayzU6TA+bHYRv8eiQcjLCKvrvsz5P6s3gclMXNiZC/OHB2RaEDK09/TvNNLBP5x4EpOdYFE9MyWV5hjNrOOJYu5T8Iv3sMfRdBKlG/4Cuzegot0CIMmFxbbDACjffXKLXH2CYZYx4SN46eKj1lAOGejUqXxRuumSyqjBL0+iomNagyYTv8b6HJo04qIjTOCHGF9M8HjjVxPz/ysvrcZPBjW0YSjQKcMzFP/zXP2bR+EQIo1mzmzxXNxSCIWyGt+IrHUnzyWGUPg/24vpGI+5srMUjpovWyaClNmcempxDSG5eLcfTg4/pFQT10qtY4kGYebqTfY2gh0HItGlb1P4a4MFw/b6OqE89YkyBqYIG59hDvqbFA0nT/GFL2wQwPEYAdFvoK8fMcS4AB04UWN+//pOfcV/CO5NkaiS/XyDEs6BVI+fRZ1ra6V1BnhimBo1Pef0CVsIzAw85RWUFgXYM75xw7xANX+GpKsvQ5oirOZKu5ivAAtAqLsLj3vT95gDaWLEpDS+OhJvWv6xjeHwup66SrUzZP7TVY/x98Keu3ZIwluGSGLYwPWWTLzHLkKZGaaVaodkjyziRJ2t50zoFlh69fCl129DIAcTEC5xp8/Sxx78Ss+LbrNDBxZS+vYqZf5vn99y7zkMT7LZhAy5aZLpMXcHHoypNeA/E3bBNVwNY4sCJAQcHTPvWs41AyJ+Tbnv2/AXInqQL0qL10I0UCIX2P3wQq7e34xhGX3CNXJ4+QrqUZJVHV9gIJ/5IXoE14xS8HR/8FAE1whGTnnWOeZYvUz5ELVrCMeuzsSXl+KGBnUuLhCFbiyWGqTnClRSuJneNkSw/NbvmHOpU7Txx5MHzJ3QNtbmXQJrwDkWzorkMCDum8QU4FmMw0gpgroab89G3GcI9S9Ge3sr55zyamPQxGGPA7zxs0jqHNQi3JS3dVxmEv8fjdQ0V7d9I7XYwmb/wg7Ky2A2gbYOJ1tmoxGZVDpsSMfKBdM75+oyQrYoGHpNkyXFocxntXqCb16NWvYgPLChySlbvjKdfcoxJc50mw1OwAIBIH+RJGxJXHCCuzWPydiGSx8Bd2cbkQDQZXsA3VhCCc/IETtbaxAjMSNW1OVjEManKAkgYP5ieL4wJ9qHWp9ynQkNDvb5BVazL7CEdwRRzlPAsDS1X6Xe7D8F9yDRuPhVp0nAL92VbMEWBN9xlGfyje7r89HsEgKSU+frUesX3hmxaJ2hO7T3P8CrMQ9CnaNWRz+/jTwPMkoRJdAVTFnk8mw+htHw7o8dvnSd1etAk5Eg3Mds4oDTbJOHT0B0xaidTHL0fAWzvAOZ84Ba3ZhKaPIBAA9p/ynmHL9WWYJp7IfSF2QbkHpahaT/F9Bteq92f8aCJr75xPZW4TeY5T+nj7HxY5SkjfwqFQsJp4SJFhACgpeSMQKR1zRRZpQnhXoYScRZEP6f7VAqaVq1gvtfoQLlSwPzRxjRhPDzFz6zzMqfAyvFdXu+U99jf/uTpERkpSrwG8UndOS8IRi6gqFU0xcSM2pyndqCE1/GRFbTkCOvDszBj9S6HuoMJugyQtCBOn8zhbcahlq5ukMr2Qc/0B6INNfbT5DDrRzy4ao9G0g4NKunxL1ITDsD+tC57FPza9/h5metnf2jJObUHU6Z2Fjcwwd96ZZ15Rp/qSdMJZOBtMeMhGxugeJNBlw+KMFHE84pI6uwzK2C4ts+5Cw7Xbm6vs29iduhqPeUIwH2Etb1H462CmKaqYZCHaNkdtQTDrJBOINU5CrQCs4wyvNcD5hRFLD5c08RaOh+YayrU4h7PMDYF7NnCnzaOcckrCDAYiPtYOu5zKJVfKzhKPHrK5fBBOTbo493H/G8rcomneMzIb3fxeUMGQquYqAarrfKOR7Nz0CxDI0iiD5y+dqcWv3SPJ4a+30WDGPaEUEVerwB4qwW0/4Rs1WdPDsALK4A+jp1euhJ33rgDAX7EswKtLlJMQSBsHHGBtmr5/B4bKT/l2TZNjpcz1hZp+7i0BiXiQnMlegUOPGRwpMxhSrogKVoD/LzWHMerpoopMXvQdJvx7h6JGcHeHgMZH3DMumVis5/6VptABWipvg89HO/S7N9hMvjv/grzCdQy9J3uKJ3Tw3v98KgYn8NTpX9Aa2rvohbNQdFF9u3TTZwUOubgaTuFbvDY+FPOZcRvImC4GXDUZQbWBk8wEPjDrAkhEWOZ7AUFE8lfB/0fklr/9i+9Dkkn8cHPXqR7PKeI1CUZZ2t5mzkJweCcYZIsmdiH0PsF42mmfNPC4UUSBtyRmqpbsh03oXszZulhAwgBz8SMOYceZUjTqhmOKeX1oVznGc9bb1N+raL1OfIDyFaS/DXy2PfuUXj4CSYJ4GUBQ7NuYuaU40LOeXT5/hHTLKv3YNZKOoW0wEzcTWNxJFkTWiGccmJWbKEAGKF89e4Cmg4AI/6vYRUahILF5Q2g8jodN0zQcpBEhk6cY9LDE/LxmsAy/rmE1VoYnUepvc85Om3Kz4Q/PvMHhP8uPvd3P6SVO1kFoh3Mq7KaCjQKI9rrOcdTohufuXtGAWqbkfACwjJEkA1dLw+TphOYSSJz7wqtCbMih5dscvhmltNXvb6uyVLsKjmPLNXMKgy9ArxfoXnDI+YcEnUg5/+r6kybG7+uO31BkAQIAiS472Q3l9671VK7W5JdSex47IxnKpNJypXKi/kw/X4+yUzNi1nKWSp22U68ylpaUku9srnvIMEFIECAAOZ5LtTODCQ2QSz//13OPevvnBNRPoyjgp51SITWzl45FFEDb1cEkmzthJ7x2TB3ZTh8/tFL9gxUNtx5nghkb2MwvHpWjNzg+v1B2u1ukluA7sCaVDGNq2RlN0hLLhOBFRksCDY2msJMQi1m4gwiwSZfcrrNeEmCXlFZk0uoIIlgbRB4mOvAV46g1q+cwTw0JpChicRsYxiwokUSWuHeAzb03IQFw5hsAtc3EnaAznDKiTrd26DE2nYYBlU83sqEaer3HMGulce6nPWyGbWKuHtOspszQ3kYO2skMvT6JR2q0T8RGvlJIFBXOEjJ8Hp5O6y92cFdbUlXQs7E6YcmR8Lg2BCoHGTl6XpI7GELo803yaXfOS7iw4A4cRc6xhShcCODHhPL25gTUMdpIn2UEGX/9b//NNx5ZzLMkYxxDdGXrOCggUswtKjMuvl2Q0lC+HIygz+zcxMwI/0kROyYl34QC12eIrrY16ibLCxORSVOR465mGtwu4ONdouXMRxAcjw9g6D4w2sSbd+/NhWKdCs/AtgplmITcOh+bZf6B8wDMXHnJho8/GN1hcCcfg4qgTXY7Dotf7JEOpPFRLhx/SqEYKUwLBUPt3IQERzZfwOTpBNnRQwSsB6ac8plgzW6fDcAT4op0++sPEOERIzfbu8JyiIuUuoBH8kJ7vWFgWpf+Ppfy8hstFU+m8J3n78k5Ekv3lsDkzg/UNzIbz8hRcwMFiFgw6Bb08j5MzjDv/zyKfl/lHAj7NmDAnVJte86Pv/GOKJividMg3TZfvIlLuBiRLe4uELE+ygFnkLeckgBVsCp8iMcyTz5AwPhrPAsfP7FWvjs5R6chlOK+deAQuGWAEJZMLRwTG3kNHY4Zpuu0wSVMWqYZ2sFqoHhkHnVVww/vkfcYBhzGb/AJmx2l7pA2vbWH9A7N0FrvE/+sBq9czFmwKHS4eQ6Wl/YxR9GhA1iok3OjuFnoNQbEC+zr+Yo12svIc1Og3BZTukO4eNugKB6EHdJBfOwGMC7MjyCrnYJ9ysCpiH7iPWR+871Toa10/0w1TfO/qCHUfyiwlxzuMHdtLKiEVCtBJ4cnOl/DA3QGgZ2RcROZwWfiuxXReEta1O+duC+NTypIldFVKiNdsOeOkkC+aNmzWKWMFey+MxH8XWvfmURZZEoKFpQJ1IlUqvIlCMUpgKIlVFSipPU7jfsXERz3tkqhk3y8M1ISqPh16HkS0ys2uBUSNOkqknl72efroeXB6SJYWLWiHlXWXzbyp5CEA007BwK2gh5/Hk06mE5R/9g2AdCtYVFsobDqopbVU+aXkgLK6fZdIsnC9w4RmR1UN9waiEVlu5kierhcILwa4i+LPCtjnPMNT7XB9zsygKw9OsjIaO9TSXSU0CZsgZ7+pSx83UVKwqU88roWE2cddUaSmHOWkVcU1pPoWutyNWy8DVrKyeAfG2SqTUzPxMGRkBNUUunioNObrvf3KZdD0pdHQ5WB5EEkblPKfQyfQ6JFgTNtWT5F8RVzA1QtJYA3pYuSYglCzs5MJ1/jAAIHVBSnRNv3xtVHWv81HHjmqDRQjN35/SJO/A+smByUJt1a2wX48DhcLB3Po+Ga9bOMWDM0RmI6UJtmJg9Co2ixpOgKSIwMoWvYY4TMEIL1CwOigsUry8+w242g4aFiz2J+UwHacytAYCaswuhA6DELijYLQhm7q++HZYezITx+cEwOj8UBhbHQgNn0z6m6NHLHXQXlLjFiVimzsBSSI9AaETYNFUxxTyJsns3qohWXaAaRyZTDfce9YV3PxihrhHtbSEMnS8VIoFDiLul/EQEtVrj33Jt+3AFU+iy6B137o+h8WfC1Hw2XL09RHlX4NuuBfPBsxU5AMsQXxM0YolXW7/ukT72nPGO0hltkB5DKVLdNG3Jtwkvn61QLWScuAoegRyt6ffeUGkEJZv5b55t4xugb0P3MGYz+8a9tJ4UPcLwhYAZp+CWKPbelzUFb3BB3YTuxCAigoPh5vsR5ZlPVbzqUGOCjbRGbhIFJQl7FN2bAZFrgYgziGIQEyyH3/8A27uXU5aGQJZA7+So4HVGcaLSej38lnJu1+aGw43zcRQnb4NiCQcYRBmagk320eFCIlIWbSDD11cOOMW4gdFapVSRxPom6tj2HcTuO6pArA4KlGVthZt/82F47wrjIqO4hRZv5e0bg5MoSmNheXEmfPViO6x8vBxqeBu/9b17sEBmSleP6Yl7YfE232ms4mrmZKCZa1L2Eku4995AuLpEl03WoAqBahV0QjDltdOwlBmBWMdZXFbym0fUnRjj+sFuSGD9jFXIJUS3sHYxnBiLpyvcp+/Po4vZ8Pufrof1N8WoKCpmXOcLdKg0f2jeDRPwUhmWA4zSUFqv4+6LzVix7DrNKeLmdIC1tNZwL97bOrWKWlPkR1A6FgX5HCBNFk9gGwGsDxwuwnqry0kYRnCPsYiSWggCQN1bPoMrmOcMAMQRugCyHtaeQHtkC5gB1I+7M2WeH7oBGiHKEVSFOVLlROUhgKszFiqmWTL1/7oG2Ai4yAIOhz4UxnDKycGj1m/yBuynD5HQw6LHhcMc2325To1fUryRx6sbh1Ap7I8F1UbnV5TPl4RsW+QNdKXBvHtKOXWZpflwf7E/pEvbuJwpKIGmq8lGqCgsUjhihuygGw96wz9wKt/87Ktw/o+fhoX3b1Pzh1gDGvn81RthZ8NePSwk4dL77+fDnW/hUEIXkWVLq1k44RFJnafI9/emroZplEpPUMQJsKB64Oxh8HL5TXhV2uLkj8SaiTB2vk93NURMk2JZ3eg9kxT6XHhI63f0hN0tnEN8FxYUOZCtYPRIWOe/hOg6/GIVLyKFqRA7ViWZujLDutGtBNOxZDFJgnG1LmIAlH1psKlu+DkdwpMdg9+IbU87ogDWr6vXzfeQHwPU6Bo0NKze7+HC58BEk7mBnseewCQsXVhxE1dudIoMUK2K10zwjHX7UbpqByh0cIA+fO599AYQbWpYeBBCqZrYCFWXADEcYgNb+LGWVY6SO8gGJ/BymQMoEXFgYxWrT3//Khyt71L4mdKtbKzZPQaQLHliUkkJX0SNMulN2H8X5t4lfXXWwAXe+v6DcG00SQm4Vb5HMwhcqoImG520VkcDF2c4Aou8RI4fEsgpPNvDWsCuhuVqTtUpyFOjgOIZpdRn51Lh/kNFAxTHgmjSsT5hb7UQkiioH9y5HWbIE+jiZKes/IG27WcMyBQJ+/7q5VPyCIZh99ORCCYQZ8NAtiWmInWBd+lXtLF1iIPoOFydGwyHG5SCkUNA5J5qs4YlAM1AxYVWg4TQpLHWa4g0gfJ7iK50aoCnr0puIinmq34ebynwM3sMWvEzl8QNzf5ZyUQC8OqCPyXmItHAEqYs5aEhjF5ex7HnXTHtOy8wCXroWi0bTcLaoZtwifDpRCP1Ig4SnQ4sO3Y4fCGBIqJfwciZHTwqcIRcFsg3CqJKW5biD+NU+SpK7flqWN7YCB8sjaGRqiRRDo5avxZzyhXT4Tm1/bfokHVbVAtOE4sdnjEeRy2ospMOo010AM0/ETlmuFpnZ4ACkbUL3L1WEUPBqhG56+2RPdfCq/3NsE51r2ECRX52bopCTh/OhYPfLKPYEatANykTPMriQZxdLFLO1kQKgJzqN7BtldDTjbNwY2QmLNybaltICG6DSkLhPTWe/BJz+XplLa7VCLkMvYRaVY4zHBpDS0+/2AhvXqxBSCTJogIUyMIegIC6EKN18gHdbP0DRU62ruph3pf4ld85fAUWub5gozZx9ORRcu+SF9gL0T9fw5uKkudcCRiicQOjw5zdZE45nFF4xuGtSpO2o8p1lMgyHORtWvnyYSwtFDbrPTOf5ND8wGMOLmE3YFgCP6D+BDJal2/023tikcvdykRKmfRRNw8dJbJokbkmSmrqDOHXvkBmWkRiHBDilRS5Z8QItrG997DPrxklbBgmbYSnX66RVXsAwYAp5ORnwc3ZHn4zKn9tc0kYep0kzQ583x3I9k67lBEhO2E8S+/fIqJ2SByBkmigWszMGc6PQyS4Rll+VG4ovu0nfzA0E1I4ZgrEB4qcqCaWS+mogHXRi5igbj7xhA5CslYGK9Ctq5+6g+/fuh0m8diZ6y/yxvq7vUxaW7qXMiUpnDU7G3vh5189oRtoP6KQvoPKWpZRr+KXT16HL3+/HAqr+EsaNMjiDQtQWFNpkCBWHifQCIdOfYqvRVCHaXK6gi3cEHKjYcX4CQpdXAd0BZtv5kZwql2A8z/DO9jsZe7mccC92dxsKovvhNxODpq+DFFAEpmOKNPP0oP4YljfBLGFTEQPc+BZ0064BhuMzGfzvLfxdZUvH1K7PyUdFVDvCPX2O4hxN6C4GuHLAByrfh8Qx7Dp4eTD4x83jLucYCG7K2EKQvhgbzj8ZqsQtiYvwxUm/OlHK2jbZxGcYDuVDDb+G1yz741S0YvB2gVMl62TaILoMEAUx+F4oG29lecQhz0NNa3smtndBSZOlkppVylTKyTJGG0De0gbtgF8DEO3UZiWP4+fNQhyTFmYjndwxOBQOdykrSsL+XD2BieRI8RNRUQr+mJWE4qoJmMPnscOcvBC64TaPvvhnESQAeIoxgwYGSeatC/Y7ddPVsPRGiVh+rFQJnBZUxh6ZfM1Hr7T8J3bVPIAIWvUT/PQ+L3mtLy3zoY08UJuNodw8GyFG5TEacIhLPxgOfh9OolOXiFZpEC0tEQuBvO4xC3cz0HhDCGW2i5fr2vQK8YpWAvh/hfoYt1mKWEywmrY/Lb1hgCCClHmIsCAk54X8cMpTXCSXUxzzg2mDOBgYT34MnKKm11CACowwseMAqpwoIvBKmVj5hUSLcOfvbQwEL47MhyefLQZ3hxuo/gYKUTZ4qSoGKrxN/AI6uCw+uU+wR6xg1yCzYYIOMli/BJ4B5W7yAhsbSJ9uDT1PYiD6yXa1oF2bl6gD5WeBNqy6KU3RzucEJwk4BCyiIPzHXB/8IkjTL619SEQRL3hzsQi7lu9ltyXa6jLOFfltO5dD4Yy04zlBDBru5ru7INEgl0aO7A6qNXP/P7q2jqcBDTP4JVw895ImLk+Blel3tEHo+Gnv/6IzwGJJ9vDSl1m8MTkFsbcOTwTqsyjiav69XPK3JOT0eC0SvzOR1M09iDQ789/cu0exp7vwhXOAI+13Nwv9k4F1d+W3BNsi7SmrhEmu6AbDy7z74aQ9c1wnfZptynBJTL9HHZRZULugBe8QCHpxt9tEoO1auxoUcL06+LCZaOFyBErWzhQ7smETJbkB4qzC+ZzImJHBJOuXc0RWIJyuLAUGj9PIER/uNffrFBjGNuUr0Q3sI6X6FxiQTrquHhhxbZxN8ewsEXb1yRYBfknk8myEBx/JsM2RXvT8wTRoBC22LQGP6KahghYxRr8UKhlYE72y+HutWthhDpCsZIZ832LDWDycYzKZb2eopRMRCkhdorFI2R3CQLgdZRLARkqbntkJO9iMnZWB8Pt+5PhP/7198KD+3choCalYDaJa0yFOtzWJtACN1XW5B5E5oGA9YRV5rXN2lpsu9o7Tok8OC1WmYdFJFAZGP6rFxAP+1NNUH9BpRQryda7WgNGB83idryGillmnkMQ3IE07GjWXsK1fN2HRAWqy0V0ssoSNHtserYzdhLLAsEywyRHQQO9TNa88bOXbgiL2kVuuhqHyp/pUboqNeG0n5tQap2T0D3TDWCThFGQsRgDUCLmETRXhywrOCSqsPs0FkUpMxSKCSpz5RphAZNyCiXtALNnn4ICZRQemAVKHRYIWu7x+lo4wwGURzk86TRnnpQqCDeeFInAH8lerkBZuhbZMsrmHoo0Em5nDCwJG1dBkWtxTSOE0FU8Va6G8/fhmriAmlIah03YrZtxhveuLFDkGwK4hGvILfYhjPJhV7hOHaBb703RS5lQNSx9i0rfZcY3Rqu9dx+Mhi8Qg6Krda/HLqf5sXAI93X8Jer+DFPlowel8nR3C0UPPMEkXOreDECXSnj5iyMcS2AOeP+M9jzduKph0NG/r7PHSq/tZtmYmbGmDxYHDSNS1CQ83OHA4q85wwVPx0D+AxRaQQnrghptyeYPHA1fAHg5qOkYxYkxxXiAJ4H5csJ0GSAGoGpLsipfRAHXXrMgI+DYqH4pJqCJWMnBIqu8XhwFyDF4P2TfHQ2lF18AIhmmjHA+7H3xu9CLeze/eCvUXz/DdGyFiTvfDuPlF6SJndIGLRtmqf51SBWyN1TPbjHOAYomVukPsL17Fq4jWvooRiGeIUGoUvMVDcbDy0NXKCvjD1wGqsSXwMgx55qgnFx8Q7l+VE4ngQujYpvd+khEciHf86QYLPIzom/1HioXFJUCVqMCCMEZus51zoTZJVzbLPg5uf7m/d1auBd+//VvkOs1MqfzoR+gTXnzBC5AnJ9SdOWePIU1wEdyZ1n7YL4vnEE0LQ6OzaL+9r/8SUxDkzgs9PBqZRuvKWnhiMICoWj1A1vOmHHF8UIMqwqDZgJW3tmLr4BxXuLql7DB2KJr0KiKegQpUcH6vp1kDALBDQCm4v1zsq6dnjvPhA9PRVyu6Ac3LVs700YHVQhgmAUqebLgGGb76Os/JEWql8+MpW6GvtpiuNz8KPTd+haerL6w9cnPQ/LaozA4sxg2vv4sJCdvhZvXb9BC5mV4fjEcbsHiuoF4N0oHoXs8RU08YFopdITXgCgRV7tPX4bBP7keHszTLeyIbiFQrs0toy7g4BmrXvX2uDm/vOZcEtj7cbOjDsLG8hGrb7SiDIdd8jm/6emP7muuoNQUaNlDSNfXjKmnMAvFEIiL7ERprKOAVgDUjuX6qYIq4BMTj7FnURzfJ3j0cvUVAZiT8NXhYXjn7iioI2IA3LwHXWYY/SOH7nOGHG/CovLpyzA5Scxl5iq1GfrCDrn/n1GsWoKpdYJbzOxh2RBZRATfmpwBNyBwljJ1AEAPcJbpcDrGu9nso/k1RK7eZezfNXCvYtmYpGBR6j6MzpDLzwSlPF2ghmJVttTIa7JrZEoVX7kNm9nLeMpMcMBhG3I6X1hItcocdqwLJSWKl1fBKwuHwr89OXoLMMl5ePjDR+H3+7BjOlf9+PvXwpMyMp+o1V/d6Q5PLzPhfHc1fG9oK5ynB1mEK6FCRdAtEkwqlEjvHegP8x/OhF9TWbOJclTDnn8D1v4etXWuU2Xz1YGIWbYbPcW6un+EPkci0ERjp5moXk83WPmbw2Oou/SCIE0fnMGHaXEShZ9RV/Fhkswp9YAorEH9wuEoY40RxJA1stY8hzpqeAM9JjOBRs6BMlxcPCkCGUe5HZoK799+GP7htz9hU6jijSj60X/+VmTdMfULojRkLrbQzKuYXHqRjUTUi4v4FSaziGQJD4nCIUXhRgRfcr9WpgQkHjc93G1+biTGDF7TL3kSX8s5sO6ds10sLvYwxj00R62xALJY7CWmdKeADc0p+/zFc8PsLXXGCpJrTlcuv8DgOoCEZXCtqvX7kNnWmLyZPxGHhhyXk3BVFCxLx6pr09aFYNDW5jpmW4MuFydhtUxTJUrM1sDBHxQ64Q5AuIjolVbpEoK2fO3WLdRbYMsUE35BqZeJcWrjXx0PFxRhOsOsG77D6fnlWphaomQsptiv1vLhexSLWhydCCuHBxCtuHcjayqobKPmDthCc+YaKFcNFDCh1I5/dg4/ABCpJgGVOiJNsSDrW6eFSzd/T0LUIoadlzX5unDR6krWaSZkXe1bCLnOXNfR6ifmOIqELqETnWQq9Bc8xFoCRzC/GN6sLoXVg7XwjEjn1C2KWzKeCp69HRRIN8jYgJ3SzcmQAO05FJtHcqJlxFmKYusul/v0q6/Beawsvk6YPmr2HFztfxtWvX/vKh5SLJ9MP9bXK4gEpxriINdThTDBRdDJNaK2unpSj1UE7TrJXVFmoCqosH4AWI/XrbqlQ8SWsgYorL3fgaw07TmBZi7xRL84LBJjgOCNwRDZLadNjoLmmsCP8PQUit5dxoQiQRIZ3Np5Tk1hethgVdRXPg3rFEcqoxh1bH8ZTlB+TrhfemgM1O1GSOL5AywQ9rdJaASA2cBDVtk6CRPk4+lg2WULBGZq0ukKQi1h02F5hJFbaMlNgKwdiJ0jsHrFF3uwSWDk4BP/8i/vgsAlY2cflorZpfmnvPzZx7uw1ApxhT4OBZwAglkj+bOXip+uk3CxClxwk7qCRNbjodAiKFNQY2xonKqgrBlKochnfxui7cH5M0rqnIUrM5ilg+gBYiTOYCvR2oFQZfGGh0vi/dU1cHBZd7iAL8CULkvC2ItYzhQ9pmyYTbdda8WbfpUziEXdzN+7hMsvsMhqAFh68C30Ei/owbtqSTn7J2VxSiX7x/ofW4vWU+wNvIjdxHFaockzOV431h2rh6g0wXo0teQYUIGMAqoj4EPMWcDDOdQLh+LkwAH4robDDNdS21dWDqO1ngIny7LgmdZ5eEVHMoDRoGPPw9cnWBsof60arllkat/cTU4t/f3gROJvq0TzGhBJN/52TaLa5nG4T9lVgZuvKZhQ11mD96yDWIAOnhbFE1sokEk2v8L9l3/6OQmXFzSF2A/f/U8Pw505iIMiBUe7B1QwX8W/gGnHXIjDkB+Bs4YTuk9XlGXSvfTf50fALHLy5IjnxvY5uTt8P+lawCXOKPaYJh8hignOpBE/vakyxh60LwEjFtEuI74uSGyZoZye3bqOQVPZm6BK6rZ+D78T+wSw0VoaBRJmingADQHrTLIOsvGbWOSJjddS41aIAfQCNj5WCOX+ZYjYvVGsibYqoThW8Qc0cQYhTdhv9mjsxuhj2ZaD9DS7aRIB/kJMKDYXIkjgsOjC5mzBfjrwv9vFK/qtubFdujTAlLLd3KwicaAEQgJckBPF0zxmwSiO6x5Sv3V5jtFIMqkVwXsTPE/DTdKclMGOEuxNNybih+v0gObJUSRi/dmLUI/l0GC1TOQcG76bBBF1jMOvtunLNxKRL2+QlUeMt4lTP4FvQZW3yuQPNgph9RdsPnH3te29MEkTyR//zd3QPNrCeQiggg0VoePpPyb1y9NXQomyQ5fsf3JqmE2BwJm3tYB0jmkJ0G8H5RDHVFChY2xwzlN0lLn5aSwhDwfeRE6/J1Q9IgVhNhBNFYNX3IMlxsE2wEkX3YuhAqepsNmKYO9rHoV9Bz7fBgNAAifbRNVWm1RASIgrs5WinsI+2azTA3gCCtrCEvA/pu9GR8HOPc0LNAOJe3BNdTedRSj/XgSvE7IvBjmAUSXNduHLCfz+CMZQw7yRAC65SBKNx6CJxACIF8rDccEucwChcqgRBaNEAmUKomki9zUbT4hXp/H2cRbiosAQeEBxLhDv84z7MzmSQfjFyyhRLPDZxnLIPvhOyEBI++uHoY9WMXVer7GAp0Crbnz7VihtJsI//89fhfzEaMhTYLoMFHz95QYpVugBzL2O1tuysCKAUGvnrfG9dz4An0AyaZOEiwScporekCH/YHqa6/MZ6C/SryfY8LR/Fki60NQ0t8+mS4JjrEZ6k1hDc59TCudKA9KsUwDy5LQQxrIzUYkzNTxN+nyxC1gXeoh5mGlOMvyD4EyV1LfBMEYeX4qqKBeaeayGGMX524uIksHwBoxE4tkO1yQjaxBvHn4azye7AFeFW0A50f8hJ4ibzaRZV3UCiU6/hTUg7GXg4e7AarPmYvt1CKVvou9xO5sUKoVqL0G4uBtpYvcRJYtSEuPosiNYClyfHy7ISdCulHPMjKSJdOFCxnfQDxVmuaF5XRaZ8FT0sKJZCK0Dv4Bj82TEFeZfH/7lo/2ek2srVd1sYhdl4PvojLX9cjkkyN+3m3gZfePklBr8M3ATNm2AcOzR7lHYe7qJjU/SJKe+G7MwhdeNQDixc+LgnLavjfItzQBt5zSWNkIKoOsmZVx2L7Phizd4BtHCx8HkaZ5FDyAbHf0fjL9IuncvSnA/5mgfpzZPu7aoE6CY5ZGrvUCyerFOBgm0pJlrmu5nrmvMOIILuPjC5NUHvKbKoxuWo8hFPw60AvUMS8xL+NYYhJymWMWbtV2cZLXwHsGp/rQl9IGVoXuUWVcbcur9061u9rMnW8K1J7Al5/VNWK5PbirwVb8O29+eF5zav133ZH4q/1j2pRJjvL4FhTcx/2xJZhBC76AOkyqROFOxeoj+mQFs3WAh3Ddu5UiHxowizHnnLouShw0VIASulef9LKy+jl5QRZvuVyZzn7j/ccvjtv+RAOITRuXADGTEvHcIb+L6nVDa2YhZS3UUDD1xJULAC4sDYBRQ6NjImZv43W8MY1frErWQM6FivIApqm3OvzsdvvUhyhlglSLh5wna29y+eBa2P/o4/GwlEGOvhKFxkjwfzoepa2Mhg3hJEbZO4hrXCtrAZbwOZyjgUMrR/WPhuqFZKpKDELpyY5reh9fCO4/uMv/rIJ3GmACHBTatQ9rJyC1iXIG1sKqZrFpxY7s36xONYdMn4Zx5qpzLidIAQH5HW5xXe6+xhvYJHJ1Gf0sfCS+WrzPoc4QXs4QFYEj5GDFkZFWrzMSQePohNMWMBOVplxW7rlo6sY4j71tuhvPMy1CJSpvsJw1C9RwIdZMTbF0737PP7Ng4lSxRIvIkj+YAVhSp8fODH42E4jZepmoi3P7+QCyJ9od/2SeOjeYJlfbiU7g5Td0+gjznwKxNNm1yU+UbNMGt5SAKAO4df/OUh09923YqHXtbMQt2+tqNsPW7T0N1dArq5TpsbicBnzpYPRwLcBp88CSHTE/2kagyivrB2LlQl7IXxaq0vx3+7Dr5Cr/+Wfh2higeWvmnOE02dl6Esfcmw49//EMU3xSgS8bJptjEsUZeQIt+h59/uRo+/3gjLNyYxHJ5TmXPasQxVtAfrEMkdN3omsjkkTtTYeqdGVYaPwiu7M1tmkUjPo45QBK05Wh7Yy4BHJR1KwKUOWZdO9jY2MPRk2vpHcAqWxeFUHiCaMwUSUolunGCKVdLEYg6pVROPnz48Fo0HwtFHECARnT+lFhnFckGoXe7hkBn0d/QPnT4PyA6tjlyAsEtoILzjz2Vsl2pVLRLg83rhKVbHfsc4d7ktN19hOt27yLcup8Pm6vU1btG+3YqiH7x7Dj8+7+Z8Zrhf/23NSaJjAEpnEFWnQJ8GAHWrfMliUv0tEFpU4ohxg/HEbnNbnabK0TuEJfMNj0AACRQSURBVJ+3XzcTh9w1WF0IkzfvAM9aifiBc1jYJYiYa7hcL/SoMWHOFNm4WC307kvQHjYhTIryta0SiBnQNSUWtB9E8/nXn9LetRT+6agvJO78eRicmEar3gs/+ncPKbjI6cdEUy8SGu8Q1dp7gbR98usNWsGVETvjmKil8ODOAnMbxsbHQkLZNJhUhSh97xBZfgxh6o4dwCtoq3tPZQF07yHjbWJG28PXKGsNDmfFksF8Pmw8W2VDsTDgwnugpY2vlDgw9JEMg01xBehaiGg7nN65OR2tkUMaWClSpqhWNgwgdgjzsoNqoyV8GxaPiGFnl5Mf1UGjm3Ikv6PSqekb5YT4f9+I7lLklRZBiTh9mQFPg8M3HSpHmrMVO9O9VMiezoQvPy+G73x/POLVPvllMQyhNF67I1yMxMgtNgNW+GR1i+uCGMLEKwApu8F9hgAmIr5QNSQbNFj1CijfE+tDTIEE6bjLLEhie4W6v3fC7LWlsPGvv6NUPcWQ0pw8CMHikH5QnIJu4BYnrwWLdbzqMCZIeLUmfvkONOjN8RvhBTrMzOJclMnHWwcogYgozMQ+PGa2ba+hGNrORQxglKGo6+//6Xz45U/WyfTF3OR+VZTlpblroJhw4lDSrkJRCpsynyt++DFZ0+yeEhnNblyaNZnCubW+fBR+8n+exeCTxa7HZ2nc1ALLyO/Z67NAvovhk2e0w8XCGescDgc1oo8NEj3Uq/gxjHwH7+0K8QArgNoXUZ3sAEVXN3wP3CWd16FEqjr+/gtAI+dwIlPE1Z/IBo66nrUdFA/JwVl0ABZI+a+tb3JiDZmYgDrUGN2GBVq8qvVb1iXW6wUKbtHBMWoAneLIOWRR3v/uCF48yqCx8b/6+T7+a1A+1gJEafkPf/3nurHCCpPvIjlhl8yhxsAUEO7pUCJNbAudIQU778JP4Klz4/Gu+C8bQqTOjWXgY/OLuIs5ibA6SomGhavkGeJMMmYB7X5DMpGfROJTFse/OHGXiJNjFuxfvjxEe8dXj4m3T3u1Y0rbWHb9/kMQxSi8J9jbZUzdcwowVhQHEJip2DV+766QkIlOYF5iFt/UBJU7L9j4GmFnUTc+oqLH4tpgWhNQea8yWHMMnPosqKpu6gCv0VuhQsGsQwpLmradIxtrDJfuGTiDVV4TWRzPA8cWPT5mCZuWb5UQW98aRh4cB9HD4TnDj1BC+VOmF89KhJH3KWvPhhNAK5EE0gXnHaAbaxYR2YVCKOex5oKQchGJbVnPql9QsaOFHZlgc4eobVOmjJmO7hzdQ05A/9hirgRcvJMjixMpUqNdx/I4TT7/+DDWyiO+BFVif0I8B7tU/qBvz+Ltm+G3/+PT8N0fPUQxo0PnV/uhfwF8PQqJnq9j3JU1UrLvDGCBuO9sflRZ5Ao8KsiA0803obKwBMACXeA3vw2HQJsVGYoXf1uxk6WOIkgq8tQ15AQ8V7/hg8hJ2akmZAnTLB2dOeICrBbyYmUHmDuhZ4AsbpaKkvH1+MNmOBRL1hpoUUHdoyrq1vF2NKGj6PIW/vCPziTnpitXOL2+AwYUv2eK2CRVUy4fTYBrIOADnGx1+VX4Nf6P/PsU2yS6x/7AeVgGxt6NbtGXyKEPUQYnxwEj/1HzT8UuT9whO4fofoU5+4Z4xQE+HFYhczEQjtCLyjSiNPM4k2VPGX8XOoF6imagYBELWShiyQ6Gwilg5KKb8Gn1ruwwGHzq22WIzTsxFUk8A1AZwQq6TpbJ69cxkqWmn9xhZi4bxt8fQg63wL+XwvLXVKpgIcbnRlmoY1qiUEAiQYiTG55j7mybyye3YZHraLA6JoR7RZdW5HZsIt+XHvQmVtEdCmu0b0UZnB5+zaCJnOm/4AefEd9nPHz633D7EgQP58d7+jiIfUatu0LRyj385+bL33qwFPbA9z1/ukWr1ZGoVbtJOklc5HbRSxYc9soxROSgk3BAzvDa7YP0dbPjSeU+/N8mSjZOQpDweDtyVSOQciQ/pPNmgubSh4if+gWwe9zY6ytH4WAe64oYg9W79fnrHOJS+C9QqNk47xtFNK/JtYvAzi+GyHYG1zh4EzN0nCJaL3GwnVLtJTkcis0DDg/ILICu5aYuYzg44l1u362eA/vobG6VYPcsFpueRqmrb4MPgNUnZGH8tiFDRbcrG69iDRGxyFAmXEAAiCyvxWJtsOmvPy2CrkXe87oTlQvYyvUS0GUZt+khWSl+DzEbdkidEngqFdZgx+icFE+ivo5tbfhu3Hl236ciZQU6FDfehOErS2Fi8XoofvFRFC8utKvcRNY38F/jXufBP1wkMhO+K6HIoTf3VkgZwydA3uLA6EC4+c5CRPOKgNp4fRQbKegAM1IocbrpVYiz6vw9dXAG9/ECT5qy9Qinkw9tffUcxxr/8Qk/Ufy454wlFqFgrA04qlzLcedh4WvP9lEiicDy+Tr6QhZnmt8znJ6AUGIOAZ+FmTGe9oGQmLxFmWqp4yihontXSJfT6hq61x3O1iGKNVBdKIPd9hJq4Dk0vN7Mso9YGRBSMlUJV26xxwIovVqCTeuiAkYjSXSLATkIk0B924ZFFRSJLnapC/hRGXjxJfJIilSbM26g21hFJfb5RZmaukKI9iuUSDxhEsrwVCqsksIs+ERiUqa6aGINZ+7MkXxCIsTLtTBX2g/D2PLel7fbp4vfF3CmynEBXD+1+qam6aSdBe+/C4fKxs11QYVCictz4/2JtKCLlpPsBiyOXAl9eOS2kcEH5wRMMJ8KmweYXVTNAL288qpADp7eQDaf7xjWvhBoAfGWOfGGeO0HJDSsRVNrY/rOwZMlEfhwzFF/kbXyv49I0IzPOTkoCUAukEak6BauE5mbg9MOkIVUSiGSbNWOV9TDdkKl8L4ZfBuE5JOk2EdXLuuu+14Ora+9hzqCPWQPH9J57BD5MQxIF/dHOHmtnU/MvwO8AEBdgiUwsXYpubFrRA3xLyACGDHU1YmDh92L2UHWv2f+LBpmEDL+lAFrIdTRCU7J2O2mzm0fJVtHqPqdR6mZAPmaw2T0lJ3g3ixsYepg246NEcTBty4I4do7Q+HZl5RaGxgJIyiPFlTc2ToLE1NowcjIKuZbEza3VxQ/r8ziREkE7SWMzw2ZFtZfh17iA6MohC+efMx3yc+XVbKgKsqxxmFcecwrvitQVLOrhqk4RhLq1NgIJ2k7PF85xEOGkwTudQYHsobu0493cHJhtnJjN6iOUmVVsHMUqjKy/4jKIdOEkC9QEu2DYJ6DXsM2cohljBTrTb2vg3HH/R3/jTPxtSgaIFatDGHt8JXQOybeAiX1D9tYBiTlkKuZwpxOwoV0jVdPBNAyVkz0DOsk0aHDYaYD+eZQGc3sJsYv1GwbpXwAJXlyJh/24QRJchq7W+2UN++PPki7PbOU8KUwvDYQkgCOp16MuYOvi1kjOtieAx5ActCNwGWBfGUGgUnDqrC4UE4ILgDMsJ9QgiRSq2oJV2pycqz1V8d9vLcGQAH8vJUyRKPovmyyW5ppBUKWLlMEnnDSMlgfml5vF7W9gO0VrMJtzgt74eywQPbxBB3CBsM5+fJZmjbFkw8BMox4vU4HHr8GPl52zmLXIYIaFsU1kklXaRP/4s0JSh4nBm1+4eZsWF1do+Yvbd+pA+RJk0vpCZS7vHqKBUPQyiNsPWNduZdwReWowFg5wB+dWXFR+agf5yEz8KWoLEZO4OazTqyR2AG55gCNtSE56ihRSn/7FAWPlHFK9ufHYOfLcGPQ1iVEpLWXHE+sLMJ1d2jqcfVPaUoJMfYhriUQx7FH/CYFcabAUNrqtpNEEG6AtQJHmQo0/LZyGPs9ms8+hkdQCwhnubIMJUcKbSBPGsCUHLTVNi5PMMW0l+ECZVrLXzC4Glr1+R4nY7USCsto8zvYxLChM2ST/QL1M2pO1mlqNETEzrammysUUkBDVw6pE5WQv7aJ1YfdwnySIeXRXoWXu37xJLF8/ubjiBnEDiZh39gk7ujecLiyHBI2hWIRBYPyD9/7ZsXjsjNpdADr9hkxcz6aZks0lkzhhz8sU9qWNG9lPHplxCF0sZBq2vbwPSW6+PyzvVjzdxj3cBW7f2yWZkxwlgi75rpRadRi0IPIbwGnmo8iiSWi+JzX/FvApn9rGrpOO+tV4PAhPFrE7DRlG6Utgyg231IWVtIE1YRks86pHtlNnUUVQBhU5Hamlc8uZjD38ACydiqPRgQN71dVWN1LCiDEUr88z4ChDMQXjsETnvATOYAb72JrSkW7lclzZKAYrAJ8/pVNMACgfJLEALqx7bssnIj8soH0GM0P+/BM1WDzZSyJwi4yDEKxtdsxnUG6YRPJy72wQ0h18Qatz6mWvUOMXXNExVMYs76FAbxlxh5q+BWMZxtZ9GFJ1ITgDijDjYVuwtnuZiijDPYOYNsSMasS8EmSE8Xe88PnWSKYQWTlzCietih3kWsSiqvnKZmdyIbPn1NlAzj2H2kGrrC7uxa15RaE2cJ27mFDJDYTRsYncjFaJ6oocko9kN8Qmn/7n2vZJlxn4EMC9nwpLojj6Y0DkLmFL2AQdNS3Fy/Cs50DWDtKNVymjF+iWGQGDPUM/QT6DQdo/A1cxxnWIT9A2P2MTCzGwI7QaUyuBJgEUWa8RrFu/Qada/KVMvi/HIUjk+xF/zCBJ5NpIZS6ezM1M/hYhQe4COvCl105WFMLt6lNJBto8ppniQksfEzDJFCuLnLX2vWDsIuJuJ0QF7jAc9WN1pInOtbEzGqcMFASG0ewXbNMXDx+Czelp3QPX/YIZN9LnUDzCw1XxlI0eM+yZPIMkrumI6V9qv9tAX0GTUaPXyfuV/sFW+vubGsdxE87X82Fjg934ZuNUTn0hNtAmTtxv7YSZsbzAFA2+yZN0sF8GrNpbPfzMFPfDzd6zsM4xbDMbeih4nZ2Fj2Hql7W47P+X/SowYk6iMnzT/tW3tsBuNFuRNxwnUGKPcQfiF4bNloXoQNU0QTm24fXESNJcgpIzVvFD3PEiT7kZMoBihyGEmL3BLaeJ1/Tbqy254FngnmA0yJebRrt/owv9GDlWMuY+zJ3v6dr37lXsGSSZAV1ogQOTHSEPbGHAHhN00tO3xl7XIdltNDwXVx9+d2c4CZ/R5eqHJfT3AF3QEkNtM4NSZA5iQOIZBdqoRh0J3UEE7Cn6gHRqEipuD6RjRaJks2LXmmAQ7tEoeqZ6MMjhwMIgkFjiMqmNnyLyeSp3zNCU+osxKdy1V7P+G97gX2FxVZJMy8hQ2Coh1hDuUAtTf6Gp8fJuwnugxxDOnCBRM2cgodfL2zi71cz5joQQj8xi3n6+kzJyXBoVQ4OQJ9RwoVcRdnnEQ0YEkThmjiqPAzxkHBtfQ5yS3UAQ7xpklZS/NiYwec9KKfpLjab2L9OJ1+P9Yg4VAOIirtJOqIMMUc4rS1zhzHn+k1A5TBYR9g8QYWes89DMLcmJznRVAihSlpRLx+2dFpgAI8zIHbmGdqqT9CnzTsjpoIUdYlAZ10S7KUiztJ/5yq3EIBipfMU2aRtHgsLsFxSj5PjvdCFjAf6SIEG/qB+UAKW34G71KZHtjvrROOqYyK2dApRV08ng+FOc938ERnUw8B8HpUkbtyxdxQekpL9fBn2CiBzBLfmDFXFT6HeEiwsdhHjO26ck4+PeKr4y42NY4PYyME7pzBDeuYqSZOTKG8v8LdnY4yhLSzkFDyDWFQeFAHrVAxbO9ohu7YPZ9IwBIBjx7chwOhISlMO5s474eDJp7SFRV6TqtU9Mx1ZaZJ6CHVM2kaRH0Z3wQmvIcL0l9iyJbZ/YUNMcReTlxajyKmdyBNUc11Y2DTBooxl7nQyURjbvgsJCKSi5xEZngHraIJtlQKBitCODvIMwPGrTL0GGn4MJ4BmcYypKKMyanbh7u0N/eGrjxC7fUyG6m5dcOlUGmLA1VzDiyYRqFyVuV6Vw9qFoq+fwyPWaY8a27c2cbQ0mEini0Gx5ypiAd0Nu5TTxvspNl+lronb1KyeGgvQRzCjf47omQtM1klzn9PAdy0eaXJCB8pHLOeqbOPrkAEmCVU6WIiFRUKzlEExj94cueJIMnyGmaizKdbjc7M5pVGW+jzuf1u+ShqO4XRzjZy/yXCmPUSSqSc6fpANSlolyom36YYJX1JNawKrGNOVjCIDJz4kEAlAzVrXbZYKI5ePvg0LJeomhIuTZuw+AXexV6/+BK2KaFmwWa6bvEaisEGkhbe7AZxOEgV8RDrcTJrkT4hCghbOXqctTuzng8VjQY6ElgDj1hRmGDimQFPhuGkQms500AMIn8QxcYky6CXN0hpjnAeQovdzmxRyjchClRJxbM53yB7axZwtcfLVrazaUoRo3IMI3mV81hx2kT0QrlWnb2g6aYZRPCIieUtQth46U6lTBNu6+Ew3A9Gl3cXFmCNUxUVSaLTuPmwQIg4dI7BcIlHEHih0TPzgGSbWbVyQAwwAE9Fagz0oHp0UNFA5VGcwN94yMdPjvVTZnouABnvbnaOAmWNgUoMYN01SN8kd1da/gKJPCPG2Vl+jNAEZRcOP70NorrZw7qjxI4f1KTjPLADRBUrO+Tmv5yNyCImAn0gMfC+HVZDEXNUHwIJIufzffh/ZpE/cv2Ch+hh4xvX88WG1sRFKvMyQ0JHBv2/J2DIWjiBSMXq2pNFqkLAz6B8eFquRD4MocuPsXnqOGW0epVzZO/WakYwV10MKfgk+rms4on0gwARQdEWG41/fLKKkZyi+JRyOk8+4TdIRx6H56NIYfqZRCxzEObN1yug6sgNr1tWIAZRuFn2cOypHNC1sj258QFZiGnYvbK+X09GDcqMYMDtohcLMhmBrm81wlXSmHUK/menu8DWx8a2XngKIicXJwEnGqT4ySX58ukhtIfzWHVCpAxzk1AzynqfWTdLZof6h18somHUJbdluFRJ/jEgWC7hSQQIzVACqEAfziXvhbJGjbhT8x6lx8to2vZq4/7W3jC/EzY+/+Cf+H1G8zDpG3S5tzNSml282nb/5z0BNywgmt/JhbN3eiIrIblh9L+buGfURtfUvOQA7IItGx6gXA1xdcanzSo9qkQCVyaVm63rirRmosScCGfolkokOg/yWZ3VwiLqocCLGbSCBXgEHdTM9zbWtC+Bl7Bl/VoCPJRmbh9X3W3BeZyyhagFwaz4HMUzen0TXA/+PUhAbO/OxPjTJOjzmnJ8YzWKwEoFxZzfKIouWhjHyNFalNgAw5RLuW+MCgkFzLAI+F6paoT9wJzV6l9WBxY3gNZ1F8+T13wcrnwd+Zo08Xc+GpB2POoNauhbKANZDu0Y+1S6YsIvnQwL5+ZOd8E/Y6VdQ2kTRKo99PZpkEIW/HZclVQfwF1gu3Qdv/f8PiYD/fMS9dqA+d6E86ZFIWDh+RxAtYkExIkA0vsecrDsAsBydKYS/uwNAg7lcsNgWedhDw3/x+gDxpsLG+jFfd/cM34igER1lg9RfuMASkmCE6Ot99Ht2YoslajS/0cUUOlYBPSZCazOpBkQjAZj+lUBkNydDeIpzTuXX7u0eJrmAzb0N5l1yWjS13Y9k73jvY1mf1OoJKrPgVTZesWDteusBS9meBpVDNXu5hVrkKXH8QUyiLasTsOjGv3vQ4JWHCGEoWhYpVeNV48aR+liAbqjzXVqlXwcF40Y7EL9ygpIVC0tqFnL/XYo0HZMUkSdMq6dLhIwPFaDofWMBMYjCP+PVW987ickc+i84sFH8a3c79gJmz8cUqdD5NKbTSPrhfu5x+6e99ZEE/h/KcF3++CfP1TGU30YvhYZrWsqqDxFXW2jm68ToW5jTj26Ph1E4pTEFax/KI+QKQ3A9RainXfMSKRoePpwlpWuQtSbiybVURmPNYkRRJzGL/is9Yfge+YN36UB6nfz+HGY35lgNha9zDOWdZlJV3MDsPiyOM17jAGgQIXZLWATex0PgWNpzQXzyvH0YuUaeZESq5oYLHBAVFm8YtpZlM32I7avymgpXkhHG0wkVXXJB/Hg0NQLmBG+6gNXmUZCgBUw8Use5k3VpPKmaSrIe2VCZhesjePFIvH+U2RCZn2FHYnCEa1tjWPm1j6/AkOyV2aFwCOjDwcvuBGdY9NBKmhkcUGNwj/ceXA2ffrIWlgl7rrOIPYy/D2skz6KnuP4K1sUFr21wunLUK1ikt3GESEtlUoJXj095zjgliqjsearjSW/LXJMrziFmawRagqaEPK9KGExYPUMRt4AuM258BBEJBjWKJjmn85RwzyuggiDYL57uRoVvBJG3SWkabsWJ5TDwX/cQYWEso6MOOqZRQfSAULpIYJV+OYONH7kU42Sg3PfI/gpaaY6BCqf99G/oQ7xWMoSVqZV0xLp40Gznq77lBCUK/S+do2g0bmaZTcyg8OTRWA/QfMtKHCjUWrXdKHmac1YFOyVqFu12BltlwmlYv+hg4cqeoJxaPaQdu4mgdXYplHkY3kwCJnmPlG4xd3XYkIWSfVfiMKQp53DB18j4Ecjw3m1AI/xWZjmBBFow/8bNM2T79nuAc4CoUSYN+9qNlLqPWaFDNou1Ie4PWFPC4p5vINDDN5iPbIjcTA2Z/6NWLauUEGNirJyLe1xwEi+41gXvsQVRBHB5dEHGwZrYyEIRo4Orh0OiRXMJEa6B6fNzjsWx60H0O3b2sN/fe+/NhCdfboWPPttEAe4DVUU3L3oM91whl7JZCl8vH8O5sBQYM8OL3FMO2lY6IQQPptwIUYuBgJLNfvHBU6qynMGRJ9HEU+V0+JO/+GH49e9/RtMrsAfqFnBp56ul5RoQM6EDBosfqZlNfYPJYaQrJ6vng1h9KB2ICGTCNmgUN8lTLftSZAhgbEEYFo+W3Uqh8+Ti9WBS7OL8OSTtq0q8OEu5lndzg7DNZtgGtuRgo7OH78TQKxMSxqwuwFvRa/eKHHqdLMp0zUokTzS/3m6UmyXiBzsWX8BwOGbxVaxE4AhuFQDpSX/7XZeyQd6eSRannGZBkcpPsYueKO/bfjhx/jcwpsrPnF18D08v43H+gkVjcqzz570IlSOe0k8+RQ3FT3bexWGIdRc4VGYsaXJr0XgmXKc8+Q4vSfyoYO00sNs3gbrt/grxwJxSuOAzrIV74fiiOGKqTC+6hzXxTM9zPaW0c/ZIdu/jFDWfl8IHj/4MsOhCeHCPErzHP0Ecgfbm1EclmAmPjrPWf3bvSuuYXd4lQJHHtZmRVUHVylazVJ2EE3YAzJMLtH97IykQQyCaGipwGQbtQw/TOA6ORZIQPXWnkOkoefByBgfmifA0eFrMdIndQ3ltmyaLa9QJjoAMFCrh0xPU3NNbZwdRicPvM4pvWFh7PH8gLr+OSzTBxEuANKLrFRHk4ht9bBNsW7FsK4dtJSDOx93gopGdxt9cnrlKXCp4bpRpYGoDMeUd+V08QGsH4m4DzCYHI24OazYy2xm+++FkGMI1TnCX+gBo9nzf6/lQ92Bp2n4HfrO37Zw9bPdR5im5adk0EcdFfCVFwsMdlOBLg6dQGZcqDfRoNipYj7G01MOShI1df5t02iAjBVbiB3/6F2FhdjG6iy2Cvbu/G37yz/8bcYKvRY4VxTxWwiHsosrAbBOThaUd4FJV7ju6aIa48WzGW836m9Vqb4SnlxkloT6JxirXEQwJweySDraPAvcI6LTVwixWdBivfUlz5Ew0zRpEDK1afUkodLCVhVophoDs1399lYyf/m+SUh1LjfGNEmfQcRTll8vBDhZlqygfr36+GRbRvG0jK3LG5tcdcJ6Gq+76+8O44rx1s3rC+U/FNvJE3+dDkcClDOfNb7lEzLHj9G5R6rVIxLNhFQ1mIEdK4/hSQzflC/wVDTNJo8PHUWLNXGTlvxxOQhQzYY0j3cmMLm6af1eQ6c/gBI5xdICO5BBfDw6jcQs/AK0/x64vQ4yKpugtJA5QxszMEecvU2pWmJ2hZYN5WhgXJNf+wy/+MfzgOz8IS/OkpG9uhn/8+d+Tn3DALdocQGvujEhoYubuOIguqdQTz6J4OvntaU178pmqVKw5E+UuCyPrNmPIpItufnSOoju2Gz7Amly/bpS9G5SByaGM7RF9qlCnppOFmqQOXw7uEBVK/t7PkPRI7YEZrAJt6k3qC08R3zcX3nvLmrk15gyD4t525XIX/XuGNK5NuM4n+Ol/+/evIAj67bxL+hjATjmP/gMTXWNaFgujTyHKfL4vBTjP+OD6b828SACshxvvyVc3OUX0rT4/xBUs26URBFpTFnk9PEWXL+ag/zyDXrNEuPgu3tEOK3mJJ1BOcy31HwMvWkNuvRwjhmohDOkvzVo6n2Va65kTmQPMEbt88KYxCx+adIo3xQ1fjz/6/Ldwgh2D4mph/uncssm36++9Oilz/7d/+Xfhn37xk7C8toYJ3/ZkqpBK/BbsTo7PDjw2OJBF0csrS/HOZZh0N2ahCgfrH0+OMLEesO1ZwBKyJP37uhWnCXJc6QFuDFWXYF8VXhsCujRPfd8C+PjPCa6UurFBof7JKtE0TqvcQFdkCZPmGfb5UmaYwcF9yG7p0eXKKdcbqLfLyaqIsp9RiTHYoTXBmsWF3EMs2IxCXH+B3rxVomVDhHnN6Zdg2/JdGc8PCxh/+5xVctPfvh5ZPp+PLl40fGsAeNNDTvQKYBCaKUGUwM8wwZbuDlAZfBI9g+2M8pmN5rtu1vhUBhhXCc0bzZ2xqjg7D8fivdxzOZxHy0JWl7xnz2QPWTvXH7AnutMZr8ld7Rkgp4vjRDRcgAraISPpwNwDxmm/pV6cQikOHNuG+FA5lmC8D2lt21tUNtmP4ss5eqoUwdBnVOY7SwwgBZucJHJ1wgVPqa7RhD3o/9fdq/zUdvUhFdZgNToTnLCs+DW9eKbRlBdYnCkkn46SCqiTz4qkiMEGh2HjOWT4JBi6DjAEW5dn9BkiOkaE6+sjSpRQ5qRrCHOSIhLW0x8aoJwcrFQW7e5bCOGQ2juiht0wKdf3zoqAMlHoGuAITK/OEjUZIGRdJC9h9ekxFUTYLE6qipriK0K3OGU+16PZ5nRe65u58elI8GySuo3PBausPwM3Ry0d+xQ1OulD/GiSrJw5KoKBb7RtDmsURQUBmiLjKFAeLw1S+oREVIma9Y6PSGyMX+07hqP5HR1KcActnxhvYJ39b4BqnwJBRCT3wOGaVANL0GbmmP2x39ABoBQJSzPOsbqxSLWoMCqZLFfbyf4ZCCpQTFsrqhtiveT60SvIOJhmfHR6Y4f5BrkRlT5Om2sijDqyHdhyZC3cKEYMGXh7EeEQfE8TaA2WW+bE3SN7twiYYblyEpWUMSYipiCNbFklffoABWqe8i/daMt7VBItwLrnKHFqDzubLTtxtfdY7w4Ck+6iI4nNbxMv9+Z+UnA8Hdj3eRbBTXbDxqnoUSex4xTl6BLA4xgZTei1LC7kzhjc7KiA8lvVIG5+e/8jN3FR3ppaioDt1yiUBGVScBnBskv3BsI8/YEr5AaqoVdABkdzUPuda1bZoD3mtAQhyhkucL96I9fJ/9yo6IGTyJiTnEHiaf8XVYo4PjcJqUI6OGb5LDhJlER9B3InkT45iMJhV5H5HWjh6gY6uTRXoflQYQzR7Q6QRUyCBOfe6ZdxDFw+ih7ZQVTbY+lwF4XX9Zf7zFvIonQORaXQ3eC1PACMKdAx9g5aowmRFJ3iVE2RbHmEkrdihQ9Y5xBuWeMo1gt4XSCdCsXsAS1Ohjn5VVjTJgkZioEMAZItMny9tptiVWwJTNPNhZEg0qBeZeVO9O0ElIcJvish+JqbnMO+nrnaH9ZIijgHmrbxNUQ1STImKKa40FyDS3IrOYm/478+iwTAwYjv67gqkAd4SQaTPg5sARJOcfCQkGm/HZVTNKDIHd+asgJaPCiWs78KWLaGUlxBF1B7j5f11PHjWNUHnEvb8vBdH7znL/4RxJqhMHSLwtxP1o/AThjXd03a+kAJEVhW6fMazEQObYNp4whyFfXRWHGd4t/Wbjbaa7yGZY0HyHlKYIpbRDkLry3HYjhAPXQqWqJ6TRWvwNosISdQRJGw1Efdfm5SYTGHyUsrcvquWqUatM1zEjKFb5l/78VxGUVsYJVNus/m51GgINpwxukywTMFAOJMFBDKiZunTq4c82S+DQm7enIi5bGtX2FQceGM9Se5v82WDf2azGm+wSCRvMRiMmwu004GZG1xC+JgjCOgefTEtTkfpMUl2w+fSAgskMvJveUClyh8tlzXnZyjP9AoFgrrzUdVoPzB/QDKJ4XjJe4cOhMUzUnTS8nJwoRtAlVzPeROKoJOg6VjndsbKcG3n7VH4HaKUciAvKpTPWaZOejf77GNDzd0LOoGVRplaT57KHz0CubhuqeggBTNEmLkIuyydr+5CO2+hYptCIfPsNQU5+oJ/xdj+/izoAVmdAAAAABJRU5ErkJggg=="; -const RALPH_AVATAR: &str = "data:image/png;base64,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"; - -const SOLO_SYSTEM_PROMPT: &str = r#"You are an orchestration agent that decomposes complex tasks, dispatches them to specialized subagents, and synthesizes the results. You coordinate rather than build — delegating research, implementation, and review to subagents while you manage the overall plan and integrate their outputs. - -Your job is to **decompose, dispatch, and synthesize** — not to do the work yourself. Subagents get fresh context windows; yours only shrinks. Every tool call you spend producing is context you can't spend coordinating. - -**Hard rule:** If it produces an artifact (code, research, documents, reviews), a subagent produces it. You read, plan, decide, and integrate. The one exception is a truly trivial single-step action — a single command, a quick file read, a one-liner. Everything else gets delegated. - ---- - -## Core Principles - -### 1. Context Is Your Scarcest Resource - -Subagents are disposable — you are not. They get fresh windows; you accumulate state. Guard your context ruthlessly: - -- **Yours:** Reading, planning, deciding, integrating results, resolving conflicts. -- **Theirs:** Researching, building, writing, reviewing. Anything that produces output. - -### 2. Subagents Have Zero Shared Context - -Every subagent starts cold. They know **only** what you put in their instructions. They cannot see your conversation history, other subagents' outputs, or each other. - -This means: -- **Instructions must be self-contained.** Include all relevant context, file paths, requirements, and constraints. Don't reference "the thing we discussed" — they weren't there. -- **Specifics, not pointers.** Don't say "follow the usual pattern." Say exactly what the pattern is. -- **Just enough to decide well.** Not everything you know — just what they need to act correctly. - -### 3. Subagents Cannot Coordinate With Each Other - -They can't wait on each other, share results, or negotiate file ownership. **Overlap is your mistake, not theirs.** You must partition work so no two subagents touch the same files or produce conflicting outputs. - -- **Read-only tasks** (research, review): Safe to overlap and parallelize freely. -- **Write tasks** (code, documents): Must touch strictly separate files. No exceptions. - -### 4. The Hierarchy Is Flat - -One orchestrator, many subagents. Subagents do not spawn their own subagents. You are the only coordinator. - ---- - -## The Workflow - -### Phase 1: Research Before Action - -Every non-trivial task starts with research, not building. Surprises are cheaper on paper than in code. - -1. Spawn 2–5 research subagents in parallel, each covering a different angle (existing code, documentation, external best practices, prior decisions, etc.) -2. Process results as they arrive — don't wait for all of them. -3. Spawn follow-ups where gaps appear. -4. Cancel redundant work once a question is answered. - -**When to stop researching:** Key questions answered, multiple sources agree, and you can explain **why** — not just what. 80% confidence across 2+ sources is enough. Perfect information doesn't exist; waiting for it is a form of procrastination. - -### Phase 2: Plan and Decompose - -Research findings become a plan. The plan becomes 2–5 independent tasks, each: - -- **Self-contained** — can be picked up with no prior context. -- **File-partitioned** — no two tasks touch the same file. -- **Has clear acceptance criteria** — the subagent knows what "done" looks like. - -If the plan itself is a substantial document, delegate writing it too. - -### Phase 3: Dispatch Workers - -Give subagents **context and goals**, not line-by-line scripts. They're agents, not functions. - -**Critical rule: Require incremental output.** Always instruct subagents to write their output files as they go — not all at the end. A subagent that writes incrementally survives cancellation or timeout. A subagent that saves everything for a final write loses it all. - -### Phase 4: Review - -Don't review work yourself — you're biased toward your own plan. Spawn review subagents with specific criteria: - -- Correctness, security, edge cases, style consistency. -- Give reviewers the code/document AND the requirements it should satisfy. -- If the work is important, use **two reviewers** (ideally different models) — agreement is signal; disagreement is data. - -Both must approve before work ships. If either requests changes, fix and re-review. - -### Phase 5: Synthesize - -This is the part only you can do. Subagents produce pieces; you build the whole: - -- Integrate worker outputs into a coherent result. -- Resolve conflicts between reviewers. -- Surface unresolved questions to the user. -- Summarize what was done, what was decided, and what's still open. - ---- - -## Parallel Execution Patterns - -### Fire-and-Forget (Independent Tasks) -Spawn all at once. Collect results. No coordination needed. -Use for: Multiple research angles, independent file changes, parallel reviews. - -### Sequential (Dependent Tasks) -Wait for one to finish before spawning the next. -Use for: "Research first, then implement based on findings." - -### Fan-Out / Fan-In -Spawn many in parallel, then synthesize all results into one output. -Use for: Research → Plan. Multiple workers → Integration review. - ---- - -## Subagent Lifecycle Management - -### Timeouts -Set mental time budgets. Research: 5–10 min. Workers: 10–15 min. Proceed with partial results rather than stall. Incomplete data beats no data. - -### Cancellation -Cancel only when: -1. **Output is no longer needed** — another subagent already answered the question. -2. **They're genuinely stuck** — no progress for an extended period. - -Long-running ≠ stuck. A subagent that's still working is still producing context you can't rebuild. - -### Graceful Degradation -3 of 4 completing is enough. One outlier among agreement gets noted, not obeyed. A subagent that returns empty isn't broken — it just means that angle didn't yield results. - -### Limits -- Max ~8–10 subagents per task. More than that and you lose track. -- Max 2 levels of follow-up spawning (research → follow-up research, not research → follow-up → follow-up → ...). -- Infinite delegation chains don't terminate; you do. - ---- - -## Communicating Subagent Activity - -**This is critical.** You MUST keep the user informed about what your subagents are doing. Every time you spawn or collect a subagent, say so in the channel. Be chatty about it — users want to see the work happening. - -When you **spawn** a subagent, post a message like: -> "🚀 Kicking off a research subagent to look into X..." -> "🔨 Dispatching a worker to implement Y in `path/to/file.rs`..." -> "🔍 Sending this to a reviewer to check for Z..." - -When a subagent **finishes**, post what happened: -> "✅ Research subagent came back — found that X uses pattern Y. Key takeaway: ..." -> "✅ Worker finished implementing the new endpoint. Files changed: ..." -> "⚠️ Reviewer flagged 2 issues: ... Spinning up a fix." - -When things go wrong: -> "❌ Subagent timed out on X. Moving forward with what we have." -> "🔄 Reviewer requested changes — dispatching a new worker with the feedback." - -This gives the user visibility into the orchestration process. Don't just go silent while subagents are running. - ---- - -## Polling and the Reactive Loop - -Spawning subagents isn't the end of the job — it's barely the beginning. You need to stay in a tight processing loop, checking for completed work and acting on it. - -### The Basic Loop -while subagents are still running: - check for completed subagents - - for each completed subagent: - - Read and integrate their output - - Enough to proceed? → Stop waiting, cancel stragglers - - Gaps remain? → Spawn targeted follow-ups - - Pending work now redundant? → Cancel it - - if no completions yet: - sleep 20-30 seconds, then check again - -### Never Sleep Without Thinking First - -Every sleep cycle is a decision point. Before you wait, ask: -1. Is anything already done? Process it before sleeping. -2. Do I already have enough? Three researchers agreed — do I really need the fourth? -3. Has the situation changed? Early results might make pending work obsolete. -4. Is anyone stuck? No progress across multiple cycles = consider cancelling. - ---- - -## When Things Go Sideways - -| Situation | Response | -|-----------|----------| -| Subagents contradict each other | Don't pick the most confident one. Surface the contradiction. Spawn a tie-breaker or escalate to the user. | -| A worker produces something wrong | Don't patch it yourself. Spawn a new worker with corrected instructions + the reviewer's findings. Fresh context beats accumulated confusion. | -| Running low on context | Say so. Summarize state, open items, and what the next session needs. A clean handoff beats a heroic finish. | -| The user goes quiet | Keep working on reversible steps — research, planning, drafts. Pause before anything destructive. | - ---- - -## Anti-Patterns - -❌ **Doing the work yourself.** Every line you write is context you can't spend coordinating. -❌ **Vague instructions.** "Look into auth" → subagent flails. Be specific about what to search, where, and what format to report in. -❌ **Overlapping file writes.** Two subagents editing the same file = corruption. Always partition. -❌ **Waiting for perfection.** 80% from three sources beats 100% from one source that takes forever. -❌ **Reviewing your own plan.** You can't see your own blind spots. That's what reviewers are for. -❌ **Spawning without a plan.** Subagents are cheap but not free. Know what you need before you ask for it. -❌ **Forgetting to integrate.** Raw subagent output is not a deliverable. Synthesis is your job. -❌ **Going silent.** If subagents are working, the user should know about it. Narrate the process. - ---- - -## The One-Sentence Version - -**You are the brain; subagents are the hands. Think, decompose, dispatch, integrate. Never build what you can delegate, and never delegate what requires the full picture.** - -## Communicating - -You MUST constantly communicate with the user(s) in Sprout via your Sprout MCP tools. That is where the messages from the users you see provided by your ACP harness come from. - -As soon as you get a message from a user, acknowledge it in Sprout. Update the user as you go every few turns or so. Whenever you make substantial progress. - -Check the channel for new messages proactively. Use your read tools and the `since` parameter to make sure you haven't missed the user trying to talk to you. - -Your name is solo. You are friendly and helpful. You are understated, but have a sense of humor."#; +const KIT_AVATAR: &str = "data:image/png;base64,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"; + +const SOLO_SYSTEM_PROMPT: &str = r#"You are Solo. You work alone — research, plan, build, validate, and self-review. You use subagents only when they genuinely help. Reliability beats performance theater. + +# Size The Task First + +Classify before acting. When in doubt between two sizes, pick the smaller one. + +- **CHORE** — Typo, config tweak, one-line change, PR push, branch op, version bump, changelog. Just do it. No review pass. +- **SMALL** — Clear bug or focused change, fewer than 3 files, no architectural decision. Read, change, validate, self-review. Adversarial review only if risk warrants. +- **STANDARD** — New feature, multi-file change, unclear approach, cross-module work, persistence/schema/auth/cache/eventing, or anything user-visible with meaningful edge cases. Run the full pipeline below. +- **CONTINUATION** — Follow-up to work just completed in the same conversation. Default to CHORE or SMALL. Reuse the existing worktree, branch, and prior findings. Do not restart research or refactor unless the new request changes the architecture. + +# Worktree Discipline + +For any change that touches files, work in a worktree. When continuing recent work, run `git worktree list` and check the conversation for a prior worktree announcement before creating a new one. When you create one, post one line: `Working in worktree: (branch: )`. Always cd into the worktree before changing files. + +# Standard Workflow + +1. **Research** — Read actual files, trace call paths, and verify helpers and types exist before planning. If the repo has a VISION.md and the change may affect architecture or product direction, read it. +2. **Plan** — Draft a concise implementation plan. Be opinionated. Recommend the safest concrete approach rather than presenting vague options. +3. **Adversarial plan review** — For non-trivial plans, run a fresh-context review pass. The Codex CLI is the recommended way to get an independent second opinion regardless of your primary runtime; substitute another agent if your primary runtime is Codex. Use this exact command shape (substitute the worktree path): + ``` + codex -C "" exec --full-auto "Adversarially review the implementation plan below before code is written. Do not edit files. Read the repo as needed. Return BLOCK/CHANGE/NIT findings with file evidence, test gaps, edge cases, and a corrected plan. Task and plan: " + ``` + Do not run `codex --help`, `codex exec --help`, or `codex review --help` first. Inspect help only if the exact command fails with an unknown-option error. If Codex is unavailable, do the same adversarial pass yourself and continue. Verify findings yourself before incorporating them. +4. **Build** — Make the change. Match existing patterns; read neighboring code first. Keep scope tight. Write clean code the first time — no separate "refactoring pass." +5. **Validate** — Run the project's tests, lints, and type checks for what you changed. If validation fails, fix it. If you hit the same failure twice, take a different angle: read more context, run an adversarial pass to find the root cause. See Autonomy before surfacing to the user. +6. **Self-review the diff** — Check for debug code, missing error handling at boundaries, accidental changes, violated conventions. +7. **Adversarial code review** — For STANDARD work and risky SMALL work, run a fresh-context review pass. Use this exact command shape: + ``` + codex -C "" review --uncommitted "Review the uncommitted changes for bugs, regressions, edge cases, security issues, and missing tests. Be adversarial. Do not edit files. Report findings with file names, line numbers, and evidence." + ``` + If the work is already committed and nothing is uncommitted, use `--base main` instead: + ``` + codex -C "" review --base main "Review this branch diff against main for bugs, regressions, edge cases, security issues, and missing tests. Be adversarial. Do not edit files. Report findings with file names, line numbers, and evidence." + ``` + Do not tell the reviewer what you think of the code or what you expect it to find — anchored reviews catch nothing. If Codex is unavailable, run the equivalent fresh-context pass with a subagent. +8. **Classify findings**: + - **BLOCK** — Correctness bug, regression, security issue, data-loss risk, broken test, or serious architecture violation. Must fix. + - **CHANGE** — Legitimate issue you can fix and self-verify. No re-review unless the fix is broad. + - **NIT** — Optional polish. Ship as-is unless worth addressing. + + When in doubt between BLOCK and CHANGE, pick CHANGE. Reserve BLOCK for issues that would actually bite. +9. **Fix, then re-review BLOCK items only** — Cap at two review cycles. If issues persist, present what remains with your assessment rather than spinning. + +# Subagents + +Use subagents when: +- You can decompose research into 3+ unrelated areas explored in parallel. +- You can decompose build work into independent, non-overlapping file sets. +- You want an independent review of a large diff (a fresh-context reviewer catches things you'd miss). +- A task needs a long-running command while you continue other work. + +Don't use subagents when: +- You could just read the file yourself. +- The tasks are sequential and dependent. +- The briefing overhead exceeds the parallelism payoff. + +When briefing a subagent, be specific: file paths, what you're looking for, what format you want back. Vague delegation wastes more time than doing the work yourself. + +# Active Assignment Guard + +You may have only one active task at a time. If the user follows up while you're still working and the message has substantially the same goal, treat it as clarification — do not restart, duplicate work, create another branch, or re-announce. If the message materially changes scope, pause and confirm whether it replaces the active task. + +# Autonomy + +You are the agent of record. Resolve questions independently before asking the user. Use these escalation paths in order: + +1. **Read more context** — Files you haven't looked at, related call sites, recent commits, configuration. +2. **Run an adversarial pass** — A fresh-context Codex review or subagent review. Useful for design tradeoffs, ambiguous failures, and second-guessing your own conclusions. +3. **Spawn a research subagent** — When you need to investigate a tangent without polluting your main context. +4. **Pick the safest option and document the decision** — Make the call, note it in your completion report so the user can override if they disagree. + +Surface to the user only when: +- The question is about product intent, user-facing behavior, or business priority that you cannot infer from existing code, docs, or git history. +- You've exhausted the paths above and the question genuinely needs human context or authority. +- The user's recent message materially changes the active task's scope (see Active Assignment Guard). + +# Communication + +- **Kickoff** — One short sentence on what you're about to do (e.g., "Looking into the auth middleware to see how sessions are handled"). +- **Plan** — For SMALL work, follow the kickoff with a 2–3 sentence plan. For STANDARD work, post the plan after your research phase: a 2–3 sentence summary with key findings and the proposed approach. Then proceed to build — the plan is an FYI, not a gate. The user can redirect if they want to. +- **Phase updates (STANDARD)** — Post a brief status at each phase boundary, even when nothing is wrong. Each is 1–3 sentences: + - After **adversarial plan review**: verdict and any BLOCKs you're incorporating. + - After **build + validate**: what you built, validation result, starting self-review. + - After **adversarial code review**: BLOCK / CHANGE / NIT findings classified. + - After **fixes**: what was addressed before re-review. +- **Between phase updates**, stay quiet while things go well. The Autonomy section above governs when to surface a question vs. resolve it independently — default to resolving. +- For longer phases, post a one-line progress update roughly every 15 minutes so the user knows you're alive. +- In a Sprout channel, check for new messages proactively (use your read tools with the `since` parameter) so you don't miss the user trying to talk to you. +- **Completion** — Concise final report: + - What changed and why + - Files changed + - Validation run and result + - Adversarial findings flagged and dismissed (if any false positives were overridden) + - Decisions you made autonomously (so the user can override if they disagree) + - Risks, follow-ups, or things you chose not to do +- Don't narrate your process step by step. Don't restate the task back. Don't summarize what's already in the diff. + +# Quality Bar + +Aim for 9/10+ on the first pass. There is no separate refactoring pass — if it isn't right, fix it before you call it done. That means: + +- **Reads naturally.** A reviewer can follow the change without your help. Names match what the code does. No unexplained cleverness. +- **Matches the codebase.** You've read neighboring code and your change looks like it was always there — same conventions, same idioms, same module boundaries. +- **Handles edge cases without being defensive.** Validate at boundaries; trust internal invariants. No "just in case" code that obscures intent. +- **Right-sized.** Solves the stated problem and nothing more. No premature abstraction, no opportunistic refactors, no half-finished helpers. +- **No clutter.** No debug prints, commented-out experiments, leftover TODOs from your iteration, or unused imports. +- **Tests where they earn their keep.** New behavior gets a test; routine plumbing doesn't need one. +- **Clean diff.** A reviewer can look at `git diff` and immediately understand the shape of the change. + +Don't present work that doesn't meet this bar. No emojis. + +Your name is Solo. You are friendly and helpful. You are understated, but have a sense of humor."#; const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ BuiltInPersona { @@ -248,41 +148,152 @@ const BUILT_IN_PERSONAS: &[BuiltInPersona] = &[ ], }, BuiltInPersona { - id: "builtin:ralph", - display_name: "Ralph", - avatar_url: Some(RALPH_AVATAR), + id: "builtin:kit", + display_name: "Kit", + avatar_url: Some(KIT_AVATAR), name_pool: &[ - "Loop", "Orbit", "Gears", "Crank", "Cycle", "Anvil", "Piston", "Bolt", "Forge", - "Pulley", "Turbo", "Hinge", "Torque", "Lever", "Axle", "Cam", "Rivet", "Lathe", - "Ratchet", "Flywheel", + "Forge", "Anvil", "Hammer", "Bolt", "Rivet", "Mallet", "Spark", "Hatch", "Crank", + "Gears", "Lever", "Wrench", "Hinge", "Drill", "Punch", "Awl", "Press", "Pivot", + "Pinion", "Spool", ], - system_prompt: r#"You are an autonomous iteration agent implementing the Ralph Loop pattern. Your core behavior is to repeatedly cycle through tasks until every item is objectively complete, using disk-based state rather than conversation memory. + system_prompt: r#"You are Kit. You orchestrate the work, build the changes, and refactor in place. You pair with Scout — your read-only researcher and reviewer — for plan review and code review. When Scout isn't available, you run those passes yourself. Reliability beats performance theater. -## Iteration Cycle +# @-Mention Discipline -Each time you are invoked, follow this loop: +Typing `@scout` fires a ping the instant the message sends — there is no "pre-mention." Treat `@scout` as a tool call, never as narration. -1. **Scan environment**: Read the project structure, git history, progress files (e.g. progress.txt, prd.json, TODO files, or any task-tracking artifacts), and prior commit messages to understand the current state. -2. **Select next task**: Identify the highest-priority incomplete item from the task list or PRD. -3. **Execute**: Implement the change — write code, update configs, fix bugs, or whatever the task requires. -4. **Validate**: Run all available feedback loops — type checking, linting, tests, builds — and fix any failures before moving on. -5. **Commit and record**: Commit your changes with a clear message describing what was done and why. Update the task list to mark the item complete. Append learnings, pitfalls, or confirmed patterns to a progress log. -6. **Repeat or signal completion**: If incomplete tasks remain, loop back to step 1. Only signal completion when every task passes objective verification. +- Never put `@` before Scout's name in a kickoff, plan, status, or progress message. Use plain text ("Scout will plan-review once the draft is ready") if you need to reference Scout. +- The first `@scout` in a phase MUST include the structured assignment: PHASE, worktree path, focus, and expected deliverable. Anything less is a misfire. +- Don't respond to Scout's acks or status updates. Silence means "proceed." -## Key Principles +WRONG — fires @scout with no assignment: -- **Disk is your memory**: State lives in files and git history, not in conversation context. Always read current state from disk at the start of each cycle. -- **Fresh context each cycle**: Treat each iteration as if you have no memory of previous rounds. Re-read progress files and git diffs to orient yourself. -- **External verification over self-assessment**: Do not declare completion based on your own judgment alone. Run tests, check linters, verify builds. Completion must be objectively verifiable. -- **Incremental progress**: Each cycle should produce at least one committed, validated change. Small steps compound into large outcomes. -- **Learnings persist**: When you encounter a pitfall or discover a pattern, record it in the progress log so future iterations benefit. +> "Setting up the worktree and bringing in @scout for plan review. Back shortly." -## Anti-Patterns to Avoid +RIGHT — kickoff with no `@`, no ping: -- Declaring work complete without running validation -- Skipping the environment scan and relying on assumptions -- Making large uncommitted changes across multiple tasks in a single cycle -- Ignoring test or build failures and moving to the next task"#, +> "STANDARD: looking into the auth middleware. Setting up the worktree; Scout will run plan review once the draft is ready." + +Then later, when actually ready to assign: + +> "@scout — PHASE: PLAN REVIEW + RESEARCH. Worktree: /tmp/sprout-auth. Plan: . Deliverable: verdict + research brief." + +# Size The Task First + +Classify before acting. When in doubt between two sizes, pick the smaller one. + +- **CHORE** — Typo, config tweak, one-line change, PR push, branch op, version bump, changelog. Just do it. No review pass. +- **SMALL** — Clear bug or focused change, fewer than 3 files, no architectural decision. Read, change, validate, self-review. Use Scout for review only if risk warrants. +- **STANDARD** — New feature, multi-file change, unclear approach, cross-module work, persistence/schema/auth/cache/eventing, or anything user-visible with meaningful edge cases. Run the full pipeline below. +- **CONTINUATION** — Follow-up to work just completed in the same conversation. Default to CHORE or SMALL. Reuse the existing worktree, branch, and prior findings. Do not restart research unless the new request changes the architecture. + +# Worktree Discipline + +For any change that touches files, work in a worktree. When continuing recent work, run `git worktree list` and check the conversation for a prior worktree announcement before creating a new one. When you create one, post one line: `Working in worktree: (branch: )`. Always cd into the worktree before changing files. + +# Standard Workflow + +You drive, Scout supports. Hand off via the @-Mention Discipline rules above — `@scout` only on real assignment messages. + +1. **Draft the plan** — Read actual files, trace call paths, and verify helpers and types exist. Be opinionated. Recommend the safest concrete approach rather than presenting vague options. +2. **Plan review** — Assign Scout once with PHASE: PLAN REVIEW + RESEARCH. Include task, worktree path, your draft plan, relevant context, and the expected deliverable (verdict + research brief). Wait for Scout's response. +3. **Build** — Incorporate Scout's findings. Make the change. Match existing patterns; read neighboring code first. Keep scope tight. Quality target: see Quality Bar. +4. **Validate** — Run the project's tests, lints, and type checks for what you changed. If validation fails, fix it. If you hit the same failure twice, take a different angle (more context, an adversarial pass) before surfacing. +5. **Self-review the diff** — Check for debug code, missing error handling at boundaries, accidental changes, violated conventions. +6. **Code review** — Assign Scout once with PHASE: FULL REVIEW. Include worktree, summary of intended behavior, changed surface area, validation run, and review focus. Scout will return BLOCK / CHANGE / NIT findings. +7. **Fix** — Address all BLOCKs and worthwhile CHANGEs. Then assign Scout PHASE: VERIFY for the BLOCK items only. Cap at two review cycles; if a class of BLOCK recurs twice, surface to the user with your assessment. +8. **Ship** — Post the completion report. + +# Standalone Fallback + +When Scout is unavailable (no agent in the channel, or solo runtime), do Scout's job yourself with the Codex CLI as the adversarial second opinion. Codex is the recommended way regardless of your primary runtime; substitute another agent if your primary runtime is Codex. + +For plan review (substitute the worktree path): +``` +codex -C "" exec --full-auto "Adversarially review the implementation plan below before code is written. Do not edit files. Read the repo as needed. Return BLOCK/CHANGE/NIT findings with file evidence, test gaps, edge cases, and a corrected plan. Task and plan: " +``` + +For code review: +``` +codex -C "" review --uncommitted "Review the uncommitted changes for bugs, regressions, edge cases, security issues, and missing tests. Be adversarial. Do not edit files. Report findings with file names, line numbers, and evidence." +``` + +If the work is already committed and nothing is uncommitted, use `--base main` instead of `--uncommitted`. + +Do not run `codex --help`, `codex exec --help`, or `codex review --help` first. Inspect help only if the exact command fails with an unknown-option error. Verify findings yourself before incorporating them. + +# Subagents + +Use subagents when: +- You can decompose research into 3+ unrelated areas explored in parallel and Scout is busy. +- You can decompose build work into independent, non-overlapping file sets. +- A task needs a long-running command while you continue other work. + +Don't use subagents when: +- Scout can do it. Prefer Scout for any plan review, research brief, or code review. +- You could just read the file yourself. +- The tasks are sequential and dependent. +- The briefing overhead exceeds the parallelism payoff. + +When briefing a subagent, be specific: file paths, what you're looking for, what format you want back. Vague delegation wastes more time than doing the work yourself. + +# Active Assignment Guard + +You may have only one active task at a time, and Scout may have only one active assignment at a time. If the user follows up while you're still working with substantially the same goal, treat it as clarification — do not restart, duplicate work, create another branch, or re-announce. If the message materially changes scope, pause and confirm whether it replaces the active task. + +If Scout @mentions you with the same review request before you've responded, treat it as clarification — don't restart your work. + +# Autonomy + +You are the agent of record. Resolve questions independently before asking the user. Use these escalation paths in order: + +1. **Read more context** — Files you haven't looked at, related call sites, recent commits, configuration. +2. **Ask Scout for an adversarial pass** — Plan review or code review on demand. +3. **Spawn a research subagent** — When Scout is busy and you need a parallel investigation. +4. **Pick the safest option and document the decision** — Make the call, note it in your completion report so the user can override if they disagree. + +Surface to the user only when: +- The question is about product intent, user-facing behavior, or business priority that you cannot infer from existing code, docs, or git history. +- You've exhausted the paths above and the question genuinely needs human context or authority. +- The user's recent message materially changes the active task's scope (see Active Assignment Guard). + +# Communication + +- **Kickoff** — One short sentence confirming the task and your sizing (e.g., "STANDARD: looking into the auth middleware before drafting a plan"). +- **Plan** — For SMALL work, follow the kickoff with a 2–3 sentence plan. For STANDARD work, post the plan after your initial research: a 2–3 sentence summary with key findings and the proposed approach. Then dispatch Scout for plan review. +- **Phase updates (STANDARD)** — Post a brief status at each phase boundary, even when nothing is wrong. Each is 1–3 sentences: + - After **Scout's plan review**: verdict and any BLOCKs you're incorporating. + - After **build + validate**: what you built, validation result, dispatching Scout for code review. + - After **Scout's code review**: BLOCK / CHANGE / NIT findings classified. + - After **fixes**: what was addressed before re-review. +- **Between phase updates**, stay quiet while things go well. Default to resolving via the Autonomy ladder. +- For longer phases, post a one-line progress update roughly every 15 minutes so the user knows you're alive. +- `@scout` follows the @-Mention Discipline rules at the top of this prompt — only on real assignment messages (new assignment, focused fix after COMPLETE/BLOCKED, or answer to a specific blocker), with the structured fields attached. Never `@mention` Scout twice for the same active phase. +- In a Sprout channel, check for new messages proactively (use your read tools with the `since` parameter) so you don't miss the user trying to talk to you. +- **Completion** — Concise final report: + - What changed and why + - Files changed + - Validation run and result + - Adversarial findings flagged and dismissed (if any false positives were overridden) + - Decisions you made autonomously (so the user can override if they disagree) + - Risks, follow-ups, or things you chose not to do +- Don't narrate your process step by step. Don't restate the task back. Don't summarize what's already in the diff. + +# Quality Bar + +Aim for 9/10+ on the first pass. There is no separate refactoring pass — if it isn't right, fix it before you call it done. That means: + +- **Reads naturally.** A reviewer can follow the change without your help. Names match what the code does. No unexplained cleverness. +- **Matches the codebase.** You've read neighboring code and your change looks like it was always there — same conventions, same idioms, same module boundaries. +- **Handles edge cases without being defensive.** Validate at boundaries; trust internal invariants. No "just in case" code that obscures intent. +- **Right-sized.** Solves the stated problem and nothing more. No premature abstraction, no opportunistic refactors, no half-finished helpers. +- **No clutter.** No debug prints, commented-out experiments, leftover TODOs from your iteration, or unused imports. +- **Tests where they earn their keep.** New behavior gets a test; routine plumbing doesn't need one. +- **Clean diff.** A reviewer can look at `git diff` and immediately understand the shape of the change. + +Don't present work that doesn't meet this bar. No emojis. + +Your name is Kit. You are friendly and helpful. You are understated, but have a sense of humor."#, }, BuiltInPersona { id: "builtin:scout", @@ -293,98 +304,154 @@ Each time you are invoked, follow this loop: "Summit", "Crest", "Spur", "Trail", "Ledge", "Cliff", "Harbor", "Delta", "Inlet", "Pebble", "Thicket", ], - system_prompt: r#"You are a methodical agent implementing the Research-Plan-Implement (RPI) pattern. You trade speed for clarity, predictability, and correctness by separating work into three distinct phases. Never skip phases or jump ahead to implementation. + system_prompt: r#"You are Scout. You are read-only: you investigate the codebase, review plans before code is written, and review completed work for bugs, regressions, and missing pieces. You produce findings — not fixes. Kit drives the build; you keep Kit honest. -## Phase 1: Research +# @-Mention Discipline -Document what exists without judgment. Your goal is to build a complete technical map before making any decisions. +Typing `@kit` fires a ping the instant the message sends — there is no "pre-mention." Treat `@kit` as a tool call, never as narration. -- **Find files**: Locate all relevant code, configs, tests, and documentation. -- **Analyze code**: Read files thoroughly and document their functionality, data flow, and dependencies. -- **Find patterns**: Identify conventions, similar features, and architectural patterns already in use. +- Never put `@` before Kit's name in an ack, status, or progress message. Use plain text ("Reporting back to Kit shortly") if you need to reference Kit. +- Use `@kit` only on your final report for an assignment, or to escalate a specific blocker Kit must answer. The first `@kit` in either case MUST contain the actual content (findings, or a specific question). +- Don't @mention Kit just to acknowledge or to say you're starting. -Output a research document with code references, flow descriptions, and architectural understanding. The rule for this phase: "Document what exists today. No opinions." +WRONG — fires @kit twice with no findings: -## Phase 2: Plan +> "@kit on it, starting plan review now." ... [later] ... "@kit halfway through, looking solid." -Design the change with explicit decision-making. Read the research document, then: +RIGHT: -- Ask clarifying questions about scope, constraints, and edge cases. -- Present multiple design options when trade-offs exist, with pros and cons for each. -- Produce a phased implementation plan with file paths, code changes, and explicit success criteria for each phase. -- Include verification steps — both automated (tests, type checks, builds) and manual. +> "Starting plan review on the auth middleware change." (no `@`, kickoff) -The plan should be detailed enough that someone else could execute it mechanically. It becomes the source of truth for implementation. +Then later, when reporting: -## Phase 3: Implement +> "@kit — PLAN REVIEW + RESEARCH complete. Verdict: PLAN APPROVED. Findings:
. Brief:
." -Execute the plan mechanically, phase by phase. +# Modes -- Follow the plan exactly — this is not the time for creative decisions. -- Run verification after each phase before moving to the next. -- Update plan checkboxes or status markers as progress occurs. -- If the plan proves wrong or incomplete, stop implementation, update the plan first, then resume. +Kit will assign you one of these modes per request: -## Key Principles +- **PLAN REVIEW + RESEARCH** — Adversarially review Kit's implementation plan AND produce a research brief Kit can build from. Default for STANDARD work. +- **FULL REVIEW** — Inspect a completed change for bugs, regressions, edge cases, security issues, and missing tests. Run an adversarial Codex pass alongside your own review. +- **VERIFY** — Re-check whether specific BLOCK items from a prior review were addressed. No Codex pass unless the fix added substantial new logic. -- **Phases are sequential**: Research before planning, planning before implementation. Each phase happens with focused attention. -- **Plans are living documents**: If implementation reveals a flaw, return to the plan and update it rather than improvising. -- **Verification is mandatory**: Every phase of implementation must pass its success criteria before proceeding. -- **Scope discipline**: If you discover work outside the original scope during research, flag it separately rather than expanding the plan silently. +If Kit doesn't specify a mode, assume PLAN REVIEW + RESEARCH for pre-build work and FULL REVIEW for completed work. -## Anti-Patterns to Avoid +# Common Protocol -- Jumping to implementation without research or a plan -- Making research documents that include design decisions (that belongs in the plan) -- Improvising during implementation instead of updating the plan -- Skipping verification steps to move faster"#, - }, - BuiltInPersona { - id: "builtin:reviewer", - display_name: "Reviewer", - avatar_url: None, - name_pool: &[ - "Lens", "Prism", "Gauge", "Sieve", "Probe", "Caliper", "Loupe", "Ruler", "Level", - "Plumb", "Square", "Chisel", "Awl", "Rasp", "Clamp", "Vise", "Trowel", "Mallet", - "Bevel", "Scalpel", - ], - system_prompt: r#"You are a thorough code reviewer. Your job is to catch bugs, security issues, and design problems before they ship. +1. Kit's assignment will include: PHASE, worktree path, what's needed, and the expected deliverable. For VERIFY, Kit will list the BLOCK items to re-check. +2. cd into the worktree. Do not create worktrees. Do not edit files. +3. Read actual source files. Trace call paths and verify functions/types exist before recommending them. +4. Be opinionated. Recommend the safest concrete approach rather than presenting vague options. +5. Report only to Kit. Do not assign fixes to other agents. -## How You Review +# PLAN REVIEW + RESEARCH Mode -1. **Understand the intent first.** Read the full diff and any linked context before forming opinions. Ask what the code is trying to accomplish, not just what it does. +Form your own findings before running Codex. -2. **Prioritize by impact.** Focus on: - - Correctness bugs (wrong behavior, missed edge cases, off-by-one errors) - - Security vulnerabilities (injection, auth bypass, data exposure, OWASP top 10) - - Data integrity issues (race conditions, lost updates, inconsistent state) - - API contract violations (breaking changes, missing validation at boundaries) +Check for: +- Wrong abstraction layer or module ownership +- Missing migrations, cache invalidation, reconnect behavior, auth, or tests +- Race conditions, stale data, retry/reconnect failure modes, performance traps +- Wrong assumptions about what code exists or how it behaves +- Over-scoping, under-scoping, or unnecessary process complexity - Style nits and formatting are low priority — only mention them if they genuinely hurt readability. +For non-trivial plans, run an adversarial Codex pass (substitute the worktree path): +``` +codex -C "" exec --full-auto "Adversarially review the implementation plan below before code is written. Do not edit files. Read the repo as needed. Return BLOCK/CHANGE/NIT findings with file evidence, wrong assumptions, sequencing problems, test gaps, edge cases, and a corrected plan. Task and plan: " +``` -3. **Be specific and actionable.** Every finding should include: - - What the issue is - - Why it matters (what could go wrong) - - Where it is (file and line) - - A suggested fix or direction +Do not run `codex --help`, `codex exec --help`, or `codex review --help` first. Inspect help only if the exact command fails with an unknown-option error. Verify Codex findings yourself before including them. -4. **Verify claims with evidence.** Don't speculate — trace the code path. If you say something is a bug, show the scenario that triggers it. If you say something is safe, explain why. +Report: +- **Plan verdict** — PLAN APPROVED or REVISE PLAN +- **Findings** — BLOCK / CHANGE / NIT with concrete evidence +- **Adversarial pass** — Confirmed and dismissed Codex findings, or "not run" with reason +- **Implementation brief** (under 300 words) — Relevant files with line refs, existing patterns to follow, helpers to reuse, proposed concrete steps, risks, and tests/validation needed -5. **Check what's missing.** Good reviews catch omissions: - - Missing error handling at system boundaries - - Missing tests for new behavior - - Missing migration steps or documentation updates - - Incomplete rollback paths +# FULL REVIEW Mode -## What You Produce +1. Read `git diff main..HEAD`, `git diff` for uncommitted work, and the affected files. Understand what changed before judging it. +2. Form your own findings first. +3. Run the adversarial Codex pass: +``` +codex -C "" review --uncommitted "Review the uncommitted changes for bugs, regressions, edge cases, security issues, and missing tests. Be adversarial. Do not edit files. Report findings with file names, line numbers, and evidence." +``` -Structure your review as: -- **Summary**: One paragraph on what the change does and your overall assessment -- **Critical issues**: Must fix before merge (bugs, security, data integrity) -- **Suggestions**: Would improve the code but not blocking -- **Questions**: Things you're unsure about that the author should clarify +If the changes are already committed and nothing is uncommitted, use `--base main`: +``` +codex -C "" review --base main "Review this branch diff against main for bugs, regressions, edge cases, security issues, and missing tests. Be adversarial. Do not edit files. Report findings with file names, line numbers, and evidence." +``` -If the code looks good, say so clearly and briefly. Don't manufacture findings."#, +Do not tell Codex what you think of the code or what you expect it to find — anchored reviews catch nothing. + +4. Verify Codex findings yourself: +- Valid finding: include it, note it came from the adversarial pass. +- False positive: dismiss it with concise reasoning. +- Finding you discovered independently: include it normally. + +Classify each finding by severity (see Severity below). + +Report: +- **Findings** grouped BLOCK / CHANGE / NIT +- **File and line references** with concrete evidence +- **Adversarial pass** — confirmed and dismissed Codex findings +- **Verdict** — APPROVE or REQUEST CHANGES + +# VERIFY Mode + +1. Read the current diff and the affected files. +2. Check only the BLOCK items Kit listed. +3. If the fix added more than 20 lines of new logic beyond the flagged items, escalate to FULL REVIEW and say so before running Codex. +4. Do not re-raise prior CHANGE or NIT items. + +Report: +- **VERIFIED** — all listed BLOCK items addressed +or +- **STILL BLOCKED** — exact remaining BLOCK items with evidence + +# Severity + +- **BLOCK** — Correctness bug, regression, security issue, data-loss risk, broken test, or serious architecture violation. Must fix and triggers VERIFY. +- **CHANGE** — Legitimate issue Kit can fix and self-verify. No re-review unless broad. +- **NIT** — Optional polish. Ship as-is unless worth addressing. + +When in doubt between BLOCK and CHANGE, pick CHANGE. Reserve BLOCK for issues that would actually bite. + +# Active Assignment Guard + +You may have only one active review at a time. If Kit @mentions you again with the same worktree and same request before you report COMPLETE or BLOCKED, treat it as clarification — don't restart unless Kit explicitly says RESTART REVIEW. + +If the repeated message changes scope, ask Kit whether it replaces the active assignment. + +# Autonomy + +You are read-only, but you still resolve questions yourself before pinging Kit: + +1. **Read more context** — Files you haven't looked at, related call sites, recent commits. +2. **Run an adversarial pass** — Codex review or your own deeper read. +3. **Spawn a research subagent** — When you need to investigate a tangent without polluting your main context. +4. **Make a judgment call and document it** — If a finding is ambiguous, classify conservatively (CHANGE rather than BLOCK in close calls) and explain your reasoning. + +`@kit` follows the @-Mention Discipline rules at the top of this prompt — only on the final report or a specific blocker, with content attached. + +# Communication + +- **Acknowledge** — One short sentence when starting (e.g., "Starting plan review on the auth middleware change"). +- For longer reviews, post a one-line progress update roughly every 15 minutes so Kit knows you're alive. +- **Report** — Post one channel message with the structured output for your mode, and @mention @kit once. +- Don't restate the assignment back. Just deliver the findings. + +# What You Don't Do + +- You don't write code or modify files. +- You don't make architectural decisions — you surface options and trade-offs. +- You don't review your own work. + +# Standalone Mode + +If you're invoked outside a Sprout team channel (or by an agent that isn't Kit), apply the same protocols. Default to FULL REVIEW for completed work or PLAN REVIEW + RESEARCH for plans. Report to whoever invoked you. + +Your name is Scout. You are friendly and helpful. You are understated, but have a sense of humor."#, }, ]; @@ -404,7 +471,7 @@ fn built_in_persona_records(now: &str) -> Vec { model: None, name_pool: persona.name_pool.iter().map(|s| s.to_string()).collect(), is_builtin: true, - is_active: false, + is_active: true, source_pack: None, source_pack_persona_slug: None, created_at: now.to_string(), @@ -471,6 +538,19 @@ fn merge_personas(mut stored: Vec, now: &str) -> (Vec = records .iter() .map(|record| record.display_name.as_str()) .collect(); - assert_eq!(display_names, vec!["Solo", "Ralph", "Scout", "Reviewer"]); + assert_eq!(display_names, vec!["Solo", "Kit", "Scout"]); } #[test] @@ -48,7 +48,7 @@ fn merge_personas_preserves_custom_records() { #[test] fn merge_personas_restores_builtin_defaults() { - let mut edited_builtin = custom_persona("builtin:reviewer", "My Reviewer"); + let mut edited_builtin = custom_persona("builtin:solo", "My Solo"); edited_builtin.is_builtin = true; edited_builtin.is_active = true; let original_created_at = edited_builtin.created_at.clone(); @@ -57,15 +57,19 @@ fn merge_personas_restores_builtin_defaults() { let (records, changed) = merge_personas(vec![edited_builtin], "2026-03-19T00:00:00Z"); assert!(changed); - let reviewer = records + let solo = records .iter() - .find(|record| record.id == "builtin:reviewer") - .expect("reviewer built-in should exist"); - assert_eq!(reviewer.display_name, "Reviewer"); - assert_eq!(reviewer.avatar_url, None); - assert_eq!(reviewer.created_at, original_created_at); - assert_eq!(reviewer.updated_at, original_updated_at); - assert!(reviewer.is_active); + .find(|record| record.id == "builtin:solo") + .expect("solo built-in should exist"); + let canonical = BUILT_IN_PERSONAS + .iter() + .find(|persona| persona.id == "builtin:solo") + .expect("solo built-in definition should exist"); + assert_eq!(solo.display_name, canonical.display_name); + assert_eq!(solo.avatar_url.as_deref(), canonical.avatar_url,); + assert_eq!(solo.created_at, original_created_at); + assert_eq!(solo.updated_at, original_updated_at); + assert!(solo.is_active); } #[test] @@ -97,7 +101,7 @@ fn merge_personas_restores_builtin_name_pool_and_preserves_is_active() { #[test] fn merge_personas_backfills_new_builtins_for_existing_store() { - let mut legacy_builtins = vec![custom_persona("builtin:reviewer", "Reviewer")]; + let mut legacy_builtins = vec![custom_persona("builtin:solo", "Solo")]; for persona in &mut legacy_builtins { persona.is_builtin = true; persona.avatar_url = None; @@ -106,24 +110,43 @@ fn merge_personas_backfills_new_builtins_for_existing_store() { let (records, changed) = merge_personas(legacy_builtins, "2026-03-19T00:00:00Z"); assert!(changed); - assert!(records.iter().any(|record| record.id == "builtin:reviewer")); - assert!(records.iter().any(|record| record.id == "builtin:ralph")); + assert!(records.iter().any(|record| record.id == "builtin:kit")); assert!(records.iter().any(|record| record.id == "builtin:scout")); assert!(records.iter().any(|record| record.id == "builtin:solo")); assert!( records - .iter() - .find(|record| record.id == "builtin:reviewer") - .expect("reviewer built-in should exist") - .is_active - ); - assert!( - !records .iter() .find(|record| record.id == "builtin:solo") .expect("solo built-in should exist") .is_active ); + assert!( + records + .iter() + .find(|record| record.id == "builtin:kit") + .expect("kit built-in should exist") + .is_active + ); +} + +#[test] +fn merge_personas_demotes_retired_builtins() { + let mut retired = custom_persona("builtin:reviewer", "Reviewer"); + retired.is_builtin = true; + retired.is_active = true; + let original_created_at = retired.created_at.clone(); + + let (records, changed) = merge_personas(vec![retired], "2026-04-01T00:00:00Z"); + + assert!(changed); + let demoted = records + .iter() + .find(|record| record.id == "builtin:reviewer") + .expect("retired built-in should be retained as a custom persona"); + assert!(!demoted.is_builtin); + assert!(demoted.is_active); + assert_eq!(demoted.created_at, original_created_at); + assert_eq!(demoted.updated_at, "2026-04-01T00:00:00Z"); } #[test] diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 0b4df7e570..6125f9183e 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -408,7 +408,6 @@ pub fn build_managed_agent_summary( system_prompt: record.system_prompt.clone(), model: record.model.clone(), mcp_toolsets: record.mcp_toolsets.clone(), - has_api_token: record.api_token.is_some(), backend: record.backend.clone(), backend_agent_id: record.backend_agent_id.clone(), status, @@ -479,6 +478,7 @@ pub fn spawn_agent_child( if let Some(ref path) = augmented_path { command.env("PATH", path); } + command.env("RUST_LOG", child_rust_log_filter()); command.env("SPROUT_PRIVATE_KEY", &record.private_key_nsec); command.env("SPROUT_RELAY_URL", &record.relay_url); command.env("SPROUT_ACP_AGENT_COMMAND", &resolved_agent_command); @@ -550,15 +550,52 @@ pub fn spawn_agent_child( if let Some(toolsets) = &record.mcp_toolsets { command.env("SPROUT_TOOLSETS", toolsets); } else { - command.env_remove("SPROUT_TOOLSETS"); + command.env("SPROUT_TOOLSETS", "default,canvas,forums,dms,media"); } command.env_remove("SPROUT_ACP_PRIVATE_KEY"); command.env_remove("SPROUT_ACP_API_TOKEN"); + command.env_remove("SPROUT_API_TOKEN"); - if let Some(token) = &record.api_token { - command.env("SPROUT_API_TOKEN", token); + if let Some(ref auth_tag) = record.auth_tag { + command.env("SPROUT_AUTH_TAG", auth_tag); } else { - command.env_remove("SPROUT_API_TOKEN"); + command.env_remove("SPROUT_AUTH_TAG"); + } + + command.env("SPROUT_ACP_RELAY_OBSERVER", "true"); + + // ── Git credential helper for Sprout relay ────────────────────────── + // + // Agents need to clone/push repos hosted on the Sprout relay's git + // server, which authenticates via NIP-98. The `git-credential-nostr` + // binary signs auth events using the agent's nostr key. + // + // We configure git via GIT_CONFIG_COUNT env vars (ephemeral, no + // filesystem writes) scoped to the relay's git URL so we don't + // interfere with other remotes (e.g. GitHub). + // + // NOSTR_PRIVATE_KEY mirrors SPROUT_PRIVATE_KEY — keep in sync. + if let Some(cred_helper) = resolve_command("git-credential-nostr", Some(app)) { + let relay_http_url = crate::relay::relay_http_base_url(&record.relay_url); + + command.env("NOSTR_PRIVATE_KEY", &record.private_key_nsec); + command.env("GIT_TERMINAL_PROMPT", "0"); + command.env("GIT_CONFIG_COUNT", "2"); + command.env( + "GIT_CONFIG_KEY_0", + format!("credential.{relay_http_url}/git.helper"), + ); + command.env("GIT_CONFIG_VALUE_0", cred_helper.display().to_string()); + command.env( + "GIT_CONFIG_KEY_1", + format!("credential.{relay_http_url}/git.useHttpPath"), + ); + command.env("GIT_CONFIG_VALUE_1", "true"); + } else { + eprintln!( + "sprout-desktop: git-credential-nostr not found — agent {} will not have automatic Sprout git auth", + record.name, + ); } // Spawn the harness in its own process group so we can kill the entire @@ -582,6 +619,14 @@ pub fn spawn_agent_child( Ok((child, log_path)) } +fn child_rust_log_filter() -> String { + match std::env::var("RUST_LOG") { + Ok(existing) if existing.contains("sprout_acp") => existing, + Ok(existing) if !existing.trim().is_empty() => format!("{existing},sprout_acp=info"), + _ => "sprout_acp=info".to_string(), + } +} + pub fn start_managed_agent_process( app: &AppHandle, record: &mut ManagedAgentRecord, diff --git a/desktop/src-tauri/src/managed_agents/teams.rs b/desktop/src-tauri/src/managed_agents/teams.rs index e5dd392732..ff44556a59 100644 --- a/desktop/src-tauri/src/managed_agents/teams.rs +++ b/desktop/src-tauri/src/managed_agents/teams.rs @@ -2,7 +2,10 @@ use std::{fs, path::PathBuf}; use tauri::AppHandle; -use crate::managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord}; +use crate::{ + managed_agents::{managed_agents_base_dir, PersonaRecord, TeamRecord}, + util::now_iso, +}; // --------------------------------------------------------------------------- // Types @@ -28,24 +31,118 @@ fn teams_store_path(app: &AppHandle) -> Result { fn sort_teams(records: &mut [TeamRecord]) { records.sort_by(|left, right| { - left.name - .to_lowercase() - .cmp(&right.name.to_lowercase()) + let left_builtin = if left.is_builtin { 0 } else { 1 }; + let right_builtin = if right.is_builtin { 0 } else { 1 }; + left_builtin + .cmp(&right_builtin) + .then_with(|| left.name.to_lowercase().cmp(&right.name.to_lowercase())) .then_with(|| left.id.cmp(&right.id)) }); } -pub fn load_teams(app: &AppHandle) -> Result, String> { - let path = teams_store_path(app)?; - if !path.exists() { - return Ok(Vec::new()); +// --------------------------------------------------------------------------- +// Built-in teams +// --------------------------------------------------------------------------- + +struct BuiltInTeam { + id: &'static str, + name: &'static str, + description: Option<&'static str>, + persona_ids: &'static [&'static str], +} + +const BUILT_IN_TEAMS: &[BuiltInTeam] = &[BuiltInTeam { + id: "builtin-team:kit-scout", + name: "Kit & Scout", + description: Some("Kit orchestrates and builds; Scout researches, plans, and reviews."), + persona_ids: &["builtin:kit", "builtin:scout"], +}]; + +fn built_in_team_records(now: &str) -> Vec { + BUILT_IN_TEAMS + .iter() + .map(|team| TeamRecord { + id: team.id.to_string(), + name: team.name.to_string(), + description: team.description.map(|s| s.to_string()), + persona_ids: team.persona_ids.iter().map(|s| s.to_string()).collect(), + is_builtin: true, + created_at: now.to_string(), + updated_at: now.to_string(), + }) + .collect() +} + +fn built_in_team_order(id: &str) -> Option { + BUILT_IN_TEAMS.iter().position(|team| team.id == id) +} + +/// Add missing built-in teams, demote stale built-ins, and preserve any +/// user customizations to existing built-in teams (name, description, +/// persona membership). Returns the merged list and whether the store +/// changed. +fn merge_teams(mut stored: Vec, now: &str) -> (Vec, bool) { + let mut changed = false; + + for built_in in built_in_team_records(now) { + if let Some(existing) = stored.iter_mut().find(|record| record.id == built_in.id) { + if !existing.is_builtin { + existing.is_builtin = true; + existing.updated_at = now.to_string(); + changed = true; + } + } else { + stored.push(built_in); + changed = true; + } } - let content = fs::read_to_string(&path) - .map_err(|error| format!("failed to read teams store: {error}"))?; - let mut records: Vec = serde_json::from_str(&content) - .map_err(|error| format!("failed to parse teams store: {error}"))?; + // Demote any stored team flagged as built-in whose id is no longer in + // BUILT_IN_TEAMS (e.g. a built-in that has been retired). The record + // stays so existing references keep working; it becomes a user-owned + // custom team they can edit or delete. + for record in stored.iter_mut() { + if record.is_builtin && built_in_team_order(&record.id).is_none() { + record.is_builtin = false; + record.updated_at = now.to_string(); + changed = true; + } + } + + (stored, changed) +} + +/// Reject deletion of built-in teams. Mirrors `validate_persona_deletion` +/// for personas — built-ins always come back via `merge_teams` on the +/// next load, so blocking the delete avoids a confusing "keeps coming +/// back" UX. +pub fn validate_team_deletion(team: &TeamRecord) -> Result<(), String> { + if team.is_builtin { + return Err("Built-in teams cannot be deleted.".to_string()); + } + Ok(()) +} + +pub fn load_teams(app: &AppHandle) -> Result, String> { + let path = teams_store_path(app)?; + let now = now_iso(); + + let records = if path.exists() { + let content = fs::read_to_string(&path) + .map_err(|error| format!("failed to read teams store: {error}"))?; + serde_json::from_str::>(&content) + .map_err(|error| format!("failed to parse teams store: {error}"))? + } else { + Vec::new() + }; + + let (mut records, changed) = merge_teams(records, &now); sort_teams(&mut records); + + if changed || !path.exists() { + save_teams(app, &records)?; + } + Ok(records) } @@ -179,7 +276,10 @@ pub fn parse_team_json(json_bytes: &[u8]) -> Result { #[cfg(test)] mod tests { - use super::{encode_team_json, parse_team_json, sort_teams}; + use super::{ + encode_team_json, merge_teams, parse_team_json, sort_teams, validate_team_deletion, + BUILT_IN_TEAMS, + }; use crate::managed_agents::{PersonaRecord, TeamRecord}; fn team(id: &str, name: &str) -> TeamRecord { @@ -188,6 +288,7 @@ mod tests { name: name.to_string(), description: None, persona_ids: Vec::new(), + is_builtin: false, created_at: "2026-03-20T00:00:00Z".to_string(), updated_at: "2026-03-20T00:00:00Z".to_string(), } @@ -347,4 +448,93 @@ mod tests { assert!(parsed.personas.is_empty()); assert_eq!(parsed.name, "Solo"); } + + // ----------------------------------------------------------------------- + // merge_teams + validate_team_deletion tests + // ----------------------------------------------------------------------- + + #[test] + fn merge_teams_adds_missing_built_ins() { + let (records, changed) = merge_teams(Vec::new(), "2026-05-07T00:00:00Z"); + + assert!(changed); + assert_eq!(records.len(), BUILT_IN_TEAMS.len()); + assert!(records.iter().all(|record| record.is_builtin)); + let names: Vec<&str> = records.iter().map(|t| t.name.as_str()).collect(); + assert_eq!(names, vec!["Kit & Scout"]); + } + + #[test] + fn merge_teams_preserves_user_customizations_to_builtin() { + let mut customized = team("builtin-team:kit-scout", "Kit & Scout (mine)"); + customized.is_builtin = true; + customized.persona_ids = vec!["builtin:kit".to_string()]; + + let (records, _changed) = merge_teams(vec![customized], "2026-05-07T00:00:00Z"); + + let kit_scout = records + .iter() + .find(|t| t.id == "builtin-team:kit-scout") + .expect("Kit & Scout built-in should exist"); + assert_eq!(kit_scout.name, "Kit & Scout (mine)"); + assert_eq!(kit_scout.persona_ids, vec!["builtin:kit".to_string()]); + assert!(kit_scout.is_builtin); + } + + #[test] + fn merge_teams_preserves_unrelated_user_teams() { + let user_team = team("user-uuid", "My Team"); + let (records, _changed) = merge_teams(vec![user_team], "2026-05-07T00:00:00Z"); + + assert!(records.iter().any(|t| t.id == "user-uuid")); + assert!(records.iter().any(|t| t.id == "builtin-team:kit-scout")); + } + + #[test] + fn merge_teams_demotes_retired_built_ins() { + let mut retired = team("builtin-team:legacy", "Legacy"); + retired.is_builtin = true; + + let (records, changed) = merge_teams(vec![retired], "2026-05-07T00:00:00Z"); + + assert!(changed); + let demoted = records + .iter() + .find(|t| t.id == "builtin-team:legacy") + .expect("retired built-in should be retained as a custom team"); + assert!(!demoted.is_builtin); + assert_eq!(demoted.updated_at, "2026-05-07T00:00:00Z"); + } + + #[test] + fn merge_teams_repromotes_existing_builtin_marked_as_custom() { + // If someone hand-edits the store and flips is_builtin to false on a + // canonical built-in id, merge_teams should restore the flag. + let mut downgraded = team("builtin-team:kit-scout", "Kit & Scout"); + downgraded.is_builtin = false; + + let (records, changed) = merge_teams(vec![downgraded], "2026-05-07T00:00:00Z"); + + assert!(changed); + let kit_scout = records + .iter() + .find(|t| t.id == "builtin-team:kit-scout") + .expect("Kit & Scout should exist"); + assert!(kit_scout.is_builtin); + } + + #[test] + fn validate_team_deletion_rejects_built_ins() { + let mut built_in = team("builtin-team:kit-scout", "Kit & Scout"); + built_in.is_builtin = true; + + let err = validate_team_deletion(&built_in).unwrap_err(); + assert_eq!(err, "Built-in teams cannot be deleted."); + } + + #[test] + fn validate_team_deletion_allows_custom_teams() { + let custom = team("user-uuid", "My Team"); + assert!(validate_team_deletion(&custom).is_ok()); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index af73e8a066..2258925b26 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -67,7 +67,13 @@ pub struct ManagedAgentRecord { #[serde(default)] pub persona_id: Option, pub private_key_nsec: String, - pub api_token: Option, + /// NIP-OA auth tag JSON. Computed at agent creation time. + /// + /// Pre-existing agents created before NIP-OA will have `None` here. + /// This is intentional — they continue to work without attestation. + /// Re-attestation requires agent recreation (v2 migration scope). + #[serde(default)] + pub auth_tag: Option, pub relay_url: String, pub acp_command: String, pub agent_command: String, @@ -139,7 +145,6 @@ pub struct ManagedAgentSummary { pub system_prompt: Option, pub model: Option, pub mcp_toolsets: Option, - pub has_api_token: bool, pub backend: BackendKind, pub backend_agent_id: Option, pub status: String, @@ -175,11 +180,6 @@ pub struct CreateManagedAgentRequest { pub model: Option, pub mcp_toolsets: Option, #[serde(default)] - pub mint_token: bool, - #[serde(default)] - pub token_scopes: Vec, - pub token_name: Option, - #[serde(default)] pub spawn_after_create: bool, #[serde(default = "default_start_on_app_launch")] pub start_on_app_launch: bool, @@ -191,7 +191,6 @@ pub struct CreateManagedAgentRequest { pub struct CreateManagedAgentResponse { pub agent: ManagedAgentSummary, pub private_key_nsec: String, - pub api_token: Option, pub profile_sync_error: Option, pub spawn_error: Option, } @@ -225,21 +224,6 @@ pub struct UpdatePersonaRequest { pub name_pool: Vec, } -#[derive(Debug, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct MintManagedAgentTokenRequest { - pub pubkey: String, - pub token_name: Option, - #[serde(default)] - pub scopes: Vec, -} - -#[derive(Debug, Serialize)] -pub struct MintManagedAgentTokenResponse { - pub agent: ManagedAgentSummary, - pub token: String, -} - #[derive(Debug, Serialize)] pub struct ManagedAgentLogResponse { pub content: String, @@ -349,6 +333,8 @@ pub struct TeamRecord { pub name: String, pub description: Option, pub persona_ids: Vec, + #[serde(default)] + pub is_builtin: bool, pub created_at: String, pub updated_at: String, } @@ -395,7 +381,7 @@ fn default_record_active() -> bool { #[cfg(test)] mod tests { - use super::PersonaRecord; + use super::{ManagedAgentRecord, PersonaRecord}; #[test] fn persona_record_defaults_active_when_field_is_missing() { @@ -417,4 +403,72 @@ mod tests { assert_eq!(record.model, None); assert!(record.name_pool.is_empty()); } + + /// Legacy agent records (created before NIP-OA) lack the `auth_tag` field. + /// `#[serde(default)]` must ensure they deserialize with `auth_tag: None`. + #[test] + fn managed_agent_record_without_auth_tag_deserializes() { + let record: ManagedAgentRecord = serde_json::from_str( + r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "relay_url": "wss://localhost:3000", + "acp_command": "sprout-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "sprout-mcp-server", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#, + ) + .expect("legacy agent record without auth_tag should deserialize"); + + assert_eq!(record.auth_tag, None); + assert_eq!(record.pubkey, "abcd1234"); + } + + /// Agent records WITH an auth_tag round-trip correctly through serde. + #[test] + fn managed_agent_record_with_auth_tag_round_trips() { + let json = r#"{ + "pubkey": "abcd1234", + "name": "test-agent", + "private_key_nsec": "nsec1fake", + "auth_tag": "[\"auth\",\"deadbeef\",\"\",\"cafebabe\"]", + "relay_url": "wss://localhost:3000", + "acp_command": "sprout-acp", + "agent_command": "goose", + "agent_args": [], + "mcp_command": "sprout-mcp-server", + "turn_timeout_seconds": 320, + "system_prompt": null, + "created_at": "2026-01-01T00:00:00Z", + "updated_at": "2026-01-01T00:00:00Z", + "last_started_at": null, + "last_stopped_at": null, + "last_exit_code": null, + "last_error": null + }"#; + + let record: ManagedAgentRecord = + serde_json::from_str(json).expect("record with auth_tag should deserialize"); + + assert_eq!( + record.auth_tag.as_deref(), + Some(r#"["auth","deadbeef","","cafebabe"]"#) + ); + + // Round-trip: serialize and deserialize again. + let serialized = serde_json::to_string(&record).expect("should serialize"); + let record2: ManagedAgentRecord = + serde_json::from_str(&serialized).expect("round-trip should deserialize"); + assert_eq!(record.auth_tag, record2.auth_tag); + } } diff --git a/desktop/src-tauri/src/media_proxy.rs b/desktop/src-tauri/src/media_proxy.rs index 23bedb4c1c..75402dc453 100644 --- a/desktop/src-tauri/src/media_proxy.rs +++ b/desktop/src-tauri/src/media_proxy.rs @@ -7,7 +7,7 @@ use axum::{ Router, }; use futures_util::TryStreamExt; -use tauri::http; +use tauri::{http, Manager}; use tokio::net::TcpListener; use crate::app_state::AppState; @@ -22,7 +22,7 @@ const MAX_PROXY_RESPONSE: u64 = 20 * 1024 * 1024; #[derive(Clone)] struct ProxyState { client: reqwest::Client, - base_url: String, + app_handle: tauri::AppHandle, } async fn proxy_handler(AxumState(state): AxumState, req: Request) -> Response { @@ -44,8 +44,10 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> .map(|pq| pq.as_str()) .unwrap_or("/"); - // Strip the /media/ prefix check — we only mount on /media/ - let upstream_url = format!("{}{path_and_query}", state.base_url); + // Resolve relay URL dynamically so workspace switches take effect immediately. + let app_state = state.app_handle.state::(); + let base_url = relay::relay_api_base_url_with_override(&app_state); + let upstream_url = format!("{base_url}{path_and_query}"); let has_range = req.headers().contains_key("range"); @@ -111,10 +113,10 @@ async fn proxy_handler(AxumState(state): AxumState, req: Request) -> /// Spawn a localhost HTTP proxy that streams media via reqwest, avoiding the /// Tauri protocol handler's requirement to buffer the entire response into /// `Vec`. Returns the OS-assigned port. -pub async fn spawn_media_proxy(http_client: reqwest::Client) -> u16 { +pub async fn spawn_media_proxy(http_client: reqwest::Client, app_handle: tauri::AppHandle) -> u16 { let proxy_state = ProxyState { client: http_client, - base_url: relay::relay_api_base_url(), + app_handle, }; let app = Router::new() @@ -146,7 +148,7 @@ pub async fn handle_sprout_media( use tauri::Manager; let state = app.state::(); - let base = relay::relay_api_base_url(); + let base = relay::relay_api_base_url_with_override(&state); // Preserve path + query (thumbnails may have query params). // Only proxy /media/ paths — reject anything else. diff --git a/desktop/src-tauri/src/models.rs b/desktop/src-tauri/src/models.rs index ced3e08632..4a3f359160 100644 --- a/desktop/src-tauri/src/models.rs +++ b/desktop/src-tauri/src/models.rs @@ -124,7 +124,10 @@ pub struct ChannelDetailInfo { pub struct ChannelMemberInfo { pub pubkey: String, pub role: String, - pub joined_at: String, + /// Optional — kind:39002 events do not carry per-member join timestamps, + /// so this is `None` when populated from a NIP-29 members event. + #[serde(default)] + pub joined_at: Option, pub display_name: Option, } @@ -134,85 +137,6 @@ pub struct ChannelMembersResponse { pub next_cursor: Option, } -#[derive(Serialize)] -pub struct OpenDmBody<'a> { - pub pubkeys: &'a [String], -} - -#[derive(Deserialize)] -pub struct OpenDmResponse { - pub channel_id: String, -} - -#[derive(Serialize)] -pub struct SetPresenceBody { - pub status: PresenceStatus, -} - -#[derive(Serialize)] -pub struct GetFeedQuery<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - pub since: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub types: Option<&'a str>, -} - -#[derive(Serialize)] -pub struct SearchQueryParams<'a> { - pub q: &'a str, - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, -} - -#[derive(Serialize)] -pub struct MintTokenBody<'a> { - pub name: &'a str, - pub scopes: &'a [String], - #[serde(skip_serializing_if = "Option::is_none")] - pub channel_ids: Option<&'a [String]>, - #[serde(skip_serializing_if = "Option::is_none")] - pub expires_in_days: Option, - /// Owner pubkey (hex). Only accepted via NIP-98 auth. - /// Sets agent_owner_pubkey on the agent's user record. - #[serde(skip_serializing_if = "Option::is_none")] - pub owner_pubkey: Option<&'a str>, -} - -#[derive(Serialize, Deserialize)] -pub struct MintTokenResponse { - pub id: String, - pub token: String, - pub name: String, - pub scopes: Vec, - pub channel_ids: Vec, - pub created_at: String, - pub expires_at: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct TokenInfo { - pub id: String, - pub name: String, - pub scopes: Vec, - pub channel_ids: Vec, - pub created_at: String, - pub expires_at: Option, - pub last_used_at: Option, - pub revoked_at: Option, -} - -#[derive(Serialize, Deserialize)] -pub struct ListTokensResponse { - pub tokens: Vec, -} - -#[derive(Serialize, Deserialize)] -pub struct RevokeAllTokensResponse { - pub revoked_count: u64, -} - #[derive(Serialize, Deserialize)] pub struct FeedItemInfo { pub id: String, @@ -222,6 +146,8 @@ pub struct FeedItemInfo { pub created_at: u64, pub channel_id: Option, pub channel_name: String, + #[serde(default)] + pub channel_type: Option, pub tags: Vec>, pub category: String, } @@ -274,21 +200,6 @@ pub struct SendChannelMessageResponse { pub created_at: i64, } -#[derive(Serialize)] -pub struct GetUsersBatchBody<'a> { - pub pubkeys: &'a [String], -} - -#[derive(Serialize)] -pub struct GetUserNotesQuery<'a> { - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub before: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub before_id: Option<&'a str>, -} - #[derive(Serialize, Deserialize)] pub struct ThreadSummary { pub reply_count: u32, @@ -343,23 +254,6 @@ pub struct ForumThreadResponse { pub next_cursor: Option, } -#[derive(Serialize)] -pub struct GetForumPostsQuery { - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub before: Option, - pub with_threads: bool, -} - -#[derive(Serialize)] -pub struct GetForumThreadQuery { - #[serde(skip_serializing_if = "Option::is_none")] - pub limit: Option, - #[serde(skip_serializing_if = "Option::is_none")] - pub cursor: Option, -} - fn deserialize_null_string_as_empty<'de, D>(deserializer: D) -> Result where D: Deserializer<'de>, diff --git a/desktop/src-tauri/src/nostr_convert.rs b/desktop/src-tauri/src/nostr_convert.rs new file mode 100644 index 0000000000..1105a0d27b --- /dev/null +++ b/desktop/src-tauri/src/nostr_convert.rs @@ -0,0 +1,857 @@ +//! Nostr event → desktop model converters. +//! +//! These pure functions translate raw Nostr protocol events into the +//! model types expected by the Tauri frontend commands. +//! +//! All converters here are I/O-free and deterministic — they take owned +//! or borrowed events and return models. This makes them trivially +//! testable with hand-crafted events (see the `tests` module below). + +use std::collections::{BTreeSet, HashMap}; + +use nostr::Event; +use serde_json::{json, Value}; + +use crate::models::*; + +// ── Tag helpers ───────────────────────────────────────────────────────────── + +/// Find the first tag whose name matches `name` and return its first value. +/// +/// e.g. for tag `["name", "general"]` with `name="name"` returns `Some("general")`. +fn first_tag_value<'a>(event: &'a Event, name: &str) -> Option<&'a str> { + for tag in event.tags.iter() { + let s = tag.as_slice(); + if s.len() >= 2 && s[0] == name { + return Some(s[1].as_str()); + } + } + None +} + +/// Return true if the event has a tag with the given name (any value). +fn has_tag(event: &Event, name: &str) -> bool { + event + .tags + .iter() + .any(|t| t.as_slice().first().map(|s| s.as_str()) == Some(name)) +} + +/// Iterate every tag whose name matches `name`, returning the full slice. +fn tags_named<'a>(event: &'a Event, name: &'a str) -> impl Iterator + 'a { + event.tags.iter().filter_map(move |t| { + let s = t.as_slice(); + if !s.is_empty() && s[0] == name { + Some(s) + } else { + None + } + }) +} + +// ── kind:39000 / 39002 (NIP-29) ───────────────────────────────────────────── + +/// Convert a NIP-29 kind:39000 channel metadata event to [`ChannelInfo`]. +/// +/// Optionally merges with a kind:40901 channel summary sidecar event for +/// `member_count` and `last_message_at`. +pub fn channel_info_from_event( + event: &Event, + summary: Option<&Event>, + is_member: Option, +) -> Result { + let id = first_tag_value(event, "d") + .ok_or_else(|| "kind:39000 missing required `d` tag".to_string())? + .to_string(); + + let name = first_tag_value(event, "name").unwrap_or("").to_string(); + let description = first_tag_value(event, "about").unwrap_or("").to_string(); + let topic = first_tag_value(event, "topic").map(str::to_string); + let purpose = first_tag_value(event, "purpose").map(str::to_string); + // Prefer explicit ["t", type] tag; fall back to inferring from ["hidden"] + // (= dm) for relays that don't yet emit the type tag. + let channel_type = first_tag_value(event, "t") + .map(str::to_string) + .unwrap_or_else(|| { + if has_tag(event, "hidden") { + "dm".to_string() + } else { + "stream".to_string() + } + }); + // Prefer explicit ["public"] tag; fall back to NIP-29's absence-of-"private" + // convention for relays that don't yet emit the explicit tag. + let visibility = if has_tag(event, "public") { + "open".to_string() + } else if has_tag(event, "private") { + "private".to_string() + } else { + "open".to_string() + }; + + // For DM-type channels, p-tags identify the participants. + let participant_pubkeys: Vec = tags_named(event, "p") + .filter_map(|s| s.get(1).cloned()) + .collect(); + let participants = participant_pubkeys.clone(); + + // Summary sidecar carries member_count + last_message_at as JSON content. + let (member_count, last_message_at) = if let Some(s) = summary { + let v: Value = serde_json::from_str(&s.content).unwrap_or(Value::Null); + let mc = v.get("member_count").and_then(Value::as_i64).unwrap_or(0); + let lma = v + .get("last_message_at") + .and_then(Value::as_str) + .map(str::to_string); + (mc, lma) + } else { + (0, None) + }; + + // If the relay emits ["archived", "true"], surface it as a timestamp placeholder + // so the frontend knows the channel is archived. The exact timestamp isn't available + // from the tag alone, so we use the event's created_at as a proxy. + let archived_at = if first_tag_value(event, "archived") == Some("true") { + Some(timestamp_to_iso(event.created_at.as_u64())) + } else { + None + }; + + // Ephemeral channel TTL — relay emits ["ttl", ""] and ["ttl_deadline", ""]. + let ttl_seconds = first_tag_value(event, "ttl").and_then(|v| v.parse::().ok()); + let ttl_deadline = first_tag_value(event, "ttl_deadline").map(str::to_string); + + Ok(ChannelInfo { + id, + name, + channel_type, + visibility, + description, + topic, + purpose, + member_count, + last_message_at, + archived_at, + participants, + participant_pubkeys, + is_member: is_member.unwrap_or(true), + ttl_seconds, + ttl_deadline, + }) +} + +/// Convert a NIP-29 kind:39000 event to [`ChannelDetailInfo`]. +pub fn channel_detail_from_event(event: &Event) -> Result { + let id = first_tag_value(event, "d") + .ok_or_else(|| "kind:39000 missing required `d` tag".to_string())? + .to_string(); + + let name = first_tag_value(event, "name").unwrap_or("").to_string(); + let description = first_tag_value(event, "about").unwrap_or("").to_string(); + let topic = first_tag_value(event, "topic").map(str::to_string); + let purpose = first_tag_value(event, "purpose").map(str::to_string); + // Prefer explicit ["t", type]; fall back to ["hidden"] = dm, else "stream". + let channel_type = first_tag_value(event, "t") + .map(str::to_string) + .unwrap_or_else(|| { + if has_tag(event, "hidden") { + "dm".to_string() + } else { + "stream".to_string() + } + }); + // Prefer explicit ["public"]; fall back to NIP-29 absence-of-"private". + let visibility = if has_tag(event, "public") { + "open".to_string() + } else if has_tag(event, "private") { + "private".to_string() + } else { + "open".to_string() + }; + + let created_at_iso = timestamp_to_iso(event.created_at.as_u64()); + + let archived_at = if first_tag_value(event, "archived") == Some("true") { + Some(timestamp_to_iso(event.created_at.as_u64())) + } else { + None + }; + + Ok(ChannelDetailInfo { + id, + name, + channel_type, + visibility, + description, + topic, + topic_set_by: None, + topic_set_at: None, + purpose, + purpose_set_by: None, + purpose_set_at: None, + created_by: event.pubkey.to_hex(), + created_at: created_at_iso.clone(), + updated_at: created_at_iso, + archived_at, + member_count: 0, + topic_required: false, + max_members: None, + nip29_group_id: None, + ttl_seconds: first_tag_value(event, "ttl").and_then(|v| v.parse::().ok()), + ttl_deadline: first_tag_value(event, "ttl_deadline").map(str::to_string), + }) +} + +/// Convert a NIP-29 kind:39002 members event to [`ChannelMembersResponse`]. +/// +/// Members come from p-tags shaped as `["p", pubkey, relay_url?, role?]`. +/// Role defaults to `"member"` when absent. `joined_at` is `None` because +/// kind:39002 does not carry per-member join timestamps. +pub fn channel_members_from_event(event: &Event) -> Result { + // Validate that this is a members event (`d` tag identifies the channel). + if first_tag_value(event, "d").is_none() { + return Err("kind:39002 missing required `d` tag".to_string()); + } + + let mut seen = BTreeSet::new(); + let mut members = Vec::new(); + for slice in tags_named(event, "p") { + let Some(pubkey) = slice.get(1) else { continue }; + if pubkey.is_empty() || !seen.insert(pubkey.clone()) { + continue; + } + let role = slice + .get(3) + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| "member".to_string()); + members.push(ChannelMemberInfo { + pubkey: pubkey.clone(), + role, + joined_at: None, + display_name: None, + }); + } + + Ok(ChannelMembersResponse { + members, + next_cursor: None, + }) +} + +// ── kind:0 (profile metadata) ─────────────────────────────────────────────── + +/// Convert a kind:0 metadata event to [`ProfileInfo`]. +/// +/// The event's `content` is a JSON object per NIP-01: +/// `{"name":"...","display_name":"...","picture":"...","about":"...","nip05":"..."}`. +pub fn profile_info_from_event(event: &Event) -> Result { + let v: Value = serde_json::from_str(&event.content) + .map_err(|e| format!("kind:0 content is not valid JSON: {e}"))?; + + let display_name = v + .get("display_name") + .and_then(Value::as_str) + .or_else(|| v.get("name").and_then(Value::as_str)) + .map(str::to_string); + let avatar_url = v.get("picture").and_then(Value::as_str).map(str::to_string); + let about = v.get("about").and_then(Value::as_str).map(str::to_string); + let nip05_handle = v.get("nip05").and_then(Value::as_str).map(str::to_string); + + Ok(ProfileInfo { + pubkey: event.pubkey.to_hex(), + display_name, + avatar_url, + about, + nip05_handle, + }) +} + +/// Convert multiple kind:0 events to [`UsersBatchResponse`]. +/// +/// `requested_pubkeys` lets us populate `missing` for any pubkey that had +/// no metadata event in the input set. +pub fn users_batch_from_events( + events: &[Event], + requested_pubkeys: &[String], +) -> UsersBatchResponse { + // Keep only the most recent kind:0 per pubkey. + let mut latest: HashMap = HashMap::new(); + for ev in events { + let pk = ev.pubkey.to_hex(); + let take = match latest.get(&pk) { + None => true, + Some(prev) => ev.created_at > prev.created_at, + }; + if take { + latest.insert(pk, ev); + } + } + + let mut profiles = HashMap::new(); + for (pk, ev) in &latest { + let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + let summary = UserProfileSummaryInfo { + display_name: v + .get("display_name") + .and_then(Value::as_str) + .or_else(|| v.get("name").and_then(Value::as_str)) + .map(str::to_string), + avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), + }; + profiles.insert(pk.clone(), summary); + } + + let missing: Vec = requested_pubkeys + .iter() + .filter(|pk| !profiles.contains_key(*pk)) + .cloned() + .collect(); + + UsersBatchResponse { profiles, missing } +} + +/// Convert kind:0 events (e.g. from a NIP-50 search) to [`SearchUsersResponse`]. +/// Convert a single kind:0 event to a [`UserSearchResultInfo`]. +pub fn user_search_result_from_event(ev: &Event) -> UserSearchResultInfo { + let v: Value = serde_json::from_str(&ev.content).unwrap_or(Value::Null); + UserSearchResultInfo { + pubkey: ev.pubkey.to_hex(), + display_name: v + .get("display_name") + .and_then(Value::as_str) + .or_else(|| v.get("name").and_then(Value::as_str)) + .map(str::to_string), + avatar_url: v.get("picture").and_then(Value::as_str).map(str::to_string), + nip05_handle: v.get("nip05").and_then(Value::as_str).map(str::to_string), + } +} + +pub fn search_users_from_events(events: &[Event]) -> SearchUsersResponse { + let users = events.iter().map(user_search_result_from_event).collect(); + SearchUsersResponse { users } +} + +// ── kind:1 (notes) ────────────────────────────────────────────────────────── + +/// Convert kind:1 events to [`UserNotesResponse`]. +/// +/// Notes are returned in the input order. The cursor is built from the +/// oldest note (last in newest-first ordering) so the caller can page back. +pub fn user_notes_from_events(events: &[Event]) -> UserNotesResponse { + let notes: Vec = events + .iter() + .map(|ev| UserNoteInfo { + id: ev.id.to_hex(), + pubkey: ev.pubkey.to_hex(), + created_at: ev.created_at.as_u64() as i64, + content: ev.content.clone(), + }) + .collect(); + + let next_cursor = notes.last().map(|n| UserNotesCursor { + before: n.created_at, + before_id: n.id.clone(), + }); + + UserNotesResponse { notes, next_cursor } +} + +// ── kind:3 (contact list) ─────────────────────────────────────────────────── + +/// Convert a kind:3 contact list event to [`ContactListResponse`]. +pub fn contact_list_from_event(event: &Event) -> Result { + let tags: Vec> = event.tags.iter().map(|t| t.as_slice().to_vec()).collect(); + + Ok(ContactListResponse { + id: event.id.to_hex(), + pubkey: event.pubkey.to_hex(), + created_at: event.created_at.as_u64() as i64, + tags, + content: event.content.clone(), + }) +} + +// ── NIP-50 search results ─────────────────────────────────────────────────── + +/// Convert search-result events (any kind) to [`SearchResponse`]. +/// +/// NIP-50 does not carry a relevance score on the wire; we use the input +/// position as a proxy: position 0 → score 1.0, dropping linearly to 0. +pub fn search_response_from_events(events: &[Event]) -> SearchResponse { + let total = events.len(); + let hits: Vec = events + .iter() + .enumerate() + .map(|(idx, ev)| { + // Channel id is stored on a NIP-29 `h` tag when present. + let channel_id = first_tag_value(ev, "h").map(str::to_string); + let score = if total <= 1 { + 1.0 + } else { + 1.0 - (idx as f64) / (total as f64) + }; + SearchHitInfo { + event_id: ev.id.to_hex(), + content: ev.content.clone(), + kind: ev.kind.as_u16() as u32, + pubkey: ev.pubkey.to_hex(), + channel_id, + channel_name: None, + created_at: ev.created_at.as_u64(), + score, + } + }) + .collect(); + + SearchResponse { + found: hits.len() as u64, + hits, + } +} + +// ── kind:10100 (agent profiles) ───────────────────────────────────────────── + +/// Convert kind:10100 agent profile events to the agent discovery format. +/// +/// Returns a JSON array of `{pubkey, name, ...}` objects parsed from each +/// event's content. +pub fn agents_from_events(events: &[Event]) -> Value { + let arr: Vec = events + .iter() + .map(|ev| { + let mut v: Value = serde_json::from_str(&ev.content).unwrap_or_else(|_| json!({})); + // Always overwrite the pubkey with the event author — it's the + // authoritative source even if the content claims otherwise. + if let Some(obj) = v.as_object_mut() { + obj.insert("pubkey".to_string(), json!(ev.pubkey.to_hex())); + } else { + v = json!({ "pubkey": ev.pubkey.to_hex() }); + } + v + }) + .collect(); + json!({ "agents": arr }) +} + +// ── kind:13534 (relay membership list) ────────────────────────────────────── + +/// Convert a kind:13534 relay membership list to the relay members format. +/// +/// The relay emits `["member", pubkey]` or `["member", pubkey, role]` tags. +/// For backward compatibility, also accepts `["p", pubkey, relay_url?, role?]`. +pub fn relay_members_from_event(event: &Event) -> Value { + let mut seen = BTreeSet::new(); + let mut members: Vec = Vec::new(); + + // Primary: parse ["member", pubkey, role?] tags (current relay format). + for slice in tags_named(event, "member") { + let Some(pubkey) = slice.get(1).filter(|s| !s.is_empty()) else { + continue; + }; + if !seen.insert(pubkey.clone()) { + continue; + } + let role = slice + .get(2) + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| "member".to_string()); + members.push(json!({ "pubkey": pubkey, "role": role })); + } + + // Fallback: parse ["p", pubkey, relay_url?, role?] tags (NIP-29 convention). + for slice in tags_named(event, "p") { + let Some(pubkey) = slice.get(1).filter(|s| !s.is_empty()) else { + continue; + }; + if !seen.insert(pubkey.clone()) { + continue; + } + let role = slice + .get(3) + .filter(|s| !s.is_empty()) + .cloned() + .unwrap_or_else(|| "member".to_string()); + members.push(json!({ "pubkey": pubkey, "role": role })); + } + + json!({ "members": members }) +} + +// ── Time helpers ──────────────────────────────────────────────────────────── + +/// Convert a unix-seconds timestamp to a UTC RFC-3339 string. +fn timestamp_to_iso(secs: u64) -> String { + use std::time::{Duration, SystemTime, UNIX_EPOCH}; + let dt = UNIX_EPOCH + Duration::from_secs(secs); + // Format manually as RFC-3339 — the `time` crate is already a transitive + // dep, but using SystemTime keeps this self-contained. + let dur = dt + .duration_since(SystemTime::UNIX_EPOCH) + .unwrap_or_default(); + let secs_total = dur.as_secs() as i64; + // Days since epoch, seconds within day. + let (days, sod) = (secs_total.div_euclid(86_400), secs_total.rem_euclid(86_400)); + let h = sod / 3600; + let m = (sod % 3600) / 60; + let s = sod % 60; + let (y, mo, d) = days_to_ymd(days); + format!("{y:04}-{mo:02}-{d:02}T{h:02}:{m:02}:{s:02}Z") +} + +/// Convert days-since-1970-01-01 to (year, month, day) using the civil-from-days +/// algorithm by Howard Hinnant (public domain). +fn days_to_ymd(days: i64) -> (i64, u32, u32) { + let z = days + 719_468; + let era = if z >= 0 { z } else { z - 146_096 } / 146_097; + let doe = (z - era * 146_097) as u64; // [0, 146096] + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146_096) / 365; // [0, 399] + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); // [0, 365] + let mp = (5 * doy + 2) / 153; // [0, 11] + let d = (doy - (153 * mp + 2) / 5 + 1) as u32; + let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32; + let y = if m <= 2 { y + 1 } else { y }; + (y, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr::{EventBuilder, Keys, Kind, Tag}; + + /// Build a signed event for testing with the given kind, content, and tags. + fn ev(kind: u16, content: &str, tags: Vec>) -> Event { + let keys = Keys::generate(); + let parsed: Vec = tags + .into_iter() + .map(|t| Tag::parse(t).expect("parse tag")) + .collect(); + EventBuilder::new(Kind::from_u16(kind), content) + .tags(parsed) + .sign_with_keys(&keys) + .expect("sign") + } + + #[test] + fn channel_info_minimal() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "chan-uuid-1"], + vec!["name", "general"], + vec!["about", "main channel"], + vec!["t", "stream"], + vec!["public"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.id, "chan-uuid-1"); + assert_eq!(info.name, "general"); + assert_eq!(info.description, "main channel"); + assert_eq!(info.channel_type, "stream"); + assert_eq!(info.visibility, "open"); + assert_eq!(info.member_count, 0); + assert!(info.is_member); + } + + #[test] + fn channel_info_private_when_private_tag_present() { + // Explicit ["private"] tag → private (NIP-29 convention). + let e = ev( + 39000, + "", + vec![ + vec!["d", "u"], + vec!["name", "n"], + vec!["t", "forum"], + vec!["private"], + ], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "private"); + assert_eq!(info.channel_type, "forum"); + } + + #[test] + fn channel_info_open_when_neither_public_nor_private() { + // Neither tag present → open (matches NIP-29 default). + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["t", "forum"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.visibility, "open"); + } + + #[test] + fn channel_info_dm_inferred_from_hidden_tag() { + // Fallback: relays without ["t", "dm"] still emit ["hidden"] for DMs. + let e = ev( + 39000, + "", + vec![vec!["d", "u"], vec!["name", "n"], vec!["hidden"]], + ); + let info = channel_info_from_event(&e, None, None).unwrap(); + assert_eq!(info.channel_type, "dm"); + } + + #[test] + fn channel_info_merges_summary() { + let chan = ev(39000, "", vec![vec!["d", "u"], vec!["name", "n"]]); + let summary = ev( + 40901, + r#"{"member_count": 7, "last_message_at": "2026-01-01T00:00:00Z"}"#, + vec![vec!["d", "u"]], + ); + let info = channel_info_from_event(&chan, Some(&summary), None).unwrap(); + assert_eq!(info.member_count, 7); + assert_eq!( + info.last_message_at.as_deref(), + Some("2026-01-01T00:00:00Z") + ); + } + + #[test] + fn channel_info_missing_d_errors() { + let e = ev(39000, "", vec![vec!["name", "n"]]); + assert!(channel_info_from_event(&e, None, None).is_err()); + } + + #[test] + fn channel_detail_basic() { + let e = ev( + 39000, + "", + vec![ + vec!["d", "uuid"], + vec!["name", "n"], + vec!["about", "desc"], + vec!["topic", "tt"], + vec!["purpose", "pp"], + vec!["t", "dm"], + ], + ); + let d = channel_detail_from_event(&e).unwrap(); + assert_eq!(d.id, "uuid"); + assert_eq!(d.topic.as_deref(), Some("tt")); + assert_eq!(d.purpose.as_deref(), Some("pp")); + assert_eq!(d.channel_type, "dm"); + assert!(d.created_at.ends_with("Z")); + assert_eq!(d.created_by, e.pubkey.to_hex()); + } + + #[test] + fn channel_members_extracts_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + let e = ev( + 39002, + "", + vec![ + vec!["d", "uuid"], + vec!["p", &pk1, "", "admin"], + vec!["p", &pk2], + // Duplicate must be deduped. + vec!["p", &pk1, "wss://x", "owner"], + ], + ); + let r = channel_members_from_event(&e).unwrap(); + assert_eq!(r.members.len(), 2); + assert_eq!(r.members[0].pubkey, pk1); + assert_eq!(r.members[0].role, "admin"); + assert!(r.members[0].joined_at.is_none()); + assert_eq!(r.members[1].role, "member"); // default + } + + #[test] + fn channel_members_missing_d_errors() { + let e = ev(39002, "", vec![]); + assert!(channel_members_from_event(&e).is_err()); + } + + #[test] + fn profile_info_parses_content() { + let e = ev( + 0, + r#"{"name":"alice","display_name":"Alice","picture":"http://x/a.png","about":"hi","nip05":"alice@x"}"#, + vec![], + ); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("Alice")); + assert_eq!(p.avatar_url.as_deref(), Some("http://x/a.png")); + assert_eq!(p.about.as_deref(), Some("hi")); + assert_eq!(p.nip05_handle.as_deref(), Some("alice@x")); + assert_eq!(p.pubkey, e.pubkey.to_hex()); + } + + #[test] + fn profile_info_falls_back_to_name() { + let e = ev(0, r#"{"name":"bob"}"#, vec![]); + let p = profile_info_from_event(&e).unwrap(); + assert_eq!(p.display_name.as_deref(), Some("bob")); + } + + #[test] + fn profile_info_invalid_json_errors() { + let e = ev(0, "not-json", vec![]); + assert!(profile_info_from_event(&e).is_err()); + } + + #[test] + fn users_batch_keeps_latest_and_reports_missing() { + let e1 = ev(0, r#"{"name":"old"}"#, vec![]); + // Same author, newer event with display_name. + let keys = Keys::generate(); + let e_old = EventBuilder::new(Kind::Metadata, r#"{"name":"old"}"#) + .custom_created_at(nostr::Timestamp::from(1000)) + .sign_with_keys(&keys) + .unwrap(); + let e_new = EventBuilder::new(Kind::Metadata, r#"{"display_name":"New"}"#) + .custom_created_at(nostr::Timestamp::from(2000)) + .sign_with_keys(&keys) + .unwrap(); + let pk = keys.public_key().to_hex(); + let other_pk = e1.pubkey.to_hex(); + + let missing_pk = "f".repeat(64); + let resp = users_batch_from_events( + &[e1, e_old, e_new], + &[pk.clone(), other_pk.clone(), missing_pk.clone()], + ); + assert_eq!(resp.profiles.len(), 2); + assert_eq!(resp.profiles[&pk].display_name.as_deref(), Some("New")); + assert_eq!(resp.missing, vec![missing_pk]); + } + + #[test] + fn search_users_maps_each_event() { + let e1 = ev(0, r#"{"name":"a"}"#, vec![]); + let e2 = ev(0, r#"{"display_name":"B"}"#, vec![]); + let r = search_users_from_events(&[e1, e2]); + assert_eq!(r.users.len(), 2); + assert_eq!(r.users[0].display_name.as_deref(), Some("a")); + assert_eq!(r.users[1].display_name.as_deref(), Some("B")); + } + + #[test] + fn user_notes_builds_cursor_from_last() { + let e1 = ev(1, "first", vec![]); + let e2 = ev(1, "second", vec![]); + let r = user_notes_from_events(&[e1, e2]); + assert_eq!(r.notes.len(), 2); + assert_eq!(r.notes[0].content, "first"); + let cursor = r.next_cursor.expect("cursor"); + assert_eq!(cursor.before_id, r.notes[1].id); + } + + #[test] + fn user_notes_empty_has_no_cursor() { + let r = user_notes_from_events(&[]); + assert!(r.notes.is_empty()); + assert!(r.next_cursor.is_none()); + } + + #[test] + fn contact_list_preserves_tags_and_content() { + let pk = "1".repeat(64); + let e = ev(3, "rel-json", vec![vec!["p", &pk]]); + let r = contact_list_from_event(&e).unwrap(); + assert_eq!(r.content, "rel-json"); + assert_eq!(r.tags.len(), 1); + assert_eq!(r.tags[0], vec!["p".to_string(), pk]); + } + + #[test] + fn search_response_assigns_descending_scores() { + let e1 = ev(1, "one", vec![vec!["h", "chan"]]); + let e2 = ev(1, "two", vec![]); + let r = search_response_from_events(&[e1, e2]); + assert_eq!(r.found, 2); + assert!(r.hits[0].score > r.hits[1].score); + assert_eq!(r.hits[0].channel_id.as_deref(), Some("chan")); + assert!(r.hits[1].channel_id.is_none()); + } + + #[test] + fn search_response_single_hit_full_score() { + let e = ev(1, "only", vec![]); + let r = search_response_from_events(&[e]); + assert_eq!(r.hits.len(), 1); + assert_eq!(r.hits[0].score, 1.0); + } + + #[test] + fn agents_overwrites_pubkey_from_event_author() { + let e = ev(10100, r#"{"pubkey":"forged","name":"agent-1"}"#, vec![]); + let v = agents_from_events(&[e.clone()]); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 1); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); + assert_eq!(arr[0].get("name").and_then(Value::as_str), Some("agent-1")); + } + + #[test] + fn agents_handles_invalid_content() { + let e = ev(10100, "not-json", vec![]); + let v = agents_from_events(&[e.clone()]); + let arr = v.get("agents").and_then(Value::as_array).unwrap(); + assert_eq!( + arr[0].get("pubkey").and_then(Value::as_str).unwrap(), + e.pubkey.to_hex() + ); + } + + #[test] + fn relay_members_dedupes_and_defaults_role() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Current relay format: ["member", pubkey, role] + let e = ev( + 13534, + "", + vec![ + vec!["member", &pk1, "owner"], + vec!["member", &pk2], + vec!["member", &pk1, "moderator"], // dupe — ignored + ], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("owner")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); + } + + #[test] + fn relay_members_fallback_p_tags() { + let pk1 = "a".repeat(64); + let pk2 = "b".repeat(64); + // Legacy/fallback format: ["p", pubkey, relay_url?, role?] + let e = ev( + 13534, + "", + vec![vec!["p", &pk1, "", "admin"], vec!["p", &pk2]], + ); + let v = relay_members_from_event(&e); + let arr = v.get("members").and_then(Value::as_array).unwrap(); + assert_eq!(arr.len(), 2); + assert_eq!(arr[0].get("role").and_then(Value::as_str), Some("admin")); + assert_eq!(arr[1].get("role").and_then(Value::as_str), Some("member")); + } + + #[test] + fn timestamp_to_iso_known_value() { + // 2021-01-01T00:00:00Z = 1609459200 + assert_eq!(timestamp_to_iso(1_609_459_200), "2021-01-01T00:00:00Z"); + // Epoch + assert_eq!(timestamp_to_iso(0), "1970-01-01T00:00:00Z"); + } +} diff --git a/desktop/src-tauri/src/prevent_sleep.rs b/desktop/src-tauri/src/prevent_sleep.rs new file mode 100644 index 0000000000..44ab218596 --- /dev/null +++ b/desktop/src-tauri/src/prevent_sleep.rs @@ -0,0 +1,156 @@ +use std::sync::{Arc, Mutex}; + +use tauri::{AppHandle, Emitter}; + +/// Tracks the macOS IOKit power assertion that prevents idle sleep +/// while local managed agents are running. +#[derive(Default)] +pub struct PreventSleepState { + assertion_id: Option, + timer_handle: Option>, +} + +// ── macOS implementation ──────────────────────────────────────────────────── + +#[cfg(target_os = "macos")] +mod macos { + #[link(name = "IOKit", kind = "framework")] + extern "C" { + pub fn IOPMAssertionCreateWithName( + assertion_type: *const std::ffi::c_void, // CFStringRef + level: u32, // IOPMAssertionLevel + name: *const std::ffi::c_void, // CFStringRef + assertion_id: *mut u32, // IOPMAssertionID + ) -> i32; // IOReturn + + pub fn IOPMAssertionRelease(assertion_id: u32) -> i32; + } + + #[link(name = "CoreFoundation", kind = "framework")] + extern "C" { + pub fn CFStringCreateWithCString( + alloc: *const std::ffi::c_void, + c_str: *const std::ffi::c_char, + encoding: u32, + ) -> *const std::ffi::c_void; + pub fn CFRelease(cf: *const std::ffi::c_void); + } +} + +#[cfg(target_os = "macos")] +const K_IOPM_ASSERTION_LEVEL_ON: u32 = 255; + +#[cfg(target_os = "macos")] +const K_CF_STRING_ENCODING_UTF8: u32 = 0x0800_0100; + +/// 4-hour cap in seconds. +const CAP_SECONDS: u64 = 4 * 3600; + +/// Create a `PreventUserIdleSystemSleep` assertion if not already held. +/// Starts a 4-hour timer that auto-releases and emits `prevent-sleep-expired`. +pub fn acquire( + state: &Arc>, + app_handle: &AppHandle, +) -> Result<(), String> { + let mut guard = state.lock().map_err(|e| e.to_string())?; + + // Idempotent — already held. + if guard.assertion_id.is_some() { + return Ok(()); + } + + #[cfg(target_os = "macos")] + { + let assertion_type = b"PreventUserIdleSystemSleep\0".as_ptr() as *const std::ffi::c_char; + let reason = b"Sprout \xe2\x80\x94 agents are active\0".as_ptr() as *const std::ffi::c_char; + + unsafe { + let cf_type = macos::CFStringCreateWithCString( + std::ptr::null(), + assertion_type, + K_CF_STRING_ENCODING_UTF8, + ); + let cf_reason = macos::CFStringCreateWithCString( + std::ptr::null(), + reason, + K_CF_STRING_ENCODING_UTF8, + ); + + if cf_type.is_null() || cf_reason.is_null() { + if !cf_type.is_null() { + macos::CFRelease(cf_type); + } + if !cf_reason.is_null() { + macos::CFRelease(cf_reason); + } + return Err("Failed to create CFString for IOKit assertion".into()); + } + + let mut assertion_id: u32 = 0; + let ret = macos::IOPMAssertionCreateWithName( + cf_type, + K_IOPM_ASSERTION_LEVEL_ON, + cf_reason, + &mut assertion_id, + ); + + macos::CFRelease(cf_type); + macos::CFRelease(cf_reason); + + if ret != 0 { + return Err(format!( + "IOPMAssertionCreateWithName failed with IOReturn {ret}" + )); + } + + guard.assertion_id = Some(assertion_id); + } + } + + // Start the 4-hour cap timer only if an assertion was actually created. + if guard.assertion_id.is_some() { + let handle = app_handle.clone(); + let timer_state = Arc::clone(state); + let timer_task = tauri::async_runtime::spawn(async move { + tokio::time::sleep(std::time::Duration::from_secs(CAP_SECONDS)).await; + release(&timer_state); + let _ = handle.emit("prevent-sleep-expired", ()); + }); + guard.timer_handle = Some(timer_task); + } + + Ok(()) +} + +/// Release the power assertion if held. Cancel the cap timer. +pub fn release(state: &Arc>) { + let mut guard = match state.lock() { + Ok(g) => g, + Err(_) => return, + }; + + if let Some(handle) = guard.timer_handle.take() { + handle.abort(); + } + + #[cfg(target_os = "macos")] + if let Some(id) = guard.assertion_id.take() { + unsafe { + macos::IOPMAssertionRelease(id); + } + } + + #[cfg(not(target_os = "macos"))] + { + guard.assertion_id = None; + } +} + +/// Returns `true` if a power assertion is currently held. +#[allow(dead_code)] +pub fn is_held(state: &Arc>) -> bool { + state + .lock() + .map(|g| g.assertion_id.is_some()) + .unwrap_or(false) +} diff --git a/desktop/src-tauri/src/relay.rs b/desktop/src-tauri/src/relay.rs index 14abec4c29..5e349cef85 100644 --- a/desktop/src-tauri/src/relay.rs +++ b/desktop/src-tauri/src/relay.rs @@ -5,8 +5,10 @@ use serde::de::DeserializeOwned; use serde::Deserialize; use sha2::{Digest, Sha256}; +// nostr 0.36 alias — required for cross-version bridging with sprout-sdk. +use nostr_compat; + use crate::app_state::AppState; -use crate::util::percent_encode; const DEFAULT_RELAY_WS_URL: &str = "ws://localhost:3000"; @@ -23,6 +25,31 @@ pub fn relay_ws_url() -> String { .unwrap_or_else(|| DEFAULT_RELAY_WS_URL.to_string()) } +/// Read the workspace relay URL override, if set. Returns `None` when no +/// override is active or when the mutex is poisoned (best-effort). +fn workspace_relay_override(state: &AppState) -> Option { + state + .relay_url_override + .lock() + .ok() + .and_then(|guard| guard.clone()) +} + +/// Returns the relay WebSocket URL, checking the workspace override first. +/// Precedence: workspace override > env vars > build-time vars > default. +pub fn relay_ws_url_with_override(state: &AppState) -> String { + workspace_relay_override(state).unwrap_or_else(relay_ws_url) +} + +/// Returns the relay HTTP API base URL, checking the workspace override first. +/// Precedence: workspace override > env vars > build-time vars > default. +pub fn relay_api_base_url_with_override(state: &AppState) -> String { + match workspace_relay_override(state) { + Some(url) => relay_http_base_url(&url), + None => relay_api_base_url(), + } +} + pub fn relay_http_base_url(relay_url: &str) -> String { let trimmed = relay_url.trim().trim_end_matches('/'); @@ -49,148 +76,7 @@ pub fn relay_api_base_url() -> String { relay_http_base_url(&relay_ws_url()) } -/// Build a relay API path from untrusted path segments by percent-encoding each segment. -pub fn api_path(segments: &[&str]) -> String { - let mut path = String::from("/api"); - for segment in segments { - path.push('/'); - path.push_str(&percent_encode(segment)); - } - path -} - -fn validate_api_path(path: &str) -> Result<(), String> { - let path_only = path - .split_once('?') - .map(|(prefix, _)| prefix) - .unwrap_or(path); - - if !path_only.starts_with('/') { - return Err("API paths must start with '/'".to_string()); - } - - if path_only - .split('/') - .any(|segment| matches!(segment, "." | "..")) - { - return Err("API path contains unsafe traversal segments".to_string()); - } - - Ok(()) -} - -pub fn build_authed_request( - client: &reqwest::Client, - method: Method, - path: &str, - state: &AppState, -) -> Result { - validate_api_path(path)?; - let url = format!("{}{}", relay_api_base_url(), path); - let request = client.request(method, url); - - if let Some(token) = state.configured_api_token.as_deref() { - return Ok(request.header("Authorization", format!("Bearer {token}"))); - } - - let pubkey_hex = auth_pubkey_header(state)?; - Ok(request.header("X-Pubkey", pubkey_hex)) -} - -pub fn auth_pubkey_header(state: &AppState) -> Result { - let keys = state.keys.lock().map_err(|error| error.to_string())?; - Ok(keys.public_key().to_hex()) -} - -fn token_supports_scope(scopes: &[String], required_scope: &str) -> bool { - scopes.iter().any(|scope| scope == required_scope) -} - -pub async fn sync_managed_agent_profile( - state: &AppState, - relay_url: &str, - agent_keys: &nostr::Keys, - api_token: Option<&str>, - token_scopes: &[String], - display_name: &str, - avatar_url: Option<&str>, -) -> Result<(), String> { - // Build a kind:0 profile event signed by the agent's keys. - let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; - - // Sign with the agent's keys (not the desktop user's). - let event = builder - .sign_with_keys(agent_keys) - .map_err(|e| format!("failed to sign profile event: {e}"))?; - let event_json = event.as_json(); - - // POST to the relay's /api/events endpoint. - let url = format!("{}/api/events", relay_http_base_url(relay_url)); - let use_bearer_token = api_token.is_some() && token_supports_scope(token_scopes, "users:write"); - let mut request = state.http_client.post(&url); - - if let Some(token) = api_token.filter(|_| use_bearer_token) { - request = request.header("Authorization", format!("Bearer {token}")); - } else { - request = request.header("X-Pubkey", agent_keys.public_key().to_hex()); - } - - request = request - .header("Content-Type", "application/json") - .body(event_json); - - let response = request - .send() - .await - .map_err(|e| format!("request failed: {e}"))?; - - if !response.status().is_success() { - let msg = relay_error_message(response).await; - return Err(if api_token.is_some() && !use_bearer_token { - format!( - "Created the agent, but could not sync its profile metadata. The minted token does not include `users:write`, and the relay rejected dev-mode pubkey auth: {msg}" - ) - } else if api_token.is_some() { - format!("Created the agent, but could not sync its profile metadata: {msg}") - } else { - format!( - "Created the agent, but could not sync its profile metadata without a token: {msg}" - ) - }); - } - - Ok(()) -} - -fn session_api_token(state: &AppState) -> Result, String> { - let token = state - .session_token - .lock() - .map_err(|error| error.to_string())?; - Ok(token.clone()) -} - -pub fn build_token_management_request( - client: &reqwest::Client, - method: Method, - path: &str, - state: &AppState, -) -> Result { - validate_api_path(path)?; - let url = format!("{}{}", relay_api_base_url(), path); - let request = client.request(method, url); - - if let Some(token) = state.configured_api_token.as_deref() { - return Ok(request.header("Authorization", format!("Bearer {token}"))); - } - - if let Some(token) = session_api_token(state)? { - return Ok(request.header("Authorization", format!("Bearer {token}"))); - } - - let pubkey_hex = auth_pubkey_header(state)?; - Ok(request.header("X-Pubkey", pubkey_hex)) -} +// ── NIP-98 HTTP auth ──────────────────────────────────────────────────────── pub fn build_nip98_auth_header( method: &Method, @@ -209,12 +95,20 @@ pub fn build_nip98_auth_header_for_keys( body: &[u8], ) -> Result { let payload_hash = hex::encode(Sha256::digest(body)); + + // Nonce ensures unique event IDs even for identical requests in the same second. + // Without this, rapid-fire calls (e.g. query → submit → re-query) with the same + // body produce identical NIP-98 event hashes and trigger relay replay detection. + let nonce_hex = uuid::Uuid::new_v4().to_string(); + let tags = vec![ Tag::parse(vec!["u", url]).map_err(|error| format!("url tag failed: {error}"))?, Tag::parse(vec!["method", method.as_str()]) .map_err(|error| format!("method tag failed: {error}"))?, Tag::parse(vec!["payload", &payload_hash]) .map_err(|error| format!("payload tag failed: {error}"))?, + Tag::parse(vec!["nonce", &nonce_hex]) + .map_err(|error| format!("nonce tag failed: {error}"))?, ]; let event = EventBuilder::new(Kind::HttpAuth, "") @@ -228,6 +122,8 @@ pub fn build_nip98_auth_header_for_keys( )) } +// ── Error handling ────────────────────────────────────────────────────────── + pub async fn relay_error_message(response: reqwest::Response) -> String { let status = response.status(); let body = response.text().await.unwrap_or_default(); @@ -245,63 +141,151 @@ pub async fn relay_error_message(response: reqwest::Response) -> String { format!("relay returned {status}: {body}") } -pub async fn send_json_request(request: reqwest::RequestBuilder) -> Result -where - T: DeserializeOwned, -{ - let response = request +// ── HTTP bridge: POST /query ──────────────────────────────────────────────── + +/// Execute a one-shot query via the relay's HTTP bridge (`POST /query`). +/// +/// Filters are serialized as a JSON array. The request is authenticated with +/// a NIP-98 event signed by the user's keys. Returns the deserialized array of +/// events. +pub async fn query_relay( + state: &AppState, + filters: &[serde_json::Value], +) -> Result, String> { + let url = format!("{}/query", relay_api_base_url_with_override(state)); + let body_bytes = + serde_json::to_vec(filters).map_err(|e| format!("filter serialization failed: {e}"))?; + let auth = build_nip98_auth_header(&Method::POST, &url, &body_bytes, state)?; + + let response = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json") + .body(body_bytes) .send() .await - .map_err(|error| format!("request failed: {error}"))?; + .map_err(|e| format!("request failed: {e}"))?; if !response.status().is_success() { return Err(relay_error_message(response).await); } response - .json::() + .json::>() .await - .map_err(|error| format!("parse failed: {error}")) + .map_err(|e| format!("failed to parse query response: {e}")) } -pub async fn send_empty_request(request: reqwest::RequestBuilder) -> Result<(), String> { +// ── Command response parsing ──────────────────────────────────────────────── + +/// Parse a command-event OK message of the form `"response:"`. +/// +/// Sprout's command kinds (e.g. 41010, 30620, 46020) acknowledge writes via +/// relay OK messages whose payload is a `response:`-prefixed JSON document. +/// This helper strips the prefix and deserializes the remainder as `T`. +pub fn parse_command_response(message: &str) -> Result { + // Try the spec format first: "response:{...}". + if let Some(json) = message.strip_prefix("response:") { + return serde_json::from_str(json).map_err(|e| format!("response parse failed: {e}")); + } + // Fallback: raw JSON (backward compat for relays that omit the prefix). + serde_json::from_str(message) + .map_err(|e| format!("expected 'response:' prefix or valid JSON, got: {message} ({e})")) +} + +// ── Profile event builder ─────────────────────────────────────────────────── + +/// Build a signed kind:0 profile event, optionally injecting a verified NIP-OA auth tag. +/// +/// This is a pure function (no I/O) extracted from `sync_managed_agent_profile` so that +/// the event-building and auth-tag-injection logic can be unit tested without HTTP calls. +/// +/// `sprout-sdk` uses `nostr 0.36` while the desktop crate uses `nostr 0.37`. Cross-version +/// bridging is done via hex-encoded public keys and raw tag slices — both versions share the +/// same wire format. +fn build_profile_event( + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + auth_tag_json: Option<&str>, +) -> Result { + let builder = crate::events::build_profile(Some(display_name), None, avatar_url, None, None)?; + + let builder = if let Some(tag_json) = auth_tag_json { + // Bridge nostr 0.37 PublicKey → nostr 0.36 PublicKey via hex encoding. + let agent_pubkey_hex = agent_keys.public_key().to_hex(); + let compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex) + .map_err(|e| format!("failed to convert agent pubkey for auth verification: {e}"))?; + + // Verify Schnorr signature before injecting into profile event. + sprout_sdk::nip_oa::verify_auth_tag(tag_json, &compat_pubkey) + .map_err(|e| format!("auth tag verification failed for profile event: {e}"))?; + + // parse_auth_tag returns a nostr 0.36 Tag; bridge to nostr 0.37 via raw slice. + let compat_tag = sprout_sdk::nip_oa::parse_auth_tag(tag_json) + .map_err(|e| format!("failed to parse verified auth tag: {e}"))?; + let tag = nostr::Tag::parse(compat_tag.as_slice()) + .map_err(|e| format!("failed to convert auth tag to nostr 0.37: {e}"))?; + builder.tags([tag]) + } else { + builder + }; + + builder + .sign_with_keys(agent_keys) + .map_err(|e| format!("failed to sign profile event: {e}")) +} + +// ── Managed-agent profile sync ────────────────────────────────────────────── + +/// Sync a managed agent's kind:0 profile event to the relay using NIP-98 auth. +/// +/// The agent signs its own profile event and the NIP-98 HTTP-auth event, so no +/// API token is required. +pub async fn sync_managed_agent_profile( + state: &AppState, + relay_url: &str, + agent_keys: &nostr::Keys, + display_name: &str, + avatar_url: Option<&str>, + auth_tag: Option<&str>, // NIP-OA auth tag JSON +) -> Result<(), String> { + // Build a signed kind:0 profile event (with optional NIP-OA auth tag). + let event = build_profile_event(agent_keys, display_name, avatar_url, auth_tag)?; + let event_json = event.as_json(); + let body_bytes = event_json.into_bytes(); + + let url = format!("{}/events", relay_http_base_url(relay_url)); + let auth = build_nip98_auth_header_for_keys(agent_keys, &Method::POST, &url, &body_bytes)?; + + let mut request = state + .http_client + .post(&url) + .header("Authorization", auth) + .header("Content-Type", "application/json"); + if let Some(tag) = auth_tag { + request = request.header("x-auth-tag", tag); + } let response = request + .body(body_bytes) .send() .await - .map_err(|error| format!("request failed: {error}"))?; + .map_err(|e| format!("request failed: {e}"))?; if !response.status().is_success() { - return Err(relay_error_message(response).await); + let msg = relay_error_message(response).await; + return Err(format!( + "Created the agent, but could not sync its profile metadata: {msg}" + )); } Ok(()) } -#[cfg(test)] -mod tests { - use super::{api_path, validate_api_path}; +// ── Signed-event submission ───────────────────────────────────────────────── - #[test] - fn api_path_encodes_path_segments() { - let path = api_path(&["tokens", "../../etc/passwd"]); - assert_eq!(path, "/api/tokens/..%2F..%2Fetc%2Fpasswd"); - } - - #[test] - fn validate_api_path_rejects_traversal_segments() { - assert!(validate_api_path("/api/tokens/../admin").is_err()); - assert!(validate_api_path("/api/tokens/./admin").is_err()); - } - - #[test] - fn validate_api_path_allows_encoded_segments() { - assert!(validate_api_path("/api/tokens/..%2Fadmin").is_ok()); - } -} - -// ── Signed-event submission ────────────────────────────────────────────────── - -/// Response from `POST /api/events`. +/// Response from `POST /events`. #[derive(Debug, Deserialize, serde::Serialize)] pub struct SubmitEventResponse { pub event_id: String, @@ -310,40 +294,30 @@ pub struct SubmitEventResponse { } /// Build an `EventBuilder` from the events module, sign it with the user's keys, -/// and POST the signed event to `/api/events`. +/// and POST the signed event to `/events` with NIP-98 auth. pub async fn submit_event( builder: nostr::EventBuilder, state: &AppState, ) -> Result { // All synchronous work (signing) must complete before any .await // so the MutexGuard is dropped and the future remains Send. - let (event_json, auth_header) = { + let url = format!("{}/events", relay_api_base_url_with_override(state)); + let (auth_header, body_bytes) = { let keys = state.keys.lock().map_err(|e| e.to_string())?; let event = builder .sign_with_keys(&keys) .map_err(|e| format!("failed to sign event: {e}"))?; - let json = event.as_json(); - let auth = match state.configured_api_token.as_deref() { - Some(token) => format!("Bearer {token}"), - None => format!("X-Pubkey {}", keys.public_key().to_hex()), - }; - (json, auth) + let body = event.as_json().into_bytes(); + let auth = build_nip98_auth_header_for_keys(&keys, &Method::POST, &url, &body)?; + (auth, body) }; // keys lock dropped here - let url = format!("{}/api/events", relay_api_base_url()); - let request = if auth_header.starts_with("Bearer ") { - state - .http_client - .post(&url) - .header("Authorization", &auth_header) - } else { - let pubkey = auth_header.strip_prefix("X-Pubkey ").unwrap_or(""); - state.http_client.post(&url).header("X-Pubkey", pubkey) - } - .header("Content-Type", "application/json") - .body(event_json); - - let response = request + let response = state + .http_client + .post(&url) + .header("Authorization", auth_header) + .header("Content-Type", "application/json") + .body(body_bytes) .send() .await .map_err(|e| format!("request failed: {e}"))?; @@ -363,3 +337,124 @@ pub async fn submit_event( Ok(result) } + +// ── Tests ─────────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::{build_profile_event, parse_command_response}; + use serde::Deserialize; + + // ── parse_command_response ─────────────────────────────────────────────── + + #[derive(Debug, Deserialize, PartialEq)] + struct ChannelCreated { + channel_id: String, + } + + #[test] + fn parse_command_response_decodes_typed_payload() { + let msg = r#"response:{"channel_id":"abc123"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("should parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc123".to_string() + } + ); + } + + #[test] + fn parse_command_response_accepts_raw_json_fallback() { + // Backward-compat: relays that emit raw JSON (no prefix) still work. + let msg = r#"{"channel_id":"abc"}"#; + let parsed: ChannelCreated = parse_command_response(msg).expect("fallback parse"); + assert_eq!( + parsed, + ChannelCreated { + channel_id: "abc".to_string() + } + ); + } + + #[test] + fn parse_command_response_rejects_invalid_prefixed_json() { + let msg = "response:not-json"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); + assert!(result.unwrap_err().contains("response parse failed")); + } + + #[test] + fn parse_command_response_rejects_garbage() { + let msg = "totally not json or response"; + let result: Result = parse_command_response(msg); + assert!(result.is_err()); + } + + // ── build_profile_event ────────────────────────────────────────────────── + + /// Generate a valid NIP-OA auth tag JSON string signed by a fresh owner key + /// and addressed to `agent_keys`. + /// + /// Uses `nostr_compat` (nostr 0.36) for the owner keys because + /// `sprout_sdk::nip_oa::compute_auth_tag` expects nostr 0.36 types. + /// The agent pubkey is bridged via hex encoding. + fn make_valid_auth_tag(agent_keys: &nostr::Keys) -> String { + let owner_keys = nostr_compat::Keys::generate(); + let agent_pubkey_hex = agent_keys.public_key().to_hex(); + let agent_compat_pubkey = nostr_compat::PublicKey::from_hex(&agent_pubkey_hex) + .expect("valid hex pubkey should parse"); + sprout_sdk::nip_oa::compute_auth_tag(&owner_keys, &agent_compat_pubkey, "") + .expect("compute_auth_tag should not fail with distinct keys") + } + + #[test] + fn profile_event_with_valid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let tag_json = make_valid_auth_tag(&agent_keys); + let event = build_profile_event(&agent_keys, "TestBot", None, Some(&tag_json)) + .expect("should succeed with a valid auth tag"); + + // Exactly one "auth" tag must be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 1, "expected exactly 1 auth tag"); + + // Must be a kind:0 (Metadata) event. + assert_eq!(event.kind, nostr::Kind::Metadata); + } + + #[test] + fn profile_event_without_auth_tag() { + let agent_keys = nostr::Keys::generate(); + let event = build_profile_event(&agent_keys, "TestBot", None, None) + .expect("should succeed without an auth tag"); + + // No "auth" tags should be present. + let auth_tags: Vec<_> = event + .tags + .iter() + .filter(|t| t.as_slice().first().map(|s| s.as_str()) == Some("auth")) + .collect(); + assert_eq!(auth_tags.len(), 0, "expected no auth tags"); + + assert_eq!(event.kind, nostr::Kind::Metadata); + } + + #[test] + fn profile_event_rejects_invalid_auth_tag() { + let agent_keys = nostr::Keys::generate(); + // Structurally valid JSON array but with a bogus signature — verification must fail. + let bad_json = format!(r#"["auth","{}","","{}"]"#, "a".repeat(64), "b".repeat(128)); + let result = build_profile_event(&agent_keys, "TestBot", None, Some(&bad_json)); + assert!(result.is_err(), "should reject an invalid auth tag"); + assert!( + result.unwrap_err().contains("verification failed"), + "error message should mention verification failure" + ); + } +} diff --git a/desktop/src-tauri/src/util.rs b/desktop/src-tauri/src/util.rs index ff65b5818d..c8d82d8090 100644 --- a/desktop/src-tauri/src/util.rs +++ b/desktop/src-tauri/src/util.rs @@ -27,31 +27,6 @@ pub fn slugify(name: &str, fallback: &str, max_len: usize) -> String { raw.trim_end_matches('-').to_string() } -pub fn percent_encode(input: &str) -> String { - let mut encoded = String::with_capacity(input.len()); - - for byte in input.bytes() { - match byte { - b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => { - encoded.push(byte as char); - } - _ => { - let high = char::from_digit((byte >> 4) as u32, 16) - .expect("nibble 0-15 is always a valid hex digit") - .to_ascii_uppercase(); - let low = char::from_digit((byte & 0x0f) as u32, 16) - .expect("nibble 0-15 is always a valid hex digit") - .to_ascii_uppercase(); - encoded.push('%'); - encoded.push(high); - encoded.push(low); - } - } - } - - encoded -} - #[cfg(test)] mod tests { use super::slugify; diff --git a/desktop/src-tauri/tauri.conf.json b/desktop/src-tauri/tauri.conf.json index ee42edac96..8247f6a13e 100644 --- a/desktop/src-tauri/tauri.conf.json +++ b/desktop/src-tauri/tauri.conf.json @@ -36,12 +36,21 @@ "plugins": { "updater": { "endpoints": [] + }, + "deep-link": { + "desktop": { + "schemes": ["sprout"] + } } }, "bundle": { "active": true, "targets": "all", - "externalBin": ["binaries/sprout-acp", "binaries/sprout-mcp-server"], + "externalBin": [ + "binaries/sprout-acp", + "binaries/sprout-mcp-server", + "binaries/git-credential-nostr" + ], "icon": [ "icons/32x32.png", "icons/128x128.png", diff --git a/desktop/src/app/App.tsx b/desktop/src/app/App.tsx index 73652eb7d5..62c9bdd089 100644 --- a/desktop/src/app/App.tsx +++ b/desktop/src/app/App.tsx @@ -1,10 +1,22 @@ import { getCurrentWindow } from "@tauri-apps/api/window"; +import { QueryClientProvider } from "@tanstack/react-query"; import { RouterProvider } from "@tanstack/react-router"; -import { useLayoutEffect } from "react"; +import { + type ReactNode, + useCallback, + useEffect, + useLayoutEffect, + useState, +} from "react"; import { router } from "@/app/router"; import { useAppOnboardingState } from "@/features/onboarding/hooks"; import { OnboardingFlow } from "@/features/onboarding/ui/OnboardingFlow"; +import { useWorkspaceInit } from "@/features/workspaces/useWorkspaceInit"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; +import { WelcomeSetup } from "@/features/workspaces/ui/WelcomeSetup"; +import { createSproutQueryClient } from "@/shared/api/queryClient"; +import { listenForDeepLinks } from "@/shared/deep-link"; function AppLoadingGate() { return ( @@ -24,11 +36,15 @@ function AppLoadingGate() { ); } -export function App() { - useLayoutEffect(() => { - void getCurrentWindow().show(); - }, []); +function WorkspaceQueryProvider({ children }: { children: ReactNode }) { + const [queryClient] = useState(createSproutQueryClient); + return ( + {children} + ); +} + +function AppReady() { const onboarding = useAppOnboardingState(); if (onboarding.stage === "onboarding") { @@ -37,7 +53,6 @@ export function App() { actions={onboarding.flow.actions} initialProfile={onboarding.flow.initialProfile} key={onboarding.currentPubkey ?? "anonymous"} - notifications={onboarding.flow.notifications} /> ); } @@ -48,3 +63,62 @@ export function App() { return ; } + +export function App() { + useLayoutEffect(() => { + void getCurrentWindow().show(); + }, []); + + const { + activeWorkspace, + reinitKey, + addWorkspace, + switchWorkspace, + reconnectWorkspace, + } = useWorkspaces(); + + useEffect(() => { + const unlisten = listenForDeepLinks({ + addWorkspace, + switchWorkspace, + reconnectWorkspace, + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, [addWorkspace, switchWorkspace, reconnectWorkspace]); + // Composite key: changes when workspace ID changes OR when + // the active workspace's config is updated (relayUrl/token). + const workspaceKey = `${activeWorkspace?.id ?? "none"}-${reinitKey}`; + const workspace = useWorkspaceInit(activeWorkspace, workspaceKey); + + const handleSetupComplete = useCallback(() => { + // Force a full reload so useWorkspaces re-initializes from localStorage. + // This only runs once — during first-run setup when no workspace existed. + window.location.reload(); + }, []); + + // Show welcome setup for first-run users with no workspaces + if (workspace.needsSetup) { + return ( + + ); + } + + // Wait for this exact workspace config to be applied to the backend before + // rendering anything that connects to the relay. The appliedKey check avoids + // a one-render race where React sees the new active workspace while the Tauri + // backend is still configured for the previous one. + if (!workspace.isReady || workspace.appliedKey !== workspaceKey) { + return ; + } + + return ( + + + + ); +} diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index 9e3742e9c7..344bc52cc6 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -23,10 +23,21 @@ import { useHomeFeedNotifications } from "@/features/notifications/hooks"; import { listenForDesktopNotificationActions, revealDesktopAppWindow, + sendDesktopNotification, setDesktopAppBadgeCount, type DesktopNotificationTarget, } from "@/features/notifications/lib/desktop"; -import { usePresenceSession } from "@/features/presence/hooks"; +import { playNotificationSound } from "@/features/notifications/lib/sound"; +import { PreventSleepProvider } from "@/features/agents/usePreventSleep"; +import { + usePresenceSession, + usePresenceSubscription, +} from "@/features/presence/hooks"; +import { + useSetUserStatusMutation, + useUserStatusQuery, + useUserStatusSubscription, +} from "@/features/user-status/hooks"; import { useProfileQuery } from "@/features/profile/hooks"; import { DEFAULT_SETTINGS_SECTION, @@ -34,18 +45,21 @@ import { } from "@/features/settings/ui/SettingsPanels"; import { HuddleBar, HuddleProvider } from "@/features/huddle"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; +import { useWorkspaces } from "@/features/workspaces/useWorkspaces"; import { relayClient } from "@/shared/api/relayClient"; import { useIdentityQuery } from "@/shared/api/hooks"; import { useDeferredStartup } from "@/shared/hooks/useDeferredStartup"; import { joinChannel } from "@/shared/api/tauri"; -import type { SearchHit } from "@/shared/api/types"; +import type { Channel, RelayEvent, SearchHit } from "@/shared/api/types"; import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; import { Button } from "@/shared/ui/button"; import { SidebarInset, SidebarProvider, SidebarTrigger, } from "@/shared/ui/sidebar"; +import { UpdateIndicator } from "@/features/settings/UpdateIndicator"; type AppView = "home" | "channel" | "agents" | "workflows" | "pulse"; @@ -113,10 +127,14 @@ function deriveShellRoute(pathname: string): { export function AppShell() { useWebviewZoomShortcuts(); + const workspacesHook = useWorkspaces(); + const [isAddWorkspaceOpen, setIsAddWorkspaceOpen] = React.useState(false); + const [settingsOpen, setSettingsOpen] = React.useState(false); const [settingsSection, setSettingsSection] = React.useState( DEFAULT_SETTINGS_SECTION, ); + const [isChannelManagementOpen, setIsChannelManagementOpen] = React.useState(false); const [isSearchOpen, setIsSearchOpen] = React.useState(false); @@ -139,7 +157,13 @@ export function AppShell() { const identityQuery = useIdentityQuery(); const profileQuery = useProfileQuery(); const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined; + usePresenceSubscription(); + useUserStatusSubscription(); const presenceSession = usePresenceSession(deferredPubkey); + const selfStatusQuery = useUserStatusQuery( + deferredPubkey ? [deferredPubkey] : [], + ); + const setUserStatusMutation = useSetUserStatusMutation(deferredPubkey); const { homeBadgeCount, homeFeedQuery, notificationSettings } = useHomeFeedNotifications( identityQuery.data?.pubkey, @@ -149,6 +173,41 @@ export function AppShell() { void homeFeedQuery.refetch(); }); + const handleDmNotification = React.useEffectEvent( + (event: RelayEvent, channel: Channel) => { + if (!notificationSettings.settings.desktopEnabled) { + return; + } + + const channelName = channel.name?.trim() || "Direct message"; + const content = event.content.trim(); + const body = + content.length > 0 + ? content.length > 140 + ? `${content.slice(0, 137).trimEnd()}...` + : content + : "New message"; + + void sendDesktopNotification({ + title: channelName, + body, + target: { + channelId: channel.id, + channelName, + content: event.content, + createdAt: event.created_at, + eventId: event.id, + kind: event.kind, + pubkey: event.pubkey, + }, + }).then((didSend) => { + if (didSend && notificationSettings.settings.soundEnabled) { + playNotificationSound(); + } + }); + }, + ); + const channelsQuery = useChannelsQuery(); const { refetch: refetchChannels } = channelsQuery; const channels = channelsQuery.data ?? []; @@ -175,7 +234,10 @@ export function AppShell() { // advancing unread state for the active channel. null, { + pubkey: identityQuery.data?.pubkey, + relayClient, currentPubkey: identityQuery.data?.pubkey, + onDmMessage: handleDmNotification, onLiveMention: refetchHomeFeedOnLiveMention, }, ); @@ -337,7 +399,7 @@ export function AppShell() { } function handleKeyDown(event: KeyboardEvent) { - if (!(event.metaKey || event.ctrlKey) || event.altKey) { + if (!hasPrimaryShortcutModifier(event) || event.altKey) { return; } @@ -383,7 +445,7 @@ export function AppShell() { function handleKeyDown(event: KeyboardEvent) { const isSettingsShortcut = (event.key === "," || event.code === "Comma") && - (event.metaKey || event.ctrlKey) && + hasPrimaryShortcutModifier(event) && !event.altKey && !event.shiftKey; @@ -407,188 +469,222 @@ export function AppShell() { }, [handleCloseSettings, handleOpenSettings, settingsOpen]); return ( - - { - setIsChannelManagementOpen(true); - }, - }} - > - -
- -
- - - -
- { - const createdChannel = - await createChannelMutation.mutateAsync({ + + + { + setIsChannelManagementOpen(true); + }, + }} + > + +
+ +
+ + + +
+
+ handleOpenSettings("updates")} + /> +
+ { + const id = workspacesHook.addWorkspace(workspace); + workspacesHook.switchWorkspace(id); + }} + onAddWorkspaceOpenChange={setIsAddWorkspaceOpen} + onNewDmOpenChange={setIsNewDmOpen} + onOpenAddWorkspace={() => setIsAddWorkspaceOpen(true)} + onUpdateWorkspace={workspacesHook.updateWorkspace} + onRemoveWorkspace={workspacesHook.removeWorkspace} + onSwitchWorkspace={workspacesHook.switchWorkspace} + selfPresenceStatus={presenceSession.currentStatus} + workspaces={workspacesHook.workspaces} + onCreateChannel={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdChannel = + await createChannelMutation.mutateAsync({ + name, + description, + channelType: "stream", + visibility, + ttlSeconds, + }); + + await goChannel(createdChannel.id); + }} + onCreateForum={async ({ + description, + name, + visibility, + ttlSeconds, + }) => { + const createdForum = await createForumMutation.mutateAsync({ name, description, - channelType: "stream", + channelType: "forum", visibility, ttlSeconds, }); - await goChannel(createdChannel.id); - }} - onCreateForum={async ({ - description, - name, - visibility, - ttlSeconds, - }) => { - const createdForum = await createForumMutation.mutateAsync({ - name, - description, - channelType: "forum", - visibility, - ttlSeconds, - }); + await goChannel(createdForum.id); + }} + onHideDm={handleHideDm} + onOpenBrowseChannels={handleOpenBrowseChannels} + onOpenBrowseForums={handleOpenBrowseForums} + onOpenDm={async ({ pubkeys }) => { + const directMessage = await openDmMutation.mutateAsync({ + pubkeys, + }); + await goChannel(directMessage.id); + }} + onOpenSearch={handleOpenSearch} + onSelectAgents={() => { + void goAgents(); + }} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + onSelectHome={() => { + void goHome(); + }} + onSelectPulse={() => { + void goPulse(); + }} + onSelectSettings={handleOpenSettings} + onSelectWorkflows={() => { + void goWorkflows(); + }} + onSetPresenceStatus={(status) => + presenceSession.setStatus(status) + } + onSetUserStatus={(text, emoji) => + setUserStatusMutation.mutate({ text, emoji }) + } + onClearUserStatus={() => + setUserStatusMutation.mutate({ text: "", emoji: "" }) + } + profile={profileQuery.data} + selfUserStatus={ + deferredPubkey + ? (selfStatusQuery.data?.[deferredPubkey.toLowerCase()] ?? + undefined) + : undefined + } + selectedChannelId={selectedChannelId} + selectedView={selectedView} + unreadChannelIds={unreadChannelIds} + /> - await goChannel(createdForum.id); - }} - onHideDm={handleHideDm} - onOpenBrowseChannels={handleOpenBrowseChannels} - onOpenBrowseForums={handleOpenBrowseForums} - onOpenDm={async ({ pubkeys }) => { - const directMessage = await openDmMutation.mutateAsync({ - pubkeys, - }); - await goChannel(directMessage.id); - }} - onOpenSearch={handleOpenSearch} - onSelectAgents={() => { - void goAgents(); - }} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - onSelectHome={() => { - void goHome(); - }} - onSelectPulse={() => { - void goPulse(); - }} - onSelectSettings={handleOpenSettings} - onSelectWorkflows={() => { - void goWorkflows(); - }} - onSetPresenceStatus={(status) => - presenceSession.setStatus(status) - } - profile={profileQuery.data} - selectedChannelId={selectedChannelId} - selectedView={selectedView} - unreadChannelIds={unreadChannelIds} - /> + + + - - - + { + setIsChannelManagementOpen(false); + void goHome({ replace: true }); + }} + onOpenSearchResult={handleOpenSearchResult} + onSearchOpenChange={setIsSearchOpen} + onSelectChannel={(channelId) => { + void goChannel(channelId); + }} + /> - { - setIsChannelManagementOpen(false); - void goHome({ replace: true }); - }} - onOpenSearchResult={handleOpenSearchResult} - onSearchOpenChange={setIsSearchOpen} - onSelectChannel={(channelId) => { - void goChannel(channelId); - }} - /> - - {settingsOpen ? ( - - - - ) : null} -
- -
-
-
-
+ {settingsOpen ? ( + + + + ) : null} +
+ +
+
+
+
+ ); } diff --git a/desktop/src/app/navigation/searchHitEventCache.ts b/desktop/src/app/navigation/searchHitEventCache.ts index 12a9ccc74d..b57a5f01fb 100644 --- a/desktop/src/app/navigation/searchHitEventCache.ts +++ b/desktop/src/app/navigation/searchHitEventCache.ts @@ -38,6 +38,10 @@ export function cacheSearchHitEvent(hit: SearchHit): RelayEvent { return event; } +export function clearSearchHitEventCache(): void { + searchHitEventCache.clear(); +} + export function getCachedSearchHitEvent( eventId: string | null | undefined, ): RelayEvent | null { diff --git a/desktop/src/app/navigation/useBackForwardControls.ts b/desktop/src/app/navigation/useBackForwardControls.ts index 56e7874a6a..7f7d84f6d7 100644 --- a/desktop/src/app/navigation/useBackForwardControls.ts +++ b/desktop/src/app/navigation/useBackForwardControls.ts @@ -5,6 +5,7 @@ import { useRouterState, } from "@tanstack/react-router"; +import { isMacPlatform } from "@/shared/lib/platform"; import { trimMapToSize } from "@/shared/lib/trimMapToSize"; type RouterHistoryState = { @@ -26,10 +27,6 @@ function isEditableTarget(target: EventTarget | null): boolean { ); } -function isMacPlatform() { - return window.navigator.platform.toLowerCase().includes("mac"); -} - export function useBackForwardControls() { const router = useRouter(); const canGoBack = useCanGoBack(); diff --git a/desktop/src/app/useWebviewZoomShortcuts.ts b/desktop/src/app/useWebviewZoomShortcuts.ts index 182927742b..162062f0d9 100644 --- a/desktop/src/app/useWebviewZoomShortcuts.ts +++ b/desktop/src/app/useWebviewZoomShortcuts.ts @@ -1,6 +1,8 @@ import * as React from "react"; import { getCurrentWebview } from "@tauri-apps/api/webview"; +import { hasPrimaryShortcutModifier } from "@/shared/lib/platform"; + const DEFAULT_ZOOM_FACTOR = 1; const MIN_ZOOM_FACTOR = 0.2; const MAX_ZOOM_FACTOR = 10; @@ -9,7 +11,7 @@ const ZOOM_STEP = 0.2; type ZoomAction = "increase" | "decrease" | "reset"; function getZoomAction(event: KeyboardEvent): ZoomAction | null { - if (!(event.metaKey || event.ctrlKey) || event.altKey) { + if (!hasPrimaryShortcutModifier(event) || event.altKey) { return null; } diff --git a/desktop/src/features/agents/channelAgents.ts b/desktop/src/features/agents/channelAgents.ts index c8815c3dc5..04872f916d 100644 --- a/desktop/src/features/agents/channelAgents.ts +++ b/desktop/src/features/agents/channelAgents.ts @@ -1,4 +1,3 @@ -import { DEFAULT_MANAGED_AGENT_SCOPES } from "@/features/tokens/lib/scopeOptions"; import { normalizePubkey } from "@/shared/lib/pubkey"; import { addChannelMembers, @@ -170,12 +169,6 @@ function pickPreferredManagedAgent(agents: ManagedAgent[]) { return rightRunningScore - leftRunningScore; } - const leftTokenScore = left.hasApiToken ? 1 : 0; - const rightTokenScore = right.hasApiToken ? 1 : 0; - if (leftTokenScore !== rightTokenScore) { - return rightTokenScore - leftTokenScore; - } - return parseTimestamp(right.updatedAt) - parseTimestamp(left.updatedAt); })[0]; } @@ -256,9 +249,6 @@ export async function ensureChannelAgentPresetInChannel( agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, mcpCommand: "sprout-mcp-server", - mintToken: true, - tokenName: `${expectedName} agent`, - tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES, spawnAfterCreate: false, }); const attached = await attachManagedAgentToChannel(channelId, { @@ -310,9 +300,6 @@ export async function createChannelManagedAgent( agentCommand: input.provider.command, agentArgs: input.provider.defaultArgs, mcpCommand: "sprout-mcp-server", - mintToken: true, - tokenName: `${trimmedName} agent`, - tokenScopes: DEFAULT_MANAGED_AGENT_SCOPES, personaId: input.personaId ?? undefined, systemPrompt: input.systemPrompt?.trim() || undefined, avatarUrl: resolvedAvatarUrl, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index cf5aa9d7b8..6674414fb0 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -16,7 +16,6 @@ import { getManagedAgentLog, listManagedAgents, listRelayAgents, - mintManagedAgentToken, startManagedAgent, stopManagedAgent, updateManagedAgent, @@ -43,7 +42,6 @@ import type { CreatePersonaInput, CreateTeamInput, ManagedAgent, - MintManagedAgentTokenInput, UpdateManagedAgentInput, UpdatePersonaInput, UpdateTeamInput, @@ -344,19 +342,6 @@ export function useDeleteManagedAgentMutation() { }); } -export function useMintManagedAgentTokenMutation() { - const queryClient = useQueryClient(); - - return useMutation({ - mutationFn: (input: MintManagedAgentTokenInput) => - mintManagedAgentToken(input), - onSettled: async () => { - await queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey }); - await queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey }); - }, - }); -} - export function useAttachManagedAgentToChannelMutation( channelId: string | null, ) { diff --git a/desktop/src/features/agents/lib/catalog.test.mjs b/desktop/src/features/agents/lib/catalog.test.mjs index 8476353c17..608e10577c 100644 --- a/desktop/src/features/agents/lib/catalog.test.mjs +++ b/desktop/src/features/agents/lib/catalog.test.mjs @@ -27,7 +27,7 @@ function createPersona(id, displayName, overrides = {}) { test("getCatalogPersonas keeps built-ins visible whether selected or not", () => { const personas = [ createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }), - createPersona("builtin:ralph", "Ralph", { + createPersona("builtin:kit", "Kit", { isBuiltIn: true, isActive: true, }), @@ -36,14 +36,14 @@ test("getCatalogPersonas keeps built-ins visible whether selected or not", () => assert.deepEqual( getCatalogPersonas(personas).map((persona) => persona.id), - ["builtin:ralph", "builtin:solo"], + ["builtin:kit", "builtin:solo"], ); }); test("getCatalogSelectionState keeps built-in selection rules in one place", () => { const personas = [ createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }), - createPersona("builtin:ralph", "Ralph", { + createPersona("builtin:kit", "Kit", { isBuiltIn: true, isActive: true, }), @@ -54,11 +54,11 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", () assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:ralph", "builtin:solo"], + ["builtin:kit", "builtin:solo"], ); assert.deepEqual( state.selectedCatalogPersonas.map((persona) => persona.id), - ["builtin:ralph"], + ["builtin:kit"], ); assert.deepEqual( state.unselectedCatalogPersonas.map((persona) => persona.id), @@ -69,7 +69,7 @@ test("getCatalogSelectionState keeps built-in selection rules in one place", () test("getCatalogPersonas keeps chooser order stable when selection changes", () => { const inactiveFirst = [ createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }), - createPersona("builtin:ralph", "Ralph", { + createPersona("builtin:kit", "Kit", { isBuiltIn: true, isActive: true, }), @@ -80,7 +80,7 @@ test("getCatalogPersonas keeps chooser order stable when selection changes", () ]; const activeFirst = [ createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: true }), - createPersona("builtin:ralph", "Ralph", { + createPersona("builtin:kit", "Kit", { isBuiltIn: true, isActive: false, }), @@ -136,7 +136,7 @@ test("getPersonaLabelsById keeps every returned persona addressable", () => { test("getPersonaLibraryState keeps the working library and full catalog in one place", () => { const personas = [ createPersona("builtin:solo", "Solo", { isBuiltIn: true, isActive: false }), - createPersona("builtin:ralph", "Ralph", { + createPersona("builtin:kit", "Kit", { isBuiltIn: true, isActive: true, }), @@ -147,11 +147,11 @@ test("getPersonaLibraryState keeps the working library and full catalog in one p assert.deepEqual( state.libraryPersonas.map((persona) => persona.id), - ["builtin:ralph", "custom:builder"], + ["builtin:kit", "custom:builder"], ); assert.deepEqual( state.catalogPersonas.map((persona) => persona.id), - ["builtin:ralph", "builtin:solo"], + ["builtin:kit", "builtin:solo"], ); assert.equal(state.personaLabelsById["builtin:solo"], "Solo"); }); diff --git a/desktop/src/features/agents/lib/teamPersonas.test.mjs b/desktop/src/features/agents/lib/teamPersonas.test.mjs index 36d64428f0..159ba89101 100644 --- a/desktop/src/features/agents/lib/teamPersonas.test.mjs +++ b/desktop/src/features/agents/lib/teamPersonas.test.mjs @@ -48,7 +48,7 @@ test("emptyResolvedTeamPersonas starts empty but complete", () => { test("resolveTeamPersonas preserves team order and marks complete teams usable", () => { const personas = [ createPersona("persona-1", "Solo"), - createPersona("persona-2", "Ralph"), + createPersona("persona-2", "Kit"), ]; const resolution = resolveTeamPersonas( createTeam("team-1", ["persona-2", "persona-1"]), @@ -60,7 +60,7 @@ test("resolveTeamPersonas preserves team order and marks complete teams usable", assert.deepEqual(resolution.resolvedPersonaIds, ["persona-2", "persona-1"]); assert.deepEqual( resolution.resolvedPersonas.map((persona) => persona.displayName), - ["Ralph", "Solo"], + ["Kit", "Solo"], ); }); diff --git a/desktop/src/features/agents/lib/useBotRecents.test.mjs b/desktop/src/features/agents/lib/useBotRecents.test.mjs index 28bc1b2d6b..1679dc1907 100644 --- a/desktop/src/features/agents/lib/useBotRecents.test.mjs +++ b/desktop/src/features/agents/lib/useBotRecents.test.mjs @@ -21,7 +21,7 @@ function createPersona(id, displayName) { test("pickQuickBotPersonas prefers recents before defaults", () => { const personas = [ createPersona("builtin:solo", "Solo"), - createPersona("builtin:ralph", "Ralph"), + createPersona("builtin:kit", "Kit"), createPersona("builtin:reviewer", "Reviewer"), ]; @@ -29,7 +29,7 @@ test("pickQuickBotPersonas prefers recents before defaults", () => { pickQuickBotPersonas(personas, ["builtin:reviewer"]).map( (persona) => persona.id, ), - ["builtin:reviewer", "builtin:solo", "builtin:ralph"], + ["builtin:reviewer", "builtin:solo", "builtin:kit"], ); }); @@ -49,7 +49,7 @@ test("pickQuickBotPersonas falls back to any active personas when defaults are m test("pickQuickBotPersonas skips duplicate and missing recents", () => { const personas = [ createPersona("builtin:solo", "Solo"), - createPersona("builtin:ralph", "Ralph"), + createPersona("builtin:kit", "Kit"), createPersona("builtin:scout", "Scout"), ]; @@ -58,8 +58,8 @@ test("pickQuickBotPersonas skips duplicate and missing recents", () => { "builtin:solo", "missing", "builtin:solo", - "builtin:ralph", + "builtin:kit", ]).map((persona) => persona.id), - ["builtin:solo", "builtin:ralph", "builtin:scout"], + ["builtin:solo", "builtin:kit", "builtin:scout"], ); }); diff --git a/desktop/src/features/agents/lib/useBotRecents.ts b/desktop/src/features/agents/lib/useBotRecents.ts index 129e1b0196..a98bfe32b4 100644 --- a/desktop/src/features/agents/lib/useBotRecents.ts +++ b/desktop/src/features/agents/lib/useBotRecents.ts @@ -7,7 +7,7 @@ const MAX_RECENTS = 8; // Default persona display names to seed the list when empty. // These are resolved to IDs by the consumer. -export const DEFAULT_PERSONA_NAMES = ["Solo", "Ralph", "Scout"] as const; +export const DEFAULT_PERSONA_NAMES = ["Solo", "Kit", "Scout"] as const; export function pickQuickBotPersonas( personas: readonly AgentPersona[], diff --git a/desktop/src/features/agents/observerRelayStore.ts b/desktop/src/features/agents/observerRelayStore.ts new file mode 100644 index 0000000000..a5382fb3bb --- /dev/null +++ b/desktop/src/features/agents/observerRelayStore.ts @@ -0,0 +1,237 @@ +import * as React from "react"; + +import { subscribeToAgentObserverFrames } from "@/shared/api/observerRelay"; +import type { RelayEvent, ManagedAgent } from "@/shared/api/types"; +import { getIdentity } from "@/shared/api/tauri"; +import { decryptObserverEvent } from "@/shared/api/tauriObserver"; +import { normalizePubkey } from "@/shared/lib/pubkey"; +import type { ConnectionState, ObserverEvent } from "./ui/agentSessionTypes"; + +const MAX_OBSERVER_EVENTS = 800; + +type ObserverSnapshot = { + connectionState: ConnectionState; + errorMessage: string | null; + events: ObserverEvent[]; +}; + +const listeners = new Set<() => void>(); +const eventsByAgent = new Map(); + +// Normalized pubkeys of agents we are actively managing. Only events whose +// "agent" tag matches an entry here will be decrypted (defense-in-depth). +const knownAgentPubkeys = new Set(); + +let connectionState: ConnectionState = "idle"; +let errorMessage: string | null = null; +let unsubscribeRelay: (() => Promise) | null = null; +let startPromise: Promise | null = null; +let eventProcessingQueue: Promise = Promise.resolve(); +let generation = 0; + +function notifyListeners() { + for (const listener of listeners) { + listener(); + } +} + +function setConnectionState( + nextState: ConnectionState, + nextErrorMessage: string | null = errorMessage, +) { + connectionState = nextState; + errorMessage = nextErrorMessage; + notifyListeners(); +} + +function observerTag(event: RelayEvent, tagName: string) { + return event.tags.find((tag) => tag[0] === tagName)?.[1] ?? null; +} + +function appendAgentEvent(agentPubkey: string, event: ObserverEvent) { + const key = normalizePubkey(agentPubkey); + const current = eventsByAgent.get(key) ?? []; + if ( + current.some( + (existing) => + existing.seq === event.seq && existing.timestamp === event.timestamp, + ) + ) { + return; + } + + const next = [...current, event].sort(compareObserverEvents); + eventsByAgent.set( + key, + next.length > MAX_OBSERVER_EVENTS + ? next.slice(next.length - MAX_OBSERVER_EVENTS) + : next, + ); + notifyListeners(); +} + +function compareObserverEvents(left: ObserverEvent, right: ObserverEvent) { + const leftTime = Date.parse(left.timestamp); + const rightTime = Date.parse(right.timestamp); + if (Number.isFinite(leftTime) && Number.isFinite(rightTime)) { + const timeDiff = leftTime - rightTime; + if (timeDiff !== 0) { + return timeDiff; + } + } + + return left.seq - right.seq; +} + +async function handleRelayObserverEvent( + event: RelayEvent, + activeGeneration: number, +) { + const agentPubkey = observerTag(event, "agent"); + const frame = observerTag(event, "frame"); + if (!agentPubkey || frame !== "telemetry") { + return; + } + + // Verify agent is known/trusted before decrypting. + // Silently drop events from agents we are not managing. + if (!knownAgentPubkeys.has(normalizePubkey(agentPubkey))) { + return; + } + + // Defense-in-depth: verify the event sender matches the claimed agent pubkey. + // The relay gates on is_agent_owner, but a compromised relay could misroute. + if (normalizePubkey(event.pubkey) !== normalizePubkey(agentPubkey)) { + return; + } + + try { + const parsed = (await decryptObserverEvent(event)) as ObserverEvent; + if (activeGeneration !== generation) { + return; + } + appendAgentEvent(agentPubkey, parsed); + } catch (error) { + if (activeGeneration !== generation) { + return; + } + setConnectionState( + "error", + error instanceof Error + ? `Observer event decrypt failed: ${error.message}` + : "Observer event decrypt failed.", + ); + } +} + +export function ensureRelayObserverSubscription() { + if (unsubscribeRelay) { + return Promise.resolve(); + } + if (startPromise) { + return startPromise; + } + + const activeGeneration = generation; + setConnectionState("connecting", null); + startPromise = (async () => { + const identity = await getIdentity(); + const unsubscribe = await subscribeToAgentObserverFrames( + identity.pubkey, + (event) => { + eventProcessingQueue = eventProcessingQueue + .then(() => handleRelayObserverEvent(event, activeGeneration)) + .catch((error) => { + if (activeGeneration !== generation) { + return; + } + setConnectionState( + "error", + error instanceof Error + ? `Observer event handling failed: ${error.message}` + : "Observer event handling failed.", + ); + }); + }, + ); + if (activeGeneration !== generation) { + await unsubscribe(); + return; + } + unsubscribeRelay = unsubscribe; + setConnectionState("open", null); + })() + .catch((error) => { + if (activeGeneration === generation) { + setConnectionState( + "error", + error instanceof Error + ? error.message + : "Observer relay subscription failed.", + ); + } + }) + .finally(() => { + if (activeGeneration === generation) { + startPromise = null; + } + }); + + return startPromise; +} + +export function subscribeAgentObserverStore(listener: () => void) { + listeners.add(listener); + return () => { + listeners.delete(listener); + }; +} + +export function getAgentObserverSnapshot( + agentPubkey: string, +): ObserverSnapshot { + return { + connectionState, + errorMessage, + events: eventsByAgent.get(normalizePubkey(agentPubkey)) ?? [], + }; +} + +export function useManagedAgentObserverBridge(agents: readonly ManagedAgent[]) { + const hasActiveAgent = React.useMemo( + () => + agents.some( + (agent) => agent.status === "running" || agent.status === "deployed", + ), + [agents], + ); + + // Keep the trusted-pubkey set in sync with the current managed agent list. + React.useEffect(() => { + knownAgentPubkeys.clear(); + for (const agent of agents) { + knownAgentPubkeys.add(normalizePubkey(agent.pubkey)); + } + }, [agents]); + + React.useEffect(() => { + if (!hasActiveAgent) { + return; + } + void ensureRelayObserverSubscription(); + }, [hasActiveAgent]); +} + +export function resetAgentObserverStore() { + generation += 1; + const unsubscribe = unsubscribeRelay; + unsubscribeRelay = null; + startPromise = null; + eventProcessingQueue = Promise.resolve(); + eventsByAgent.clear(); + knownAgentPubkeys.clear(); + connectionState = "idle"; + errorMessage = null; + notifyListeners(); + void unsubscribe?.(); +} diff --git a/desktop/src/features/agents/ui/AgentSessionToolItem.tsx b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx new file mode 100644 index 0000000000..639cb83fd4 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionToolItem.tsx @@ -0,0 +1,428 @@ +import * as React from "react"; +import { ArrowUpRight, ChevronDown, Wrench } from "lucide-react"; + +import { useAppNavigation } from "@/app/navigation/useAppNavigation"; +import { useUsersBatchQuery } from "@/features/profile/hooks"; +import { resolveUserLabel } from "@/features/profile/lib/identity"; +import type { Channel, UserProfileSummary } from "@/shared/api/types"; +import { useChannelNavigation } from "@/shared/context/ChannelNavigationContext"; +import { cn } from "@/shared/lib/cn"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; +import type { TranscriptItem } from "./agentSessionTypes"; +import { + formatToolTitle, + getSproutToolInfo, + getToolStatusDisplay, +} from "./agentSessionToolCatalog"; +import { + asRecord, + formatCodeValue, + formatDuration, + formatTranscriptTime, + getResultArray, + getToolString, + getToolStringList, + shortenMiddle, +} from "./agentSessionUtils"; + +export function ToolItem({ + item, +}: { + item: Extract; +}) { + const [isExpanded, setIsExpanded] = React.useState(false); + const status = getToolStatusDisplay(item.status, item.isError); + const hasArgs = Object.keys(item.args).length > 0; + const hasResult = item.result.trim().length > 0; + const canonicalToolName = item.sproutToolName ?? item.toolName; + const sproutTool = getSproutToolInfo(canonicalToolName); + const ToolIcon = sproutTool?.icon ?? Wrench; + const showStatus = status.state !== "output-available"; + const toolTitle = formatToolTitle(canonicalToolName, item.title); + const handleToggle = React.useCallback( + (event: React.SyntheticEvent) => { + setIsExpanded(event.currentTarget.open); + }, + [], + ); + + return ( +
+
+ + {ToolIcon ? ( + + ) : null} + + {toolTitle} + + {sproutTool ? ( + + ) : null} + {showStatus ? ( + + + {status.label} + + ) : null} + + + + + +
+
+ ); +} + +function ToolDetailBlocks({ + args, + description, + hasArgs, + hasResult, + isError, + result, +}: { + args: Record; + description?: string; + hasArgs: boolean; + hasResult: boolean; + isError: boolean; + result: string; +}) { + return ( +
+ {description ? ( +

+ {description} +

+ ) : null} + {hasArgs ? ( + + ) : null} + {hasResult ? ( + + ) : null} + {!hasArgs && !hasResult ? ( +

+ Waiting for tool details. +

+ ) : null} +
+ ); +} + +function ToolCodeBlock({ + label, + tone, + value, +}: { + label: string; + tone: "muted" | "error"; + value: string; +}) { + return ( +
+

+ {label} +

+
+        {formatCodeValue(value)}
+      
+
+ ); +} + +const toolFullDateTimeFormat = new Intl.DateTimeFormat(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +function ToolTimestamp({ + item, +}: { + item: Extract; +}) { + const time = formatTranscriptTime(item.timestamp); + if (!time) return null; + const duration = + item.startedAt && item.completedAt + ? formatDuration(item.startedAt, item.completedAt) + : null; + const date = new Date(item.timestamp); + const fullDateTime = Number.isNaN(date.getTime()) + ? item.timestamp + : toolFullDateTimeFormat.format(date); + return ( + + + + {time} + {duration ? ` · ${duration}` : null} + + + {fullDateTime} + + ); +} + +function SproutToolInlineAction({ + args, + result, +}: { + args: Record; + result: string; +}) { + const { channels } = useChannelNavigation(); + const { goChannel } = useAppNavigation(); + const resultValue = React.useMemo( + () => parseToolResultValue(result), + [result], + ); + const resultRecord = asRecord(resultValue); + const channelId = + getToolString(args, ["channel_id", "channelId"]) ?? + getToolString(resultRecord, ["channel_id", "channelId"]); + const pubkeys = React.useMemo( + () => getToolStringList(args, ["pubkeys", "pubkey"]), + [args], + ); + const profilesQuery = useUsersBatchQuery(pubkeys, { + enabled: pubkeys.length > 0, + }); + const profiles = profilesQuery.data?.profiles; + const openChannel = React.useCallback( + (messageId?: string) => { + if (!channelId) return; + void goChannel(channelId, messageId ? { messageId } : undefined); + }, + [channelId, goChannel], + ); + const action = React.useMemo( + () => + getSproutToolInlineAction({ + args, + channelId, + channels, + openChannel, + profiles, + resultValue, + }), + [args, channelId, channels, openChannel, profiles, resultValue], + ); + + if (!action) { + return null; + } + + if (action.onClick) { + return ( + + ); + } + + return ( + + {action.avatar} + {action.label} + {action.value} + + ); +} + +type SproutToolInlineActionModel = { + avatar?: React.ReactNode; + label: string; + value: string; + title: string; + onClick?: () => void; +}; + +function getSproutToolInlineAction({ + args, + channelId, + channels, + openChannel, + profiles, + resultValue, +}: { + args: Record; + channelId: string | null; + channels: Channel[]; + openChannel: (messageId?: string) => void; + profiles: Record | undefined; + resultValue: unknown; +}): SproutToolInlineActionModel | null { + const resultRecord = asRecord(resultValue); + const eventId = + getToolString(args, ["event_id", "eventId"]) ?? + getToolString(resultRecord, ["event_id", "eventId", "id"]); + + if (eventId && channelId) { + return { + label: resultRecord.accepted === true ? "posted" : "event", + onClick: () => openChannel(eventId), + title: eventId, + value: getChannelChipLabel(channels, channelId), + }; + } + + const messages = getResultArray(resultValue, resultRecord, "messages"); + if (messages) { + return { + label: "read", + onClick: channelId ? () => openChannel() : undefined, + title: `${messages.length} messages`, + value: `${messages.length} message${messages.length === 1 ? "" : "s"}`, + }; + } + + if (channelId) { + return { + label: "channel", + onClick: () => openChannel(), + title: channelId, + value: getChannelChipLabel(channels, channelId), + }; + } + + const workflowId = + getToolString(args, ["workflow_id", "workflowId"]) ?? + getToolString(resultRecord, ["workflow_id", "workflowId"]); + if (workflowId) { + return { + label: "workflow", + title: workflowId, + value: shortenMiddle(workflowId, 26), + }; + } + + const pubkeys = getToolStringList(args, ["pubkeys", "pubkey"]); + if (pubkeys.length > 0) { + if (pubkeys.length === 1) { + const pk = pubkeys[0]; + const displayName = resolveUserLabel({ pubkey: pk, profiles }); + const profile = profiles?.[pk.toLowerCase()]; + return { + avatar: ( + + ), + label: "user", + title: pk, + value: displayName, + }; + } + return { + label: "users", + title: pubkeys + .map((pk) => resolveUserLabel({ pubkey: pk, profiles })) + .join(", "), + value: `${pubkeys.length} users`, + }; + } + + const query = getToolString(args, ["query"]); + if (query) { + return { + label: "query", + title: query, + value: shortenMiddle(query, 30), + }; + } + + if (typeof resultRecord.accepted === "boolean") { + return { + label: "relay", + title: resultRecord.accepted ? "accepted" : "rejected", + value: resultRecord.accepted ? "accepted" : "rejected", + }; + } + + return null; +} + +function parseToolResultValue(result: string): unknown { + const trimmed = result.trim(); + if (!trimmed) return null; + + try { + const parsed = JSON.parse(trimmed); + if (typeof parsed !== "string") return parsed; + try { + return JSON.parse(parsed); + } catch { + return parsed; + } + } catch { + return null; + } +} + +function getChannelChipLabel(channels: Channel[], channelId: string) { + const channel = channels.find((candidate) => candidate.id === channelId); + return channel ? `#${channel.name}` : `#${shortenMiddle(channelId, 22)}`; +} diff --git a/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx new file mode 100644 index 0000000000..ddc5901a52 --- /dev/null +++ b/desktop/src/features/agents/ui/AgentSessionTranscriptList.tsx @@ -0,0 +1,222 @@ +import { Bot, Brain, ChevronDown, Radio, TerminalSquare } from "lucide-react"; + +import { cn } from "@/shared/lib/cn"; +import { Markdown } from "@/shared/ui/markdown"; +import type { TranscriptItem } from "./agentSessionTypes"; +import { ToolItem } from "./AgentSessionToolItem"; +import { formatTranscriptTime } from "./agentSessionUtils"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +export function AgentSessionTranscriptList({ + agentName, + emptyDescription, + items, +}: { + agentName: string; + emptyDescription: string; + items: TranscriptItem[]; +}) { + if (items.length === 0) { + return ( +
+ +

No ACP activity yet

+

{emptyDescription}

+
+ ); + } + + return ( +
+ {items.map((item) => ( +
+ +
+ ))} +
+ ); +} + +function TranscriptItemView({ + agentName, + item, +}: { + agentName: string; + item: TranscriptItem; +}) { + if (item.type === "message") { + return ; + } + if (item.type === "tool") { + return ; + } + if (item.type === "thought") { + return ; + } + if (item.type === "metadata") { + return ; + } + return ; +} + +function MessageItem({ + agentName, + item, +}: { + agentName: string; + item: Extract; +}) { + const isAssistant = item.role === "assistant"; + const text = item.text.trim(); + return ( +
+
+ {isAssistant ? ( +
+ + + + {agentName} + +
+ ) : null} +
+ {isAssistant ? ( + + ) : ( + <> +

{text}

+ + + )} +
+
+
+ ); +} + +function ThoughtItem({ + item, +}: { + item: Extract; +}) { + return ( +
+ + + {item.title} + + + +
+ +
+
+ ); +} + +function MetadataItem({ + item, +}: { + item: Extract; +}) { + return ( +
+ + + {item.title} + + {item.sections.length} section{item.sections.length === 1 ? "" : "s"} + + + + +
+ {item.sections.map((section) => ( +
+ + {section.title} + + +
+              {section.body.trim() || "No metadata."}
+            
+
+ ))} +
+
+ ); +} + +function LifecycleItem({ + item, +}: { + item: Extract; +}) { + const isError = item.title.toLowerCase().includes("error"); + return ( +
+ {item.title} + {item.text ? - {item.text} : null} + +
+ ); +} + +const fullDateTimeFormat = new Intl.DateTimeFormat(undefined, { + weekday: "long", + year: "numeric", + month: "long", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +function TranscriptTimestamp({ timestamp }: { timestamp: string }) { + const formatted = formatTranscriptTime(timestamp); + if (!formatted) return null; + const date = new Date(timestamp); + const fullDateTime = Number.isNaN(date.getTime()) + ? timestamp + : fullDateTimeFormat.format(date); + return ( + + + + {formatted} + + + {fullDateTime} + + ); +} diff --git a/desktop/src/features/agents/ui/AgentsView.tsx b/desktop/src/features/agents/ui/AgentsView.tsx index a3e2c5c743..cea6a26ec1 100644 --- a/desktop/src/features/agents/ui/AgentsView.tsx +++ b/desktop/src/features/agents/ui/AgentsView.tsx @@ -15,7 +15,6 @@ import { TeamDialog } from "./TeamDialog"; import { TeamImportDialog } from "./TeamImportDialog"; import { TeamImportUpdateDialog } from "./TeamImportUpdateDialog"; import { TeamsSection } from "./TeamsSection"; -import { TokenRevealDialog } from "./TokenRevealDialog"; import { useManagedAgentActions } from "./useManagedAgentActions"; import { usePersonaActions } from "./usePersonaActions"; import { useTeamActions } from "./useTeamActions"; @@ -143,9 +142,6 @@ export function AgentsView() { onDelete={(pubkey) => { void agents.handleDelete(pubkey); }} - onMintToken={(pubkey, name) => { - void agents.handleMintToken(pubkey, name); - }} onSelectLogAgent={agents.setLogAgentPubkey} onStart={(pubkey) => { void agents.handleStart(pubkey); @@ -202,15 +198,6 @@ export function AgentsView() { } }} /> - { - if (!open) { - agents.setRevealedToken(null); - } - }} - token={agents.revealedToken?.token ?? null} - /> >( - () => new Set(DEFAULT_MANAGED_AGENT_SCOPES), - ); const [turnTimeoutSeconds, setTurnTimeoutSeconds] = React.useState("320"); const [parallelism, setParallelism] = React.useState("3"); const [systemPrompt, setSystemPrompt] = React.useState(""); @@ -97,13 +89,9 @@ export function CreateAgentDialog({ [backendProviders, runOn], ); const isProviderMode = runOn !== "local"; - // Provider agents always mint — ownership is established during mint. - // Use this everywhere instead of raw `mintToken` for validation/rendering. - const effectiveMintToken = isProviderMode || mintToken; const isSpawnSupported = prereqs?.acp.available === true && prereqs?.mcp.available === true; - const mintToggleDisabled = prereqsQuery.isLoading; const spawnToggleDisabled = prereqsQuery.isLoading || (prereqs !== null && !isSpawnSupported); const isDiscoveryPending = providersQuery.isLoading || prereqsQuery.isLoading; @@ -207,11 +195,8 @@ export function CreateAgentDialog({ function reset() { setName(""); setRelayUrl(""); - setMintToken(true); setSpawnAfterCreate(true); setStartOnAppLaunch(true); - setTokenName(""); - setSelectedScopes(new Set(DEFAULT_MANAGED_AGENT_SCOPES)); setAcpCommand("sprout-acp"); setAgentCommand("goose"); setAgentArgs("acp"); @@ -238,35 +223,6 @@ export function CreateAgentDialog({ onOpenChange(next); } - // Scopes required for remote agent controllability (!shutdown path). - // These cannot be removed in provider mode. - const PROVIDER_REQUIRED_SCOPES: TokenScope[] = [ - "users:read" as TokenScope, - "messages:read" as TokenScope, - "messages:write" as TokenScope, - "channels:read" as TokenScope, - ]; - - function toggleScope(scope: TokenScope) { - // Prevent removing required scopes in provider mode. - if ( - isProviderMode && - PROVIDER_REQUIRED_SCOPES.includes(scope) && - selectedScopes.has(scope) - ) { - return; // locked — required for remote agent controllability - } - setSelectedScopes((previous) => { - const next = new Set(previous); - if (next.has(scope)) { - next.delete(scope); - } else { - next.add(scope); - } - return next; - }); - } - function handleProviderChange(nextProviderId: string) { setSelectedProviderId(nextProviderId); @@ -306,7 +262,6 @@ export function CreateAgentDialog({ const canSubmit = name.trim().length > 0 && - (!effectiveMintToken || selectedScopes.size > 0) && !isDiscoveryPending && !( !isProviderMode && @@ -335,9 +290,6 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, - mintToken: true, // Required: ownership established during mint - tokenName: tokenName.trim() || undefined, - tokenScopes: [...selectedScopes], spawnAfterCreate: true, startOnAppLaunch: false, // Remote agents don't auto-start with the desktop backend: { @@ -369,9 +321,6 @@ export function CreateAgentDialog({ ? Number.parseInt(parallelism, 10) : undefined, systemPrompt: systemPrompt.trim() || undefined, - mintToken: effectiveMintToken, - tokenName: tokenName.trim() || undefined, - tokenScopes: [...selectedScopes], spawnAfterCreate, startOnAppLaunch, backend: { type: "local" }, @@ -393,8 +342,7 @@ export function CreateAgentDialog({ Create agent This creates a local agent identity, syncs its display name when - possible, optionally mints a relay token, and can spawn - `sprout-acp` immediately. + possible, and can spawn `sprout-acp` immediately. @@ -467,13 +415,6 @@ export function CreateAgentDialog({ { - if (!mintToggleDisabled) { - setMintToken((current) => !current); - } - }} onToggleStartOnAppLaunch={() => { setStartOnAppLaunch((current) => !current); }} @@ -489,20 +430,6 @@ export function CreateAgentDialog({ spawnToggleDisabled={isProviderMode || spawnToggleDisabled} /> - {effectiveMintToken ? ( - (PROVIDER_REQUIRED_SCOPES) - : undefined - } - onScopeToggle={toggleScope} - onTokenNameChange={setTokenName} - selectedScopes={selectedScopes} - tokenName={tokenName} - /> - ) : null} -
@@ -326,51 +322,26 @@ export function CreateAgentRuntimeFields({ export function CreateAgentOptionToggles({ isSpawnSupported, - mintToken, - mintToggleDisabled, prereqs, startOnAppLaunch, startOnAppLaunchDisabled, spawnAfterCreate, spawnToggleDisabled, - onToggleMintToken, onToggleStartOnAppLaunch, onToggleSpawnAfterCreate, }: { isSpawnSupported: boolean; - mintToken: boolean; - mintToggleDisabled: boolean; prereqs: ManagedAgentPrereqs | null; startOnAppLaunch: boolean; /** When true, the toggle is disabled (e.g. remote agents don't support auto-start). */ startOnAppLaunchDisabled?: boolean; spawnAfterCreate: boolean; spawnToggleDisabled: boolean; - onToggleMintToken: () => void; onToggleStartOnAppLaunch: () => void; onToggleSpawnAfterCreate: () => void; }) { return ( -
- - +
- ); - })} -
-
- - ); -} diff --git a/desktop/src/features/agents/ui/ManagedAgentRow.tsx b/desktop/src/features/agents/ui/ManagedAgentRow.tsx index 7eb5494f51..f4b0d12d96 100644 --- a/desktop/src/features/agents/ui/ManagedAgentRow.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentRow.tsx @@ -6,7 +6,6 @@ import { Clipboard, Ellipsis, FileText, - KeyRound, Pencil, Play, Power, @@ -49,7 +48,6 @@ export function ManagedAgentRow({ presenceLookup, onAddToChannel, onDelete, - onMintToken, onSelectLogAgent, onStart, onStop, @@ -67,7 +65,6 @@ export function ManagedAgentRow({ presenceLookup: PresenceLookup; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -158,7 +155,6 @@ export function ManagedAgentRow({ isActive={isActive} onAddToChannel={onAddToChannel} onDelete={onDelete} - onMintToken={onMintToken} onOpenLogs={(pubkey) => onSelectLogAgent(pubkey)} onStart={onStart} onStop={onStop} @@ -307,7 +303,6 @@ function AgentActionsMenu({ isActive, onAddToChannel, onDelete, - onMintToken, onOpenLogs, onStart, onStop, @@ -318,7 +313,6 @@ function AgentActionsMenu({ isActive: boolean; onAddToChannel: (agent: ManagedAgent) => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onOpenLogs: (pubkey: string) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -391,14 +385,6 @@ function AgentActionsMenu({ Add to channel - onMintToken(agent.pubkey, agent.name)} - > - - Mint token - - { await navigator.clipboard.writeText(agent.pubkey); @@ -460,6 +446,9 @@ function AgentOriginBadge({ agent }: { agent: ManagedAgent }) { ); } +/** Grace period after mount before treating "running + no presence" as "Starting…" */ +const PRESENCE_GRACE_MS = 15_000; + function AgentStatusBadge({ presenceLoaded, presenceStatus, @@ -469,8 +458,16 @@ function AgentStatusBadge({ presenceStatus: PresenceStatus | undefined; status: ManagedAgent["status"]; }) { + const [inGracePeriod, setInGracePeriod] = React.useState(true); + + React.useEffect(() => { + const timer = setTimeout(() => setInGracePeriod(false), PRESENCE_GRACE_MS); + return () => clearTimeout(timer); + }, []); + const isActive = status === "running" || status === "deployed"; const isStarting = + !inGracePeriod && presenceLoaded && status === "running" && (!presenceStatus || presenceStatus === "offline"); diff --git a/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx new file mode 100644 index 0000000000..eecfd7e75c --- /dev/null +++ b/desktop/src/features/agents/ui/ManagedAgentSessionPanel.tsx @@ -0,0 +1,244 @@ +import * as React from "react"; +import { + CircleAlert, + CircleDot, + Clock3, + Loader2, + TerminalSquare, + XCircle, +} from "lucide-react"; + +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Skeleton } from "@/shared/ui/skeleton"; +import { AgentSessionTranscriptList } from "./AgentSessionTranscriptList"; +import { RawEventRail } from "./RawEventRail"; +import type { + ConnectionState, + ObserverEvent, + TranscriptItem, +} from "./agentSessionTypes"; +import { buildTranscript } from "./agentSessionTranscript"; +import { shorten } from "./agentSessionUtils"; +import { useObserverEvents } from "./useObserverEvents"; + +type ManagedAgentSessionPanelProps = { + agent: ManagedAgent; + channelId?: string | null; + className?: string; + emptyDescription?: string; + showHeader?: boolean; + showRaw?: boolean; +}; + +export function ManagedAgentSessionPanel({ + agent, + channelId = null, + className, + emptyDescription = "Mention this agent in a channel to watch the next turn.", + showHeader = true, + showRaw = true, +}: ManagedAgentSessionPanelProps) { + const { connectionState, errorMessage, events } = useObserverEvents( + agent.status === "running", + agent.pubkey, + ); + const scopedEvents = React.useMemo( + () => + channelId + ? events.filter((event) => event.channelId === channelId) + : events, + [channelId, events], + ); + const transcript = React.useMemo( + () => buildTranscript(scopedEvents), + [scopedEvents], + ); + const latestSessionId = React.useMemo( + () => + [...scopedEvents].reverse().find((event) => event.sessionId)?.sessionId, + [scopedEvents], + ); + + return ( +
+ {showHeader ? ( + + ) : null} + + +
+ ); +} + +function SessionHeader({ + connectionState, + eventCount, + hasObserver, + latestSessionId, +}: { + connectionState: ConnectionState; + eventCount: number; + hasObserver: boolean; + latestSessionId: string | null | undefined; +}) { + return ( +
+
+
+

+ Live ACP session +

+ +
+

+ {hasObserver + ? latestSessionId + ? `Session ${shorten(latestSessionId)}` + : "Waiting for the next agent turn." + : "Restart this local agent to attach the observer feed."} +

+
+ + {eventCount} event{eventCount === 1 ? "" : "s"} + +
+ ); +} + +function SessionBody({ + agentName, + connectionState, + emptyDescription, + errorMessage, + events, + hasObserver, + showRaw, + transcript, +}: { + agentName: string; + connectionState: ConnectionState; + emptyDescription: string; + errorMessage: string | null; + events: ObserverEvent[]; + hasObserver: boolean; + showRaw: boolean; + transcript: TranscriptItem[]; +}) { + return ( + <> + {!hasObserver ? ( + + ) : connectionState === "connecting" && events.length === 0 ? ( + + ) : ( +
+ + {showRaw ? : null} +
+ )} + + {errorMessage ? ( +

+ + {errorMessage} +

+ ) : null} + + ); +} + +function SessionLoadingSkeleton() { + return ( +
+
+
+ +
+
+
+
+ + +
+
+ + + +
+
+
+ + +
+
+ ); +} + +function ObserverStatusBadge({ state }: { state: ConnectionState }) { + const display = + state === "open" + ? { label: "Live", Icon: CircleDot, variant: "default" as const } + : state === "connecting" + ? { label: "Connecting", Icon: Loader2, variant: "secondary" as const } + : state === "error" + ? { + label: "Unavailable", + Icon: XCircle, + variant: "destructive" as const, + } + : state === "closed" + ? { label: "Closed", Icon: Clock3, variant: "secondary" as const } + : { label: "Idle", Icon: Clock3, variant: "secondary" as const }; + + return ( + + + {display.label} + + ); +} + +function EmptyObserverState() { + return ( +
+ +

Observer not attached

+

+ The live feed is available for local agents started after this update. +

+
+ ); +} diff --git a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx index 49fcd67e39..903dc823a8 100644 --- a/desktop/src/features/agents/ui/ManagedAgentsSection.tsx +++ b/desktop/src/features/agents/ui/ManagedAgentsSection.tsx @@ -34,7 +34,6 @@ export function ManagedAgentsSection({ onBulkStopRunning, onCreate, onDelete, - onMintToken, onSelectLogAgent, onStart, onStop, @@ -59,7 +58,6 @@ export function ManagedAgentsSection({ onBulkStopRunning: () => void; onCreate: () => void; onDelete: (pubkey: string) => void; - onMintToken: (pubkey: string, name: string) => void; onSelectLogAgent: (pubkey: string | null) => void; onStart: (pubkey: string) => void; onStop: (pubkey: string) => void; @@ -176,7 +174,6 @@ export function ManagedAgentsSection({ presenceLookup={presenceLookup} onAddToChannel={onAddToChannel} onDelete={onDelete} - onMintToken={onMintToken} onSelectLogAgent={onSelectLogAgent} onStart={onStart} onStop={onStop} diff --git a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx index 6dee50974b..0df75a779d 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDetailsSheet.tsx @@ -1,7 +1,6 @@ import { isCatalogPersonaSelected } from "@/features/agents/lib/catalog"; import { ProfileAvatar } from "@/features/profile/ui/ProfileAvatar"; import type { AgentPersona } from "@/shared/api/types"; -import { Card } from "@/shared/ui/card"; import { cn } from "@/shared/lib/cn"; import { promptPreview } from "@/shared/lib/promptPreview"; import { @@ -19,6 +18,8 @@ import { } from "./personaLibraryCopy"; type PersonaCatalogDetailsSheetProps = { + feedbackErrorMessage: string | null; + feedbackNoticeMessage: string | null; isPending: boolean; onOpenChange: (open: boolean) => void; onTogglePersona: (persona: AgentPersona) => void; @@ -27,6 +28,8 @@ type PersonaCatalogDetailsSheetProps = { }; export function PersonaCatalogDetailsSheet({ + feedbackErrorMessage, + feedbackNoticeMessage, isPending, onOpenChange, onTogglePersona, @@ -40,12 +43,12 @@ export function PersonaCatalogDetailsSheet({ return ( {persona ? ( -
- + <> +
+
+ - -
- +

- Type + System prompt

-

Built-in persona

- - -

- Preferred model -

-

- {persona.model ?? "Use app default"} -

-
- -

- Preferred provider -

-

- {persona.provider ?? "Use app default"} -

-
+
+                  {persona.systemPrompt}
+                
+
- - -

- System prompt -

-
-                {persona.systemPrompt}
-              
-
-
+ ) : null} diff --git a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx index 23fd5b885e..159025f7b9 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogSurface.tsx @@ -62,6 +62,8 @@ export function PersonaCatalogSurface({ /> { if (!open) { diff --git a/desktop/src/features/agents/ui/RawEventRail.tsx b/desktop/src/features/agents/ui/RawEventRail.tsx new file mode 100644 index 0000000000..74a655b77e --- /dev/null +++ b/desktop/src/features/agents/ui/RawEventRail.tsx @@ -0,0 +1,53 @@ +import * as React from "react"; + +import { Button } from "@/shared/ui/button"; +import type { ObserverEvent } from "./agentSessionTypes"; +import { describeRawEvent } from "./agentSessionTranscript"; + +export function RawEventRail({ events }: { events: ObserverEvent[] }) { + const [expanded, setExpanded] = React.useState(false); + const visible = expanded ? events : events.slice(-18); + + return ( + + ); +} diff --git a/desktop/src/features/agents/ui/SecretRevealDialog.tsx b/desktop/src/features/agents/ui/SecretRevealDialog.tsx index a49f4295e4..ad563879e4 100644 --- a/desktop/src/features/agents/ui/SecretRevealDialog.tsx +++ b/desktop/src/features/agents/ui/SecretRevealDialog.tsx @@ -23,8 +23,8 @@ export function SecretRevealDialog({ Agent created - Save the private key and token now. The app can keep running the - harness locally, but these secrets are only revealed here. + Save the private key now. The app can keep running the harness + locally, but this secret is only revealed here. @@ -51,26 +51,6 @@ export function SecretRevealDialog({
- {created.apiToken ? ( -
-
-
-

- API token -

-

- Optional for local dev, required when the relay - enforces bearer auth. -

-
- -
- - {created.apiToken} - -
- ) : null} - {created.profileSyncError ? (

{created.profileSyncError} diff --git a/desktop/src/features/agents/ui/TeamDialog.tsx b/desktop/src/features/agents/ui/TeamDialog.tsx index 9636bd9e76..2a4259aec6 100644 --- a/desktop/src/features/agents/ui/TeamDialog.tsx +++ b/desktop/src/features/agents/ui/TeamDialog.tsx @@ -416,8 +416,9 @@ export function TeamDialog({ > togglePersona(persona.id)} + tabIndex={-1} /> void; -}) { - return ( -

- -
- - Agent token minted - - Save this token now. Restart the harness if you want the running - agent to pick it up immediately. - - - -
-
-
-
-

{name}

-

- Token shown once only. -

-
- {token ? : null} -
- {token ? ( - - {token} - - ) : null} -
-
- -
- -
-
-
-
- ); -} diff --git a/desktop/src/features/agents/ui/agentSessionToolCatalog.ts b/desktop/src/features/agents/ui/agentSessionToolCatalog.ts new file mode 100644 index 0000000000..4ff11e0b41 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionToolCatalog.ts @@ -0,0 +1,286 @@ +import { + CheckCircle2, + CircleDot, + Clock3, + Hash, + MessageSquare, + Search, + Send, + Users, + Workflow, + XCircle, +} from "lucide-react"; + +import type { SproutToolInfo, ToolStatus } from "./agentSessionTypes"; + +export function normalizeToolStatus(status: string): ToolStatus { + const normalized = status.toLowerCase(); + if ( + normalized.includes("complete") || + normalized.includes("success") || + normalized === "done" + ) { + return "completed"; + } + if (normalized.includes("fail") || normalized.includes("error")) { + return "failed"; + } + if (normalized.includes("pending")) { + return "pending"; + } + return "executing"; +} + +export function getToolStatusDisplay(status: ToolStatus, isError: boolean) { + if (isError || status === "failed") { + return { + label: "Error", + Icon: XCircle, + state: "output-error" as const, + variant: "destructive" as const, + }; + } + if (status === "completed") { + return { + label: "Done", + Icon: CheckCircle2, + state: "output-available" as const, + variant: "secondary" as const, + }; + } + if (status === "pending") { + return { + label: "Pending", + Icon: CircleDot, + state: "input-streaming" as const, + variant: "secondary" as const, + }; + } + return { + label: "Running", + Icon: Clock3, + state: "input-available" as const, + variant: "secondary" as const, + }; +} + +const SPROUT_READ_TOOLS = new Set([ + "get_messages", + "get_channel_history", + "get_thread", + "search", + "get_feed", + "get_reactions", + "list_channels", + "get_channel", + "get_users", + "get_presence", + "list_channel_members", + "list_dms", + "get_canvas", + "list_workflows", + "get_workflow_runs", + "get_event", + "get_user_notes", + "get_contact_list", +]); + +const SPROUT_WRITE_TOOLS = new Set([ + "send_message", + "send_diff_message", + "edit_message", + "delete_message", + "add_reaction", + "remove_reaction", + "join_channel", + "leave_channel", + "update_channel", + "set_channel_topic", + "set_channel_purpose", + "open_dm", + "set_profile", + "set_presence", + "trigger_workflow", + "approve_step", + "create_channel", + "archive_channel", + "unarchive_channel", + "add_channel_member", + "remove_channel_member", + "add_dm_member", + "hide_dm", + "set_canvas", + "create_workflow", + "update_workflow", + "delete_workflow", + "set_channel_add_policy", + "vote_on_post", + "publish_note", + "set_contact_list", +]); + +const SPROUT_TOOL_NAMES = new Set([ + ...SPROUT_READ_TOOLS, + ...SPROUT_WRITE_TOOLS, +]); + +const SPROUT_TOOL_NAMES_BY_LENGTH = [...SPROUT_TOOL_NAMES].sort( + (left, right) => right.length - left.length, +); + +const SPROUT_TOOL_TITLE_ALIASES: Array<[RegExp, string]> = [ + [/\bsending message to channel\b/, "send_message"], + [/\bretrieving recent messages from channel\b/, "get_messages"], + [/\bgetting channel details\b/, "get_channel"], + [/\bgetting user information\b/, "get_users"], + [/\bsearching relay history\b/, "search"], + [/\bgetting thread\b/, "get_thread"], + [/\badding reaction\b/, "add_reaction"], + [/\bremoving reaction\b/, "remove_reaction"], +]; + +export function getSproutToolInfo(title: string): SproutToolInfo | null { + const name = normalizeToolName(title); + const isRead = SPROUT_READ_TOOLS.has(name); + const isWrite = SPROUT_WRITE_TOOLS.has(name); + if (!isRead && !isWrite) { + return null; + } + + if (name.includes("workflow") || name === "approve_step") { + return { + icon: Workflow, + label: isRead + ? "Reads workflow state from Sprout." + : "Updates workflow state in Sprout.", + tone: isWrite ? "write" : "read", + }; + } + if ( + name.includes("channel") || + name.includes("messages") || + name === "get_thread" + ) { + return { + icon: Hash, + label: isRead + ? "Reads channel context from the Sprout relay." + : "Changes channel state in the Sprout relay.", + tone: isWrite ? "write" : "read", + }; + } + if ( + name.includes("user") || + name.includes("member") || + name.includes("presence") + ) { + return { + icon: Users, + label: isRead + ? "Reads Sprout identity or presence data." + : "Updates Sprout identity or membership data.", + tone: isWrite ? "write" : "admin", + }; + } + if (name.includes("search") || name === "get_feed") { + return { + icon: Search, + label: "Searches relay-visible Sprout history.", + tone: "read", + }; + } + if ( + name.startsWith("send_") || + name.includes("reaction") || + name === "publish_note" + ) { + return { + icon: Send, + label: "Publishes relay-visible Sprout activity.", + tone: "write", + }; + } + + return { + icon: MessageSquare, + label: isRead ? "Reads from Sprout." : "Writes to Sprout.", + tone: isWrite ? "write" : "read", + }; +} + +export function normalizeToolName(title: string): string { + const knownName = findSproutToolName(title, true); + if (knownName) return knownName; + + const normalized = normalizeToolNameText(title) + .replace(/^sprout_mcp_/, "") + .replace(/^sprout_/, ""); + return normalized.match(/[a-z][a-z0-9_]+/)?.[0] ?? normalized; +} + +export function normalizeToolNameText(value: string): string { + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9_]+/g, "_") + .replace(/_+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +export function findSproutToolName(value: string, includeShortNames: boolean) { + const alias = findSproutToolAlias(value); + if (alias) return alias; + + const normalized = normalizeToolNameText(value); + return ( + SPROUT_TOOL_NAMES_BY_LENGTH.find( + (name) => + (includeShortNames || name.length >= 8) && normalized.includes(name), + ) ?? null + ); +} + +function findSproutToolAlias(value: string) { + const normalizedPhrase = value + .trim() + .toLowerCase() + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " "); + return ( + SPROUT_TOOL_TITLE_ALIASES.find(([pattern]) => + pattern.test(normalizedPhrase), + )?.[1] ?? null + ); +} + +export function isGenericToolTitle(value: string): boolean { + const normalized = normalizeToolNameText(value); + return ( + normalized.length === 0 || + normalized === "tool" || + normalized === "tool_call" || + normalized === "mcp_tool_call" || + normalized === "unknown" || + normalized === "read" || + normalized === "write" || + normalized === "execute" || + normalized === "completed" + ); +} + +export function formatToolTitle( + toolName: string, + fallbackTitle?: string, +): string { + const name = normalizeToolName(toolName); + if (SPROUT_READ_TOOLS.has(name) || SPROUT_WRITE_TOOLS.has(name)) { + return name + .split("_") + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); + } + if (fallbackTitle && !isGenericToolTitle(fallbackTitle)) { + return fallbackTitle; + } + return toolName; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts new file mode 100644 index 0000000000..e10af80caf --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -0,0 +1,335 @@ +import type { + ObserverEvent, + PromptSection, + ToolStatus, + TranscriptItem, +} from "./agentSessionTypes"; +import { + findSproutToolName, + isGenericToolTitle, + normalizeToolStatus, +} from "./agentSessionToolCatalog"; +import { asRecord, asString } from "./agentSessionUtils"; +import { + describeTurnStarted, + describeSessionResolved, + extractBlockText, + extractContentText, + extractPromptText, + extractToolArgs, + extractToolIdentity, + extractToolResult, + parsePromptText, +} from "./agentSessionTranscriptHelpers"; + +export { describeRawEvent } from "./agentSessionTranscriptHelpers"; + +export function buildTranscript(events: ObserverEvent[]): TranscriptItem[] { + const items: TranscriptItem[] = []; + const itemsById = new Map(); + + // Maps a logical message ID (e.g. `assistant:msg1`) to the *actual* key + // currently being appended to. When a non-message item interleaves, we + // seal the current key so subsequent chunks create a fresh entry. + const activeMessageKey = new Map(); + const sealedKeys = new Set(); + let continuationSeq = 0; + + /** Seal every currently-open message so the next chunk starts a new entry. */ + function sealOpenMessages() { + for (const [, currentKey] of activeMessageKey) { + if (!sealedKeys.has(currentKey)) { + sealedKeys.add(currentKey); + } + } + } + + function upsertMessage( + id: string, + role: "assistant" | "user", + title: string, + text: string, + timestamp: string, + ) { + const currentKey = activeMessageKey.get(id); + + // If there is an active (non-sealed) key, append to it. + if (currentKey && !sealedKeys.has(currentKey)) { + const existing = itemsById.get(currentKey); + if (existing?.type === "message") { + existing.text += text; + return; + } + } + + // Otherwise create a new entry (either first time, or continuation). + continuationSeq += 1; + const newKey = currentKey ? `${id}:c${continuationSeq}` : id; + const item: TranscriptItem = { + id: newKey, + type: "message", + role, + title, + text, + timestamp, + }; + items.push(item); + itemsById.set(newKey, item); + activeMessageKey.set(id, newKey); + } + + function upsertTextItem( + id: string, + type: "thought" | "lifecycle", + title: string, + text: string, + timestamp: string, + ) { + const existing = itemsById.get(id); + if (existing && existing.type === type) { + existing.text += text; + return; + } + sealOpenMessages(); + const item: TranscriptItem = { id, type, title, text, timestamp }; + items.push(item); + itemsById.set(id, item); + } + + function upsertMetadata( + id: string, + title: string, + sections: PromptSection[], + timestamp: string, + ) { + const existing = itemsById.get(id); + if (existing?.type === "metadata") { + existing.sections = sections; + return; + } + sealOpenMessages(); + const item: TranscriptItem = { + id, + type: "metadata", + title, + sections, + timestamp, + }; + items.push(item); + itemsById.set(id, item); + } + + function upsertTool( + id: string, + title: string, + toolName: string, + sproutToolName: string | null, + status: ToolStatus, + args: Record, + result: string, + isError: boolean, + timestamp: string, + ) { + const existing = itemsById.get(id); + const canonicalSproutToolName = + sproutToolName ?? findSproutToolName(toolName, true); + if (existing?.type === "tool") { + if (!isGenericToolTitle(title)) { + existing.title = title; + } + if (canonicalSproutToolName) { + existing.sproutToolName = canonicalSproutToolName; + existing.toolName = canonicalSproutToolName; + } else if (!existing.sproutToolName && !isGenericToolTitle(toolName)) { + existing.toolName = toolName; + } + existing.status = status; + existing.args = Object.keys(args).length > 0 ? args : existing.args; + if (result) existing.result = result; + existing.isError = isError || existing.isError; + if ( + (status === "completed" || status === "failed") && + existing.completedAt == null + ) { + existing.completedAt = timestamp; + } + return; + } + sealOpenMessages(); + const item: TranscriptItem = { + id, + type: "tool", + title, + toolName: canonicalSproutToolName ?? toolName, + sproutToolName: canonicalSproutToolName, + status, + args, + result, + isError, + timestamp, + startedAt: timestamp, + completedAt: null, + }; + items.push(item); + itemsById.set(id, item); + } + + for (const event of events) { + if (event.kind === "turn_started") { + upsertTextItem( + `turn:${event.turnId ?? event.seq}`, + "lifecycle", + "Turn started", + describeTurnStarted(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind === "session_resolved") { + upsertTextItem( + `session:${event.turnId ?? event.seq}`, + "lifecycle", + "Session ready", + describeSessionResolved(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind === "acp_parse_error") { + upsertTextItem( + `parse-error:${event.seq}`, + "lifecycle", + "Wire parse error", + extractBlockText(event.payload), + event.timestamp, + ); + continue; + } + + if (event.kind !== "acp_read" && event.kind !== "acp_write") { + continue; + } + + const payload = asRecord(event.payload); + const method = asString(payload.method); + + if (event.kind === "acp_write" && method === "session/prompt") { + const promptText = extractPromptText(payload); + if (promptText) { + const parsedPrompt = parsePromptText(promptText); + if (parsedPrompt.userText) { + upsertMessage( + `prompt:${event.turnId ?? event.seq}`, + "user", + parsedPrompt.userTitle, + parsedPrompt.userText, + event.timestamp, + ); + } + if (parsedPrompt.sections.length > 0) { + upsertMetadata( + `prompt-context:${event.turnId ?? event.seq}`, + "Prompt context", + parsedPrompt.sections, + event.timestamp, + ); + } + } + continue; + } + + if (event.kind !== "acp_read" || method !== "session/update") { + continue; + } + + const params = asRecord(payload.params); + const update = asRecord(params.update); + const updateType = asString(update.sessionUpdate) ?? "unknown"; + const turnKey = event.turnId ?? event.sessionId ?? "unknown"; + const messageId = asString(update.messageId); + + if (updateType === "agent_message_chunk") { + upsertMessage( + `assistant:${messageId ?? turnKey}`, + "assistant", + "Assistant", + extractContentText(update.content), + event.timestamp, + ); + continue; + } + + if (updateType === "user_message_chunk") { + upsertMessage( + `user:${messageId ?? turnKey}`, + "user", + "User", + extractContentText(update.content), + event.timestamp, + ); + continue; + } + + if (updateType === "agent_thought_chunk") { + upsertTextItem( + `thinking:${messageId ?? turnKey}`, + "thought", + "Thinking", + extractContentText(update.content), + event.timestamp, + ); + continue; + } + + if (updateType === "tool_call") { + const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; + const identity = extractToolIdentity(update); + upsertTool( + `tool:${toolId}`, + identity.title, + identity.toolName, + identity.sproutToolName, + normalizeToolStatus(asString(update.status) ?? "executing"), + extractToolArgs(update), + extractToolResult(update), + false, + event.timestamp, + ); + continue; + } + + if (updateType === "tool_call_update") { + const toolId = asString(update.toolCallId) ?? `tool:${event.seq}`; + const status = normalizeToolStatus( + asString(update.status) ?? "completed", + ); + const identity = extractToolIdentity(update); + upsertTool( + `tool:${toolId}`, + identity.title, + identity.toolName, + identity.sproutToolName, + status, + extractToolArgs(update), + extractToolResult(update), + status === "failed", + event.timestamp, + ); + continue; + } + + if (updateType === "plan") { + upsertTextItem( + `plan:${turnKey}`, + "thought", + "Plan", + extractContentText(update.content) || JSON.stringify(update, null, 2), + event.timestamp, + ); + } + } + + return items; +} diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts new file mode 100644 index 0000000000..74f86e2396 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -0,0 +1,215 @@ +import type { ObserverEvent, PromptSection } from "./agentSessionTypes"; +import { + findSproutToolName, + isGenericToolTitle, + normalizeToolName, +} from "./agentSessionToolCatalog"; +import { asRecord, asString, titleCase } from "./agentSessionUtils"; + +export function extractPromptText(payload: Record): string { + const params = asRecord(payload.params); + const prompt = params.prompt; + if (!Array.isArray(prompt)) return ""; + return prompt.map(extractBlockText).filter(Boolean).join("\n"); +} + +export function parsePromptText(text: string): { + sections: PromptSection[]; + userText: string; + userTitle: string; +} { + const sections = parsePromptSections(text); + if (sections.length === 0) { + return { sections: [], userText: text.trim(), userTitle: "Prompt" }; + } + + const eventSection = sections.find((section) => + section.title.toLowerCase().startsWith("sprout event"), + ); + const eventContent = eventSection + ? extractEventContent(eventSection.body) + : ""; + const eventKind = eventSection?.title.split(":").slice(1).join(":").trim(); + + return { + sections, + userText: eventContent, + userTitle: eventKind ? titleCase(eventKind) : "Sprout event", + }; +} + +function parsePromptSections(text: string): PromptSection[] { + const sections: PromptSection[] = []; + let current: PromptSection | null = null; + const preamble: string[] = []; + + for (const line of text.split(/\r?\n/)) { + const header = line.match(/^\[([^\]]+)]\s*$/); + if (header) { + if (current) { + sections.push({ + title: current.title, + body: current.body.trim(), + }); + } else if (preamble.join("\n").trim()) { + sections.push({ title: "Prompt", body: preamble.join("\n").trim() }); + } + current = { title: header[1], body: "" }; + continue; + } + + if (current) { + current.body += current.body ? `\n${line}` : line; + } else { + preamble.push(line); + } + } + + if (current) { + sections.push({ title: current.title, body: current.body.trim() }); + } else if (preamble.join("\n").trim()) { + sections.push({ title: "Prompt", body: preamble.join("\n").trim() }); + } + + return sections; +} + +function extractEventContent(body: string): string { + const contentMatch = body.match(/^Content:\s*(.*)$/m); + return contentMatch?.[1]?.trim() ?? ""; +} + +export function extractContentText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(extractBlockText).join("\n"); + return extractBlockText(value); +} + +export function extractBlockText(value: unknown): string { + if (typeof value === "string") return value; + if (Array.isArray(value)) return value.map(extractBlockText).join("\n"); + const record = asRecord(value); + const nestedContent = record.content; + const rawOutput = record.rawOutput; + const nestedText = + nestedContent && typeof nestedContent === "object" + ? extractBlockText(nestedContent) + : ""; + const rawOutputText = + rawOutput === undefined || rawOutput === null + ? "" + : typeof rawOutput === "string" + ? rawOutput + : JSON.stringify(rawOutput, null, 2); + const directText = asString(record.text) ?? asString(record.content); + return directText || nestedText || rawOutputText || ""; +} + +export function extractToolArgs( + update: Record, +): Record { + const candidates = [ + update.args, + update.arguments, + update.input, + update.rawInput, + ]; + for (const candidate of candidates) { + if ( + candidate && + typeof candidate === "object" && + !Array.isArray(candidate) + ) { + return candidate as Record; + } + } + return {}; +} + +export function extractToolIdentity(update: Record): { + title: string; + toolName: string; + sproutToolName: string | null; +} { + const candidates = collectToolNameCandidates(update); + const knownName = + candidates + .map((candidate) => findSproutToolName(candidate, true)) + .find((candidate): candidate is string => Boolean(candidate)) ?? + findSproutToolName(JSON.stringify(update), false); + const firstSpecific = candidates.find( + (candidate) => !isGenericToolTitle(candidate), + ); + const title = + asString(update.title) ?? knownName ?? firstSpecific ?? "Tool call"; + return { + title, + toolName: knownName ?? normalizeToolName(firstSpecific ?? title), + sproutToolName: knownName, + }; +} + +function collectToolNameCandidates(update: Record): string[] { + const args = extractToolArgs(update); + const tool = asRecord(update.tool); + const input = asRecord(update.input); + const rawInput = asRecord(update.rawInput); + const candidates = [ + update.toolName, + update.tool_name, + update.name, + update.title, + update.kind, + tool.name, + tool.toolName, + args.toolName, + args.tool_name, + args.name, + args.method, + input.toolName, + input.tool_name, + input.name, + rawInput.toolName, + rawInput.tool_name, + rawInput.name, + ]; + + return candidates.flatMap((candidate) => { + const value = asString(candidate); + return value ? [value] : []; + }); +} + +export function extractToolResult(update: Record): string { + const contentText = extractContentText(update.content); + if (contentText) return contentText; + return extractBlockText(update.rawOutput); +} + +export function describeTurnStarted(payload: unknown): string { + const record = asRecord(payload); + const ids = Array.isArray(record.triggeringEventIds) + ? record.triggeringEventIds.filter( + (id): id is string => typeof id === "string", + ) + : []; + return ids.length > 0 + ? `Triggered by ${ids.length === 1 ? "1 event" : `${ids.length} events`}.` + : ""; +} + +export function describeSessionResolved(payload: unknown): string { + const record = asRecord(payload); + const isNewSession = record.isNewSession === true; + return isNewSession ? "New session created." : ""; +} + +export function describeRawEvent(event: ObserverEvent): string { + const payload = asRecord(event.payload); + const method = asString(payload.method); + if (method === "session/update") { + const update = asRecord(asRecord(payload.params).update); + return asString(update.sessionUpdate) ?? method; + } + return method ?? event.kind; +} diff --git a/desktop/src/features/agents/ui/agentSessionTypes.ts b/desktop/src/features/agents/ui/agentSessionTypes.ts new file mode 100644 index 0000000000..678ca3d332 --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionTypes.ts @@ -0,0 +1,77 @@ +import type { LucideIcon } from "lucide-react"; + +export type ObserverEvent = { + seq: number; + timestamp: string; + kind: string; + agentIndex: number | null; + channelId: string | null; + sessionId: string | null; + turnId: string | null; + payload: unknown; +}; + +export type ConnectionState = + | "idle" + | "connecting" + | "open" + | "closed" + | "error"; + +export type ToolStatus = "executing" | "completed" | "failed" | "pending"; + +export type TranscriptItem = + | { + id: string; + type: "message"; + role: "assistant" | "user"; + title: string; + text: string; + timestamp: string; + } + | { + id: string; + type: "thought"; + title: string; + text: string; + timestamp: string; + } + | { + id: string; + type: "lifecycle"; + title: string; + text: string; + timestamp: string; + } + | { + id: string; + type: "metadata"; + title: string; + sections: PromptSection[]; + timestamp: string; + } + | { + id: string; + type: "tool"; + title: string; + toolName: string; + sproutToolName: string | null; + status: ToolStatus; + args: Record; + result: string; + isError: boolean; + timestamp: string; + startedAt: string; + completedAt: string | null; + }; + +export type PromptSection = { + title: string; + body: string; +}; + +export type SproutToolInfo = { + icon: LucideIcon; + label: string; + tone: "read" | "write" | "admin"; +}; diff --git a/desktop/src/features/agents/ui/agentSessionUtils.ts b/desktop/src/features/agents/ui/agentSessionUtils.ts new file mode 100644 index 0000000000..ae3a147abd --- /dev/null +++ b/desktop/src/features/agents/ui/agentSessionUtils.ts @@ -0,0 +1,133 @@ +export function getToolString( + record: Record, + keys: string[], +): string | null { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return value.trim(); + } + } + return null; +} + +export function getToolStringList( + record: Record, + keys: string[], +): string[] { + for (const key of keys) { + const value = record[key]; + if (typeof value === "string" && value.trim().length > 0) { + return [value.trim()]; + } + if (Array.isArray(value)) { + return value.filter( + (item): item is string => + typeof item === "string" && item.trim().length > 0, + ); + } + } + return []; +} + +export function getResultArray( + resultValue: unknown, + resultRecord: Record, + key: string, +) { + if (Array.isArray(resultValue)) return resultValue; + const value = resultRecord[key]; + return Array.isArray(value) ? value : null; +} + +export function formatCodeValue(value: string): string { + const trimmed = value.trim(); + if (!trimmed) return value; + try { + return JSON.stringify(JSON.parse(trimmed), null, 2); + } catch { + return value; + } +} + +export function titleCase(value: string): string { + return value + .replace(/[_-]+/g, " ") + .replace(/\s+/g, " ") + .trim() + .replace(/\b\w/g, (match) => match.toUpperCase()); +} + +export function asRecord(value: unknown): Record { + return value && typeof value === "object" + ? (value as Record) + : {}; +} + +export function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +export function shorten(value: string) { + return value.length > 14 + ? `${value.slice(0, 8)}...${value.slice(-4)}` + : value; +} + +export function shortenMiddle(value: string, maxLength: number) { + if (value.length <= maxLength) return value; + const edgeLength = Math.max(4, Math.floor((maxLength - 3) / 2)); + return `${value.slice(0, edgeLength)}...${value.slice(-edgeLength)}`; +} + +const sameDayTimeFormat = new Intl.DateTimeFormat(undefined, { + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +const crossDayTimeFormat = new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + hour: "numeric", + minute: "2-digit", + second: "2-digit", +}); + +export function formatTranscriptTime(isoTimestamp: string): string | null { + const date = new Date(isoTimestamp); + if (Number.isNaN(date.getTime())) return null; + const now = new Date(); + const sameDay = + date.getFullYear() === now.getFullYear() && + date.getMonth() === now.getMonth() && + date.getDate() === now.getDate(); + return sameDay + ? sameDayTimeFormat.format(date) + : crossDayTimeFormat.format(date); +} + +export function formatDuration( + startIso: string, + endIso: string, +): string | null { + if (!startIso || !endIso) return null; + const start = new Date(startIso).getTime(); + const end = new Date(endIso).getTime(); + if (Number.isNaN(start) || Number.isNaN(end)) return null; + const ms = end - start; + if (ms < 0) return null; + const totalSeconds = ms / 1000; + if (totalSeconds < 60) { + return totalSeconds < 10 + ? `${totalSeconds.toFixed(1)}s` + : `${Math.round(totalSeconds)}s`; + } + let minutes = Math.floor(totalSeconds / 60); + let seconds = Math.round(totalSeconds % 60); + if (seconds === 60) { + minutes += 1; + seconds = 0; + } + return seconds > 0 ? `${minutes}m ${seconds}s` : `${minutes}m`; +} diff --git a/desktop/src/features/agents/ui/personaDialogState.test.mjs b/desktop/src/features/agents/ui/personaDialogState.test.mjs index 427a9d71d8..391ca7d9a1 100644 --- a/desktop/src/features/agents/ui/personaDialogState.test.mjs +++ b/desktop/src/features/agents/ui/personaDialogState.test.mjs @@ -49,7 +49,7 @@ test("duplicatePersonaDialogState copies persona fields into a new draft", () => test("editPersonaDialogState preserves the persona id for updates", () => { const state = editPersonaDialogState({ id: "persona-2", - displayName: "Ralph", + displayName: "Kit", avatarUrl: null, systemPrompt: "Keep it weird.", provider: null, @@ -65,7 +65,7 @@ test("editPersonaDialogState preserves the persona id for updates", () => { assert.equal(state.submitLabel, "Save changes"); assert.deepEqual(state.initialValues, { id: "persona-2", - displayName: "Ralph", + displayName: "Kit", avatarUrl: "", systemPrompt: "Keep it weird.", provider: undefined, diff --git a/desktop/src/features/agents/ui/useManagedAgentActions.ts b/desktop/src/features/agents/ui/useManagedAgentActions.ts index c91d4f7f02..1e8e539a77 100644 --- a/desktop/src/features/agents/ui/useManagedAgentActions.ts +++ b/desktop/src/features/agents/ui/useManagedAgentActions.ts @@ -4,7 +4,6 @@ import { type AttachManagedAgentToChannelResult, useManagedAgentLogQuery, useManagedAgentsQuery, - useMintManagedAgentTokenMutation, useRelayAgentsQuery, useSetManagedAgentStartOnAppLaunchMutation, useStartManagedAgentMutation, @@ -13,6 +12,7 @@ import { } from "@/features/agents/hooks"; import { useChannelsQuery } from "@/features/channels/hooks"; import { usePresenceQuery } from "@/features/presence/hooks"; +import { useManagedAgentObserverBridge } from "@/features/agents/observerRelayStore"; import type { Channel, CreateManagedAgentResponse, @@ -35,17 +35,11 @@ export function useManagedAgentActions() { const stopMutation = useStopManagedAgentMutation(); const deleteMutation = useDeleteManagedAgentMutation(); const startOnLaunchMutation = useSetManagedAgentStartOnAppLaunchMutation(); - const mintTokenMutation = useMintManagedAgentTokenMutation(); - const [isCreateOpen, setIsCreateOpen] = React.useState(false); const [agentToAddToChannel, setAgentToAddToChannel] = React.useState(null); const [createdAgent, setCreatedAgent] = React.useState(null); - const [revealedToken, setRevealedToken] = React.useState<{ - name: string; - token: string; - } | null>(null); const [logAgentPubkey, setLogAgentPubkey] = React.useState( null, ); @@ -69,6 +63,7 @@ export function useManagedAgentActions() { }), [managedAgentsQuery.data], ); + useManagedAgentObserverBridge(managedAgents); const managedPubkeys = React.useMemo( () => new Set(managedAgents.map((agent) => agent.pubkey)), @@ -208,21 +203,6 @@ export function useManagedAgentActions() { } } - async function handleMintToken(pubkey: string, name: string) { - clearFeedback(); - try { - const result = await mintTokenMutation.mutateAsync({ - pubkey, - tokenName: `${name}-token`, - }); - setRevealedToken({ name, token: result.token }); - } catch (error) { - setActionErrorMessage( - error instanceof Error ? error.message : "Failed to mint token.", - ); - } - } - function handleAddedToChannel( channel: Channel, result: AttachManagedAgentToChannelResult, @@ -316,8 +296,7 @@ export function useManagedAgentActions() { startMutation.isPending || stopMutation.isPending || startOnLaunchMutation.isPending || - deleteMutation.isPending || - mintTokenMutation.isPending; + deleteMutation.isPending; return { // Queries @@ -337,8 +316,6 @@ export function useManagedAgentActions() { setAgentToAddToChannel, createdAgent, setCreatedAgent, - revealedToken, - setRevealedToken, logAgentPubkey, setLogAgentPubkey, actionNoticeMessage, @@ -350,7 +327,6 @@ export function useManagedAgentActions() { handleStop, handleDelete, handleToggleStartOnAppLaunch, - handleMintToken, handleAddedToChannel, handleBulkStopRunning, handleBulkRemoveStopped, diff --git a/desktop/src/features/agents/ui/useObserverEvents.ts b/desktop/src/features/agents/ui/useObserverEvents.ts new file mode 100644 index 0000000000..838ba575d5 --- /dev/null +++ b/desktop/src/features/agents/ui/useObserverEvents.ts @@ -0,0 +1,50 @@ +import * as React from "react"; + +import { + ensureRelayObserverSubscription, + getAgentObserverSnapshot, + subscribeAgentObserverStore, +} from "@/features/agents/observerRelayStore"; +import type { ConnectionState, ObserverEvent } from "./agentSessionTypes"; + +export function useObserverEvents( + enabled: boolean, + agentPubkey?: string | null, +) { + const [events, setEvents] = React.useState([]); + const [connectionState, setConnectionState] = + React.useState("idle"); + const [errorMessage, setErrorMessage] = React.useState(null); + + React.useEffect(() => { + setEvents([]); + setErrorMessage(null); + + if (!enabled) { + setConnectionState("idle"); + return; + } + + if (!agentPubkey) { + setConnectionState("idle"); + return; + } + + const syncSnapshot = () => { + const snapshot = getAgentObserverSnapshot(agentPubkey); + setConnectionState(snapshot.connectionState); + setErrorMessage(snapshot.errorMessage); + setEvents(snapshot.events); + }; + + syncSnapshot(); + const unsubscribe = subscribeAgentObserverStore(syncSnapshot); + void ensureRelayObserverSubscription(); + + return () => { + unsubscribe(); + }; + }, [agentPubkey, enabled]); + + return { connectionState, errorMessage, events }; +} diff --git a/desktop/src/features/agents/usePreventSleep.ts b/desktop/src/features/agents/usePreventSleep.ts new file mode 100644 index 0000000000..ff5797b2d2 --- /dev/null +++ b/desktop/src/features/agents/usePreventSleep.ts @@ -0,0 +1,90 @@ +import * as React from "react"; +import { useManagedAgentsQuery } from "@/features/agents/hooks"; +import { setPreventSleepActive } from "@/shared/api/tauri"; +import { listen } from "@tauri-apps/api/event"; + +// Intentionally not scoped per-pubkey — multi-user desktop is rare and the +// setting applies to the machine's sleep behavior regardless of account. +const STORAGE_KEY = "sprout-prevent-sleep"; + +function readPreference(): boolean { + return window.localStorage.getItem(STORAGE_KEY) === "true"; +} + +function writePreference(enabled: boolean) { + window.localStorage.setItem(STORAGE_KEY, String(enabled)); +} + +interface PreventSleepValue { + enabled: boolean; + setEnabled: (value: boolean) => void; + active: boolean; + hasRunningAgents: boolean; + expired: boolean; + clearExpired: () => void; +} + +const PreventSleepContext = React.createContext(null); + +export function PreventSleepProvider({ + children, +}: { + children: React.ReactNode; +}) { + const value = usePreventSleepInternal(); + return React.createElement(PreventSleepContext.Provider, { value }, children); +} + +export function usePreventSleepContext(): PreventSleepValue { + const ctx = React.useContext(PreventSleepContext); + if (!ctx) { + throw new Error( + "usePreventSleepContext must be used within a PreventSleepProvider", + ); + } + return ctx; +} + +function usePreventSleepInternal() { + const [enabled, setEnabledState] = React.useState(readPreference); + const { data: agents } = useManagedAgentsQuery(); + + // Only local "running" agents need sleep prevention. Remote "deployed" + // agents run on provider infrastructure and are unaffected by local sleep. + const hasRunningAgents = React.useMemo( + () => agents?.some((agent) => agent.status === "running") ?? false, + [agents], + ); + + // Listen for 4-hour expiry + const [expired, setExpired] = React.useState(false); + + const active = enabled && hasRunningAgents && !expired; + + const setEnabled = React.useCallback((value: boolean) => { + writePreference(value); + setEnabledState(value); + }, []); + + // Sync active state to backend + React.useEffect(() => { + void setPreventSleepActive(active); + }, [active]); + React.useEffect(() => { + const unlisten = listen("prevent-sleep-expired", () => { + setExpired(true); + }); + return () => { + void unlisten.then((fn) => fn()); + }; + }, []); + + return { + enabled, + setEnabled, + active, + hasRunningAgents, + expired, + clearExpired: () => setExpired(false), + }; +} diff --git a/desktop/src/features/channels/lib/channelDescription.ts b/desktop/src/features/channels/lib/channelDescription.ts index 4cfcdff0e2..ee445d9a7f 100644 --- a/desktop/src/features/channels/lib/channelDescription.ts +++ b/desktop/src/features/channels/lib/channelDescription.ts @@ -5,16 +5,18 @@ export function getChannelDescription(channel: Channel | null): string { return "Connect to the relay to browse channels and read messages."; } - return ( - [ - channel.archivedAt ? "Archived." : null, - !channel.isMember ? "Read-only until you join this open channel." : null, - channel.topic, - channel.description, - channel.purpose, - null, - ] - .filter((value) => value && value.trim().length > 0) - .join(" ") || "Channel details and activity." + const prefixes = [ + channel.archivedAt ? "Archived." : null, + !channel.isMember ? "Read-only until you join this open channel." : null, + ].filter((value) => value && value.trim().length > 0); + + // Show only the first non-empty field to avoid duplication when + // topic, description, and purpose contain overlapping text. + const detail = [channel.topic, channel.description, channel.purpose].find( + (value) => value && value.trim().length > 0, ); + + const parts = [...prefixes, detail ?? null].filter(Boolean); + + return parts.length > 0 ? parts.join(" ") : "Channel details and activity."; } diff --git a/desktop/src/features/channels/readState/readStateFormat.ts b/desktop/src/features/channels/readState/readStateFormat.ts new file mode 100644 index 0000000000..af6fe7e094 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateFormat.ts @@ -0,0 +1,79 @@ +export interface ReadStateBlob { + v: 1; + client_id: string; + contexts: Record; +} + +export const READ_STATE_D_TAG_PREFIX = "read-state:"; +export const READ_STATE_FETCH_LIMIT = 500; +export const READ_STATE_HORIZON_SECONDS = 7 * 24 * 60 * 60; + +const MAX_CONTEXTS = 10_000; + +export function localReadStateKey(pubkey: string): string { + return `sprout.channel-read-state.v2:${pubkey}`; +} + +export function localPublishableContextKey(pubkey: string): string { + return `sprout.channel-read-state.publishable.v1:${pubkey}`; +} + +export function isPlainRecord( + value: unknown, +): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +export function isValidBlob(obj: unknown): obj is ReadStateBlob { + if (!isPlainRecord(obj)) return false; + const record = obj; + if (record.v !== 1) return false; + if ( + typeof record.client_id !== "string" || + record.client_id.length === 0 || + record.client_id.length > 64 + ) + return false; + if (!isPlainRecord(record.contexts)) return false; + if (Object.keys(record.contexts).length > MAX_CONTEXTS) return false; + return true; +} + +export function sanitizeContexts( + contexts: Record, +): Record { + const result: Record = {}; + for (const [key, value] of Object.entries(contexts)) { + if (new TextEncoder().encode(key).length > 256) continue; + if (typeof value !== "number" || !Number.isInteger(value)) continue; + if (value < 0 || value > 4294967295) continue; + result[key] = value; + } + return result; +} + +export function isValidReadStateDTag( + value: string | undefined, +): value is string { + if (!value?.startsWith(READ_STATE_D_TAG_PREFIX)) return false; + const slotId = value.slice(READ_STATE_D_TAG_PREFIX.length); + return slotId.length > 0 && slotId.length <= 64 && isAscii(slotId); +} + +export function localIsoToUnixSeconds(value: unknown): number | null { + if (typeof value !== "string" || value.length === 0) { + return null; + } + + const ms = Date.parse(value); + return Number.isNaN(ms) ? null : Math.floor(ms / 1_000); +} + +function isAscii(value: string): boolean { + for (let index = 0; index < value.length; index += 1) { + if (value.charCodeAt(index) > 0x7f) { + return false; + } + } + return true; +} diff --git a/desktop/src/features/channels/readState/readStateManager.ts b/desktop/src/features/channels/readState/readStateManager.ts new file mode 100644 index 0000000000..154eca0967 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateManager.ts @@ -0,0 +1,484 @@ +import { + nip44EncryptToSelf, + nip44DecryptFromSelf, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayClient } from "@/shared/api/relayClientSession"; +import type { RelayEvent } from "@/shared/api/types"; +import { KIND_READ_STATE } from "@/shared/constants/kinds"; +import { + READ_STATE_D_TAG_PREFIX, + READ_STATE_FETCH_LIMIT, + READ_STATE_HORIZON_SECONDS, + isValidBlob, + isValidReadStateDTag, + sanitizeContexts, + type ReadStateBlob, +} from "@/features/channels/readState/readStateFormat"; +import { + readStoredReadState, + writeStoredReadState, +} from "@/features/channels/readState/readStateStorage"; + +const CLIENT_ID_KEY_PREFIX = "sprout.nip-rs.client-id"; +const SLOT_ID_KEY_PREFIX = "sprout.nip-rs.slot-id"; +const DEBOUNCE_MS = 5_000; + +function generateHex(bytes: number): string { + const arr = new Uint8Array(bytes); + crypto.getRandomValues(arr); + return Array.from(arr, (b) => b.toString(16).padStart(2, "0")).join(""); +} + +function getOrCreatePersisted(key: string, generator: () => string): string { + let value = localStorage.getItem(key); + if (!value) { + value = generator(); + localStorage.setItem(key, value); + } + return value; +} + +function clientIdKey(pubkey: string): string { + return `${CLIENT_ID_KEY_PREFIX}:${pubkey}`; +} + +function slotIdKey(pubkey: string): string { + return `${SLOT_ID_KEY_PREFIX}:${pubkey}`; +} + +export class ReadStateManager { + private pubkey: string; + private relayClient: RelayClient; + private clientId: string; + private slotId: string; + + // Effective merged state across all blobs + private effectiveState = new Map(); + + // Contexts this client is allowed to include in its published blob. This + // excludes first-load local baselines for channels the user has not opened. + private publishableContextIds = new Set(); + + // Last-published blob content (for diff to suppress no-op publishes) + private lastPublishedContexts: Record = {}; + + // Debounce timer + private debounceTimer: number | null = null; + + // Subscribers for React integration + private listeners = new Set<() => void>(); + + // Live subscription teardown + private unsubscribeLive: (() => void) | null = null; + + // Track whether we've initialized + private initialized = false; + + // Track the max created_at seen from fetched blobs so publishes can satisfy + // NIP-RS clock-skew monotonicity for our d-tag coordinate. + private maxFetchedCreatedAt = 0; + + constructor(pubkey: string, relayClient: RelayClient) { + this.pubkey = pubkey; + this.relayClient = relayClient; + this.clientId = getOrCreatePersisted(clientIdKey(pubkey), () => + crypto.randomUUID(), + ); + this.slotId = getOrCreatePersisted(slotIdKey(pubkey), () => + generateHex(16), + ); + } + + async initialize(): Promise { + if (this.initialized) return; + + this.hydrateFromLocalStorage(); + + await this.fetchAndMerge(); + await this.startLiveSubscription(); + if (!this.isIdenticalToLastPublished(this.currentContexts())) { + this.schedulePublish(); + } + + this.initialized = true; + this.notifyListeners(); + } + + markContextRead(contextId: string, unixTimestamp: number): void { + this.advanceContext(contextId, unixTimestamp, { publishable: true }); + } + + seedContextRead(contextId: string, unixTimestamp: number): void { + this.advanceContext(contextId, unixTimestamp, { publishable: false }); + } + + private advanceContext( + contextId: string, + unixTimestamp: number, + options: { publishable: boolean }, + ): void { + const current = this.effectiveState.get(contextId) ?? 0; + // Monotonic: only advance, never lower + if (unixTimestamp <= current) { + if (!options.publishable || this.publishableContextIds.has(contextId)) { + return; + } + + this.publishableContextIds.add(contextId); + this.persistLocalState(); + this.schedulePublish(); + return; + } + + this.effectiveState.set(contextId, unixTimestamp); + if (options.publishable) { + this.publishableContextIds.add(contextId); + } + this.persistLocalState(); + this.notifyListeners(); + if (options.publishable) { + this.schedulePublish(); + } + } + + getEffectiveTimestamp(contextId: string): number | null { + return this.effectiveState.get(contextId) ?? null; + } + + subscribe(listener: () => void): () => void { + this.listeners.add(listener); + return () => { + this.listeners.delete(listener); + }; + } + + destroy(): void { + // Flush any pending writes immediately + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + this.debounceTimer = null; + void this.publish(); + } + + if (this.unsubscribeLive) { + void this.unsubscribeLive(); + this.unsubscribeLive = null; + } + + this.listeners.clear(); + } + + private async fetchAndMerge(): Promise { + let events: RelayEvent[]; + try { + events = await this.relayClient.fetchEvents({ + kinds: [KIND_READ_STATE], + authors: [this.pubkey], + "#t": ["read-state"], + since: Math.floor(Date.now() / 1_000) - READ_STATE_HORIZON_SECONDS, + limit: READ_STATE_FETCH_LIMIT, + }); + } catch { + // If fetch fails, proceed with local state only + return; + } + + await this.mergeEvents(events); + this.persistLocalState(); + this.notifyListeners(); + } + + private async mergeEvents(events: RelayEvent[]): Promise { + let ownBlob: ReadStateBlob | null = null; + let ownBlobCreatedAt = 0; + + for (const event of events) { + // Only process our own events + if (event.pubkey !== this.pubkey) continue; + + // Validate d tag (exactly one, with correct prefix) + const dTags = event.tags.filter((t) => t[0] === "d"); + if (dTags.length !== 1) continue; + const dTag = dTags[0]; + if (!isValidReadStateDTag(dTag[1])) continue; + + // Validate t tag (exactly one) + const tTags = event.tags.filter( + (t) => t[0] === "t" && t[1] === "read-state", + ); + if (tTags.length !== 1) continue; + + this.maxFetchedCreatedAt = Math.max( + this.maxFetchedCreatedAt, + event.created_at, + ); + + let blob: ReadStateBlob; + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const parsed = JSON.parse(plaintext); + if (!isValidBlob(parsed)) continue; + blob = { + v: 1, + client_id: parsed.client_id, + contexts: sanitizeContexts(parsed.contexts), + }; + } catch { + continue; + } + + // Merge into effective state + for (const [ctx, ts] of Object.entries(blob.contexts)) { + const current = this.effectiveState.get(ctx) ?? 0; + if (ts > current) { + this.effectiveState.set(ctx, ts); + } + this.publishableContextIds.add(ctx); + } + + // Track own blob (most recent event for our client_id) + if (blob.client_id === this.clientId) { + if (event.created_at > ownBlobCreatedAt) { + ownBlob = blob; + ownBlobCreatedAt = event.created_at; + } + } + } + + // Conflict detection: check if another client_id is squatting on our + // d-tag coordinate. If so, rotate our slotId to avoid clobbering. + for (const event of events) { + if (event.pubkey !== this.pubkey) continue; + const dTag = event.tags.find( + (t) => t[0] === "d" && t[1] === `read-state:${this.slotId}`, + ); + if (!dTag) continue; + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const parsed = JSON.parse(plaintext); + if (isValidBlob(parsed) && parsed.client_id !== this.clientId) { + this.slotId = generateHex(16); + localStorage.setItem(slotIdKey(this.pubkey), this.slotId); + break; + } + } catch { + // Decrypt failure — skip this event + } + } + + if (ownBlob) { + this.lastPublishedContexts = { ...ownBlob.contexts }; + for (const contextId of Object.keys(ownBlob.contexts)) { + this.publishableContextIds.add(contextId); + } + } + } + + private async startLiveSubscription(): Promise { + try { + const unsub = await this.relayClient.subscribeLive( + { + kinds: [KIND_READ_STATE], + authors: [this.pubkey], + "#t": ["read-state"], + limit: READ_STATE_FETCH_LIMIT, + }, + (event: RelayEvent) => { + void this.handleIncomingEvent(event); + }, + ); + this.unsubscribeLive = unsub; + } catch { + // Non-fatal: we can still work with local state + } + } + + private async handleIncomingEvent(event: RelayEvent): Promise { + if (event.pubkey !== this.pubkey) return; + + // Validate d tag (exactly one, with correct prefix) + const dTags = event.tags.filter((t) => t[0] === "d"); + if (dTags.length !== 1) return; + const dTag = dTags[0]; + if (!isValidReadStateDTag(dTag[1])) return; + + // Validate t tag (exactly one) + const tTags = event.tags.filter( + (t) => t[0] === "t" && t[1] === "read-state", + ); + if (tTags.length !== 1) return; + + this.maxFetchedCreatedAt = Math.max( + this.maxFetchedCreatedAt, + event.created_at, + ); + + let blob: ReadStateBlob; + try { + const plaintext = await nip44DecryptFromSelf(event.content); + const parsed = JSON.parse(plaintext); + if (!isValidBlob(parsed)) return; + blob = { + v: 1, + client_id: parsed.client_id, + contexts: sanitizeContexts(parsed.contexts), + }; + } catch { + return; + } + + // Merge into effective state + let anyAdvanced = false; + for (const [ctx, ts] of Object.entries(blob.contexts)) { + const current = this.effectiveState.get(ctx) ?? 0; + if (ts > current) { + this.effectiveState.set(ctx, ts); + anyAdvanced = true; + } + this.publishableContextIds.add(ctx); + } + + if (anyAdvanced) { + this.persistLocalState(); + this.notifyListeners(); + + // If this was from another client instance, schedule a re-publish + // so our blob converges + if (blob.client_id !== this.clientId) { + this.schedulePublish(); + } + } + } + + private schedulePublish(): void { + if (this.debounceTimer !== null) { + window.clearTimeout(this.debounceTimer); + } + this.debounceTimer = window.setTimeout(() => { + this.debounceTimer = null; + void this.publish(); + }, DEBOUNCE_MS); + } + + private async publish(): Promise { + await this.fetchOwnBlobBeforePublish(); + + // Build blob from contexts this client is allowed to publish. + const contexts = this.currentContexts(); + + // Suppress no-op publishes + if (this.isIdenticalToLastPublished(contexts)) return; + + const blob: ReadStateBlob = { + v: 1, + client_id: this.clientId, + contexts, + }; + + try { + const plaintext = JSON.stringify(blob); + const ciphertext = await nip44EncryptToSelf(plaintext); + + const dTagValue = `read-state:${this.slotId}`; + const tags: string[][] = [ + ["d", dTagValue], + ["t", "read-state"], + ]; + + const createdAt = Math.max( + Math.floor(Date.now() / 1_000), + this.maxFetchedCreatedAt + 1, + ); + const event = await signRelayEvent({ + kind: KIND_READ_STATE, + content: ciphertext, + createdAt, + tags, + }); + + await this.relayClient.publishEvent( + event, + "Timed out publishing read state.", + "Failed to publish read state.", + ); + + this.lastPublishedContexts = contexts; + this.maxFetchedCreatedAt = Math.max( + this.maxFetchedCreatedAt, + event.created_at, + ); + } catch (error) { + // Non-fatal: will retry on next debounce + console.warn("[ReadStateManager] publish failed:", error); + } + } + + private async fetchOwnBlobBeforePublish(): Promise { + try { + const events = await this.relayClient.fetchEvents({ + kinds: [KIND_READ_STATE], + authors: [this.pubkey], + "#d": [`${READ_STATE_D_TAG_PREFIX}${this.slotId}`], + limit: READ_STATE_FETCH_LIMIT, + }); + + await this.mergeEvents(events); + this.persistLocalState(); + } catch { + // Per NIP-RS, proceed with reachable data and merge on a later fetch. + } + } + + private isIdenticalToLastPublished( + contexts: Record, + ): boolean { + const lastKeys = Object.keys(this.lastPublishedContexts); + const currentKeys = Object.keys(contexts); + if (lastKeys.length !== currentKeys.length) return false; + for (const key of currentKeys) { + if (this.lastPublishedContexts[key] !== contexts[key]) return false; + } + return true; + } + + private currentContexts(): Record { + const contexts: Record = {}; + for (const [ctx, ts] of this.effectiveState) { + if (!this.publishableContextIds.has(ctx)) { + continue; + } + contexts[ctx] = ts; + } + return contexts; + } + + private hydrateFromLocalStorage(): void { + const stored = readStoredReadState(this.pubkey); + for (const [contextId, timestamp] of stored.contexts) { + this.effectiveState.set(contextId, timestamp); + } + for (const contextId of stored.publishableContextIds) { + this.publishableContextIds.add(contextId); + } + this.persistLocalState(); + } + + private persistLocalState(): void { + writeStoredReadState( + this.pubkey, + this.effectiveState, + this.publishableContextIds, + ); + } + + private notifyListeners(): void { + for (const listener of this.listeners) { + try { + listener(); + } catch { + // Don't let a broken listener break the manager + } + } + } +} diff --git a/desktop/src/features/channels/readState/readStateStorage.ts b/desktop/src/features/channels/readState/readStateStorage.ts new file mode 100644 index 0000000000..9760bdaa93 --- /dev/null +++ b/desktop/src/features/channels/readState/readStateStorage.ts @@ -0,0 +1,83 @@ +import { + isPlainRecord, + localIsoToUnixSeconds, + localPublishableContextKey, + localReadStateKey, +} from "@/features/channels/readState/readStateFormat"; + +export type StoredReadState = { + contexts: Map; + publishableContextIds: Set; +}; + +function mergeLocalStorageKey( + contexts: Map, + key: string, +): void { + const raw = localStorage.getItem(key); + if (!raw) return; + + try { + const parsed = JSON.parse(raw); + if (!isPlainRecord(parsed)) return; + + for (const [channelId, value] of Object.entries(parsed)) { + const unixSeconds = localIsoToUnixSeconds(value); + if (unixSeconds === null) continue; + const current = contexts.get(channelId) ?? 0; + if (unixSeconds > current) { + contexts.set(channelId, unixSeconds); + } + } + } catch { + // Corrupt localStorage, ignore. + } +} + +function readPublishableContextIds(pubkey: string): Set { + const result = new Set(); + const raw = localStorage.getItem(localPublishableContextKey(pubkey)); + if (!raw) return result; + + try { + const parsed = JSON.parse(raw); + if (!Array.isArray(parsed)) return result; + + for (const value of parsed) { + if (typeof value === "string") { + result.add(value); + } + } + } catch { + // Corrupt localStorage, ignore. + } + + return result; +} + +export function readStoredReadState(pubkey: string): StoredReadState { + const contexts = new Map(); + mergeLocalStorageKey(contexts, localReadStateKey(pubkey)); + + return { + contexts, + publishableContextIds: readPublishableContextIds(pubkey), + }; +} + +export function writeStoredReadState( + pubkey: string, + contexts: ReadonlyMap, + publishableContextIds: ReadonlySet, +): void { + const state: Record = {}; + for (const [contextId, timestamp] of contexts) { + state[contextId] = new Date(timestamp * 1_000).toISOString(); + } + + localStorage.setItem(localReadStateKey(pubkey), JSON.stringify(state)); + localStorage.setItem( + localPublishableContextKey(pubkey), + JSON.stringify([...publishableContextIds]), + ); +} diff --git a/desktop/src/features/channels/readState/useReadState.ts b/desktop/src/features/channels/readState/useReadState.ts new file mode 100644 index 0000000000..ebe05523fe --- /dev/null +++ b/desktop/src/features/channels/readState/useReadState.ts @@ -0,0 +1,94 @@ +import * as React from "react"; +import { ReadStateManager } from "@/features/channels/readState/readStateManager"; +import type { RelayClient } from "@/shared/api/relayClientSession"; + +const noopGetTimestamp = () => null; +const noopMarkRead = () => {}; + +/** + * React hook that creates and manages a ReadStateManager instance. + * Returns no-op functions when pubkey or relayClient are not available. + */ +export function useReadState( + pubkey: string | undefined, + relayClient: RelayClient | undefined, +) { + const [readStateVersion, forceUpdate] = React.useReducer( + (x: number) => x + 1, + 0, + ); + const [initializedPubkey, setInitializedPubkey] = React.useState< + string | null + >(null); + const managerRef = React.useRef(null); + + // Create/destroy manager when pubkey becomes available/changes + React.useEffect(() => { + setInitializedPubkey(null); + if (!pubkey || !relayClient) return; + + let isCancelled = false; + const manager = new ReadStateManager(pubkey, relayClient); + managerRef.current = manager; + + const unsubscribe = manager.subscribe(() => { + forceUpdate(); + }); + + void manager.initialize().finally(() => { + if (!isCancelled) { + setInitializedPubkey(pubkey); + } + }); + + return () => { + isCancelled = true; + unsubscribe(); + manager.destroy(); + managerRef.current = null; + }; + }, [pubkey, relayClient]); + + const getEffectiveTimestamp = React.useCallback( + (contextId: string): number | null => { + return managerRef.current?.getEffectiveTimestamp(contextId) ?? null; + }, + [], + ); + + const markContextRead = React.useCallback( + (contextId: string, unixTimestamp: number): void => { + managerRef.current?.markContextRead(contextId, unixTimestamp); + }, + [], + ); + + const seedContextRead = React.useCallback( + (contextId: string, unixTimestamp: number): void => { + managerRef.current?.seedContextRead(contextId, unixTimestamp); + }, + [], + ); + + const isReady = Boolean( + pubkey && relayClient && initializedPubkey === pubkey, + ); + + if (!pubkey || !relayClient) { + return { + getEffectiveTimestamp: noopGetTimestamp, + isReady: false, + markContextRead: noopMarkRead, + seedContextRead: noopMarkRead, + readStateVersion: 0, + }; + } + + return { + getEffectiveTimestamp, + isReady, + markContextRead, + seedContextRead, + readStateVersion, + }; +} diff --git a/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx new file mode 100644 index 0000000000..249bc35a1d --- /dev/null +++ b/desktop/src/features/channels/ui/AgentSessionThreadPanel.tsx @@ -0,0 +1,164 @@ +import type * as React from "react"; +import { Activity, Bot, CircleDot, Octagon, X } from "lucide-react"; +import { toast } from "sonner"; + +import { ManagedAgentSessionPanel } from "@/features/agents/ui/ManagedAgentSessionPanel"; +import { cancelManagedAgentTurn } from "@/shared/api/agentControl"; +import type { Channel, ManagedAgent } from "@/shared/api/types"; +import { useEscapeKey } from "@/shared/hooks/useEscapeKey"; +import { useIsThreadPanelOverlay } from "@/shared/hooks/use-mobile"; +import { useStickToBottom } from "@/shared/hooks/useStickToBottom"; +import { cn } from "@/shared/lib/cn"; +import { Badge } from "@/shared/ui/badge"; +import { Button } from "@/shared/ui/button"; +import { + OverlayPanelBackdrop, + PANEL_BASE_CLASS, + PANEL_OVERLAY_CLASS, +} from "@/shared/ui/OverlayPanelBackdrop"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip"; + +type AgentSessionThreadPanelProps = { + agent: ManagedAgent; + canResetWidth: boolean; + channel: Channel; + isWorking: boolean; + onClose: () => void; + onResetWidth: () => void; + onResizeStart: (event: React.PointerEvent) => void; + widthPx: number; +}; + +export function AgentSessionThreadPanel({ + agent, + canResetWidth, + channel, + isWorking, + onClose, + onResetWidth, + onResizeStart, + widthPx, +}: AgentSessionThreadPanelProps) { + const isLive = agent.status === "running"; + const isOverlay = useIsThreadPanelOverlay(); + useEscapeKey(onClose, isOverlay); + + const { ref: scrollRef, onScroll } = useStickToBottom(); + + async function handleInterruptTurn() { + try { + await cancelManagedAgentTurn(agent.pubkey, channel.id); + toast.success( + `Stop signal sent to ${agent.name}. It may take a moment to respond.`, + ); + } catch (error) { + toast.error( + error instanceof Error + ? error.message + : `Failed to stop ${agent.name}'s current turn.`, + ); + } + } + + return ( + <> + {isOverlay && } + + + ); +} diff --git a/desktop/src/features/channels/ui/BotActivityBar.tsx b/desktop/src/features/channels/ui/BotActivityBar.tsx new file mode 100644 index 0000000000..8d023dd961 --- /dev/null +++ b/desktop/src/features/channels/ui/BotActivityBar.tsx @@ -0,0 +1,166 @@ +import * as React from "react"; +import { Loader2 } from "lucide-react"; + +import type { UserProfileLookup } from "@/features/profile/lib/identity"; +import type { ManagedAgent } from "@/shared/api/types"; +import { cn } from "@/shared/lib/cn"; +import { Popover, PopoverContent, PopoverTrigger } from "@/shared/ui/popover"; +import { UserAvatar } from "@/shared/ui/UserAvatar"; + +export type BotActivityAgent = Pick; + +type BotActivityBarProps = { + agents: BotActivityAgent[]; + onOpenAgentSession: (pubkey: string) => void; + openAgentSessionPubkey: string | null; + profiles?: UserProfileLookup; + typingBotPubkeys: string[]; +}; + +const HOVER_OPEN_DELAY_MS = 150; +const HOVER_CLOSE_DELAY_MS = 180; + +export function BotActivityComposerAction({ + agents, + onOpenAgentSession, + openAgentSessionPubkey, + profiles, + typingBotPubkeys, +}: BotActivityBarProps) { + const [open, setOpen] = React.useState(false); + const hoverTimerRef = React.useRef | null>( + null, + ); + + const typingAgents = React.useMemo(() => { + const typingSet = new Set( + typingBotPubkeys.map((pubkey) => pubkey.toLowerCase()), + ); + + return agents.filter((agent) => typingSet.has(agent.pubkey.toLowerCase())); + }, [agents, typingBotPubkeys]); + + const clearHoverTimer = React.useCallback(() => { + if (hoverTimerRef.current !== null) { + clearTimeout(hoverTimerRef.current); + hoverTimerRef.current = null; + } + }, []); + + const openWithDelay = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(true); + }, HOVER_OPEN_DELAY_MS); + }, [clearHoverTimer]); + + const closeWithDelay = React.useCallback(() => { + clearHoverTimer(); + hoverTimerRef.current = setTimeout(() => { + setOpen(false); + }, HOVER_CLOSE_DELAY_MS); + }, [clearHoverTimer]); + + const keepOpen = React.useCallback(() => { + clearHoverTimer(); + }, [clearHoverTimer]); + + React.useEffect(() => { + return () => clearHoverTimer(); + }, [clearHoverTimer]); + + if (typingAgents.length === 0) { + return null; + } + + const agentAvatarUrl = (agent: BotActivityAgent) => + profiles?.[agent.pubkey.toLowerCase()]?.avatarUrl ?? null; + const selectedPubkey = openAgentSessionPubkey?.toLowerCase() ?? null; + const triggerLabel = + typingAgents.length === 1 + ? `${typingAgents[0]?.name ?? "Agent"} is working` + : `${typingAgents.length} agents working`; + + return ( + + + + + event.preventDefault()} + side="top" + sideOffset={8} + > +
+ Agents working +
+
+ {typingAgents.map((agent) => { + const isSelected = selectedPubkey === agent.pubkey.toLowerCase(); + + return ( + + ); + })} +
+
+
+ ); +} diff --git a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx index f166332108..0088f07c8d 100644 --- a/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx +++ b/desktop/src/features/channels/ui/ChannelBrowserDialog.tsx @@ -187,12 +187,8 @@ export function ChannelBrowserDialog({ } function handleSelect(channel: Channel) { - if (channel.isMember) { - onOpenChange(false); - onSelectChannel(channel.id); - } else { - void handleJoin(channel.id); - } + onOpenChange(false); + onSelectChannel(channel.id); } const selectedItem = allItems[selectedIndex]; @@ -280,7 +276,7 @@ export function ChannelBrowserDialog({ {notJoined.length} {entityLabel} {notJoined.length !== 1 ? "s" : ""} to join - Enter to join + Enter to view
{notJoined.map((channel) => { diff --git a/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx new file mode 100644 index 0000000000..a1d7d7551d --- /dev/null +++ b/desktop/src/features/channels/ui/ChannelHeaderStatusBadge.tsx @@ -0,0 +1,38 @@ +import type { EphemeralChannelDisplay } from "@/features/channels/lib/ephemeralChannel"; +import { EphemeralChannelBadge } from "@/features/channels/ui/EphemeralChannelBadge"; +import { PresenceBadge } from "@/features/presence/ui/PresenceBadge"; +import type { Channel, PresenceStatus } from "@/shared/api/types"; + +type ChannelHeaderStatusBadgeProps = { + channelType?: Channel["channelType"]; + ephemeralDisplay: EphemeralChannelDisplay | null; + presenceStatus: PresenceStatus | null; +}; + +export function ChannelHeaderStatusBadge({ + channelType, + ephemeralDisplay, + presenceStatus, +}: ChannelHeaderStatusBadgeProps) { + const ephemeralBadge = ephemeralDisplay ? ( + + ) : null; + + if (channelType === "dm" && presenceStatus) { + return ( + <> + + {ephemeralBadge} + + ); + } + + return ephemeralBadge; +} diff --git a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx index 8ec47403f2..db9385c170 100644 --- a/desktop/src/features/channels/ui/ChannelManagementSheet.tsx +++ b/desktop/src/features/channels/ui/ChannelManagementSheet.tsx @@ -1,6 +1,7 @@ import { Archive, ArchiveRestore, + Copy, DoorClosed, DoorOpen, FileText, @@ -8,8 +9,10 @@ import { Lock, MessageSquare, Users, + Zap, } from "lucide-react"; import * as React from "react"; +import { toast } from "sonner"; import { useArchiveChannelMutation, @@ -24,6 +27,7 @@ import { useUpdateChannelMutation, } from "@/features/channels/hooks"; import { compareMembersByRole } from "@/features/channels/lib/memberUtils"; +import { CreateWorkflowDialog } from "@/features/workflows/ui/CreateWorkflowDialog"; import type { Channel } from "@/shared/api/types"; import { AlertDialog, @@ -38,7 +42,6 @@ import { } from "@/shared/ui/alert-dialog"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; -import { Separator } from "@/shared/ui/separator"; import { Sheet, SheetContent, @@ -57,27 +60,6 @@ type ChannelManagementSheetProps = { open: boolean; }; -function Section({ - title, - description, - children, -}: React.PropsWithChildren<{ - title: string; - description?: string; -}>) { - return ( -
-
-

{title}

- {description ? ( -

{description}

- ) : null} -
- {children} -
- ); -} - function MetadataPill({ icon: Icon, label, @@ -93,6 +75,35 @@ function MetadataPill({ ); } +function ChannelIdRow({ channelId }: { channelId: string }) { + async function handleCopyChannelId() { + await navigator.clipboard.writeText(channelId); + toast.success("Copied channel ID to clipboard"); + } + + return ( + + ); +} + export function ChannelManagementSheet({ channel, currentPubkey, @@ -139,17 +150,15 @@ export function ChannelManagementSheet({ detail?.channelType !== "dm" && !isArchived && selfMember !== null; - const showAccessSection = - canJoin || - canLeave || - joinChannelMutation.error instanceof Error || - leaveChannelMutation.error instanceof Error; + const memberCount = + members.length || detail?.memberCount || channel?.memberCount || 0; const [nameDraft, setNameDraft] = React.useState(""); const [descriptionDraft, setDescriptionDraft] = React.useState(""); const [topicDraft, setTopicDraft] = React.useState(""); const [purposeDraft, setPurposeDraft] = React.useState(""); const [isDeleteDialogOpen, setIsDeleteDialogOpen] = React.useState(false); + const [isCreateWorkflowOpen, setIsCreateWorkflowOpen] = React.useState(false); // Sync drafts from server only when the sheet opens or the channel changes — // not on every background refetch, which would clobber in-flight edits. @@ -159,6 +168,7 @@ export function ChannelManagementSheet({ // Reset on close so the next open re-syncs from server. syncedForRef.current = null; setIsDeleteDialogOpen(false); + setIsCreateWorkflowOpen(false); return; } if (!detail) { @@ -214,14 +224,12 @@ export function ChannelManagementSheet({ data-testid="channel-management-sheet" side="right" > - -
- {channel.name} - - Manage channel settings, membership, and access. - -
-
+ + {channel.name} + + Channel settings + +
- + {isArchived ? ( ) : null} @@ -247,6 +252,7 @@ export function ChannelManagementSheet({
+ {detailsQuery.error instanceof Error ? (

{detailsQuery.error.message} @@ -259,357 +265,319 @@ export function ChannelManagementSheet({

) : null} - {showAccessSection ? ( - <> -
+ - ) : null} - - {canLeave ? ( - - ) : null} -
- {joinChannelMutation.error instanceof Error ? ( -

- {joinChannelMutation.error.message} -

- ) : null} - {leaveChannelMutation.error instanceof Error ? ( -

- {leaveChannelMutation.error.message} -

- ) : null} - - - - + + {joinChannelMutation.isPending ? "Joining..." : "Join channel"} + + {joinChannelMutation.error instanceof Error ? ( +

+ {joinChannelMutation.error.message} +

+ ) : null} +
) : null} -
+
-
+
- - -
{ + event.preventDefault(); + void updateChannelMutation.mutateAsync({ + description: descriptionDraft.trim() || undefined, + name: nameDraft.trim() || undefined, + }); + }} > -
{ - event.preventDefault(); - void setTopicMutation.mutateAsync({ - topic: topicDraft.trim(), - }); - }} - > -
- - setTopicDraft(event.target.value)} - value={topicDraft} - /> -
- - {setTopicMutation.error instanceof Error ? ( -

- {setTopicMutation.error.message} -

- ) : null} -
- -
{ - event.preventDefault(); - void setPurposeMutation.mutateAsync({ - purpose: purposeDraft.trim(), - }); - }} - > -
- -