diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..8ec8029 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,67 @@ +name: CI + +# Quality gates for the Rust SDK + FFI workspace. +# Runs on every push/PR to main and on version tags. FFI artifact/release +# packaging lives in ffi.yml. + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + +env: + CARGO_TERM_COLOR: always + RUST_BACKTRACE: 1 + +jobs: + lint: + name: fmt + clippy + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + components: rustfmt, clippy + - uses: Swatinem/rust-cache@v2 + - name: rustfmt + run: cargo fmt --all -- --check + - name: clippy (all features) + run: cargo clippy --all --all-features --all-targets -- -D warnings + + test: + name: check + test + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + - name: check (all features) + run: cargo check --all --all-features + - name: test (all features) + # Includes the FFI crate. `nak-tests` no-ops when the `nak` CLI is + # absent from PATH (see contextvm-ffi/Cargo.toml). + run: cargo test --all --all-features + - name: test (no default features) + run: cargo test --no-default-features + - name: doc + run: cargo doc --no-deps --all-features + - name: rmcp integration example + run: cargo run --example rmcp_integration_test --features rmcp -- local + + msrv: + name: MSRV 1.88 + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.88 + - uses: Swatinem/rust-cache@v2 + # Regenerate the lockfile so dependencies resolve to versions that still + # support the declared MSRV (1.88). + - name: Remove lockfile + run: rm -f Cargo.lock + - name: check + run: cargo check --all-features + - name: test + run: cargo test --all-features diff --git a/.github/workflows/ffi.yml b/.github/workflows/ffi.yml new file mode 100644 index 0000000..44eb14a --- /dev/null +++ b/.github/workflows/ffi.yml @@ -0,0 +1,192 @@ +name: FFI + +# Cross-platform build, C-test, and release packaging for the ContextVM FFI +# bindings (C header + UniFFI Python/Swift/Kotlin surfaces). +# +# On push/PR to main: builds every target, runs the C test suite, and +# generates UniFFI bindings as a verification step. +# On version tags (v*): additionally assembles per-platform release archives +# and uploads them to the GitHub Release. + +on: + push: + branches: [main] + tags: ['v*'] + pull_request: + branches: [main] + workflow_dispatch: + +env: + CARGO_TERM_COLOR: always + +jobs: + build: + name: build ${{ matrix.target }} + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - os: ubuntu-latest + target: x86_64-unknown-linux-gnu + libs: 'libcontextvm_ffi.so libcontextvm_ffi.a' + archive: tar.gz + - os: macos-latest + target: aarch64-apple-darwin + libs: 'libcontextvm_ffi.dylib libcontextvm_ffi.a' + archive: tar.gz + - os: macos-13 + target: x86_64-apple-darwin + libs: 'libcontextvm_ffi.dylib libcontextvm_ffi.a' + archive: tar.gz + - os: windows-latest + target: x86_64-pc-windows-msvc + libs: 'contextvm_ffi.dll contextvm_ffi.dll.lib contextvm_ffi.lib' + archive: zip + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v2 + with: + key: ffi-${{ matrix.target }} + - name: Build FFI (release) + run: cargo build --release -p contextvm-ffi --target ${{ matrix.target }} + - name: Stage artifacts + shell: bash + run: | + set -euo pipefail + STAGE="stage/contextvm-ffi" + mkdir -p "$STAGE/lib" "$STAGE/include" + for f in ${{ matrix.libs }}; do + cp "target/${{ matrix.target }}/release/$f" "$STAGE/lib/" + done + cp contextvm-ffi/headers/contextvm.h "$STAGE/include/" + cp contextvm-ffi/README.md "$STAGE/" + - name: Archive (tar.gz) + if: matrix.archive == 'tar.gz' + run: tar -czf contextvm-ffi-${{ matrix.target }}.tar.gz -C stage contextvm-ffi + - name: Archive (zip) + if: matrix.archive == 'zip' + shell: pwsh + run: Compress-Archive -Path stage/contextvm-ffi -DestinationPath contextvm-ffi-${{ matrix.target }}.zip + - uses: actions/upload-artifact@v4 + with: + name: contextvm-ffi-${{ matrix.target }} + path: contextvm-ffi-${{ matrix.target }}.* + retention-days: 14 + + build-macos-universal: + name: build macOS universal + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + with: + targets: aarch64-apple-darwin,x86_64-apple-darwin + - uses: Swatinem/rust-cache@v2 + with: + key: ffi-macos-universal + - name: Build arm64 + run: cargo build --release -p contextvm-ffi --target aarch64-apple-darwin + - name: Build x86_64 + run: cargo build --release -p contextvm-ffi --target x86_64-apple-darwin + - name: Create universal libraries + shell: bash + run: | + set -euo pipefail + STAGE="stage/contextvm-ffi" + mkdir -p "$STAGE/lib" "$STAGE/include" + lipo -create \ + target/aarch64-apple-darwin/release/libcontextvm_ffi.dylib \ + target/x86_64-apple-darwin/release/libcontextvm_ffi.dylib \ + -output "$STAGE/lib/libcontextvm_ffi.dylib" + lipo -create \ + target/aarch64-apple-darwin/release/libcontextvm_ffi.a \ + target/x86_64-apple-darwin/release/libcontextvm_ffi.a \ + -output "$STAGE/lib/libcontextvm_ffi.a" + cp contextvm-ffi/headers/contextvm.h "$STAGE/include/" + cp contextvm-ffi/README.md "$STAGE/" + tar -czf contextvm-ffi-universal-apple-darwin.tar.gz -C stage contextvm-ffi + - uses: actions/upload-artifact@v4 + with: + name: contextvm-ffi-universal-apple-darwin + path: contextvm-ffi-universal-apple-darwin.tar.gz + retention-days: 14 + + c-tests: + name: C tests + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: ffi-c-tests + # The C test suite is the only check that the hand-written contextvm.h + # header matches the Rust extern "C" signatures. It links against the + # built FFI library and exercises keys, errors, memory frees, and config + # structs directly from C. Linux-only because the c-tests/Makefile links + # with -ldl/-lm; signature consistency is platform-independent, and the + # macOS build jobs already confirm the dylib links on Darwin. + - name: Build and run C tests + run: make -C contextvm-ffi/c-tests test + + bindings: + name: UniFFI bindings (python/swift/kotlin) + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@stable + - uses: Swatinem/rust-cache@v2 + with: + key: ffi-bindings + - name: Build FFI library (for embedded metadata) + run: cargo build -p contextvm-ffi + # The bindgen CLI is `publish = false` in Mozilla's repo, so it is not + # on crates.io for any 0.2x/0.3x release. Install from git, pinned to the + # tag that matches the runtime `uniffi` dependency in + # contextvm-ffi/Cargo.toml. The two MUST stay in lockstep: UniFFI embeds a + # contract version + per-function checksums into both the compiled library + # and the generated language file, and a mismatch aborts at import time + # (`InternalError("UniFFI ... mismatch")`). As of 0.30 the example app + # installs a binary named `uniffi-bindgen-cli` (it was `uniffi-bindgen` + # in 0.29). + - name: Install uniffi-bindgen-cli + run: cargo install --git https://github.com/mozilla/uniffi-rs --tag v0.31.2 uniffi-bindgen-cli --locked + - name: Generate bindings + run: | + set -euo pipefail + LIB=target/debug/libcontextvm_ffi.so + for lang in python swift kotlin; do + uniffi-bindgen-cli generate "$LIB" --library \ + --language "$lang" --out-dir "bindings/$lang" + done + - uses: actions/upload-artifact@v4 + with: + name: contextvm-ffi-bindings + path: bindings/ + retention-days: 14 + + release: + name: release + if: startsWith(github.ref, 'refs/tags/v') + needs: [build, build-macos-universal, c-tests, bindings] + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - uses: actions/download-artifact@v4 + with: + path: artifacts + - name: Package bindings + run: tar -czf contextvm-ffi-bindings.tar.gz -C artifacts/contextvm-ffi-bindings . + - name: Publish to GitHub Release + uses: softprops/action-gh-release@v2 + with: + generate_release_notes: true + files: | + artifacts/contextvm-ffi-*/*.tar.gz + artifacts/contextvm-ffi-*/*.zip + contextvm-ffi-bindings.tar.gz diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..add9878 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,284 @@ +# AGENTS.md + +Guide for AI coding agents working on the ContextVM Rust SDK (`contextvm-sdk`) +and its FFI bindings crate (`contextvm-ffi`). Read this before making changes. + +## Project Overview + +`contextvm-sdk` is the Rust implementation of the **ContextVM protocol** — +**MCP (Model Context Protocol) over Nostr**. It lets MCP servers and clients +talk over the Nostr network with decentralized discovery, cryptographic +identity (NIP-06 keys, NIP-44 encryption, NIP-59 gift wrap), and optional +end-to-end encryption. + +This repository is a **Cargo workspace** with two members: + +| Crate | Path | Purpose | +|-------|------|---------| +| `contextvm-sdk` | `.` (root) | The SDK itself. Published to crates.io. | +| `contextvm-ffi` | `contextvm-ffi/` | FFI bindings for C / Python / Swift / Kotlin consumers. | + +- **Repository**: `https://github.com/ContextVM/rs-sdk` +- **MSRV**: `1.88` (declared in `Cargo.toml` as `rust-version`) +- **License**: MIT +- **Current `contextvm-sdk` version**: see `Cargo.toml` / `CHANGELOG.md` + +## Architecture + +``` +┌──────────────────────────────────────────────────────────┐ +│ Your Application │ +├──────────────┬───────────────┬────────────────────────────┤ +│ Gateway │ Proxy │ Discovery │ +│ (server → │ (nostr → │ (find servers & │ +│ nostr) │ client) │ capabilities) │ +├──────────────┴───────────────┴────────────────────────────┤ +│ Transport Layer │ +│ NostrServerTransport / NostrClientTransport │ +├───────────────────────────────────────────────────────────┤ +│ Core │ Encryption │ Relay │ Signer │ Oversized │ +│ (types, │ (NIP-44, │ (pool │ (key │ Transfer │ +│ RPC, │ NIP-59) │ mgmt) │ mgmt) │ + OpenStr │ +│ valid.) │ │ │ │ │ +├─────────┴────────────────┴───────────┴───────────┴──────────┤ +│ Nostr Network (relays) │ +└───────────────────────────────────────────────────────────┘ +``` + +### Source layout (`src/`) + +| Module | Responsibility | +|--------|----------------| +| `core/` | JSON-RPC 2.0 message types, validation, top-level `Error` enum. | +| `signer/` | Nostr key management (generate / from secret / public / secret hex). | +| `relay/` | Relay pool connection management. `MockRelayPool` lives here (behind `test-utils`). | +| `encryption/` | NIP-44 encryption + NIP-59 gift wrap (kinds `1059` / `21059`). | +| `transport/` | `NostrServerTransport` + `NostrClientTransport` (the core engines). | +| `transport/oversized_transfer/` | CEP-22 chunked reassembly for large payloads. | +| `transport/open_stream/` | CEP-41 open-stream engine (frame, session, sequencing). | +| `discovery/` | Discover servers, tools, provider profiles (kinds `11316`–`11320`). | +| `gateway/` | `NostrMCPGateway` — bridges a local MCP server onto Nostr (server side). | +| `proxy/` | `NostrMCPProxy` — bridges a local MCP client to a remote Nostr server (client side). | +| `rmcp_transport/` | Native RMCP integration (`rmcp` feature). | + +### Protocol (Nostr kinds) + +| Kind | Name | Type | CEP | +|------|------|------|-----| +| `25910` | ContextVM Messages | Ephemeral | base | +| `1059` | Gift Wrap (NIP-59) | Regular | — | +| `21059` | Ephemeral Gift Wrap | Ephemeral | CEP-19 | +| `10002` | Relay List Metadata | Replaceable | CEP-17 | +| `11316`–`11320` | Announcement / Tools / Resources / Templates / Prompts | Addressable | discovery | + +Messages route via `p` tags (recipient) and correlate via `e` tags (request event id). +See `README.md` and `DESIGN.md` for full protocol detail; per-area docs live in `docs/`. + +### Cargo features + +- `default = ["rmcp"]` — native RMCP transport on by default. +- `rmcp` — enables the `rmcp` git dependency and `rmcp_transport` module. +- `test-utils` — exposes `MockRelayPool` for downstream integration tests. + +## Setup & Commands + +```bash +# Build everything (SDK + FFI) +cargo build + +# Full quality gate (what CI runs) — MUST pass before merge +cargo fmt --all -- --check +cargo clippy --all --all-features --all-targets -- -D warnings +cargo check --all --all-features +cargo test --all --all-features +cargo doc --no-deps --all-features + +# Also verify it builds/tests with rmcp disabled +cargo test --no-default-features +``` + +### Testing strategy + +- **Unit tests** (`#[cfg(test)] mod tests` inside each module) — fast, hermetic. +- **Conformance tests** (`tests/conformance_*.rs`) — wire-format, dedup, signer, + stateless mode, stores. Pure, no network. +- **Integration tests** (`tests/e2e_happy_path.rs`, `transport_integration.rs`, + `oversized_timeout_e2e.rs`) — require `rmcp` + `test-utils`; run a full in-memory + stack via `MockRelayPool` (no live network). +- **FFI tests** (`contextvm-ffi/tests/e2e_ffi_*.rs`) — exercise the FFI surface + end-to-end through the C ABI. +- **`nak-tests` feature** (FFI): the `e2e_ffi_nak_relay` test spawns the external + `nak` CLI relay. It is **off by default**; with `--all-features` it compiles and + **no-ops** (stderr note, not a failure) when `nak` is absent from `PATH`. Do not + make this test hard-fail on a missing binary. + +Run a single test by name: + +```bash +cargo test --all-features +cargo test --all-features --test e2e_happy_path +``` + +### Examples + +`examples/` contains runnable references: `discovery.rs`, `gateway.rs`, +`proxy.rs`, `rmcp_integration_test.rs`, `native_echo_server.rs`, +`native_echo_client.rs`. The last three require the `rmcp` feature. + +## The FFI Subsystem (`contextvm-ffi/`) + +This is the crate most likely to break in subtle ways. Understand it before +touching it. + +### What it is + +A **translation layer** exposing the Rust SDK to non-Rust languages. Two +independent binding surfaces over the same SDK: + +1. **Hand-written C ABI** — `headers/contextvm.h` + `#[no_mangle] extern "C"` + functions in `src/types.rs` / `src/channel.rs`. For C/C++, and Swift via a + C module map. +2. **UniFFI proc-macros** — `src/uniffi_types.rs`. Auto-generates idiomatic + Python / Swift / Kotlin objects. + +`Cargo.toml` declares `crate-type = ["cdylib", "staticlib", "lib"]` so the same +crate produces a dynamic lib (`.so`/`.dylib`/`.dll`), a static lib +(`.a`/`.lib`), and a Rust rlib (for tests/docs). + +### The three pillars + +| Pillar | File | Job | +|--------|------|-----| +| Global Tokio runtime | `src/runtime.rs` | One lazily-initialized `Runtime` (`OnceLock`). Foreign callers never see `async`; every FFI fn does `global_runtime().block_on(...)`. Never shut down. | +| Handle table | `src/kv.rs` | Global `HashMap>`. Foreign code gets opaque integer `CvmHandle { id }`; real Rust objects live in the table. | +| Error bridge | `src/error.rs` | Maps the SDK's rich `Error` enum → 9 flat `CvmErrorCode` ints. | + +### THE maintenance contract — read this + +**The FFI is a living contract that must track the SDK.** The canonical +incident: CEP-41 added `contextvm_sdk::Error::OpenStream`; the FFI's `match` in +`src/error.rs` didn't cover it; CI broke (`error[E0004]: non-exhaustive +patterns`). This is a recurring pattern, not a one-off. + +**The explicit, non-wildcard `match` is intentional** — it forces a compile +error when the SDK adds an `Error` variant, so a new variant can never silently +fall through to `Other`. Do NOT replace it with a `_ =>` wildcard. + +**When the SDK changes, check these three sync points:** + +1. **`src/error.rs`** — `ErrorCode::from(&Error)`. Add a match arm for any new + `Error` variant and map it to the right `CvmErrorCode` (add a new `CVM_*` + constant in `headers/contextvm.h` only if warranted). +2. **`src/uniffi_types.rs`** — every `Record`/`Enum`/`Object` mirrors an SDK + type. If the SDK type gains a field, the mirror needs it or data is dropped. +3. **`headers/contextvm.h`** — hand-maintained. Adding/renameing/reordering an + `extern "C"` fn or changing a struct requires editing the header to match, + or C consumers get silent memory corruption. + +### ABI stability rules + +These are part of the public ABI; **never** change them in a patch release: + +- `CvmErrorCode` numeric values (`CVM_TRANSPORT = 1`, …). Only **append** new + codes; never renumber. +- Struct field **order** in `contextvm.h`. Only append fields; never reorder. +- Function signatures. Adding functions is fine; changing/removing is breaking. + +### FFI memory model + +- Foreign callers MUST free every Rust-owned thing returned through the C ABI + with the matching `cvm_*_free` function (`cvm_string_free`, `cvm_message_free`, + `cvm_error_free`, `cvm_keys_free`, etc.). The free functions are null-safe. +- The handle table grows unboundedly if callers don't free. This is per-call + leakage, not catastrophic — but the free requirement must stay documented. +- UniFFI consumers (Python/Swift/Kotlin) get automatic memory management. + +### FFI build & test + +```bash +# Build the bindings library +cargo build --release -p contextvm-ffi + +# Generate UniFFI language bindings. The CLI is `publish = false` in Mozilla's +# repo, so install from git at the tag matching the runtime `uniffi` version in +# contextvm-ffi/Cargo.toml. As of uniffi 0.30 the binary is `uniffi-bindgen-cli` +# (it was `uniffi-bindgen` in 0.29). The generated file MUST be paired with a +# library built from the same release: UniFFI embeds a contract version + +# per-function checksums checked at import time. +cargo install --git https://github.com/mozilla/uniffi-rs \ + --tag v0.31.2 uniffi-bindgen-cli --locked +uniffi-bindgen-cli generate target/debug/libcontextvm_ffi.so \ + --library --language python --out-dir python/ # or kotlin / swift + +# C test suite — verifies the hand-written header matches Rust signatures +cd contextvm-ffi/c-tests && make test +``` + +The C test suite (`contextvm-ffi/c-tests/test_ffi.c`) is the **only** check that +`contextvm.h` stays in sync with the Rust `extern "C"` surface — keep it in CI +and extend it when you add C functions. + +## Code Style + +- Rust 2021 edition, MSRV 1.88. +- `cargo fmt` formatting is enforced (CI fails on drift). +- `cargo clippy --all --all-features -- -D warnings` is enforced (zero warnings). +- Public items require doc comments (`#![warn(missing_docs)]` is in effect for + the FFI crate; keep new public SDK items documented too). +- Tests are required for new functionality — add or update tests even if not + asked. + +## CI / Release + +Workflows live in `.github/workflows/`: + +- **`ci.yml`** — quality gates (fmt, clippy, check, test all-features, + no-default-features, doc, rmcp integration example, MSRV 1.88). Runs on + push/PR to `main` and on `v*` tags. +- **`ffi.yml`** — FFI packaging. Builds per-platform native libraries + (linux x86_64, macOS arm64/x86_64/universal, Windows x86_64), runs the C + test suite, generates UniFFI bindings, and on `v*` tags uploads archives to + the GitHub Release. Release archives bundle the native lib + static lib + + `contextvm.h` + README per target; bindings ship as a separate archive. + +**Releasing**: push a `v*` tag. The FFI workflow assembles and uploads +artifacts automatically; `ci.yml` + `ffi.yml` must both be green. + +## Pull Request Guidelines + +- Run the full quality gate before pushing: + `cargo fmt --all -- --check && cargo clippy --all --all-features --all-targets -- -D warnings && cargo test --all --all-features` +- If you touched `src/core/error.rs` or the SDK error enum, **also** update + `contextvm-ffi/src/error.rs` and verify the FFI still compiles + (`cargo check --all --all-features`). +- If you touched an SDK type exposed via FFI, update `src/uniffi_types.rs` and + `contextvm-ffi/headers/contextvm.h`, and extend `c-tests/test_ffi.c`. +- Commit messages: conventional-style (`feat:`, `fix:`, `docs:`, `chore:`, + `refactor:`) with a scope when helpful (e.g. `fix(ffi): …`). +- Update `CHANGELOG.md` for user-visible changes. + +## Gotchas + +- **Non-exhaustive `Error` match in the FFI is intentional** — it makes SDK + additions a loud compile error instead of silent misclassification. Add the + arm; don't wildcard. +- **`headers/contextvm.h` is hand-written** and drifts silently. The C test + suite is the safety net — never disable it. +- **`recv_try` returns `Ok(None)` on mutex contention**, not only on empty — a + deliberate non-blocking semantic (see `src/uniffi_types.rs`). +- **The global FFI runtime never shuts down** (intentional). Fine for long-lived + hosts; embeds in short-lived CLIs pay a few MB of resident threads. +- **MSRV job regenerates `Cargo.lock`** — don't commit dependency bumps that + require a newer compiler without bumping `rust-version`. +- **`rmcp` is sourced from crates.io (`1.8`)**, not the old + `ContextVM-org/rust-sdk` fork. The fork carried a `progress-aware-request-timeouts` + branch (with the `transport-worker` feature this crate needs); that work shipped + upstream in the official `modelcontextprotocol/rust-sdk`, so the git fork pin was + retired. When upgrading `rmcp`, note the 1.x macro pattern: `#[tool_router]` + + `#[tool_handler]` generate the tool dispatch wiring — **do not** store a + `tool_router: ToolRouter` field on the handler struct (it's dead in 1.x and + trips `dead_code`). The fork's old 0.x-style stored-field pattern was removed + during the migration; keep it removed. +- **The `sdk/` path** (if present) is a sibling reference used during some + cross-repo work and is not part of this crate's build graph; ignore it unless + explicitly working on it. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..e1144d1 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,72 @@ +# Changelog + +## [Unreleased] + +### Changed + +- Bumped `nostr-sdk` from `0.43` to `0.44` (pulls core `nostr` `0.44.3`). No source + changes were required: the breaking removals in the unreleased 0.45 line + (`NostrSigner`, `TagKind`, `EventBuilder::sign_with_keys`, `TagStandard`) + are not yet published. The SDK pins `hex` as a direct dependency, so nostr's + internal `hex` module removal in 0.44.0 is unaffected. +- FFI: bumped `uniffi` from `0.29` to `0.31`. This raises the embedded UniFFI + contract version (`29` -> `30`), so the generated `contextvm_ffi.py` / Swift / + Kotlin bindings and the native library must be taken from the same release — + a mismatch now aborts at import time with the bumped contract id. Updated + `.github/workflows/ffi.yml` to install `uniffi-bindgen-cli` at tag `v0.31.2` + and invoke it as `uniffi-bindgen-cli` (renamed from `uniffi-bindgen` in 0.30). + +### Added + +- `examples/python/`: runnable Python examples using the UniFFI binding — an + offline install sanity check, server/tool discovery (mirrors `discovery.rs`), + and a client `tools/list` caller (mirrors `proxy.rs`). + +## [0.1.1] - 2026-05-08 + +### Added + +- End-to-end happy-path integration coverage for the full in-memory SDK stack, exercising RMCP handlers through `NostrServerWorker`, `NostrServerTransport`, `MockRelayPool`, `NostrClientTransport`, and the RMCP client without requiring a live network +- New `test-utils` feature for downstream integration tests that need access to `MockRelayPool` +- Public re-export of the relay module so downstream crates can use `MockRelayPool` through the crate root when `test-utils` is enabled + +### Fixed + +- RMCP stateless CEP-35 requests are now bridged into the RMCP lifecycle correctly by injecting synthetic initialization for first contact, allowing stateless clients to call tools and resources without an explicit `initialize` round-trip +- Corrected crates.io metadata (repository URL, keywords, categories, homepage, documentation) + +### Changed + +- Enabled the `rmcp` feature by default to make the native RMCP transport integration available out of the box +- Improved public API exports for transport, relay, gateway, and proxy types to simplify downstream usage + +## [0.1.0] - 2026-05-07 + +### Added + +- Core transport layer: `NostrClientTransport` and `NostrServerTransport` over NIP-59 gift wraps +- Gateway and Proxy high-level APIs for bridging MCP over Nostr +- Discovery API: `discover_servers`, `discover_tools`, `discover_resources`, `discover_prompts`, `discover_resource_templates` +- CEP-6: server announcement publishing and querying (kinds 11316–11320) +- CEP-19: ephemeral gift wraps (kind 21059) with `GiftWrapMode` negotiation on both client and server +- CEP-35: stateless session discovery, tag composition, and capability learning +- LRU-bounded session store with configurable capacity (default 1000 sessions) and TTL expiry +- Multi-client support in `NostrServerWorker` (removed single-peer barrier) +- Direct rmcp transport adapters via `into_rmcp_transport()` for native `ContextVM` services +- `CancellationToken`-based graceful shutdown on `close()` +- TTL sweep for client and server correlation stores to prevent pending-request leaks +- `MockRelayPool` for deterministic offline testing +- Builder pattern for all transport and worker configuration structs +- Four examples: gateway, proxy, discovery, and rmcp integration test + +### Fixed + +- Single-peer barrier in RMCP worker rejected concurrent clients (#60) +- Pending-request leak: correlation store entries never expired by TTL (#61) +- Event loop tasks not cancelled on `close()`, causing resource leaks (#63) +- `RecvError::Lagged` killing event loop under high relay throughput (#68) +- Client race condition: responses lost when publish completed before correlation registration (#55) +- Uncorrelated responses (missing `e` tag) forwarded to consumer instead of dropped (#55) +- Non-atomic `send_response` behavior in server transport (#48) +- Unbounded LRU cache initialization with zero capacity (#50) +- Announced servers not sending JSON-RPC `-32000 Unauthorized` error for disallowed clients (#53) diff --git a/Cargo.toml b/Cargo.toml index 165dae7..8d4a38b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,10 +1,16 @@ [package] name = "contextvm-sdk" -version = "0.1.0" +version = "0.1.1" edition = "2021" +rust-version = "1.88" description = "Rust SDK for the ContextVM protocol — MCP over Nostr" license = "MIT" -repository = "https://github.com/k0sti/rust-contextvm-sdk" +readme = "README.md" +repository = "https://github.com/ContextVM/rs-sdk" +homepage = "https://contextvm.org" +documentation = "https://docs.rs/contextvm-sdk" +keywords = ["nostr", "mcp", "modelcontextprotocol", "decentralized", "ai"] +categories = ["network-programming", "api-bindings", "asynchronous"] [dependencies] # Async runtime @@ -14,16 +20,71 @@ tokio = { version = "1", features = ["full"] } serde = { version = "1", features = ["derive"] } serde_json = "1" +# Hashing for CEP-22 oversized payload transfer (SHA-256 digest of reassembled payloads) +sha2 = "0.10" +hex = "0.4" + # Error handling thiserror = "2.0" async-trait = "0.1" +# Async stream/future primitives (CEP-41 open-stream `Stream` impl + boxed publish closures) +futures = "0.3" + # Nostr SDK with NIP-59 (Gift Wrap) support -nostr-sdk = { version = "0.43", features = ["nip59"] } +nostr-sdk = { version = "0.44", features = ["nip59"] } # Logging tracing = "0.1" +# Optional MCP integration (Rust equivalent to TS @modelcontextprotocol/sdk) +rmcp = { version = "1.8", features = ["server", "client", "macros", "transport-worker"], optional = true } + +# LRU cache for gift-wrap (outer event id) deduplication +lru = "0.12" + +# CancellationToken for graceful event-loop shutdown +tokio-util = { version = "0.7", features = ["rt"] } + +[features] +# Enable rmcp by default while keeping legacy APIs available. +default = ["rmcp"] +rmcp = ["dep:rmcp"] +## Exposes `MockRelayPool` for use in downstream integration tests. +test-utils = [] + +[[example]] +name = "rmcp_integration_test" +required-features = ["rmcp"] + +[[example]] +name = "native_echo_server" +required-features = ["rmcp"] + +[[example]] +name = "native_echo_client" +required-features = ["rmcp"] + +[[test]] +name = "e2e_happy_path" +required-features = ["rmcp", "test-utils"] + +[[test]] +name = "transport_integration" +required-features = ["test-utils"] + +[[test]] +name = "oversized_timeout_e2e" +required-features = ["rmcp", "test-utils"] + [dev-dependencies] +# `start_paused` deterministic-time tests (tokio's "full" does not include test-util). +tokio = { version = "1", features = ["test-util"] } tokio-test = "0.4" -tracing-subscriber = "0.3" +anyhow = "1" +schemars = "0.8" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[workspace] +members = [".", "contextvm-ffi"] +resolver = "2" diff --git a/DESIGN.md b/DESIGN.md index fc9467f..7fe0ab7 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -221,7 +221,7 @@ tracing-subscriber = "0.3" - Unit test: rejects events from wrong server pubkey - [x] **2.4** Implement `NostrServerTransport` - - Config: relay_urls, encryption_mode, server_info, is_public_server, allowed_public_keys, excluded_capabilities, cleanup_interval_ms, session_timeout_ms + - Config: relay_urls, encryption_mode, server_info, is_announced_server, allowed_public_keys, excluded_capabilities, cleanup_interval_ms, session_timeout_ms - Implements `Transport` trait - Features: - Subscribe to events targeting server pubkey diff --git a/README.md b/README.md index 79ed657..a5fa746 100644 --- a/README.md +++ b/README.md @@ -39,6 +39,8 @@ ContextVM maps MCP's JSON-RPC 2.0 messages onto Nostr events: |---------|------------------------|-------------|--------------------------------------| | `25910` | ContextVM Messages | Ephemeral | MCP request/response/notification | | `1059` | Gift Wrap (NIP-59) | Regular | Encrypted MCP messages | +| `21059` | Ephemeral Gift Wrap | Ephemeral | Encrypted MCP messages (CEP-19) | +| `10002` | Relay List Metadata | Replaceable | Server's advertised relays (CEP-17) | | `11316` | Server Announcement | Addressable | Server identity & metadata | | `11317` | Tools List | Addressable | Published tool capabilities | | `11318` | Resources List | Addressable | Published resource capabilities | @@ -53,7 +55,7 @@ Add to your `Cargo.toml`: ```toml [dependencies] -contextvm-sdk = { git = "https://github.com/k0sti/rust-contextvm-sdk" } +contextvm-sdk = { git = "https://github.com/ContextVM/rs-sdk" } ``` Or clone and use as a path dependency: @@ -77,19 +79,16 @@ use contextvm_sdk::signer; async fn main() -> contextvm_sdk::Result<()> { let keys = signer::generate(); - let config = GatewayConfig { - nostr_config: NostrServerTransportConfig { - relay_urls: vec!["wss://relay.damus.io".into()], - encryption_mode: EncryptionMode::Optional, - server_info: Some(ServerInfo { - name: Some("My MCP Server".into()), - about: Some("Tools via Nostr".into()), - ..Default::default() - }), - is_public_server: true, - ..Default::default() - }, - }; + let config = GatewayConfig::new( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_server_info( + ServerInfo::default() + .with_name("My MCP Server") + .with_about("Tools via Nostr"), + ) + .with_announced_server(true), + ); let mut gateway = NostrMCPGateway::new(keys, config).await?; let mut requests = gateway.start().await?; @@ -116,14 +115,11 @@ use contextvm_sdk::signer; async fn main() -> contextvm_sdk::Result<()> { let keys = signer::generate(); - let config = ProxyConfig { - nostr_config: NostrClientTransportConfig { - relay_urls: vec!["wss://relay.damus.io".into()], - server_pubkey: "abc123...server_hex_pubkey".into(), - encryption_mode: EncryptionMode::Optional, - ..Default::default() - }, - }; + let config = ProxyConfig::new( + NostrClientTransportConfig::default() + .with_server_pubkey("abc123...server_hex_pubkey") + .with_encryption_mode(EncryptionMode::Optional), + ); let mut proxy = NostrMCPProxy::new(keys, config).await?; let mut responses = proxy.start().await?; @@ -168,6 +164,22 @@ async fn main() -> contextvm_sdk::Result<()> { } ``` +## Documentation + +The in-repo Rust SDK guides live in [`docs/README.md`](docs/README.md): + +- For most users, the main pattern is: build an `rmcp` server or client, then attach [`NostrServerTransport`](src/transport/server/mod.rs:87) or [`NostrClientTransport`](src/transport/client/mod.rs:69). + +- [`docs/overview.md`](docs/overview.md) +- [`docs/server-transport.md`](docs/server-transport.md) +- [`docs/client-transport.md`](docs/client-transport.md) +- [`docs/discovery.md`](docs/discovery.md) +- [`docs/encryption.md`](docs/encryption.md) +- [`docs/rmcp.md`](docs/rmcp.md) +- [`docs/transports.md`](docs/transports.md) +- [`docs/gateway.md`](docs/gateway.md) +- [`docs/proxy.md`](docs/proxy.md) + ## Module Overview | Module | Description | @@ -194,6 +206,12 @@ async fn main() -> contextvm_sdk::Result<()> { Encryption uses **NIP-44** for payload encryption and **NIP-59** (Gift Wrap) for metadata-private delivery. Server announcements (kinds 11316–11320) are always public. +Messages too large for a single relay event are fragmented into ordered frames +and reassembled by the receiver (CEP-22 oversized transfer, enabled by default; +it adds no event kind — frames ride inside `notifications/progress` messages). +See [docs/oversized-transfer.md](docs/oversized-transfer.md) for the timeout +model and tuning. + ### Server Transport Config | Field | Default | Description | @@ -201,20 +219,30 @@ metadata-private delivery. Server announcements (kinds 11316–11320) are always | `relay_urls` | `["wss://relay.damus.io"]` | Nostr relays to connect to | | `encryption_mode` | `Optional` | Encryption policy | | `server_info` | `None` | Server metadata for announcements | -| `is_public_server` | `false` | Whether to publish announcements | +| `is_announced_server` | `false` | Auto-publish announcements on start (CEP-6) | | `allowed_public_keys` | `[]` (allow all) | Client pubkey allowlist (hex) | | `excluded_capabilities` | `[]` | Methods exempt from allowlist | | `session_timeout` | `300s` | Inactive session expiry | +| `relay_list_urls` | `None` | Relay URLs for kind 10002 (CEP-17); defaults to `relay_urls` | +| `bootstrap_relay_urls` | `None` | Additional relays for publishing announcements (CEP-6/17) | +| `publish_relay_list` | `true` | Whether to publish kind 10002 relay list metadata | +| `profile_metadata` | `None` | Profile metadata for kind 0 publication (CEP-23) | +| `oversized_transfer` | enabled | CEP-22 oversized payload transfer config ([guide](docs/oversized-transfer.md)) | ### Client Transport Config -| Field | Default | Description | -|-------------------|----------------------------|--------------------------------------| -| `relay_urls` | `["wss://relay.damus.io"]` | Nostr relays to connect to | -| `server_pubkey` | (required) | Target server's public key (hex) | -| `encryption_mode` | `Optional` | Encryption policy | -| `is_stateless` | `false` | Emulate initialize locally | -| `timeout` | `30s` | Response timeout | +| Field | Default | Description | +|------------------------------------|----------------------------|--------------------------------------| +| `relay_urls` | `[]` | Nostr relays to connect to (empty = use relay resolution) | +| `server_pubkey` | (required) | Target server's public key (hex, npub, or nprofile) | +| `encryption_mode` | `Optional` | Encryption policy | +| `is_stateless` | `false` | Emulate initialize locally | +| `timeout` | `30s` | Response timeout | +| `discovery_relay_urls` | `None` (bootstrap relays) | Relays for CEP-17 kind 10002 discovery | +| `fallback_operational_relay_urls` | `None` | Relays probed in parallel with CEP-17 discovery | +| `oversized_transfer` | enabled | CEP-22 oversized payload transfer config ([guide](docs/oversized-transfer.md)) | + +When `relay_urls` is empty, `start()` runs automatic relay resolution: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > bootstrap defaults. ## References diff --git a/contextvm-docs b/contextvm-docs new file mode 160000 index 0000000..75757ed --- /dev/null +++ b/contextvm-docs @@ -0,0 +1 @@ +Subproject commit 75757edcfa00695b0c8ec65b89cb842009043e5b diff --git a/contextvm-ffi/.gitignore b/contextvm-ffi/.gitignore new file mode 100644 index 0000000..ca32551 --- /dev/null +++ b/contextvm-ffi/.gitignore @@ -0,0 +1,6 @@ +/target +__pycache__/ +*.py[cod] + +# C test binary built by c-tests/Makefile +c-tests/test_ffi diff --git a/contextvm-ffi/Cargo.toml b/contextvm-ffi/Cargo.toml new file mode 100644 index 0000000..7e1bf2a --- /dev/null +++ b/contextvm-ffi/Cargo.toml @@ -0,0 +1,31 @@ +[package] +name = "contextvm-ffi" +version = "0.1.0" +edition = "2021" +description = "FFI bindings for the ContextVM SDK — C header + Python/Swift/Kotlin support" +license = "MIT" + +[lib] +name = "contextvm_ffi" +crate-type = ["cdylib", "staticlib", "lib"] + +[dependencies] +contextvm-sdk = { path = ".." } +nostr-sdk = { version = "0.44", features = ["nip59"] } +tokio = { version = "1", features = ["full"] } +serde_json = "1" +uniffi = "0.31" +parking_lot = "0.12" + +[features] +default = [] +# End-to-end tests that spawn the `nak` CLI relay (https://github.com/nbd-wtf/nostr-cli). +# Off by default so `cargo test` and CI's `cargo test --all --all-features` don't +# require `nak` to be installed. When enabled but `nak` is absent from PATH, the +# tests no-op with a stderr note instead of failing. +nak-tests = [] + +# `nak` is an external process; only build this test target when explicitly opted in. +[[test]] +name = "e2e_ffi_nak_relay" +required-features = ["nak-tests"] diff --git a/contextvm-ffi/README.md b/contextvm-ffi/README.md new file mode 100644 index 0000000..ce96532 --- /dev/null +++ b/contextvm-ffi/README.md @@ -0,0 +1,97 @@ +# contextvm-ffi + +FFI bindings for the ContextVM Rust SDK. + +This crate exposes two binding surfaces: + +- A flat C ABI in `headers/contextvm.h` +- UniFFI objects for Python, Kotlin, and Swift + +Async SDK operations are driven by an internal Tokio runtime, so foreign callers +use blocking functions and do not need to manage Rust async state. + +## Build + +```bash +cd contextvm-ffi +cargo build --release +``` + +Outputs: + +- Linux: `target/release/libcontextvm_ffi.so` +- macOS: `target/release/libcontextvm_ffi.dylib` +- Windows: `target/release/contextvm_ffi.dll` + +## Generate UniFFI Bindings + +Build the shared library first, then generate bindings from the compiled +library metadata. The bindgen version **must match** the runtime `uniffi` +version in `Cargo.toml` — UniFFI embeds a contract version and per-function +checksums into both the library and the generated file, and a mismatch aborts +at import time (`UniFFI ... mismatch`). + +The bindgen CLI is not published to crates.io; install it from git, pinned to +the tag matching your runtime version (`0.31.x` here): + +```bash +cd contextvm-ffi +cargo build + +cargo install --git https://github.com/mozilla/uniffi-rs --tag v0.31.2 uniffi-bindgen-cli --locked + +uniffi-bindgen-cli generate target/debug/libcontextvm_ffi.so \ + --library \ + --language python \ + --out-dir python/ +``` + +Use `--language kotlin` or `--language swift` for the other supported targets. +The generated Python file expects the native library (`libcontextvm_ffi.so` / +`.dylib` / `contextvm_ffi.dll`) to sit **next to it** at import time. + +## C API + +Include `headers/contextvm.h` and link against `libcontextvm_ffi`. + +```c +#include "contextvm.h" + +CvmError *error = NULL; +CvmHandle keys = cvm_keys_generate(&error); + +char *public_key = cvm_keys_public_key(keys, &error); +cvm_string_free(public_key); + +cvm_keys_free(keys); +``` + +Errors are opaque. Use `cvm_error_code`, `cvm_error_message`, and +`cvm_error_free` to inspect and release them. + +Mode fields in `CvmServerConfig` and `CvmClientConfig` are raw `int32_t` +values. Set them with the `CVM_ENCRYPTION_*` and `CVM_GIFTWRAP_*` constants; +invalid values are rejected with `CVM_VALIDATION`. + +## JSON Arguments + +Several parity APIs use JSON strings to represent SDK values that are not +portable C structs: + +- `profile_metadata_json`: a `ProfileMetadata` JSON object. +- `*_publish_tools/resources/prompts/resource_templates`: a JSON array of MCP + capability objects. +- `*_set_announcement_*_tags`: a JSON array of Nostr tag arrays, for example + `[["pricing","free"]]`. + +## Memory Management + +Rust-owned values returned through the C ABI must be released by the caller: + +- Strings: `cvm_string_free` +- Messages: `cvm_message_free` +- Incoming requests: `cvm_incoming_request_free` +- Announcement arrays: `cvm_announcements_free` +- Discovered tool arrays: `cvm_discovered_tools_free` +- Provider profile arrays: `cvm_provider_profiles_free` +- Errors: `cvm_error_free` diff --git a/contextvm-ffi/c-tests/Makefile b/contextvm-ffi/c-tests/Makefile new file mode 100644 index 0000000..f38cc3c --- /dev/null +++ b/contextvm-ffi/c-tests/Makefile @@ -0,0 +1,74 @@ +# Makefile for ContextVM FFI C Tests +# +# Usage: +# make - Build the test program +# make test - Build and run tests +# make clean - Clean build artifacts + +# Configuration +CARGO_FLAGS ?= --release +BUILD_MODE ?= release + +# Detect target directory based on build mode +ifeq ($(BUILD_MODE),debug) + TARGET_DIR = ../../target/debug + CARGO_FLAGS = +else + TARGET_DIR = ../../target/release +endif + +# Compiler settings +CC = gcc +CFLAGS = -Wall -Wextra -g -I../headers +LDFLAGS = -L$(TARGET_DIR) -lcontextvm_ffi -lpthread -ldl -lm + +# macOS specific settings (if needed) +ifeq ($(shell uname),Darwin) + LDFLAGS += -framework Security -framework CoreFoundation +endif + +# Targets +TEST_BIN = test_ffi + +.PHONY: all build test clean ffi + +all: build + +# Build the Rust FFI library +ffi: + cd .. && cargo build $(CARGO_FLAGS) + +# Build the C test program +build: ffi + @echo "Building C test program ($(BUILD_MODE) mode)..." + $(CC) $(CFLAGS) -o $(TEST_BIN) test_ffi.c $(LDFLAGS) + +# Run the tests +test: build + @echo "\n========================================" + @echo "Running C FFI Tests" + @echo "========================================\n" + @LD_LIBRARY_PATH=$(TARGET_DIR) ./$(TEST_BIN) + +# Clean build artifacts +clean: + rm -f $(TEST_BIN) + cd .. && cargo clean + +# Build in debug mode for debugging +debug: + $(MAKE) BUILD_MODE=debug + +# Help target +help: + @echo "ContextVM FFI C Tests Makefile" + @echo "" + @echo "Targets:" + @echo " make - Build the Rust FFI library and C test program (release mode)" + @echo " make test - Build and run the C tests" + @echo " make debug - Build in debug mode (with debug symbols)" + @echo " make clean - Clean all build artifacts" + @echo " make help - Show this help message" + @echo "" + @echo "Environment Variables:" + @echo " BUILD_MODE - 'release' (default) or 'debug'" diff --git a/contextvm-ffi/c-tests/test_ffi.c b/contextvm-ffi/c-tests/test_ffi.c new file mode 100644 index 0000000..6f4654b --- /dev/null +++ b/contextvm-ffi/c-tests/test_ffi.c @@ -0,0 +1,241 @@ +/** + * C End-to-End Test for ContextVM FFI Bindings + * + * This program tests the ContextVM C API by calling FFI functions directly + * from C code. It verifies: + * - Key generation and management + * - Error handling + * - Memory safety (allocations/frees) + * - Message structures + * + * Build with: make + * Run with: make test + */ + +#include +#include +#include +#include +#include +#include "../headers/contextvm.h" + +// Test counters +static int tests_passed = 0; +static int tests_failed = 0; + +#define TEST(name) void test_##name() +#define RUN_TEST(name) do { \ + printf(" Running %s... ", #name); \ + test_##name(); \ + printf("OK\n"); \ + tests_passed++; \ +} while(0) + +#define ASSERT(expr) do { \ + if (!(expr)) { \ + printf("FAILED\n Assertion failed: %s at line %d\n", #expr, __LINE__); \ + tests_failed++; \ + return; \ + } \ +} while(0) + +// Helper to print hex bytes +static void print_hex(const char* label, const char* data) { + printf("%s: %s\n", label, data ? data : "(null)"); +} + +TEST(keys_generation_and_free) { + CvmError* error = NULL; + CvmHandle keys = cvm_keys_generate(&error); + + ASSERT(keys.id > 0); + + // Get public key + char* pubkey = cvm_keys_public_key(keys, &error); + ASSERT(pubkey != NULL); + ASSERT(strlen(pubkey) == 64); // 32 bytes as hex + + print_hex(" Public key", pubkey); + + // Cleanup + cvm_string_free(pubkey); + cvm_keys_free(keys); +} + +TEST(invalid_handle_handling) { + CvmHandle invalid = { .id = 99999 }; + CvmError* error = NULL; + + // Should return NULL for invalid handle, not crash + char* pubkey = cvm_keys_public_key(invalid, &error); + // Note: The function returns NULL for invalid handle + // We just verify it doesn't crash + (void)pubkey; // Suppress unused warning +} + +TEST(string_allocation_roundtrip) { + const char* test_str = "Hello, ContextVM FFI!"; + + // Allocate and free a string through the FFI + char* c_str = strdup(test_str); + ASSERT(c_str != NULL); + ASSERT(strcmp(c_str, test_str) == 0); + + // Free using FFI function + cvm_string_free(c_str); + // If we got here without crash, the free worked +} + +TEST(message_structure_creation) { + // Create a JSON-RPC message structure + CvmJsonRpcMessage msg = { + .msg_type = CVM_MSG_REQUEST, + .id = strdup("req-123"), + .payload_json = strdup("{\"jsonrpc\":\"2.0\",\"method\":\"test\"}"), + .method = strdup("test") + }; + + ASSERT(msg.id != NULL); + ASSERT(msg.payload_json != NULL); + ASSERT(strcmp(msg.id, "req-123") == 0); + + // Free the message + cvm_message_free(msg); +} + +TEST(error_handling_lifecycle) { + CvmHandle invalid = { .id = 0 }; + CvmJsonRpcMessage out_msg; + CvmError* error = NULL; + + int result = cvm_proxy_ch_recv_timeout(invalid, 1, &out_msg, &error); + + // For invalid handle, the function should return false + ASSERT(result == 0); + + // Error might or might not be set depending on implementation + if (error != NULL) { + printf("\n Error code: %d\n", cvm_error_code(error)); + cvm_error_free(error); + } +} + +TEST(multiple_keys_isolation) { + CvmError* error = NULL; + CvmHandle keys1 = cvm_keys_generate(&error); + CvmHandle keys2 = cvm_keys_generate(&error); + + ASSERT(keys1.id > 0); + ASSERT(keys2.id > 0); + ASSERT(keys1.id != keys2.id); + + char* pubkey1 = cvm_keys_public_key(keys1, &error); + char* pubkey2 = cvm_keys_public_key(keys2, &error); + + ASSERT(pubkey1 != NULL); + ASSERT(pubkey2 != NULL); + ASSERT(strcmp(pubkey1, pubkey2) != 0); + + print_hex(" Key 1", pubkey1); + print_hex(" Key 2", pubkey2); + + cvm_string_free(pubkey1); + cvm_string_free(pubkey2); + cvm_keys_free(keys1); + cvm_keys_free(keys2); +} + +TEST(constant_values) { + // Verify constants match expected values + ASSERT(CVM_OK == 0); + ASSERT(CVM_TRANSPORT == 1); + ASSERT(CVM_ENCRYPTION == 2); + ASSERT(CVM_DECRYPTION == 3); + ASSERT(CVM_TIMEOUT == 4); + ASSERT(CVM_VALIDATION == 5); + ASSERT(CVM_UNAUTHORIZED == 6); + ASSERT(CVM_SERIALIZATION == 7); + ASSERT(CVM_OTHER == 99); + + ASSERT(CVM_ENCRYPTION_DISABLED == 2); + ASSERT(CVM_ENCRYPTION_OPTIONAL == 0); + ASSERT(CVM_ENCRYPTION_REQUIRED == 1); + + ASSERT(CVM_GIFTWRAP_OPTIONAL == 0); + ASSERT(CVM_GIFTWRAP_EPHEMERAL == 1); + ASSERT(CVM_GIFTWRAP_PERSISTENT == 2); + + ASSERT(CVM_MSG_REQUEST == 0); + ASSERT(CVM_MSG_RESPONSE == 1); + ASSERT(CVM_MSG_ERROR_RESPONSE == 2); + ASSERT(CVM_MSG_NOTIFICATION == 3); +} + +TEST(null_safety) { + // These should not crash + cvm_string_free(NULL); + cvm_error_free(NULL); + + CvmJsonRpcMessage msg = { + .msg_type = CVM_MSG_REQUEST, + .id = NULL, + .payload_json = NULL, + .method = NULL + }; + cvm_message_free(msg); + + CvmIncomingRequest req = { + .message = msg, + .client_pubkey = NULL, + .event_id = NULL, + .is_encrypted = 0 + }; + cvm_incoming_request_free(req); +} + +TEST(config_structure_sizes) { + // Verify structures have expected sizes (sanity check) + CvmServerConfig server_config = {0}; + CvmClientConfig client_config = {0}; + + // Just verify we can create and access these structures + server_config.relay_url_count = 1; + client_config.relay_url_count = 1; + + ASSERT(server_config.relay_url_count == 1); + ASSERT(client_config.relay_url_count == 1); +} + +TEST(version_info) { + char* version = cvm_version(); + ASSERT(version != NULL); + ASSERT(strlen(version) > 0); + printf("\n ContextVM Version: %s\n", version); + cvm_string_free(version); +} + +int main(int argc, char** argv) { + (void)argc; + (void)argv; + + printf("========================================\n"); + printf("ContextVM FFI C End-to-End Tests\n"); + printf("========================================\n\n"); + + RUN_TEST(keys_generation_and_free); + RUN_TEST(invalid_handle_handling); + RUN_TEST(string_allocation_roundtrip); + RUN_TEST(message_structure_creation); + RUN_TEST(error_handling_lifecycle); + RUN_TEST(multiple_keys_isolation); + RUN_TEST(constant_values); + RUN_TEST(null_safety); + RUN_TEST(config_structure_sizes); + RUN_TEST(version_info); + + printf("\n========================================\n"); + printf("Results: %d passed, %d failed\n", tests_passed, tests_failed); + printf("========================================\n"); + + return tests_failed > 0 ? 1 : 0; +} diff --git a/contextvm-ffi/headers/contextvm.h b/contextvm-ffi/headers/contextvm.h new file mode 100644 index 0000000..de247cf --- /dev/null +++ b/contextvm-ffi/headers/contextvm.h @@ -0,0 +1,336 @@ +/* + * contextvm.h — C FFI header for the ContextVM SDK + * + * Usage: + * #include "contextvm.h" + * Link against libcontextvm_ffi.a (static) or libcontextvm_ffi.so (shared) + * + * ═══════════════════════════════════════════════════════════════════════════════ + * ERROR HANDLING CONTRACT + * ═══════════════════════════════════════════════════════════════════════════════ + * + * All functions take a CvmError **error out-parameter. + * - On success: *error is left untouched (caller should initialize to NULL) + * - On failure: *error is set to a freshly-allocated CvmError object + * + * IMPORTANT: Error objects are heap-allocated. Callers MUST free them with + * cvm_error_free() to avoid memory leaks. This applies even if you ignore the + * error details - always call cvm_error_free(*error) after checking the function + * return value. + * + * Example: + * CvmError *err = NULL; + * bool ok = cvm_server_ch_recv(handle, &req, &err); + * if (!ok) { + * fprintf(stderr, "Error: %s\n", cvm_error_message(err)); + * cvm_error_free(err); // REQUIRED - frees the error object + * } + * + * ═══════════════════════════════════════════════════════════════════════════════ + * RECEIVE CALLS AND TIMEOUTS + * ═══════════════════════════════════════════════════════════════════════════════ + * + * Blocking receive functions (cvm_*_ch_recv) block indefinitely until a message + * arrives. These are suitable for dedicated worker threads. + * + * Timed receive functions (cvm_*_ch_recv_timeout) accept a timeout_secs parameter + * and return CVM_TIMEOUT if no message arrives within that duration. These are + * preferred for embedding in Python/Swift/Kotlin runtimes where you may not want + * to manage dedicated threads. + * + * Passing timeout_secs = 0 is equivalent to a try-recv: it returns immediately, + * yielding CVM_TIMEOUT if no message is already buffered. Use this for non-blocking + * polls instead of blocking worker-thread consumption. + * + * ═══════════════════════════════════════════════════════════════════════════════ + * MEMORY MANAGEMENT + * ═══════════════════════════════════════════════════════════════════════════════ + * + * All functions that return handles use the pattern: + * - On success: returns a valid handle (id > 0) + * - On error: returns handle { id: 0 } and sets *error + * + * Strings returned by this library must be freed with cvm_string_free(). + * Structs with owned strings must be freed with their respective _free functions. + * Error pointers must be freed with cvm_error_free() - see ERROR HANDLING above. + */ + +#ifndef CONTEXTVM_FFI_H +#define CONTEXTVM_FFI_H + +#include +#include +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ─── Opaque Handle ────────────────────────────────────────────────── */ + +typedef struct { + uint64_t id; +} CvmHandle; + +/* ─── Error ────────────────────────────────────────────────────────── */ + +typedef enum { + CVM_OK = 0, + CVM_TRANSPORT = 1, + CVM_ENCRYPTION = 2, + CVM_DECRYPTION = 3, + CVM_TIMEOUT = 4, + CVM_VALIDATION = 5, + CVM_UNAUTHORIZED = 6, + CVM_SERIALIZATION = 7, + CVM_OTHER = 99, +} CvmErrorCode; + +typedef struct CvmError CvmError; + +/* ─── Enums ─────────────────────────────────────────────────────────── */ + +typedef enum { + CVM_ENCRYPTION_OPTIONAL = 0, + CVM_ENCRYPTION_REQUIRED = 1, + CVM_ENCRYPTION_DISABLED = 2, +} CvmEncryptionMode; + +typedef enum { + CVM_GIFTWRAP_OPTIONAL = 0, + CVM_GIFTWRAP_EPHEMERAL = 1, + CVM_GIFTWRAP_PERSISTENT = 2, +} CvmGiftWrapMode; + +typedef enum { + CVM_MSG_REQUEST = 0, + CVM_MSG_RESPONSE = 1, + CVM_MSG_ERROR_RESPONSE = 2, + CVM_MSG_NOTIFICATION = 3, +} CvmJsonRpcType; + +/* ─── Structs ──────────────────────────────────────────────────────── */ + +typedef struct { + int32_t msg_type; /* one of CvmJsonRpcType */ + char *payload_json; /* owned JSON string */ + char *method; /* owned, may be NULL */ + char *id; /* owned, may be NULL */ +} CvmJsonRpcMessage; + +typedef struct { + CvmJsonRpcMessage message; + char *client_pubkey; /* owned hex string */ + char *event_id; /* owned hex string */ + bool is_encrypted; +} CvmIncomingRequest; + +typedef struct { + char *pubkey; /* owned hex string */ + char *name; /* owned, may be NULL */ + char *version; /* owned, may be NULL */ + char *picture; /* owned, may be NULL */ + char *about; /* owned, may be NULL */ + char *website; /* owned, may be NULL */ + char *event_id; /* owned hex string */ +} CvmServerAnnouncement; + +typedef struct { + char *provider_pubkey; /* owned hex string */ + char *provider_display_name; /* owned, may be NULL */ + char *provider_name; /* owned, may be NULL */ + char *provider_about; /* owned, may be NULL */ + char *provider_picture; /* owned, may be NULL */ + char *provider_nip05; /* owned, may be NULL */ + char *tool_name; /* owned */ + char *description; /* owned */ + char *schema_json; /* owned JSON string */ +} CvmDiscoveredTool; + +typedef struct { + char *pubkey; /* owned hex string */ + char *name; /* owned, may be NULL */ + char *about; /* owned, may be NULL */ + char *picture; /* owned, may be NULL */ + char *nip05; /* owned, may be NULL */ +} CvmProviderProfile; + +typedef struct { + char *method; /* required */ + char *name; /* optional */ +} CvmCapabilityExclusion; + +typedef struct { + bool supports_encryption; + bool supports_ephemeral_encryption; + bool supports_oversized_transfer; +} CvmPeerCapabilities; + +typedef struct { + char **relay_urls; + size_t relay_url_count; + int32_t encryption_mode; /* one of CvmEncryptionMode */ + int32_t gift_wrap_mode; /* one of CvmGiftWrapMode */ + bool is_announced_server; + char *server_name; /* may be NULL */ + char *server_version; /* may be NULL */ + char *server_picture; /* may be NULL */ + char *server_about; /* may be NULL */ + char *server_website; /* may be NULL */ + char **allowed_pubkeys; /* if count > 0, pointer and entries must be non-NULL UTF-8 */ + size_t allowed_pubkey_count; + uint64_t session_timeout_secs; + uint64_t cleanup_interval_secs; + CvmCapabilityExclusion *excluded_capabilities; + size_t excluded_capability_count; + size_t max_sessions; /* 0 keeps SDK default */ + uint64_t request_timeout_secs; /* 0 keeps SDK default */ + char **relay_list_urls; + size_t relay_list_url_count; + char **bootstrap_relay_urls; + size_t bootstrap_relay_url_count; + bool publish_relay_list; + char *profile_metadata_json; /* optional ProfileMetadata JSON object */ +} CvmServerConfig; + +typedef struct { + char **relay_urls; + size_t relay_url_count; + char *server_pubkey; /* required */ + int32_t encryption_mode; /* one of CvmEncryptionMode */ + int32_t gift_wrap_mode; /* one of CvmGiftWrapMode */ + bool is_stateless; + uint64_t timeout_secs; + char **discovery_relay_urls; + size_t discovery_relay_url_count; + char **fallback_operational_relay_urls; + size_t fallback_operational_relay_url_count; +} CvmClientConfig; + +/* ─── Free Functions ───────────────────────────────────────────────── */ + +void cvm_string_free(char *s); +void cvm_message_free(CvmJsonRpcMessage msg); +void cvm_incoming_request_free(CvmIncomingRequest req); +void cvm_announcements_free(CvmServerAnnouncement *announcements, size_t count); +void cvm_discovered_tools_free(CvmDiscoveredTool *tools, size_t count); +void cvm_provider_profiles_free(CvmProviderProfile *profiles, size_t count); +void cvm_error_free(CvmError *e); +char *cvm_error_message(const CvmError *e); +CvmErrorCode cvm_error_code(const CvmError *e); + +/* ─── Version ──────────────────────────────────────────────────────── */ + +char *cvm_version(void); + +/* ─── Keys / Signer ────────────────────────────────────────────────── */ + +CvmHandle cvm_keys_generate(CvmError **error); +CvmHandle cvm_keys_from_secret_key(const char *sk, CvmError **error); +char *cvm_keys_public_key(CvmHandle handle, CvmError **error); +char *cvm_keys_secret_key(CvmHandle handle, CvmError **error); +void cvm_keys_free(CvmHandle handle); + +/* ─── Relay Pool ───────────────────────────────────────────────────── */ + +CvmHandle cvm_relay_pool_new(CvmHandle keys_handle, CvmError **error); +bool cvm_relay_pool_connect(CvmHandle pool_handle, char **urls, size_t count, CvmError **error); +bool cvm_relay_pool_disconnect(CvmHandle pool_handle, CvmError **error); +void cvm_relay_pool_free(CvmHandle handle); + +/* ─── Server (channel-based) ──────────────────────────────────────── */ + +CvmHandle cvm_server_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmError **error); +bool cvm_server_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_server_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_server_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); +bool cvm_server_ch_send_notification(CvmHandle handle, const char *client_pubkey, const char *payload_json, const char *correlated_event_id, CvmError **error); +bool cvm_server_ch_broadcast_notification(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_server_ch_set_announcement_extra_tags(CvmHandle handle, const char *tags_json, CvmError **error); +bool cvm_server_ch_set_announcement_pricing_tags(CvmHandle handle, const char *tags_json, CvmError **error); +bool cvm_server_ch_announce(CvmHandle handle, CvmError **error); +char *cvm_server_ch_announce_event_id(CvmHandle handle, CvmError **error); +char *cvm_server_ch_publish_tools(CvmHandle handle, const char *tools_json, CvmError **error); +char *cvm_server_ch_publish_resources(CvmHandle handle, const char *resources_json, CvmError **error); +char *cvm_server_ch_publish_prompts(CvmHandle handle, const char *prompts_json, CvmError **error); +char *cvm_server_ch_publish_resource_templates(CvmHandle handle, const char *templates_json, CvmError **error); +bool cvm_server_ch_delete_announcements(CvmHandle handle, const char *reason, CvmError **error); +bool cvm_server_ch_close(CvmHandle handle, CvmError **error); + +/* ─── Client (channel-based) ──────────────────────────────────────── */ + +CvmHandle cvm_client_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmError **error); +bool cvm_client_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_client_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_client_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_client_ch_discovered_server_capabilities(CvmHandle handle, CvmPeerCapabilities *out_caps, CvmError **error); +bool cvm_client_ch_server_supports_ephemeral_encryption(CvmHandle handle, CvmError **error); +char *cvm_client_ch_server_initialize_event_json(CvmHandle handle, CvmError **error); +bool cvm_client_ch_close(CvmHandle handle, CvmError **error); + +/* ─── Gateway (channel-based) ─────────────────────────────────────── */ + +CvmHandle cvm_gateway_ch_new(CvmHandle keys_handle, CvmServerConfig config, CvmError **error); +bool cvm_gateway_ch_recv(CvmHandle handle, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_gateway_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmIncomingRequest *out_req, CvmError **error); +bool cvm_gateway_ch_send_response(CvmHandle handle, const char *event_id, const char *payload_json, CvmError **error); +bool cvm_gateway_ch_announce(CvmHandle handle, CvmError **error); +char *cvm_gateway_ch_announce_event_id(CvmHandle handle, CvmError **error); +bool cvm_gateway_ch_is_active(CvmHandle handle, CvmError **error); +bool cvm_gateway_ch_stop(CvmHandle handle, CvmError **error); + +/* ─── Proxy (channel-based) ───────────────────────────────────────── */ + +CvmHandle cvm_proxy_ch_new(CvmHandle keys_handle, CvmClientConfig config, CvmError **error); +bool cvm_proxy_ch_send(CvmHandle handle, const char *payload_json, CvmError **error); +bool cvm_proxy_ch_recv(CvmHandle handle, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_proxy_ch_recv_timeout(CvmHandle handle, uint64_t timeout_secs, CvmJsonRpcMessage *out_msg, CvmError **error); +bool cvm_proxy_ch_is_active(CvmHandle handle, CvmError **error); +bool cvm_proxy_ch_stop(CvmHandle handle, CvmError **error); + +/* ─── Discovery ────────────────────────────────────────────────────── */ + +CvmServerAnnouncement *cvm_discover_servers( + CvmHandle pool_handle, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmDiscoveredTool *cvm_discover_tools( + CvmHandle pool_handle, + const char *provider_pubkey_hex, + const char *provider_display_name, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmDiscoveredTool *cvm_discover_all_tools( + CvmHandle pool_handle, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); +CvmProviderProfile *cvm_fetch_provider_profiles( + CvmHandle pool_handle, + char **provider_pubkeys, + size_t provider_pubkey_count, + char **relay_urls, + size_t url_count, + size_t *out_count, + CvmError **error +); + +/* ─── Encryption ───────────────────────────────────────────────────── */ + +char *cvm_encrypt_nip44(CvmHandle keys_handle, const char *recipient_hex, const char *plaintext, CvmError **error); +char *cvm_decrypt_nip44(CvmHandle keys_handle, const char *sender_hex, const char *ciphertext, CvmError **error); +char *cvm_pubkey_hex_to_npub(const char *pubkey_hex, CvmError **error); + +#ifdef __cplusplus +} +#endif + +#endif /* CONTEXTVM_FFI_H */ diff --git a/contextvm-ffi/src/builders.rs b/contextvm-ffi/src/builders.rs new file mode 100644 index 0000000..3ec2a6d --- /dev/null +++ b/contextvm-ffi/src/builders.rs @@ -0,0 +1,589 @@ +//! Helper functions for building SDK types from FFI configs. +//! +//! The SDK's public types are `#[non_exhaustive]`, so we can't construct +//! them with struct literals from outside the crate. These helpers use +//! the builder pattern instead. + +use std::time::Duration; + +use crate::error::{ErrorCode, FfiError}; +use crate::types::{ + c_str_array_to_vec_checked, c_str_to_string_checked, ffi_encryption_mode_to_sdk, + ffi_gift_wrap_mode_to_sdk, optional_c_str_to_string_checked, FfiCapabilityExclusion, + FfiClientConfig, FfiServerConfig, +}; + +pub struct ServerConfigParts { + pub relay_urls: Vec, + pub encryption_mode: contextvm_sdk::EncryptionMode, + pub gift_wrap_mode: contextvm_sdk::GiftWrapMode, + pub server_name: Option, + pub server_version: Option, + pub server_picture: Option, + pub server_about: Option, + pub server_website: Option, + pub is_announced_server: bool, + pub allowed_pubkeys: Vec, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, + pub excluded_capabilities: Vec, + pub max_sessions: usize, + pub request_timeout_secs: u64, + pub relay_list_urls: Vec, + pub bootstrap_relay_urls: Vec, + pub publish_relay_list: bool, + pub profile_metadata_json: Option, +} + +pub struct ClientConfigParts { + pub relay_urls: Vec, + pub server_pubkey: String, + pub encryption_mode: contextvm_sdk::EncryptionMode, + pub gift_wrap_mode: contextvm_sdk::GiftWrapMode, + pub is_stateless: bool, + pub timeout_secs: u64, + pub discovery_relay_urls: Vec, + pub fallback_operational_relay_urls: Vec, +} + +#[derive(Clone)] +pub struct CapabilityExclusionParts { + pub method: String, + pub name: Option, +} + +/// Build a `ServerInfo` from FFI C strings. +pub fn build_server_info( + name: Option, + version: Option, + picture: Option, + about: Option, + website: Option, +) -> Option { + if name.is_none() + && version.is_none() + && picture.is_none() + && about.is_none() + && website.is_none() + { + return None; + } + + let mut info = contextvm_sdk::ServerInfo::default(); + if let Some(n) = name { + info = info.with_name(n); + } + if let Some(v) = version { + info = info.with_version(v); + } + if let Some(p) = picture { + info = info.with_picture(p); + } + if let Some(a) = about { + info = info.with_about(a); + } + if let Some(w) = website { + info = info.with_website(w); + } + Some(info) +} + +/// Extract fields from an FfiServerConfig and build an SDK server config. +pub fn build_sdk_server_config( + config: &FfiServerConfig, +) -> Result { + let relay_urls = + c_str_array_to_vec_checked(config.relay_urls, config.relay_url_count, "relay_urls")?; + let allowed = c_str_array_to_vec_checked( + config.allowed_pubkeys, + config.allowed_pubkey_count, + "allowed_pubkeys", + )?; + let excluded = ffi_capability_exclusions_to_parts( + config.excluded_capabilities, + config.excluded_capability_count, + )?; + let relay_list_urls = c_str_array_to_vec_checked( + config.relay_list_urls, + config.relay_list_url_count, + "relay_list_urls", + )?; + let bootstrap_relay_urls = c_str_array_to_vec_checked( + config.bootstrap_relay_urls, + config.bootstrap_relay_url_count, + "bootstrap_relay_urls", + )?; + let encryption_mode = ffi_encryption_mode_to_sdk(config.encryption_mode)?; + let gift_wrap_mode = ffi_gift_wrap_mode_to_sdk(config.gift_wrap_mode)?; + + let server_info = build_server_info( + optional_c_str_to_string_checked(config.server_name, "server_name")?, + optional_c_str_to_string_checked(config.server_version, "server_version")?, + optional_c_str_to_string_checked(config.server_picture, "server_picture")?, + optional_c_str_to_string_checked(config.server_about, "server_about")?, + optional_c_str_to_string_checked(config.server_website, "server_website")?, + ); + let profile_metadata = parse_profile_metadata_json(optional_c_str_to_string_checked( + config.profile_metadata_json, + "profile_metadata_json", + )?)?; + + let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() + .with_relay_urls(if relay_urls.is_empty() { + vec!["wss://relay.damus.io".to_string()] + } else { + relay_urls + }) + .with_encryption_mode(encryption_mode) + .with_gift_wrap_mode(gift_wrap_mode) + .with_announced_server(config.is_announced_server) + .with_allowed_public_keys(allowed) + .with_session_timeout(Duration::from_secs(config.session_timeout_secs.max(1))) + .with_cleanup_interval(Duration::from_secs(config.cleanup_interval_secs.max(1))) + .with_publish_relay_list(config.publish_relay_list); + + if let Some(server_info) = server_info { + sdk_config = sdk_config.with_server_info(server_info); + } + + sdk_config = apply_extended_server_config( + sdk_config, + excluded, + config.max_sessions, + config.request_timeout_secs, + relay_list_urls, + bootstrap_relay_urls, + profile_metadata, + ); + + Ok(sdk_config) +} + +/// Extract fields from an FfiClientConfig and build an SDK client config. +pub fn build_sdk_client_config( + config: &FfiClientConfig, +) -> Result { + let server_pubkey = c_str_to_string_checked(config.server_pubkey, "server_pubkey")?; + let relay_urls = + c_str_array_to_vec_checked(config.relay_urls, config.relay_url_count, "relay_urls")?; + let discovery_relay_urls = c_str_array_to_vec_checked( + config.discovery_relay_urls, + config.discovery_relay_url_count, + "discovery_relay_urls", + )?; + let fallback_operational_relay_urls = c_str_array_to_vec_checked( + config.fallback_operational_relay_urls, + config.fallback_operational_relay_url_count, + "fallback_operational_relay_urls", + )?; + let encryption_mode = ffi_encryption_mode_to_sdk(config.encryption_mode)?; + let gift_wrap_mode = ffi_gift_wrap_mode_to_sdk(config.gift_wrap_mode)?; + + Ok(build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls, + server_pubkey, + encryption_mode, + gift_wrap_mode, + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls, + fallback_operational_relay_urls, + })) +} + +/// Build a server config from UniFFI record fields. +pub fn build_sdk_server_config_from_fields( + parts: ServerConfigParts, +) -> Result { + let server_info = build_server_info( + parts.server_name, + parts.server_version, + parts.server_picture, + parts.server_about, + parts.server_website, + ); + let profile_metadata = parse_profile_metadata_json(parts.profile_metadata_json)?; + + let mut sdk_config = contextvm_sdk::NostrServerTransportConfig::default() + .with_relay_urls(if parts.relay_urls.is_empty() { + vec!["wss://relay.damus.io".to_string()] + } else { + parts.relay_urls + }) + .with_encryption_mode(parts.encryption_mode) + .with_gift_wrap_mode(parts.gift_wrap_mode) + .with_announced_server(parts.is_announced_server) + .with_allowed_public_keys(parts.allowed_pubkeys) + .with_session_timeout(Duration::from_secs(parts.session_timeout_secs.max(1))) + .with_cleanup_interval(Duration::from_secs(parts.cleanup_interval_secs.max(1))) + .with_publish_relay_list(parts.publish_relay_list); + + if let Some(server_info) = server_info { + sdk_config = sdk_config.with_server_info(server_info); + } + + sdk_config = apply_extended_server_config( + sdk_config, + parts.excluded_capabilities, + parts.max_sessions, + parts.request_timeout_secs, + parts.relay_list_urls, + parts.bootstrap_relay_urls, + profile_metadata, + ); + + Ok(sdk_config) +} + +/// Build a client config from UniFFI record fields. +pub fn build_sdk_client_config_from_fields( + parts: ClientConfigParts, +) -> contextvm_sdk::NostrClientTransportConfig { + let mut config = contextvm_sdk::NostrClientTransportConfig::default() + .with_relay_urls(parts.relay_urls) + .with_server_pubkey(parts.server_pubkey) + .with_encryption_mode(parts.encryption_mode) + .with_gift_wrap_mode(parts.gift_wrap_mode) + .with_stateless(parts.is_stateless) + .with_timeout(Duration::from_secs(parts.timeout_secs.max(1))); + + if !parts.discovery_relay_urls.is_empty() { + config = config.with_discovery_relay_urls(parts.discovery_relay_urls); + } + if !parts.fallback_operational_relay_urls.is_empty() { + config = config.with_fallback_operational_relay_urls(parts.fallback_operational_relay_urls); + } + + config +} + +fn apply_extended_server_config( + mut config: contextvm_sdk::NostrServerTransportConfig, + excluded_capabilities: Vec, + max_sessions: usize, + request_timeout_secs: u64, + relay_list_urls: Vec, + bootstrap_relay_urls: Vec, + profile_metadata: Option, +) -> contextvm_sdk::NostrServerTransportConfig { + if !excluded_capabilities.is_empty() { + config = config.with_excluded_capabilities( + excluded_capabilities + .into_iter() + .map(|cap| contextvm_sdk::CapabilityExclusion { + method: cap.method, + name: cap.name, + }) + .collect(), + ); + } + if max_sessions > 0 { + config = config.with_max_sessions(max_sessions); + } + if request_timeout_secs > 0 { + config = config.with_request_timeout(Duration::from_secs(request_timeout_secs)); + } + if !relay_list_urls.is_empty() { + config = config.with_relay_list_urls(relay_list_urls); + } + if !bootstrap_relay_urls.is_empty() { + config = config.with_bootstrap_relay_urls(bootstrap_relay_urls); + } + if let Some(profile_metadata) = profile_metadata { + config = config.with_profile_metadata(profile_metadata); + } + config +} + +fn ffi_capability_exclusions_to_parts( + ptr: *mut FfiCapabilityExclusion, + count: usize, +) -> Result, FfiError> { + if count == 0 { + return Ok(Vec::new()); + } + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("excluded_capabilities has count {count} but null pointer"), + }); + } + + unsafe { + std::slice::from_raw_parts(ptr, count) + .iter() + .map(|cap| { + let method = c_str_to_string_checked(cap.method, "capability_exclusions[].method")?; + Ok(CapabilityExclusionParts { + method, + name: optional_c_str_to_string_checked( + cap.name, + "capability_exclusions[].name", + )?, + }) + }) + .collect() + } +} + +fn parse_profile_metadata_json( + json: Option, +) -> Result, FfiError> { + match json { + Some(json) if !json.trim().is_empty() => { + serde_json::from_str(&json).map(Some).map_err(|e| FfiError { + code: ErrorCode::Serialization, + message: format!("invalid profile_metadata_json: {e}"), + }) + } + _ => Ok(None), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{ENCRYPTION_MODE_OPTIONAL, GIFT_WRAP_MODE_OPTIONAL}; + use std::ffi::CString; + use std::os::raw::c_char; + use std::ptr; + + fn minimal_ffi_server_config() -> FfiServerConfig { + FfiServerConfig { + relay_urls: ptr::null_mut(), + relay_url_count: 0, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: ptr::null_mut(), + server_version: ptr::null_mut(), + server_picture: ptr::null_mut(), + server_about: ptr::null_mut(), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + } + } + + fn minimal_ffi_client_config(server_pubkey: *mut c_char) -> FfiClientConfig { + FfiClientConfig { + relay_urls: ptr::null_mut(), + relay_url_count: 0, + server_pubkey, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + } + } + + #[test] + fn omitted_server_info_matches_sdk_default() { + assert!(build_server_info(None, None, None, None, None).is_none()); + + let config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + is_announced_server: false, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: vec![], + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: vec![], + bootstrap_relay_urls: vec![], + publish_relay_list: true, + profile_metadata_json: None, + }) + .expect("config should build"); + + assert!(config.server_info.is_none()); + } + + #[test] + fn server_info_preserves_sdk_fields() { + let info = build_server_info( + Some("name".to_string()), + Some("1.2.3".to_string()), + Some("https://example.com/pic.png".to_string()), + Some("about".to_string()), + Some("https://example.com".to_string()), + ) + .expect("server info should be present when any field is supplied"); + + assert_eq!(info.name.as_deref(), Some("name")); + assert_eq!(info.version.as_deref(), Some("1.2.3")); + assert_eq!(info.picture.as_deref(), Some("https://example.com/pic.png")); + assert_eq!(info.about.as_deref(), Some("about")); + assert_eq!(info.website.as_deref(), Some("https://example.com")); + } + + #[test] + fn server_config_preserves_extended_fields() { + let config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + is_announced_server: false, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: vec![CapabilityExclusionParts { + method: "tools/call".to_string(), + name: Some("get_weather".to_string()), + }], + max_sessions: 42, + request_timeout_secs: 17, + relay_list_urls: vec!["wss://relay-list.example.com".to_string()], + bootstrap_relay_urls: vec!["wss://bootstrap.example.com".to_string()], + publish_relay_list: false, + profile_metadata_json: Some(r#"{"name":"profile","nip05":"bot@example.com"}"#.into()), + }) + .expect("config should build"); + + assert_eq!(config.excluded_capabilities.len(), 1); + assert_eq!(config.max_sessions, 42); + assert_eq!(config.request_timeout.as_secs(), 17); + assert_eq!( + config.relay_list_urls.as_deref(), + Some(&["wss://relay-list.example.com".to_string()][..]) + ); + assert_eq!( + config.bootstrap_relay_urls.as_deref(), + Some(&["wss://bootstrap.example.com".to_string()][..]) + ); + assert!(!config.publish_relay_list); + let profile = config.profile_metadata.expect("profile metadata"); + assert_eq!(profile.name.as_deref(), Some("profile")); + assert_eq!(profile.nip05.as_deref(), Some("bot@example.com")); + } + + #[test] + fn client_config_preserves_discovery_relays() { + let config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: vec!["wss://relay.example.com".to_string()], + server_pubkey: "abc".to_string(), + encryption_mode: contextvm_sdk::EncryptionMode::Optional, + gift_wrap_mode: contextvm_sdk::GiftWrapMode::Optional, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: vec!["wss://discovery.example.com".to_string()], + fallback_operational_relay_urls: vec!["wss://fallback.example.com".to_string()], + }); + + assert_eq!( + config.discovery_relay_urls.as_deref(), + Some(&["wss://discovery.example.com".to_string()][..]) + ); + assert_eq!( + config.fallback_operational_relay_urls.as_deref(), + Some(&["wss://fallback.example.com".to_string()][..]) + ); + } + + #[test] + fn ffi_server_config_rejects_counted_null_allowlist_pointer() { + let mut config = minimal_ffi_server_config(); + config.allowed_pubkey_count = 1; + + let err = build_sdk_server_config(&config).expect_err("counted null allowlist must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys")); + } + + #[test] + fn ffi_server_config_rejects_null_allowlist_entry() { + let mut config = minimal_ffi_server_config(); + let mut entries = [ptr::null_mut()]; + config.allowed_pubkeys = entries.as_mut_ptr(); + config.allowed_pubkey_count = entries.len(); + + let err = build_sdk_server_config(&config).expect_err("null allowlist entry must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys[0]")); + } + + #[test] + fn ffi_server_config_rejects_non_utf8_allowlist_entry() { + let mut config = minimal_ffi_server_config(); + let bad = CString::new(vec![0xff]).expect("no interior nul"); + let mut entries = [bad.as_ptr() as *mut c_char]; + config.allowed_pubkeys = entries.as_mut_ptr(); + config.allowed_pubkey_count = entries.len(); + + let err = + build_sdk_server_config(&config).expect_err("non-UTF-8 allowlist entry must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("allowed_pubkeys[0]")); + } + + #[test] + fn ffi_server_config_rejects_invalid_modes() { + let mut config = minimal_ffi_server_config(); + config.encryption_mode = 99; + + let err = build_sdk_server_config(&config).expect_err("invalid encryption mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("encryption_mode")); + + let mut config = minimal_ffi_server_config(); + config.gift_wrap_mode = 99; + + let err = build_sdk_server_config(&config).expect_err("invalid gift-wrap mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("gift_wrap_mode")); + } + + #[test] + fn ffi_client_config_rejects_missing_pubkey_and_invalid_modes() { + let config = minimal_ffi_client_config(ptr::null_mut()); + + let err = build_sdk_client_config(&config).expect_err("missing server pubkey must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("server_pubkey")); + + let server_pubkey = CString::new("abc").expect("valid c string"); + let mut config = minimal_ffi_client_config(server_pubkey.as_ptr() as *mut c_char); + config.encryption_mode = 99; + + let err = + build_sdk_client_config(&config).expect_err("invalid client encryption mode must fail"); + + assert_eq!(err.code, ErrorCode::Validation); + assert!(err.message.contains("encryption_mode")); + } +} diff --git a/contextvm-ffi/src/channel.rs b/contextvm-ffi/src/channel.rs new file mode 100644 index 0000000..6cd3955 --- /dev/null +++ b/contextvm-ffi/src/channel.rs @@ -0,0 +1,1756 @@ +//! Channel-based wrapper types that allow FFI consumers to receive messages. + +use crate::builders::{build_sdk_client_config, build_sdk_server_config}; +use crate::error::{set_error, ErrorCode, FfiError}; +use crate::handle::FfiHandle; +use crate::kv; +use crate::runtime::global_runtime; +use crate::types::*; + +use std::os::raw::c_char; +use std::time::Duration; +use tokio::sync::Mutex; + +// ─── Wrappers that combine transport + receiver ──────────────────────── + +/// Server wrapper holding transport + message receiver. +pub struct ServerChannel { + transport: Mutex, + receiver: Mutex>, +} + +/// Client wrapper holding transport + message receiver. +pub struct ClientChannel { + transport: Mutex, + receiver: Mutex>, +} + +/// Gateway wrapper holding gateway + message receiver. +pub struct GatewayChannel { + gateway: Mutex, + receiver: Mutex>, +} + +/// Proxy wrapper holding proxy + message receiver. +pub struct ProxyChannel { + proxy: Mutex, + receiver: Mutex>, +} + +// ─── Server Channel API ──────────────────────────────────────────────── + +/// Create and start a server transport with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_server_ch_new( + keys_handle: FfiHandle, + config: FfiServerConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_server_config(&config) { + Ok(config) => config, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; + + let result = global_runtime().block_on(async { + let mut transport = contextvm_sdk::NostrServerTransport::new(keys, sdk_config).await?; + transport.start().await?; + transport.spawn_discoverability_publication(); + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(ServerChannel { + transport: Mutex::new(transport), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Receive the next incoming request. Blocks until available. +#[no_mangle] +pub extern "C" fn cvm_server_ch_recv( + handle: FfiHandle, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(incoming) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Receive the next incoming request, timing out after `timeout_secs`. +#[no_mangle] +pub extern "C" fn cvm_server_ch_recv_timeout( + handle: FfiHandle, + timeout_secs: u64, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), receiver.recv()).await + }) { + Ok(Some(incoming)) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + Ok(None) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + Err(_) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }, + ); + false + } + } +} + +/// Send a response through a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_send_response( + handle: FfiHandle, + event_id: *const c_char, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + let eid = match c_str_to_string(event_id) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null event_id".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.send_response(&eid, msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Send a notification to a specific client through a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_send_notification( + handle: FfiHandle, + client_pubkey: *const c_char, + payload_json: *const c_char, + correlated_event_id: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + + let client_pubkey = match c_str_to_string(client_pubkey) { + Some(s) => s, + None => { + set_null_arg_error(error, "client_pubkey"); + return false; + } + }; + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + let correlated_event_id = c_str_to_string(correlated_event_id); + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport + .send_notification(&client_pubkey, &msg, correlated_event_id.as_deref()) + .await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Broadcast a notification to all initialized clients. +#[no_mangle] +pub extern "C" fn cvm_server_ch_broadcast_notification( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.broadcast_notification(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Sets extra announcement/discovery tags from a JSON array of tag arrays. +#[no_mangle] +pub extern "C" fn cvm_server_ch_set_announcement_extra_tags( + handle: FfiHandle, + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let tags = match parse_tags_json(tags_json, error) { + Some(tags) => tags, + None => return false, + }; + + global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.set_announcement_extra_tags(tags); + }); + true +} + +/// Sets pricing tags from a JSON array of tag arrays. +#[no_mangle] +pub extern "C" fn cvm_server_ch_set_announcement_pricing_tags( + handle: FfiHandle, + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let tags = match parse_tags_json(tags_json, error) { + Some(tags) => tags, + None => return false, + }; + + global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.set_announcement_pricing_tags(tags); + }); + true +} + +/// Publish server announcement. +#[no_mangle] +pub extern "C" fn cvm_server_ch_announce(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.announce().await + }) { + Ok(_) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Publish server announcement and return the Nostr event ID. +#[no_mangle] +pub extern "C" fn cvm_server_ch_announce_event_id( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.announce().await + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_tools( + handle: FfiHandle, + tools_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let tools = match parse_json_value_array(tools_json, "tools_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, tools, ServerPublishListKind::Tools, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_resources( + handle: FfiHandle, + resources_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let resources = match parse_json_value_array(resources_json, "resources_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, resources, ServerPublishListKind::Resources, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_prompts( + handle: FfiHandle, + prompts_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let prompts = match parse_json_value_array(prompts_json, "prompts_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values(handle, prompts, ServerPublishListKind::Prompts, error) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_publish_resource_templates( + handle: FfiHandle, + templates_json: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let templates = match parse_json_value_array(templates_json, "templates_json", error) { + Some(values) => values, + None => return std::ptr::null_mut(), + }; + publish_server_values( + handle, + templates, + ServerPublishListKind::ResourceTemplates, + error, + ) +} + +#[no_mangle] +pub extern "C" fn cvm_server_ch_delete_announcements( + handle: FfiHandle, + reason: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return false; + } + }; + let reason = match c_str_to_string(reason) { + Some(s) => s, + None => { + set_null_arg_error(error, "reason"); + return false; + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.delete_announcements(&reason).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Close a server channel. +#[no_mangle] +pub extern "C" fn cvm_server_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid server channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.close().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Client Channel API ──────────────────────────────────────────────── + +/// Create and start a client transport with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_client_ch_new( + keys_handle: FfiHandle, + config: FfiClientConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_client_config(&config) { + Ok(c) => c, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; + + let result = global_runtime().block_on(async { + let mut transport = contextvm_sdk::NostrClientTransport::new(keys, sdk_config).await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(ClientChannel { + transport: Mutex::new(transport), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Send a message through a client channel. +#[no_mangle] +pub extern "C" fn cvm_client_ch_send( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.send(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Receive the next message. Blocks until available. +#[no_mangle] +pub extern "C" fn cvm_client_ch_recv( + handle: FfiHandle, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(message) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Receive the next message from a client channel, timing out after `timeout_secs`. +#[no_mangle] +pub extern "C" fn cvm_client_ch_recv_timeout( + handle: FfiHandle, + timeout_secs: u64, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), receiver.recv()).await + }) { + Ok(Some(message)) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + Ok(None) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + Err(_) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }, + ); + false + } + } +} + +/// Return a snapshot of server capabilities learned from discovery tags. +#[no_mangle] +pub extern "C" fn cvm_client_ch_discovered_server_capabilities( + handle: FfiHandle, + out_caps: *mut FfiPeerCapabilities, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return false; + } + }; + if out_caps.is_null() { + set_null_arg_error(error, "out_caps"); + return false; + } + + let caps = global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.discovered_server_capabilities() + }); + unsafe { + *out_caps = peer_capabilities_to_ffi(caps); + } + true +} + +/// Return whether the client has learned ephemeral gift-wrap support. +#[no_mangle] +pub extern "C" fn cvm_client_ch_server_supports_ephemeral_encryption( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return false; + } + }; + + global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.server_supports_ephemeral_encryption() + }) +} + +/// Return the first server event carrying discovery tags as JSON, or NULL if none. +#[no_mangle] +pub extern "C" fn cvm_client_ch_server_initialize_event_json( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "client channel"); + return std::ptr::null_mut(); + } + }; + + let event = global_runtime().block_on(async { + let transport = channel.transport.lock().await; + transport.get_server_initialize_event() + }); + + match event { + Some(event) => match serde_json::to_string(&event) { + Ok(json) => string_to_c(json), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + std::ptr::null_mut() + } + }, + None => std::ptr::null_mut(), + } +} + +/// Close a client channel. +#[no_mangle] +pub extern "C" fn cvm_client_ch_close(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid client channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut transport = channel.transport.lock().await; + transport.close().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Gateway Channel API ─────────────────────────────────────────────── + +/// Create and start a gateway with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_new( + keys_handle: FfiHandle, + config: FfiServerConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_server_config(&config) { + Ok(config) => config, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; + let gw_config = contextvm_sdk::gateway::GatewayConfig::new(sdk_config); + + let result = global_runtime().block_on(async { + let mut gw = contextvm_sdk::gateway::NostrMCPGateway::new(keys, gw_config).await?; + let receiver = gw.start().await?; + Ok::<_, contextvm_sdk::Error>(GatewayChannel { + gateway: Mutex::new(gw), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Receive the next request from a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_recv( + handle: FfiHandle, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(incoming) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Receive the next request from a gateway channel, timing out after `timeout_secs`. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_recv_timeout( + handle: FfiHandle, + timeout_secs: u64, + out_req: *mut FfiIncomingRequest, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), receiver.recv()).await + }) { + Ok(Some(incoming)) => { + if !out_req.is_null() { + unsafe { + *out_req = FfiIncomingRequest { + message: message_to_ffi(&incoming.message), + client_pubkey: string_to_c(incoming.client_pubkey), + event_id: string_to_c(incoming.event_id), + is_encrypted: incoming.is_encrypted, + }; + } + } + true + } + Ok(None) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + Err(_) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }, + ); + false + } + } +} + +/// Send a response through a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_send_response( + handle: FfiHandle, + event_id: *const c_char, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + let eid = match c_str_to_string(event_id) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null event_id".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.send_response(&eid, msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Publish announcement through a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_announce(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.announce().await + }) { + Ok(_) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Publish gateway server announcement and return the Nostr event ID. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_announce_event_id( + handle: FfiHandle, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "gateway channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.announce().await + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +/// Check if a gateway channel is active. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_is_active(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "gateway channel"); + return false; + } + }; + + global_runtime().block_on(async { + let gateway = channel.gateway.lock().await; + gateway.is_active() + }) +} + +/// Stop a gateway channel. +#[no_mangle] +pub extern "C" fn cvm_gateway_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid gateway channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut gateway = channel.gateway.lock().await; + gateway.stop().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Proxy Channel API ───────────────────────────────────────────────── + +/// Create and start a proxy with a channel receiver. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_new( + keys_handle: FfiHandle, + config: FfiClientConfig, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match get_keys(keys_handle, error) { + Some(k) => k, + None => return FfiHandle { id: 0 }, + }; + + let sdk_config = match build_sdk_client_config(&config) { + Ok(c) => c, + Err(e) => { + set_error(error, e); + return FfiHandle { id: 0 }; + } + }; + + let proxy_config = contextvm_sdk::proxy::ProxyConfig::new(sdk_config); + + let result = global_runtime().block_on(async { + let mut proxy = contextvm_sdk::proxy::NostrMCPProxy::new(keys, proxy_config).await?; + let receiver = proxy.start().await?; + Ok::<_, contextvm_sdk::Error>(ProxyChannel { + proxy: Mutex::new(proxy), + receiver: Mutex::new(receiver), + }) + }); + + match result { + Ok(ch) => kv::insert(ch), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +/// Send a message through a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_send( + handle: FfiHandle, + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(handle); + let channel = match guard { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + let msg = match parse_json_rpc_message(payload_json, error) { + Some(m) => m, + None => return false, + }; + + match global_runtime().block_on(async { + let proxy = channel.proxy.lock().await; + proxy.send(&msg).await + }) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +/// Receive the next message from a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_recv( + handle: FfiHandle, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + receiver.recv().await + }) { + Some(message) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + } +} + +/// Receive the next message from a proxy channel, timing out after `timeout_secs`. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_recv_timeout( + handle: FfiHandle, + timeout_secs: u64, + out_msg: *mut FfiJsonRpcMessage, + error: *mut *mut FfiError, +) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut receiver = channel.receiver.lock().await; + tokio::time::timeout(Duration::from_secs(timeout_secs), receiver.recv()).await + }) { + Ok(Some(message)) => { + if !out_msg.is_null() { + unsafe { + *out_msg = message_to_ffi(&message); + } + } + true + } + Ok(None) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + }, + ); + false + } + Err(_) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + }, + ); + false + } + } +} + +/// Check if a proxy channel is active. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_is_active(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "proxy channel"); + return false; + } + }; + + global_runtime().block_on(async { + let proxy = channel.proxy.lock().await; + proxy.is_active() + }) +} + +/// Stop a proxy channel. +#[no_mangle] +pub extern "C" fn cvm_proxy_ch_stop(handle: FfiHandle, error: *mut *mut FfiError) -> bool { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid proxy channel handle".into(), + }, + ); + return false; + } + }; + + match global_runtime().block_on(async { + let mut proxy = channel.proxy.lock().await; + proxy.stop().await + }) { + Ok(()) => { + kv::remove(handle); + true + } + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +// ─── Helpers ─────────────────────────────────────────────────────────── + +fn get_keys(handle: FfiHandle, error: *mut *mut FfiError) -> Option { + match kv::get::(handle) { + Some(k) => Some(k.as_ref().clone()), + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + None + } + } +} + +fn parse_json_rpc_message( + payload_json: *const c_char, + error: *mut *mut FfiError, +) -> Option { + let json_str = match c_str_to_string(payload_json) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Validation, + message: "null payload json".into(), + }, + ); + return None; + } + }; + + match serde_json::from_str(&json_str) { + Ok(m) => Some(m), + Err(e) => { + set_error( + error, + FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }, + ); + None + } + } +} + +fn parse_json_value_array( + payload_json: *const c_char, + name: &str, + error: *mut *mut FfiError, +) -> Option> { + let json_str = match c_str_to_string(payload_json) { + Some(s) => s, + None => { + set_null_arg_error(error, name); + return None; + } + }; + + match serde_json::from_str::>(&json_str) { + Ok(values) => Some(values), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + None + } + } +} + +fn parse_tags_json( + tags_json: *const c_char, + error: *mut *mut FfiError, +) -> Option> { + let json_str = match c_str_to_string(tags_json) { + Some(s) => s, + None => { + set_null_arg_error(error, "tags_json"); + return None; + } + }; + + let parts = match serde_json::from_str::>>(&json_str) { + Ok(parts) => parts, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Serialization, + message: e.to_string(), + }, + ); + return None; + } + }; + + parts + .into_iter() + .map(|tag| { + nostr_sdk::prelude::Tag::parse(tag).map_err(|e| FfiError { + code: ErrorCode::Validation, + message: e.to_string(), + }) + }) + .collect::, _>>() + .map_err(|e| set_error(error, e)) + .ok() +} + +enum ServerPublishListKind { + Tools, + Resources, + Prompts, + ResourceTemplates, +} + +fn publish_server_values( + handle: FfiHandle, + values: Vec, + kind: ServerPublishListKind, + error: *mut *mut FfiError, +) -> *mut c_char { + let channel = match kv::get::(handle) { + Some(ch) => ch, + None => { + set_invalid_handle_error(error, "server channel"); + return std::ptr::null_mut(); + } + }; + + match global_runtime().block_on(async { + let transport = channel.transport.lock().await; + match kind { + ServerPublishListKind::Tools => transport.publish_tools(values).await, + ServerPublishListKind::Resources => transport.publish_resources(values).await, + ServerPublishListKind::Prompts => transport.publish_prompts(values).await, + ServerPublishListKind::ResourceTemplates => { + transport.publish_resource_templates(values).await + } + } + }) { + Ok(event_id) => string_to_c(event_id.to_hex()), + Err(e) => { + set_error(error, e.into()); + std::ptr::null_mut() + } + } +} + +fn set_invalid_handle_error(error: *mut *mut FfiError, handle_type: &str) { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: format!("invalid {handle_type} handle"), + }, + ); +} + +fn set_null_arg_error(error: *mut *mut FfiError, name: &str) { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("null {name}"), + }, + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + mod timeout_tests { + use super::*; + + #[test] + fn test_server_recv_timeout_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_req = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let result = + cvm_server_ch_recv_timeout(invalid_handle, 1, out_req.as_mut_ptr(), &mut error); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error for invalid handle"); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + + #[test] + fn test_client_recv_timeout_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_msg = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let result = + cvm_client_ch_recv_timeout(invalid_handle, 1, out_msg.as_mut_ptr(), &mut error); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error for invalid handle"); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + + #[test] + fn test_gateway_recv_timeout_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_req = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let result = + cvm_gateway_ch_recv_timeout(invalid_handle, 1, out_req.as_mut_ptr(), &mut error); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error for invalid handle"); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + + #[test] + fn test_proxy_recv_timeout_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_msg = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let result = + cvm_proxy_ch_recv_timeout(invalid_handle, 1, out_msg.as_mut_ptr(), &mut error); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error for invalid handle"); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + } + + mod blocking_recv_tests { + use super::*; + + #[test] + fn test_server_recv_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_req = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let start = std::time::Instant::now(); + let result = cvm_server_ch_recv(invalid_handle, out_req.as_mut_ptr(), &mut error); + let elapsed = start.elapsed(); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error"); + assert!( + elapsed < std::time::Duration::from_secs(1), + "Should return quickly for invalid handle, took {:?}", + elapsed + ); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + + #[test] + fn test_client_recv_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_msg = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let start = std::time::Instant::now(); + let result = cvm_client_ch_recv(invalid_handle, out_msg.as_mut_ptr(), &mut error); + let elapsed = start.elapsed(); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error"); + assert!( + elapsed < std::time::Duration::from_secs(1), + "Should return quickly for invalid handle, took {:?}", + elapsed + ); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + + #[test] + fn test_gateway_recv_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let mut out_req = std::mem::MaybeUninit::::uninit(); + let mut error: *mut FfiError = std::ptr::null_mut(); + + let start = std::time::Instant::now(); + let result = cvm_gateway_ch_recv(invalid_handle, out_req.as_mut_ptr(), &mut error); + let elapsed = start.elapsed(); + + assert!(!result, "Should return false for invalid handle"); + assert!(!error.is_null(), "Should set error"); + assert!( + elapsed < std::time::Duration::from_secs(1), + "Should return quickly for invalid handle, took {:?}", + elapsed + ); + + unsafe { + assert_eq!((*error).code, ErrorCode::Other); + crate::types::cvm_error_free(error); + } + } + } + + mod error_handling_tests { + use super::*; + + #[test] + fn test_error_codes_match_c_header() { + assert_eq!(ErrorCode::Ok as i32, 0, "CVM_OK"); + assert_eq!(ErrorCode::Transport as i32, 1, "CVM_TRANSPORT"); + assert_eq!(ErrorCode::Encryption as i32, 2, "CVM_ENCRYPTION"); + assert_eq!(ErrorCode::Decryption as i32, 3, "CVM_DECRYPTION"); + assert_eq!(ErrorCode::Timeout as i32, 4, "CVM_TIMEOUT"); + assert_eq!(ErrorCode::Validation as i32, 5, "CVM_VALIDATION"); + assert_eq!(ErrorCode::Unauthorized as i32, 6, "CVM_UNAUTHORIZED"); + assert_eq!(ErrorCode::Serialization as i32, 7, "CVM_SERIALIZATION"); + assert_eq!(ErrorCode::Other as i32, 99, "CVM_OTHER"); + } + } +} diff --git a/contextvm-ffi/src/discovery.rs b/contextvm-ffi/src/discovery.rs new file mode 100644 index 0000000..d243e4f --- /dev/null +++ b/contextvm-ffi/src/discovery.rs @@ -0,0 +1,241 @@ +//! Shared discovery helpers for C and UniFFI bindings. + +use contextvm_sdk::signer::PublicKey; +use nostr_sdk::prelude::*; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; +use std::time::Duration; + +#[derive(Debug, Clone)] +pub(crate) struct DiscoveredToolRecord { + pub provider_pubkey: String, + pub provider_display_name: Option, + pub provider_name: Option, + pub provider_about: Option, + pub provider_picture: Option, + pub provider_nip05: Option, + pub tool_name: String, + pub description: String, + pub schema_json: String, +} + +#[derive(Debug, Clone)] +pub(crate) struct ProviderProfileRecord { + pub pubkey: String, + pub name: Option, + pub about: Option, + pub picture: Option, + pub nip05: Option, +} + +pub(crate) async fn discover_tools( + client: &Arc, + provider_pubkey: &str, + provider_display_name: Option, + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let parsed_pubkey = PublicKey::from_hex(provider_pubkey) + .map_err(|e| contextvm_sdk::Error::Validation(format!("bad pubkey: {e}")))?; + let raw_tools = + contextvm_sdk::discovery::discover_tools(client, &parsed_pubkey, relay_urls).await?; + + raw_tools + .into_iter() + .map(|tool| tool_record_from_value(provider_pubkey, provider_display_name.clone(), tool)) + .collect() +} + +pub(crate) async fn discover_all_tools( + client: &Arc, + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let servers = contextvm_sdk::discovery::discover_servers(client, relay_urls).await?; + let mut tools = Vec::new(); + + for server in servers { + match discover_tools( + client, + &server.pubkey, + server.server_info.name.clone(), + relay_urls, + ) + .await + { + Ok(mut server_tools) => tools.append(&mut server_tools), + Err(_) => continue, + } + } + + let provider_pubkeys: Vec = tools + .iter() + .map(|tool| tool.provider_pubkey.clone()) + .collect::>() + .into_iter() + .collect(); + if let Ok(profiles) = fetch_provider_profiles(client, &provider_pubkeys, relay_urls).await { + for tool in &mut tools { + if let Some(profile) = profiles.get(&tool.provider_pubkey) { + tool.provider_name = profile.name.clone(); + tool.provider_about = profile.about.clone(); + tool.provider_picture = profile.picture.clone(); + tool.provider_nip05 = profile.nip05.clone(); + } + } + } + + Ok(tools) +} + +pub(crate) async fn fetch_provider_profiles( + client: &Arc, + provider_pubkeys: &[String], + relay_urls: &[String], +) -> contextvm_sdk::Result> { + let parsed_pubkeys: Vec = provider_pubkeys + .iter() + .filter_map(|pubkey| PublicKey::from_hex(pubkey).ok()) + .collect(); + + if parsed_pubkeys.is_empty() { + return Ok(HashMap::new()); + } + + let filter = Filter::new().authors(parsed_pubkeys).kind(Kind::Metadata); + let timeout = Duration::from_secs(10); + let events = if relay_urls.is_empty() { + client.fetch_events(filter, timeout).await + } else { + client.fetch_events_from(relay_urls, filter, timeout).await + } + .map_err(|e| contextvm_sdk::Error::Transport(e.to_string()))?; + + let mut profiles = HashMap::new(); + for event in events { + if let Some(profile) = profile_from_metadata(event.pubkey.to_hex(), &event.content) { + profiles.insert(profile.pubkey.clone(), profile); + } + } + + Ok(profiles) +} + +pub(crate) fn pubkey_hex_to_npub(pubkey_hex: &str) -> contextvm_sdk::Result { + use nostr_sdk::ToBech32; + + let pubkey = PublicKey::from_hex(pubkey_hex) + .map_err(|e| contextvm_sdk::Error::Validation(format!("bad pubkey: {e}")))?; + pubkey + .to_bech32() + .map_err(|e| contextvm_sdk::Error::Other(e.to_string())) +} + +fn tool_record_from_value( + provider_pubkey: &str, + provider_display_name: Option, + value: serde_json::Value, +) -> contextvm_sdk::Result { + let tool_name = value + .get("name") + .and_then(|v| v.as_str()) + .ok_or_else(|| contextvm_sdk::Error::Other("tool announcement missing name".into()))? + .to_string(); + let description = value + .get("description") + .and_then(|v| v.as_str()) + .unwrap_or_default() + .to_string(); + let schema = value + .get("inputSchema") + .or_else(|| value.get("input_schema")) + .ok_or_else(|| { + contextvm_sdk::Error::Other(format!( + "tool announcement {tool_name} missing inputSchema" + )) + })?; + let schema_json = serde_json::to_string(schema).map_err(contextvm_sdk::Error::Serialization)?; + + Ok(DiscoveredToolRecord { + provider_pubkey: provider_pubkey.to_string(), + provider_display_name, + provider_name: None, + provider_about: None, + provider_picture: None, + provider_nip05: None, + tool_name, + description, + schema_json, + }) +} + +fn profile_from_metadata(pubkey: String, content: &str) -> Option { + let metadata: serde_json::Value = serde_json::from_str(content).ok()?; + Some(ProviderProfileRecord { + pubkey, + name: metadata + .get("name") + .and_then(|v| v.as_str()) + .map(String::from), + about: metadata + .get("about") + .and_then(|v| v.as_str()) + .map(String::from), + picture: metadata + .get("picture") + .and_then(|v| v.as_str()) + .map(String::from), + nip05: metadata + .get("nip05") + .and_then(|v| v.as_str()) + .map(String::from), + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[test] + fn tool_record_preserves_confidential_app_fields() { + let tool = tool_record_from_value( + "abc", + Some("provider".to_string()), + json!({ + "name": "echo", + "description": "Echo a message", + "inputSchema": { + "type": "object", + "properties": { + "message": { "type": "string" } + } + } + }), + ) + .unwrap(); + + assert_eq!(tool.provider_pubkey, "abc"); + assert_eq!(tool.provider_display_name.as_deref(), Some("provider")); + assert_eq!(tool.tool_name, "echo"); + assert_eq!(tool.description, "Echo a message"); + assert!(tool.schema_json.contains("message")); + } + + #[test] + fn profile_metadata_maps_provider_fields() { + let profile = profile_from_metadata( + "abc".to_string(), + r#"{"name":"Provider","about":"About","picture":"https://pic","nip05":"p@example.com"}"#, + ) + .unwrap(); + + assert_eq!(profile.name.as_deref(), Some("Provider")); + assert_eq!(profile.about.as_deref(), Some("About")); + assert_eq!(profile.picture.as_deref(), Some("https://pic")); + assert_eq!(profile.nip05.as_deref(), Some("p@example.com")); + } + + #[test] + fn pubkey_hex_to_npub_rejects_invalid_hex() { + assert!(pubkey_hex_to_npub("not-hex").is_err()); + } +} diff --git a/contextvm-ffi/src/error.rs b/contextvm-ffi/src/error.rs new file mode 100644 index 0000000..d2f477b --- /dev/null +++ b/contextvm-ffi/src/error.rs @@ -0,0 +1,94 @@ +//! FFI-safe error type. + +use std::fmt; + +/// Error codes returned by the FFI layer. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, uniffi::Enum)] +pub enum ErrorCode { + /// Success (no error). + Ok = 0, + /// Transport / relay error. + Transport = 1, + /// Encryption (NIP-44) error. + Encryption = 2, + /// Decryption error. + Decryption = 3, + /// Request timed out. + Timeout = 4, + /// Validation error (size/schema). + Validation = 5, + /// Unauthorized (pubkey not in allowlist). + Unauthorized = 6, + /// Serialization/deserialization error. + Serialization = 7, + /// Generic / unknown error. + Other = 99, +} + +impl From<&contextvm_sdk::Error> for ErrorCode { + fn from(e: &contextvm_sdk::Error) -> Self { + match e { + contextvm_sdk::Error::Transport(_) => ErrorCode::Transport, + contextvm_sdk::Error::Encryption(_) => ErrorCode::Encryption, + contextvm_sdk::Error::Decryption(_) => ErrorCode::Decryption, + contextvm_sdk::Error::Timeout => ErrorCode::Timeout, + contextvm_sdk::Error::Validation(_) => ErrorCode::Validation, + contextvm_sdk::Error::Unauthorized(_) => ErrorCode::Unauthorized, + contextvm_sdk::Error::Serialization(_) => ErrorCode::Serialization, + // CEP-22 oversized-transfer failures are transport-layer + // framing/reassembly issues, so they surface as CVM_TRANSPORT. + contextvm_sdk::Error::OversizedTransfer(_) => ErrorCode::Transport, + // CEP-41 open-stream failures (sequencing/policy/abort) are likewise + // transport-layer, so they surface as CVM_TRANSPORT. The C header + // exposes no dedicated code; mirrors `OversizedTransfer`. + contextvm_sdk::Error::OpenStream(_) => ErrorCode::Transport, + contextvm_sdk::Error::Other(_) => ErrorCode::Other, + } + } +} + +/// An FFI-safe error with a code and human-readable message. +#[derive(Debug, Clone, uniffi::Object)] +pub struct FfiError { + pub code: ErrorCode, + pub message: String, +} + +#[uniffi::export] +impl FfiError { + /// Get the error code. + pub fn code(&self) -> ErrorCode { + self.code + } + + /// Get the error message. + pub fn message(&self) -> String { + self.message.clone() + } +} + +impl std::fmt::Display for FfiError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{:?}: {}", self.code, self.message) + } +} + +impl std::error::Error for FfiError {} + +impl From for FfiError { + fn from(e: contextvm_sdk::Error) -> Self { + FfiError { + code: ErrorCode::from(&e), + message: e.to_string(), + } + } +} + +pub(crate) fn set_error(out: *mut *mut FfiError, err: FfiError) { + if !out.is_null() { + unsafe { + *out = Box::into_raw(Box::new(err)); + } + } +} diff --git a/contextvm-ffi/src/handle.rs b/contextvm-ffi/src/handle.rs new file mode 100644 index 0000000..000a612 --- /dev/null +++ b/contextvm-ffi/src/handle.rs @@ -0,0 +1,21 @@ +//! Opaque handle type for FFI consumers. + +use std::sync::atomic::{AtomicU64, Ordering}; + +static NEXT_HANDLE: AtomicU64 = AtomicU64::new(1); + +/// An opaque handle returned by the FFI layer. +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct FfiHandle { + pub id: u64, +} + +impl FfiHandle { + /// Allocate a fresh, unique handle. + pub fn next() -> Self { + Self { + id: NEXT_HANDLE.fetch_add(1, Ordering::Relaxed), + } + } +} diff --git a/contextvm-ffi/src/kv.rs b/contextvm-ffi/src/kv.rs new file mode 100644 index 0000000..c476315 --- /dev/null +++ b/contextvm-ffi/src/kv.rs @@ -0,0 +1,167 @@ +//! Global key-value store that maps FFI handles to their underlying Rust objects. + +use parking_lot::Mutex; +use std::any::Any; +use std::collections::HashMap; +use std::sync::{Arc, LazyLock}; + +use crate::handle::FfiHandle; + +static STORE: LazyLock>>> = + LazyLock::new(|| Mutex::new(HashMap::new())); + +/// Insert a value and return its handle. +pub fn insert(value: T) -> FfiHandle { + let handle = FfiHandle::next(); + let mut store = STORE.lock(); + store.insert(handle.id, Arc::new(value)); + handle +} + +/// Retrieve a typed handle. +pub fn get(handle: FfiHandle) -> Option> { + let store = STORE.lock(); + let value = Arc::clone(store.get(&handle.id)?); + value.downcast::().ok() +} + +/// Remove a handle from the store, dropping the object. +pub fn remove(handle: FfiHandle) -> bool { + STORE.lock().remove(&handle.id).is_some() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + use std::thread; + + #[derive(Debug, Clone)] + struct TestData { + value: i32, + } + + #[test] + fn test_insert_and_get() { + let data = TestData { value: 42 }; + let handle = insert(data); + assert!(handle.id > 0); + + let retrieved = get::(handle); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().value, 42); + } + + #[test] + fn test_get_invalid_handle() { + let invalid_handle = FfiHandle { id: 99999 }; + let result = get::(invalid_handle); + assert!(result.is_none()); + } + + #[test] + fn test_remove() { + let data = TestData { value: 123 }; + let handle = insert(data); + + assert!(remove(handle)); + assert!(!remove(handle)); // Second remove returns false + + let retrieved = get::(handle); + assert!(retrieved.is_none()); + } + + #[test] + fn test_concurrent_access() { + let mut handles = vec![]; + + // Spawn multiple threads that insert and read concurrently + for i in 0..10 { + let handle = thread::spawn(move || { + let data = TestData { value: i }; + let h = insert(data); + + // Immediately read back + let retrieved = get::(h); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().value, i); + + // Clean up + remove(h); + }); + handles.push(handle); + } + + for h in handles { + h.join().unwrap(); + } + } + + #[test] + fn test_concurrent_readers_same_handle() { + let data = TestData { value: 999 }; + let handle = insert(data); + + let mut threads = vec![]; + + for _ in 0..20 { + let h = handle; + let t = thread::spawn(move || { + let retrieved = get::(h); + assert!(retrieved.is_some()); + assert_eq!(retrieved.unwrap().value, 999); + }); + threads.push(t); + } + + for t in threads { + t.join().unwrap(); + } + + remove(handle); + } + + #[test] + fn test_arc_semantics() { + let data = TestData { value: 777 }; + let handle = insert(data); + + // Get multiple Arc references + let arc1 = get::(handle).unwrap(); + let arc2 = get::(handle).unwrap(); + + // Both should point to same data + assert_eq!(Arc::strong_count(&arc1), 3); // 1 in store + 2 we got + assert_eq!(Arc::strong_count(&arc2), 3); + + // Data should be the same + assert_eq!(arc1.value, 777); + assert_eq!(arc2.value, 777); + + drop(arc1); + drop(arc2); + + // Clean up + remove(handle); + } + + #[test] + fn test_different_types() { + let int_data = 42i32; + let string_data = "hello".to_string(); + + let int_handle = insert(int_data); + let string_handle = insert(string_data); + + // Should get correct types + assert_eq!(*get::(int_handle).unwrap(), 42); + assert_eq!(*get::(string_handle).unwrap(), "hello"); + + // Wrong type should return None + assert!(get::(int_handle).is_none()); + assert!(get::(string_handle).is_none()); + + remove(int_handle); + remove(string_handle); + } +} diff --git a/contextvm-ffi/src/lib.rs b/contextvm-ffi/src/lib.rs new file mode 100644 index 0000000..629a481 --- /dev/null +++ b/contextvm-ffi/src/lib.rs @@ -0,0 +1,64 @@ +// ─── ContextVM FFI — Flat C API + UniFFI for Python/Swift/Kotlin ─── +// +// This crate exposes: +// 1. A flat `#[no_mangle] extern "C"` surface for direct C interop +// (Swift via C headers, Kotlin via JNI/JNA, C/C++ directly) +// 2. UniFFI proc-macro definitions for Python and as an alternative +// to hand-written Swift/Kotlin bindings +// +// All async work is driven on an internal global tokio runtime so +// callers never need to manage an async runtime. + +// C ABI functions dereference caller-provided raw pointers. The safety +// contract is documented in `headers/contextvm.h`, not enforced via `unsafe` +// (C callers cannot write `unsafe` blocks), so we allow the FFI-specific lint. +#![allow(clippy::not_unsafe_ptr_arg_deref)] + +mod builders; +mod channel; +mod discovery; +pub mod error; +pub mod handle; +mod kv; +mod runtime; +pub mod types; +mod uniffi_types; + +// Test-only re-exports. These expose flat-C-ABI symbols and internal modules so the +// `tests/` integration binaries can call them directly. They are `#[doc(hidden)]` to +// keep them out of the published rustdoc surface; do not rely on them as a stable API. +#[doc(hidden)] +pub use channel::{ + cvm_client_ch_close, cvm_client_ch_discovered_server_capabilities, cvm_client_ch_new, + cvm_client_ch_recv, cvm_client_ch_recv_timeout, cvm_client_ch_send, + cvm_client_ch_server_initialize_event_json, cvm_client_ch_server_supports_ephemeral_encryption, + cvm_gateway_ch_announce, cvm_gateway_ch_announce_event_id, cvm_gateway_ch_is_active, + cvm_gateway_ch_new, cvm_gateway_ch_recv, cvm_gateway_ch_recv_timeout, + cvm_gateway_ch_send_response, cvm_gateway_ch_stop, cvm_proxy_ch_is_active, cvm_proxy_ch_new, + cvm_proxy_ch_recv, cvm_proxy_ch_recv_timeout, cvm_proxy_ch_send, cvm_proxy_ch_stop, + cvm_server_ch_announce, cvm_server_ch_announce_event_id, cvm_server_ch_broadcast_notification, + cvm_server_ch_close, cvm_server_ch_delete_announcements, cvm_server_ch_new, + cvm_server_ch_publish_prompts, cvm_server_ch_publish_resource_templates, + cvm_server_ch_publish_resources, cvm_server_ch_publish_tools, cvm_server_ch_recv, + cvm_server_ch_recv_timeout, cvm_server_ch_send_notification, cvm_server_ch_send_response, + cvm_server_ch_set_announcement_extra_tags, cvm_server_ch_set_announcement_pricing_tags, +}; +#[doc(hidden)] +pub use error::{ErrorCode, FfiError}; +#[doc(hidden)] +pub use handle::FfiHandle; + +// Re-export types module functions for integration testing (see note above). +#[doc(hidden)] +pub use types::{ + cvm_announcements_free, cvm_decrypt_nip44, cvm_discover_all_tools, cvm_discover_servers, + cvm_discover_tools, cvm_discovered_tools_free, cvm_encrypt_nip44, cvm_error_code, + cvm_error_free, cvm_error_message, cvm_fetch_provider_profiles, cvm_incoming_request_free, + cvm_keys_free, cvm_keys_from_secret_key, cvm_keys_generate, cvm_keys_public_key, + cvm_keys_secret_key, cvm_message_free, cvm_provider_profiles_free, cvm_pubkey_hex_to_npub, + cvm_relay_pool_connect, cvm_relay_pool_disconnect, cvm_relay_pool_free, cvm_relay_pool_new, + cvm_string_free, cvm_version, +}; + +// UniFFI scaffolding — must be at crate root, after all types it references. +uniffi::setup_scaffolding!(); diff --git a/contextvm-ffi/src/runtime.rs b/contextvm-ffi/src/runtime.rs new file mode 100644 index 0000000..3355c0b --- /dev/null +++ b/contextvm-ffi/src/runtime.rs @@ -0,0 +1,14 @@ +//! Global tokio runtime shared across all FFI calls. +//! +// The runtime is lazily initialized on first use and never shut down. +//! This avoids needing the caller to manage a runtime lifecycle. + +use std::sync::OnceLock; +use tokio::runtime::Runtime; + +static RUNTIME: OnceLock = OnceLock::new(); + +/// Return a reference to the global tokio runtime. +pub fn global_runtime() -> &'static Runtime { + RUNTIME.get_or_init(|| Runtime::new().expect("failed to create global tokio runtime for FFI")) +} diff --git a/contextvm-ffi/src/types.rs b/contextvm-ffi/src/types.rs new file mode 100644 index 0000000..e82c9c4 --- /dev/null +++ b/contextvm-ffi/src/types.rs @@ -0,0 +1,1170 @@ +//! FFI-exposed types and flat C API functions. +//! +//! This module defines the FFI-safe struct mirrors, `#[no_mangle] extern "C"` +//! functions for key management, relay pool, discovery, encryption, and utility +//! operations. The channel-based transport APIs live in `channel.rs`. + +use crate::error::{set_error, ErrorCode, FfiError}; +use crate::handle::FfiHandle; +use crate::kv; +use crate::runtime::global_runtime; + +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// ─── FFI-safe struct mirrors ─────────────────────────────────────────── + +/// Raw integer mode value supplied by C callers. +pub type FfiMode = i32; + +pub const ENCRYPTION_MODE_OPTIONAL: FfiMode = 0; +pub const ENCRYPTION_MODE_REQUIRED: FfiMode = 1; +pub const ENCRYPTION_MODE_DISABLED: FfiMode = 2; + +pub const GIFT_WRAP_MODE_OPTIONAL: FfiMode = 0; +pub const GIFT_WRAP_MODE_EPHEMERAL: FfiMode = 1; +pub const GIFT_WRAP_MODE_PERSISTENT: FfiMode = 2; + +/// JSON-RPC message type discriminator. +pub type JsonRpcType = i32; + +pub const JSON_RPC_TYPE_REQUEST: JsonRpcType = 0; +pub const JSON_RPC_TYPE_RESPONSE: JsonRpcType = 1; +pub const JSON_RPC_TYPE_ERROR_RESPONSE: JsonRpcType = 2; +pub const JSON_RPC_TYPE_NOTIFICATION: JsonRpcType = 3; + +/// An FFI-safe JSON-RPC message. +#[repr(C)] +#[derive(Debug)] +pub struct FfiJsonRpcMessage { + pub msg_type: JsonRpcType, + pub payload_json: *mut c_char, + pub method: *mut c_char, + pub id: *mut c_char, +} + +/// An FFI-safe incoming request (server-side). +#[repr(C)] +#[derive(Debug)] +pub struct FfiIncomingRequest { + pub message: FfiJsonRpcMessage, + pub client_pubkey: *mut c_char, + pub event_id: *mut c_char, + pub is_encrypted: bool, +} + +/// A discovered server announcement. +#[repr(C)] +#[derive(Debug)] +pub struct FfiServerAnnouncement { + pub pubkey: *mut c_char, + pub name: *mut c_char, + pub version: *mut c_char, + pub picture: *mut c_char, + pub about: *mut c_char, + pub website: *mut c_char, + pub event_id: *mut c_char, +} + +/// A discovered MCP tool and provider metadata. +#[repr(C)] +#[derive(Debug)] +pub struct FfiDiscoveredTool { + pub provider_pubkey: *mut c_char, + pub provider_display_name: *mut c_char, + pub provider_name: *mut c_char, + pub provider_about: *mut c_char, + pub provider_picture: *mut c_char, + pub provider_nip05: *mut c_char, + pub tool_name: *mut c_char, + pub description: *mut c_char, + pub schema_json: *mut c_char, +} + +/// Nostr profile metadata for a provider. +#[repr(C)] +#[derive(Debug)] +pub struct FfiProviderProfile { + pub pubkey: *mut c_char, + pub name: *mut c_char, + pub about: *mut c_char, + pub picture: *mut c_char, + pub nip05: *mut c_char, +} + +/// A capability exclusion pattern that bypasses pubkey whitelisting. +#[repr(C)] +#[derive(Debug)] +pub struct FfiCapabilityExclusion { + pub method: *mut c_char, + pub name: *mut c_char, +} + +/// Learned peer capability flags. +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct FfiPeerCapabilities { + pub supports_encryption: bool, + pub supports_ephemeral_encryption: bool, + pub supports_oversized_transfer: bool, +} + +/// Server transport config for FFI. +#[repr(C)] +#[derive(Debug)] +pub struct FfiServerConfig { + pub relay_urls: *mut *mut c_char, + pub relay_url_count: usize, + pub encryption_mode: FfiMode, + pub gift_wrap_mode: FfiMode, + pub is_announced_server: bool, + pub server_name: *mut c_char, + pub server_version: *mut c_char, + pub server_picture: *mut c_char, + pub server_about: *mut c_char, + pub server_website: *mut c_char, + pub allowed_pubkeys: *mut *mut c_char, + pub allowed_pubkey_count: usize, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, + pub excluded_capabilities: *mut FfiCapabilityExclusion, + pub excluded_capability_count: usize, + pub max_sessions: usize, + pub request_timeout_secs: u64, + pub relay_list_urls: *mut *mut c_char, + pub relay_list_url_count: usize, + pub bootstrap_relay_urls: *mut *mut c_char, + pub bootstrap_relay_url_count: usize, + pub publish_relay_list: bool, + pub profile_metadata_json: *mut c_char, +} + +/// Client transport config for FFI. +#[repr(C)] +#[derive(Debug)] +pub struct FfiClientConfig { + pub relay_urls: *mut *mut c_char, + pub relay_url_count: usize, + pub server_pubkey: *mut c_char, + pub encryption_mode: FfiMode, + pub gift_wrap_mode: FfiMode, + pub is_stateless: bool, + pub timeout_secs: u64, + pub discovery_relay_urls: *mut *mut c_char, + pub discovery_relay_url_count: usize, + pub fallback_operational_relay_urls: *mut *mut c_char, + pub fallback_operational_relay_url_count: usize, +} + +// ─── Internal conversion helpers ─────────────────────────────────────── + +pub fn c_str_to_string(ptr: *const c_char) -> Option { + if ptr.is_null() { + return None; + } + unsafe { CStr::from_ptr(ptr).to_str().ok().map(String::from) } +} + +pub fn c_str_to_string_checked(ptr: *const c_char, name: &str) -> Result { + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("null {name}"), + }); + } + + unsafe { + CStr::from_ptr(ptr) + .to_str() + .map(String::from) + .map_err(|e| FfiError { + code: ErrorCode::Validation, + message: format!("{name} is not valid UTF-8: {e}"), + }) + } +} + +pub fn optional_c_str_to_string_checked( + ptr: *const c_char, + name: &str, +) -> Result, FfiError> { + if ptr.is_null() { + Ok(None) + } else { + c_str_to_string_checked(ptr, name).map(Some) + } +} + +pub fn string_to_c(s: String) -> *mut c_char { + CString::new(s).unwrap_or_default().into_raw() +} + +pub fn opt_string_to_c(s: Option) -> *mut c_char { + s.map(string_to_c).unwrap_or(ptr::null_mut()) +} + +pub fn c_str_array_to_vec(ptr: *mut *mut c_char, count: usize) -> Vec { + if ptr.is_null() || count == 0 { + return Vec::new(); + } + unsafe { + let slice = std::slice::from_raw_parts(ptr, count); + slice.iter().filter_map(|&p| c_str_to_string(p)).collect() + } +} + +pub fn c_str_array_to_vec_checked( + ptr: *mut *mut c_char, + count: usize, + name: &str, +) -> Result, FfiError> { + if count == 0 { + return Ok(Vec::new()); + } + if ptr.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("{name} has count {count} but null pointer"), + }); + } + + unsafe { + std::slice::from_raw_parts(ptr, count) + .iter() + .enumerate() + .map(|(index, &p)| { + if p.is_null() { + return Err(FfiError { + code: ErrorCode::Validation, + message: format!("{name}[{index}] is null"), + }); + } + + CStr::from_ptr(p) + .to_str() + .map(String::from) + .map_err(|e| FfiError { + code: ErrorCode::Validation, + message: format!("{name}[{index}] is not valid UTF-8: {e}"), + }) + }) + .collect() + } +} + +pub fn ffi_encryption_mode_to_sdk( + mode: FfiMode, +) -> Result { + match mode { + ENCRYPTION_MODE_OPTIONAL => Ok(contextvm_sdk::EncryptionMode::Optional), + ENCRYPTION_MODE_REQUIRED => Ok(contextvm_sdk::EncryptionMode::Required), + ENCRYPTION_MODE_DISABLED => Ok(contextvm_sdk::EncryptionMode::Disabled), + _ => Err(FfiError { + code: ErrorCode::Validation, + message: format!("invalid encryption_mode {mode}"), + }), + } +} + +pub fn ffi_gift_wrap_mode_to_sdk(mode: FfiMode) -> Result { + match mode { + GIFT_WRAP_MODE_OPTIONAL => Ok(contextvm_sdk::GiftWrapMode::Optional), + GIFT_WRAP_MODE_EPHEMERAL => Ok(contextvm_sdk::GiftWrapMode::Ephemeral), + GIFT_WRAP_MODE_PERSISTENT => Ok(contextvm_sdk::GiftWrapMode::Persistent), + _ => Err(FfiError { + code: ErrorCode::Validation, + message: format!("invalid gift_wrap_mode {mode}"), + }), + } +} + +pub fn json_rpc_id_to_string(id: &serde_json::Value) -> String { + match id { + serde_json::Value::String(s) => s.clone(), + other => other.to_string(), + } +} + +pub fn peer_capabilities_to_ffi( + caps: contextvm_sdk::transport::discovery_tags::PeerCapabilities, +) -> FfiPeerCapabilities { + FfiPeerCapabilities { + supports_encryption: caps.supports_encryption, + supports_ephemeral_encryption: caps.supports_ephemeral_encryption, + supports_oversized_transfer: caps.supports_oversized_transfer, + } +} + +/// Convert an SDK `JsonRpcMessage` to the FFI representation. +pub fn message_to_ffi(msg: &contextvm_sdk::JsonRpcMessage) -> FfiJsonRpcMessage { + let json_str = serde_json::to_string(msg).unwrap_or_default(); + let msg_type = match msg { + contextvm_sdk::JsonRpcMessage::Request(_) => JSON_RPC_TYPE_REQUEST, + contextvm_sdk::JsonRpcMessage::Response(_) => JSON_RPC_TYPE_RESPONSE, + contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JSON_RPC_TYPE_ERROR_RESPONSE, + contextvm_sdk::JsonRpcMessage::Notification(_) => JSON_RPC_TYPE_NOTIFICATION, + }; + FfiJsonRpcMessage { + msg_type, + payload_json: string_to_c(json_str), + method: opt_string_to_c(msg.method().map(String::from)), + id: opt_string_to_c(msg.id().map(json_rpc_id_to_string)), + } +} + +// ─── Free functions ──────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_string_free(s: *mut c_char) { + if !s.is_null() { + unsafe { + let _ = CString::from_raw(s); + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_message_free(msg: FfiJsonRpcMessage) { + cvm_string_free(msg.payload_json); + cvm_string_free(msg.method); + cvm_string_free(msg.id); +} + +#[no_mangle] +pub extern "C" fn cvm_incoming_request_free(req: FfiIncomingRequest) { + cvm_message_free(req.message); + cvm_string_free(req.client_pubkey); + cvm_string_free(req.event_id); +} + +#[no_mangle] +pub extern "C" fn cvm_announcements_free(announcements: *mut FfiServerAnnouncement, count: usize) { + if announcements.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(announcements, count); + for ann in slice.iter_mut() { + cvm_string_free(ann.pubkey); + cvm_string_free(ann.name); + cvm_string_free(ann.version); + cvm_string_free(ann.picture); + cvm_string_free(ann.about); + cvm_string_free(ann.website); + cvm_string_free(ann.event_id); + } + let _ = Vec::from_raw_parts(announcements, count, count); + } +} + +#[no_mangle] +pub extern "C" fn cvm_discovered_tools_free(tools: *mut FfiDiscoveredTool, count: usize) { + if tools.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(tools, count); + for tool in slice.iter_mut() { + cvm_string_free(tool.provider_pubkey); + cvm_string_free(tool.provider_display_name); + cvm_string_free(tool.provider_name); + cvm_string_free(tool.provider_about); + cvm_string_free(tool.provider_picture); + cvm_string_free(tool.provider_nip05); + cvm_string_free(tool.tool_name); + cvm_string_free(tool.description); + cvm_string_free(tool.schema_json); + } + let _ = Vec::from_raw_parts(tools, count, count); + } +} + +#[no_mangle] +pub extern "C" fn cvm_provider_profiles_free(profiles: *mut FfiProviderProfile, count: usize) { + if profiles.is_null() { + return; + } + unsafe { + let slice = std::slice::from_raw_parts_mut(profiles, count); + for profile in slice.iter_mut() { + cvm_string_free(profile.pubkey); + cvm_string_free(profile.name); + cvm_string_free(profile.about); + cvm_string_free(profile.picture); + cvm_string_free(profile.nip05); + } + let _ = Vec::from_raw_parts(profiles, count, count); + } +} + +// ─── Signer / Key management ─────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_keys_generate(_error: *mut *mut FfiError) -> FfiHandle { + kv::insert(contextvm_sdk::signer::generate()) +} + +#[no_mangle] +pub extern "C" fn cvm_keys_from_secret_key( + sk: *const c_char, + error: *mut *mut FfiError, +) -> FfiHandle { + let sk_str = match c_str_to_string(sk) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null secret key string".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + match contextvm_sdk::signer::from_sk(&sk_str) { + Ok(keys) => kv::insert(keys), + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: e.to_string(), + }, + ); + FfiHandle { id: 0 } + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_public_key(handle: FfiHandle, error: *mut *mut FfiError) -> *mut c_char { + let guard = kv::get::(handle); + match guard { + Some(keys) => string_to_c(keys.public_key().to_hex()), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_secret_key(handle: FfiHandle, error: *mut *mut FfiError) -> *mut c_char { + let guard = kv::get::(handle); + match guard { + Some(keys) => string_to_c(keys.secret_key().to_secret_hex()), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_keys_free(handle: FfiHandle) { + kv::remove(handle); +} + +// ─── Relay Pool ──────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_new( + keys_handle: FfiHandle, + error: *mut *mut FfiError, +) -> FfiHandle { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return FfiHandle { id: 0 }; + } + }; + match global_runtime().block_on(contextvm_sdk::RelayPool::new(keys)) { + Ok(p) => kv::insert(p), + Err(e) => { + set_error(error, e.into()); + FfiHandle { id: 0 } + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_connect( + pool_handle: FfiHandle, + urls: *mut *mut c_char, + url_count: usize, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return false; + } + }; + let url_vec = c_str_array_to_vec(urls, url_count); + match global_runtime().block_on(pool.connect(&url_vec)) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_disconnect( + pool_handle: FfiHandle, + error: *mut *mut FfiError, +) -> bool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return false; + } + }; + match global_runtime().block_on(pool.disconnect()) { + Ok(()) => true, + Err(e) => { + set_error(error, e.into()); + false + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_relay_pool_free(handle: FfiHandle) { + kv::remove(handle); +} + +// ─── Discovery ───────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_discover_servers( + pool_handle: FfiHandle, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiServerAnnouncement { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime() + .block_on(async { contextvm_sdk::discovery::discover_servers(client, &urls).await }); + + let announcements = match result { + Ok(a) => a, + Err(e) => { + set_error(error, e.into()); + return ptr::null_mut(); + } + }; + + let count = announcements.len(); + let ffi_announcements: Vec = announcements + .into_iter() + .map(|a| FfiServerAnnouncement { + pubkey: string_to_c(a.pubkey), + name: opt_string_to_c(a.server_info.name), + version: opt_string_to_c(a.server_info.version), + picture: opt_string_to_c(a.server_info.picture), + about: opt_string_to_c(a.server_info.about), + website: opt_string_to_c(a.server_info.website), + event_id: string_to_c(a.event_id.to_hex()), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_announcements = ffi_announcements; + let ptr = ffi_announcements.as_mut_ptr(); + std::mem::forget(ffi_announcements); + ptr +} + +#[no_mangle] +pub extern "C" fn cvm_discover_tools( + pool_handle: FfiHandle, + provider_pubkey_hex: *const c_char, + provider_display_name: *const c_char, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiDiscoveredTool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let provider_pubkey = match c_str_to_string(provider_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null provider pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let provider_display_name = c_str_to_string(provider_display_name); + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime().block_on(async { + crate::discovery::discover_tools(client, &provider_pubkey, provider_display_name, &urls) + .await + }); + + match result { + Ok(tools) => tools_to_ffi_array(tools, out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_discover_all_tools( + pool_handle: FfiHandle, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiDiscoveredTool { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime() + .block_on(async { crate::discovery::discover_all_tools(client, &urls).await }); + + match result { + Ok(tools) => tools_to_ffi_array(tools, out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_fetch_provider_profiles( + pool_handle: FfiHandle, + provider_pubkeys: *mut *mut c_char, + provider_pubkey_count: usize, + relay_urls: *mut *mut c_char, + url_count: usize, + out_count: *mut usize, + error: *mut *mut FfiError, +) -> *mut FfiProviderProfile { + let guard = kv::get::(pool_handle); + let pool = match guard { + Some(p) => p, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid pool handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pubkeys = c_str_array_to_vec(provider_pubkeys, provider_pubkey_count); + let urls = c_str_array_to_vec(relay_urls, url_count); + let client = pool.client(); + + let result = global_runtime().block_on(async { + crate::discovery::fetch_provider_profiles(client, &pubkeys, &urls).await + }); + + match result { + Ok(profiles) => profiles_to_ffi_array(profiles.into_values().collect(), out_count), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +// ─── Encryption ──────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_encrypt_nip44( + keys_handle: FfiHandle, + recipient_pubkey_hex: *const c_char, + plaintext: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + + let pk_str = match c_str_to_string(recipient_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null recipient pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pk = match contextvm_sdk::signer::PublicKey::from_hex(&pk_str) { + Ok(pk) => pk, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("invalid pubkey: {e}"), + }, + ); + return ptr::null_mut(); + } + }; + let pt = match c_str_to_string(plaintext) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null plaintext".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match global_runtime().block_on(contextvm_sdk::encryption::encrypt_nip44(&keys, &pk, &pt)) { + Ok(ct) => string_to_c(ct), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_decrypt_nip44( + keys_handle: FfiHandle, + sender_pubkey_hex: *const c_char, + ciphertext: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let keys = match kv::get::(keys_handle) { + Some(k) => k.as_ref().clone(), + None => { + set_error( + error, + FfiError { + code: ErrorCode::Other, + message: "invalid key handle".into(), + }, + ); + return ptr::null_mut(); + } + }; + + let pk_str = match c_str_to_string(sender_pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null sender pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + let pk = match contextvm_sdk::signer::PublicKey::from_hex(&pk_str) { + Ok(pk) => pk, + Err(e) => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: format!("invalid pubkey: {e}"), + }, + ); + return ptr::null_mut(); + } + }; + let ct = match c_str_to_string(ciphertext) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null ciphertext".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match global_runtime().block_on(contextvm_sdk::encryption::decrypt_nip44(&keys, &pk, &ct)) { + Ok(pt) => string_to_c(pt), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +// ─── Utility ─────────────────────────────────────────────────────────── + +#[no_mangle] +pub extern "C" fn cvm_version() -> *mut c_char { + string_to_c(env!("CARGO_PKG_VERSION").to_string()) +} + +#[no_mangle] +pub extern "C" fn cvm_pubkey_hex_to_npub( + pubkey_hex: *const c_char, + error: *mut *mut FfiError, +) -> *mut c_char { + let pubkey = match c_str_to_string(pubkey_hex) { + Some(s) => s, + None => { + set_error( + error, + FfiError { + code: ErrorCode::Validation, + message: "null pubkey".into(), + }, + ); + return ptr::null_mut(); + } + }; + + match crate::discovery::pubkey_hex_to_npub(&pubkey) { + Ok(npub) => string_to_c(npub), + Err(e) => { + set_error(error, e.into()); + ptr::null_mut() + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_error_free(e: *mut FfiError) { + if !e.is_null() { + unsafe { + let _ = Box::from_raw(e); + } + } +} + +#[no_mangle] +pub extern "C" fn cvm_error_message(e: *const FfiError) -> *mut c_char { + if e.is_null() { + return ptr::null_mut(); + } + unsafe { string_to_c((*e).message.clone()) } +} + +#[no_mangle] +pub extern "C" fn cvm_error_code(e: *const FfiError) -> ErrorCode { + if e.is_null() { + return ErrorCode::Ok; + } + unsafe { (*e).code } +} + +fn tools_to_ffi_array( + tools: Vec, + out_count: *mut usize, +) -> *mut FfiDiscoveredTool { + let count = tools.len(); + let ffi_tools: Vec = tools + .into_iter() + .map(|tool| FfiDiscoveredTool { + provider_pubkey: string_to_c(tool.provider_pubkey), + provider_display_name: opt_string_to_c(tool.provider_display_name), + provider_name: opt_string_to_c(tool.provider_name), + provider_about: opt_string_to_c(tool.provider_about), + provider_picture: opt_string_to_c(tool.provider_picture), + provider_nip05: opt_string_to_c(tool.provider_nip05), + tool_name: string_to_c(tool.tool_name), + description: string_to_c(tool.description), + schema_json: string_to_c(tool.schema_json), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_tools = ffi_tools; + let ptr = ffi_tools.as_mut_ptr(); + std::mem::forget(ffi_tools); + ptr +} + +fn profiles_to_ffi_array( + profiles: Vec, + out_count: *mut usize, +) -> *mut FfiProviderProfile { + let count = profiles.len(); + let ffi_profiles: Vec = profiles + .into_iter() + .map(|profile| FfiProviderProfile { + pubkey: string_to_c(profile.pubkey), + name: opt_string_to_c(profile.name), + about: opt_string_to_c(profile.about), + picture: opt_string_to_c(profile.picture), + nip05: opt_string_to_c(profile.nip05), + }) + .collect(); + + unsafe { + if !out_count.is_null() { + *out_count = count; + } + } + + let mut ffi_profiles = ffi_profiles; + let ptr = ffi_profiles.as_mut_ptr(); + std::mem::forget(ffi_profiles); + ptr +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn message_to_ffi_string_id_is_unquoted() { + let msg = contextvm_sdk::JsonRpcMessage::Request(contextvm_sdk::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::Value::String("request-1".to_string()), + method: "tools/list".to_string(), + params: None, + }); + + let ffi = message_to_ffi(&msg); + let id = unsafe { CStr::from_ptr(ffi.id).to_str().unwrap().to_string() }; + cvm_message_free(ffi); + + assert_eq!(id, "request-1"); + } + + #[test] + fn message_to_ffi_non_string_id_remains_json_encoded() { + let msg = contextvm_sdk::JsonRpcMessage::Response(contextvm_sdk::JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + result: serde_json::json!({}), + }); + + let ffi = message_to_ffi(&msg); + let id = unsafe { CStr::from_ptr(ffi.id).to_str().unwrap().to_string() }; + cvm_message_free(ffi); + + assert_eq!(id, "42"); + } + + // Error handling lifecycle tests + mod error_lifecycle_tests { + use super::*; + use crate::error::{set_error, ErrorCode, FfiError}; + + #[test] + fn test_error_creation_and_free() { + let mut error_ptr: *mut FfiError = std::ptr::null_mut(); + + // Create an error using set_error + let test_error = FfiError { + code: ErrorCode::Transport, + message: "test error message".to_string(), + }; + set_error(&mut error_ptr, test_error); + + // Verify error was created with correct code + assert!(!error_ptr.is_null()); + unsafe { + assert_eq!((*error_ptr).code, ErrorCode::Transport); + // Verify the message round-tripped intact + assert_eq!((*error_ptr).message, "test error message"); + } + + // Free the error - this should not panic + cvm_error_free(error_ptr); + } + + #[test] + fn test_error_free_null_is_safe() { + // Should not panic on null + cvm_error_free(std::ptr::null_mut()); + } + + #[test] + fn test_error_code_values() { + // Verify error code discriminant values match C header + assert_eq!(ErrorCode::Ok as i32, 0); + assert_eq!(ErrorCode::Transport as i32, 1); + assert_eq!(ErrorCode::Encryption as i32, 2); + assert_eq!(ErrorCode::Decryption as i32, 3); + assert_eq!(ErrorCode::Timeout as i32, 4); + assert_eq!(ErrorCode::Validation as i32, 5); + assert_eq!(ErrorCode::Unauthorized as i32, 6); + assert_eq!(ErrorCode::Serialization as i32, 7); + assert_eq!(ErrorCode::Other as i32, 99); + } + + #[test] + fn test_error_display_format() { + let error = FfiError { + code: ErrorCode::Timeout, + message: "operation timed out".to_string(), + }; + let display = format!("{}", error); + assert!(display.contains("Timeout")); + assert!(display.contains("operation timed out")); + } + + #[test] + fn test_error_clone() { + let original = FfiError { + code: ErrorCode::Validation, + message: "validation failed".to_string(), + }; + let cloned = original.clone(); + + assert_eq!(original.code, cloned.code); + assert_eq!(original.message, cloned.message); + } + + #[test] + fn test_incoming_request_free_null_is_safe() { + cvm_incoming_request_free(FfiIncomingRequest { + message: FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id: std::ptr::null_mut(), + payload_json: std::ptr::null_mut(), + method: std::ptr::null_mut(), + }, + client_pubkey: std::ptr::null_mut(), + event_id: std::ptr::null_mut(), + is_encrypted: false, + }); + } + + #[test] + fn test_string_free_null_is_safe() { + cvm_string_free(std::ptr::null_mut()); + } + + #[test] + fn test_message_free_null_is_safe() { + cvm_message_free(FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_RESPONSE, + id: std::ptr::null_mut(), + payload_json: std::ptr::null_mut(), + method: std::ptr::null_mut(), + }); + } + } +} diff --git a/contextvm-ffi/src/uniffi_types.rs b/contextvm-ffi/src/uniffi_types.rs new file mode 100644 index 0000000..d2ae890 --- /dev/null +++ b/contextvm-ffi/src/uniffi_types.rs @@ -0,0 +1,1237 @@ +//! UniFFI-compatible types and high-level object-oriented API. +//! +//! These types are exposed via UniFFI proc-macros and provide a more ergonomic +//! interface than the flat C API. They are designed for Python, Swift, and +//! Kotlin consumers. + +use crate::builders::{ + build_sdk_client_config_from_fields, build_sdk_server_config_from_fields, + CapabilityExclusionParts, ClientConfigParts, ServerConfigParts, +}; +use crate::error::FfiError; +use crate::runtime::global_runtime; +use crate::types::json_rpc_id_to_string; +use std::sync::Arc; +use std::time::Duration; + +// ─── Enum mirrors for UniFFI ─────────────────────────────────────────── + +/// Encryption mode. +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum EncryptionMode { + Optional, + Required, + Disabled, +} + +/// Gift-wrap mode (CEP-19). +#[derive(Debug, Clone, Copy, uniffi::Enum)] +pub enum GiftWrapMode { + Optional, + Ephemeral, + Persistent, +} + +/// JSON-RPC message type. +#[derive(Debug, Clone, uniffi::Enum)] +pub enum JsonRpcMessageType { + Request, + Response, + ErrorResponse, + Notification, +} + +// ─── Record types for UniFFI ─────────────────────────────────────────── + +/// A JSON-RPC message. +#[derive(Debug, Clone, uniffi::Record)] +pub struct JsonRpcMessage { + pub msg_type: JsonRpcMessageType, + pub payload_json: String, + pub method: String, + pub id: String, +} + +/// An incoming MCP request (server-side). +#[derive(Debug, Clone, uniffi::Record)] +pub struct IncomingRequest { + pub message: JsonRpcMessage, + pub client_pubkey: String, + pub event_id: String, + pub is_encrypted: bool, +} + +/// A discovered server announcement. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ServerAnnouncement { + pub pubkey: String, + pub name: Option, + pub version: Option, + pub picture: Option, + pub about: Option, + pub website: Option, + pub event_id: String, +} + +/// Nostr profile metadata for a provider. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ProviderProfile { + pub pubkey: String, + pub name: Option, + pub about: Option, + pub picture: Option, + pub nip05: Option, +} + +/// A capability exclusion pattern that bypasses pubkey whitelisting. +#[derive(Debug, Clone, uniffi::Record)] +pub struct CapabilityExclusion { + pub method: String, + pub name: Option, +} + +/// Learned peer capability flags. +#[derive(Debug, Clone, Copy, uniffi::Record)] +pub struct PeerCapabilities { + pub supports_encryption: bool, + pub supports_ephemeral_encryption: bool, + pub supports_oversized_transfer: bool, +} + +/// A discovered MCP tool and provider metadata used by foreign clients. +#[derive(Debug, Clone, uniffi::Record)] +pub struct DiscoveredTool { + pub provider_pubkey: String, + pub provider_display_name: Option, + pub provider_name: Option, + pub provider_about: Option, + pub provider_picture: Option, + pub provider_nip05: Option, + pub tool_name: String, + pub description: String, + pub schema_json: String, +} + +/// Server transport configuration. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ServerConfig { + pub relay_urls: Vec, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_announced_server: bool, + pub server_name: Option, + pub server_version: Option, + pub server_picture: Option, + pub server_about: Option, + pub server_website: Option, + pub allowed_pubkeys: Vec, + pub session_timeout_secs: u64, + pub cleanup_interval_secs: u64, + pub excluded_capabilities: Vec, + pub max_sessions: u64, + pub request_timeout_secs: u64, + pub relay_list_urls: Vec, + pub bootstrap_relay_urls: Vec, + pub publish_relay_list: bool, + pub profile_metadata_json: Option, +} + +impl Default for ServerConfig { + fn default() -> Self { + Self { + relay_urls: vec!["wss://relay.damus.io".to_string()], + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + is_announced_server: false, + server_name: None, + server_version: None, + server_picture: None, + server_about: None, + server_website: None, + allowed_pubkeys: vec![], + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: vec![], + max_sessions: 1000, + request_timeout_secs: 60, + relay_list_urls: vec![], + bootstrap_relay_urls: vec![], + publish_relay_list: true, + profile_metadata_json: None, + } + } +} + +/// Client transport configuration. +#[derive(Debug, Clone, uniffi::Record)] +pub struct ClientConfig { + pub relay_urls: Vec, + pub server_pubkey: String, + pub encryption_mode: EncryptionMode, + pub gift_wrap_mode: GiftWrapMode, + pub is_stateless: bool, + pub timeout_secs: u64, + pub discovery_relay_urls: Vec, + pub fallback_operational_relay_urls: Vec, +} + +impl Default for ClientConfig { + fn default() -> Self { + Self { + relay_urls: vec![], + server_pubkey: String::new(), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: vec![], + fallback_operational_relay_urls: vec![], + } + } +} + +// ─── Conversion helpers ──────────────────────────────────────────────── + +fn sdk_encryption_mode(m: EncryptionMode) -> contextvm_sdk::EncryptionMode { + match m { + EncryptionMode::Optional => contextvm_sdk::EncryptionMode::Optional, + EncryptionMode::Required => contextvm_sdk::EncryptionMode::Required, + EncryptionMode::Disabled => contextvm_sdk::EncryptionMode::Disabled, + } +} + +fn sdk_gift_wrap_mode(m: GiftWrapMode) -> contextvm_sdk::GiftWrapMode { + match m { + GiftWrapMode::Optional => contextvm_sdk::GiftWrapMode::Optional, + GiftWrapMode::Ephemeral => contextvm_sdk::GiftWrapMode::Ephemeral, + GiftWrapMode::Persistent => contextvm_sdk::GiftWrapMode::Persistent, + } +} + +fn message_to_uniffi(msg: &contextvm_sdk::JsonRpcMessage) -> JsonRpcMessage { + let msg_type = match msg { + contextvm_sdk::JsonRpcMessage::Request(_) => JsonRpcMessageType::Request, + contextvm_sdk::JsonRpcMessage::Response(_) => JsonRpcMessageType::Response, + contextvm_sdk::JsonRpcMessage::ErrorResponse(_) => JsonRpcMessageType::ErrorResponse, + contextvm_sdk::JsonRpcMessage::Notification(_) => JsonRpcMessageType::Notification, + }; + + JsonRpcMessage { + msg_type, + payload_json: serde_json::to_string(msg).unwrap_or_default(), + method: msg.method().map(String::from).unwrap_or_default(), + id: msg.id().map(json_rpc_id_to_string).unwrap_or_default(), + } +} + +fn incoming_to_uniffi(req: &contextvm_sdk::IncomingRequest) -> IncomingRequest { + IncomingRequest { + message: message_to_uniffi(&req.message), + client_pubkey: req.client_pubkey.clone(), + event_id: req.event_id.clone(), + is_encrypted: req.is_encrypted, + } +} + +fn parse_json_rpc(json: &str) -> Result { + serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }) +} + +type IncomingRx = + Arc>>; +type MessageRx = + Arc>>; + +fn channel_closed() -> FfiError { + FfiError { + code: crate::error::ErrorCode::Transport, + message: "channel closed".into(), + } +} + +fn recv_timeout_error() -> FfiError { + FfiError { + code: crate::error::ErrorCode::Timeout, + message: "receive timed out".into(), + } +} + +fn recv_incoming(rx: IncomingRx) -> Result { + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|req| incoming_to_uniffi(&req)) + .ok_or_else(channel_closed) +} + +fn recv_incoming_timeout(rx: IncomingRx, timeout_secs: u64) -> Result { + match global_runtime().block_on(async { + tokio::time::timeout(Duration::from_secs(timeout_secs), async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .await + }) { + Ok(Some(req)) => Ok(incoming_to_uniffi(&req)), + Ok(None) => Err(channel_closed()), + Err(_) => Err(recv_timeout_error()), + } +} + +fn recv_incoming_try(rx: IncomingRx) -> Result, FfiError> { + let mut guard = match rx.try_lock() { + Ok(guard) => guard, + Err(_) => return Ok(None), + }; + match guard.try_recv() { + Ok(req) => Ok(Some(incoming_to_uniffi(&req))), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => Ok(None), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => Err(channel_closed()), + } +} + +fn recv_message(rx: MessageRx) -> Result { + global_runtime() + .block_on(async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .map(|msg| message_to_uniffi(&msg)) + .ok_or_else(channel_closed) +} + +fn recv_message_timeout(rx: MessageRx, timeout_secs: u64) -> Result { + match global_runtime().block_on(async { + tokio::time::timeout(Duration::from_secs(timeout_secs), async { + let mut guard = rx.lock().await; + guard.recv().await + }) + .await + }) { + Ok(Some(msg)) => Ok(message_to_uniffi(&msg)), + Ok(None) => Err(channel_closed()), + Err(_) => Err(recv_timeout_error()), + } +} + +fn recv_message_try(rx: MessageRx) -> Result, FfiError> { + let mut guard = match rx.try_lock() { + Ok(guard) => guard, + Err(_) => return Ok(None), + }; + match guard.try_recv() { + Ok(msg) => Ok(Some(message_to_uniffi(&msg))), + Err(tokio::sync::mpsc::error::TryRecvError::Empty) => Ok(None), + Err(tokio::sync::mpsc::error::TryRecvError::Disconnected) => Err(channel_closed()), + } +} + +fn tool_to_uniffi(tool: crate::discovery::DiscoveredToolRecord) -> DiscoveredTool { + DiscoveredTool { + provider_pubkey: tool.provider_pubkey, + provider_display_name: tool.provider_display_name, + provider_name: tool.provider_name, + provider_about: tool.provider_about, + provider_picture: tool.provider_picture, + provider_nip05: tool.provider_nip05, + tool_name: tool.tool_name, + description: tool.description, + schema_json: tool.schema_json, + } +} + +fn profile_to_uniffi(profile: crate::discovery::ProviderProfileRecord) -> ProviderProfile { + ProviderProfile { + pubkey: profile.pubkey, + name: profile.name, + about: profile.about, + picture: profile.picture, + nip05: profile.nip05, + } +} + +fn capabilities_to_uniffi(caps: contextvm_sdk::PeerCapabilities) -> PeerCapabilities { + PeerCapabilities { + supports_encryption: caps.supports_encryption, + supports_ephemeral_encryption: caps.supports_ephemeral_encryption, + supports_oversized_transfer: caps.supports_oversized_transfer, + } +} + +fn parse_json_value_array(json: &str, name: &str) -> Result, FfiError> { + serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: format!("invalid {name}: {e}"), + }) +} + +fn parse_tags_json(json: &str) -> Result, FfiError> { + let parts: Vec> = serde_json::from_str(json).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: format!("invalid tags_json: {e}"), + })?; + parts + .into_iter() + .map(|tag| { + nostr_sdk::prelude::Tag::parse(tag).map_err(|e| FfiError { + code: crate::error::ErrorCode::Validation, + message: e.to_string(), + }) + }) + .collect() +} + +// ─── High-level UniFFI objects ───────────────────────────────────────── + +/// A Nostr keypair. +#[derive(uniffi::Object)] +pub struct Keys { + inner: contextvm_sdk::signer::Keys, +} + +#[uniffi::export] +impl Keys { + /// Generate a new random keypair. + #[uniffi::constructor] + pub fn generate() -> Self { + Self { + inner: contextvm_sdk::signer::generate(), + } + } + + /// Create keys from a secret key (hex or nsec/bech32). + #[uniffi::constructor] + pub fn from_secret_key(sk: &str) -> Result { + contextvm_sdk::signer::from_sk(sk) + .map(|inner| Self { inner }) + .map_err(|e| FfiError { + code: crate::error::ErrorCode::Other, + message: e.to_string(), + }) + } + + /// Get the public key (hex). + pub fn public_key(&self) -> String { + self.inner.public_key().to_hex() + } + + /// Get the secret key (hex). + pub fn secret_key(&self) -> String { + self.inner.secret_key().to_secret_hex() + } +} + +/// A relay pool for Nostr connectivity. +#[derive(uniffi::Object)] +pub struct RelayPool { + inner: contextvm_sdk::RelayPool, +} + +#[uniffi::export] +impl RelayPool { + /// Create a new relay pool. + #[uniffi::constructor] + pub fn new(keys: &Keys) -> Result { + global_runtime() + .block_on(contextvm_sdk::RelayPool::new(keys.inner.clone())) + .map(|inner| Self { inner }) + .map_err(FfiError::from) + } + + /// Connect to relays. + pub fn connect(&self, relay_urls: Vec) -> Result<(), FfiError> { + global_runtime() + .block_on(self.inner.connect(&relay_urls)) + .map_err(FfiError::from) + } + + /// Disconnect from all relays. + pub fn disconnect(&self) -> Result<(), FfiError> { + global_runtime() + .block_on(self.inner.disconnect()) + .map_err(FfiError::from) + } +} + +/// A server transport that receives MCP requests over Nostr. +#[derive(uniffi::Object)] +pub struct Server { + transport: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Server { + /// Create and start a server transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ServerConfig) -> Result { + let sdk_config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: config.relay_urls.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + server_name: config.server_name.clone(), + server_version: config.server_version.clone(), + server_picture: config.server_picture.clone(), + server_about: config.server_about.clone(), + server_website: config.server_website.clone(), + is_announced_server: config.is_announced_server, + allowed_pubkeys: config.allowed_pubkeys.clone(), + session_timeout_secs: config.session_timeout_secs, + cleanup_interval_secs: config.cleanup_interval_secs, + excluded_capabilities: config + .excluded_capabilities + .iter() + .map(|cap| CapabilityExclusionParts { + method: cap.method.clone(), + name: cap.name.clone(), + }) + .collect(), + max_sessions: config.max_sessions as usize, + request_timeout_secs: config.request_timeout_secs, + relay_list_urls: config.relay_list_urls.clone(), + bootstrap_relay_urls: config.bootstrap_relay_urls.clone(), + publish_relay_list: config.publish_relay_list, + profile_metadata_json: config.profile_metadata_json.clone(), + })?; + + global_runtime() + .block_on(async { + let mut transport = + contextvm_sdk::NostrServerTransport::new(keys.inner.clone(), sdk_config) + .await?; + transport.start().await?; + transport.spawn_discoverability_publication(); + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(Self { + transport: Arc::new(tokio::sync::Mutex::new(transport)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Receive the next incoming request. Blocks until one is available. + pub fn recv(&self) -> Result { + recv_incoming(self.receiver.clone()) + } + + /// Receive the next incoming request, timing out after `timeout_secs`. + pub fn recv_timeout(&self, timeout_secs: u64) -> Result { + recv_incoming_timeout(self.receiver.clone(), timeout_secs) + } + + /// Return the next incoming request if one is already buffered. + pub fn recv_try(&self) -> Result, FfiError> { + recv_incoming_try(self.receiver.clone()) + } + + /// Send a response for a given event ID. + pub fn send_response(&self, event_id: &str, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.send_response(event_id, message).await + }) + .map_err(FfiError::from) + } + + /// Send a notification to a specific client. + pub fn send_notification( + &self, + client_pubkey: &str, + payload_json: &str, + correlated_event_id: Option, + ) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard + .send_notification(client_pubkey, &message, correlated_event_id.as_deref()) + .await + }) + .map_err(FfiError::from) + } + + /// Broadcast a notification to all initialized clients. + pub fn broadcast_notification(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.broadcast_notification(&message).await + }) + .map_err(FfiError::from) + } + + /// Sets extra announcement/discovery tags from a JSON array of tag arrays. + pub fn set_announcement_extra_tags(&self, tags_json: &str) -> Result<(), FfiError> { + let tags = parse_tags_json(tags_json)?; + let transport = self.transport.clone(); + global_runtime().block_on(async { + let mut guard = transport.lock().await; + guard.set_announcement_extra_tags(tags); + }); + Ok(()) + } + + /// Sets pricing tags from a JSON array of tag arrays. + pub fn set_announcement_pricing_tags(&self, tags_json: &str) -> Result<(), FfiError> { + let tags = parse_tags_json(tags_json)?; + let transport = self.transport.clone(); + global_runtime().block_on(async { + let mut guard = transport.lock().await; + guard.set_announcement_pricing_tags(tags); + }); + Ok(()) + } + + /// Publish server announcement. + pub fn announce(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.announce().await + }) + .map(|_| ()) + .map_err(FfiError::from) + } + + /// Publish server announcement and return the Nostr event ID. + pub fn announce_event_id(&self) -> Result { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.announce().await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish tools list and return the Nostr event ID. + pub fn publish_tools(&self, tools_json: &str) -> Result { + let tools = parse_json_value_array(tools_json, "tools_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_tools(tools).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish resources list and return the Nostr event ID. + pub fn publish_resources(&self, resources_json: &str) -> Result { + let resources = parse_json_value_array(resources_json, "resources_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_resources(resources).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish prompts list and return the Nostr event ID. + pub fn publish_prompts(&self, prompts_json: &str) -> Result { + let prompts = parse_json_value_array(prompts_json, "prompts_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_prompts(prompts).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Publish resource templates list and return the Nostr event ID. + pub fn publish_resource_templates(&self, templates_json: &str) -> Result { + let templates = parse_json_value_array(templates_json, "templates_json")?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.publish_resource_templates(templates).await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Delete previously published server announcements. + pub fn delete_announcements(&self, reason: &str) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.delete_announcements(reason).await + }) + .map_err(FfiError::from) + } + + /// Close the server transport. + pub fn close(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let mut guard = transport.lock().await; + guard.close().await + }) + .map_err(FfiError::from) + } +} + +/// A client transport that sends MCP requests over Nostr. +#[derive(uniffi::Object)] +pub struct Client { + transport: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Client { + /// Create and start a client transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ClientConfig) -> Result { + let sdk_config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: config.relay_urls.clone(), + server_pubkey: config.server_pubkey.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls: config.discovery_relay_urls.clone(), + fallback_operational_relay_urls: config.fallback_operational_relay_urls.clone(), + }); + + global_runtime() + .block_on(async { + let mut transport = + contextvm_sdk::NostrClientTransport::new(keys.inner.clone(), sdk_config) + .await?; + transport.start().await?; + let receiver = transport + .take_message_receiver() + .ok_or_else(|| contextvm_sdk::Error::Other("receiver already taken".into()))?; + Ok::<_, contextvm_sdk::Error>(Self { + transport: Arc::new(tokio::sync::Mutex::new(transport)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Send a JSON-RPC message. + pub fn send(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let guard = transport.lock().await; + guard.send(&message).await + }) + .map_err(FfiError::from) + } + + /// Receive the next response. Blocks until one is available. + pub fn recv(&self) -> Result { + recv_message(self.receiver.clone()) + } + + /// Receive the next response, timing out after `timeout_secs`. + pub fn recv_timeout(&self, timeout_secs: u64) -> Result { + recv_message_timeout(self.receiver.clone(), timeout_secs) + } + + /// Return the next response if one is already buffered. + pub fn recv_try(&self) -> Result, FfiError> { + recv_message_try(self.receiver.clone()) + } + + /// Return a snapshot of server capabilities learned from discovery tags. + pub fn discovered_server_capabilities(&self) -> PeerCapabilities { + let transport = self.transport.clone(); + let caps = global_runtime().block_on(async { + let guard = transport.lock().await; + guard.discovered_server_capabilities() + }); + capabilities_to_uniffi(caps) + } + + /// Return whether the client has learned ephemeral gift-wrap support. + pub fn server_supports_ephemeral_encryption(&self) -> bool { + let transport = self.transport.clone(); + global_runtime().block_on(async { + let guard = transport.lock().await; + guard.server_supports_ephemeral_encryption() + }) + } + + /// Return the first server event carrying discovery tags as JSON, if present. + pub fn server_initialize_event_json(&self) -> Result, FfiError> { + let transport = self.transport.clone(); + let event = global_runtime().block_on(async { + let guard = transport.lock().await; + guard.get_server_initialize_event() + }); + event + .map(|event| { + serde_json::to_string(&event).map_err(|e| FfiError { + code: crate::error::ErrorCode::Serialization, + message: e.to_string(), + }) + }) + .transpose() + } + + /// Close the client transport. + pub fn close(&self) -> Result<(), FfiError> { + let transport = self.transport.clone(); + global_runtime() + .block_on(async { + let mut guard = transport.lock().await; + guard.close().await + }) + .map_err(FfiError::from) + } +} + +/// A gateway that bridges a local MCP server to Nostr. +#[derive(uniffi::Object)] +pub struct Gateway { + gateway: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Gateway { + /// Create and start a gateway transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ServerConfig) -> Result { + let sdk_config = build_sdk_server_config_from_fields(ServerConfigParts { + relay_urls: config.relay_urls.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + server_name: config.server_name.clone(), + server_version: config.server_version.clone(), + server_picture: config.server_picture.clone(), + server_about: config.server_about.clone(), + server_website: config.server_website.clone(), + is_announced_server: config.is_announced_server, + allowed_pubkeys: config.allowed_pubkeys.clone(), + session_timeout_secs: config.session_timeout_secs, + cleanup_interval_secs: config.cleanup_interval_secs, + excluded_capabilities: config + .excluded_capabilities + .iter() + .map(|cap| CapabilityExclusionParts { + method: cap.method.clone(), + name: cap.name.clone(), + }) + .collect(), + max_sessions: config.max_sessions as usize, + request_timeout_secs: config.request_timeout_secs, + relay_list_urls: config.relay_list_urls.clone(), + bootstrap_relay_urls: config.bootstrap_relay_urls.clone(), + publish_relay_list: config.publish_relay_list, + profile_metadata_json: config.profile_metadata_json.clone(), + })?; + let gateway_config = contextvm_sdk::gateway::GatewayConfig::new(sdk_config); + + global_runtime() + .block_on(async { + let mut gateway = contextvm_sdk::gateway::NostrMCPGateway::new( + keys.inner.clone(), + gateway_config, + ) + .await?; + let receiver = gateway.start().await?; + Ok::<_, contextvm_sdk::Error>(Self { + gateway: Arc::new(tokio::sync::Mutex::new(gateway)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Receive the next incoming request. + pub fn recv(&self) -> Result { + recv_incoming(self.receiver.clone()) + } + + /// Receive the next incoming request, timing out after `timeout_secs`. + pub fn recv_timeout(&self, timeout_secs: u64) -> Result { + recv_incoming_timeout(self.receiver.clone(), timeout_secs) + } + + /// Return the next incoming request if one is already buffered. + pub fn recv_try(&self) -> Result, FfiError> { + recv_incoming_try(self.receiver.clone()) + } + + /// Send a response for a given event ID. + pub fn send_response(&self, event_id: &str, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.send_response(event_id, message).await + }) + .map_err(FfiError::from) + } + + /// Publish server announcement. + pub fn announce(&self) -> Result<(), FfiError> { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.announce().await + }) + .map(|_| ()) + .map_err(FfiError::from) + } + + /// Publish server announcement and return the Nostr event ID. + pub fn announce_event_id(&self) -> Result { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let guard = gateway.lock().await; + guard.announce().await + }) + .map(|event_id| event_id.to_hex()) + .map_err(FfiError::from) + } + + /// Check if the gateway is active. + pub fn is_active(&self) -> bool { + let gateway = self.gateway.clone(); + global_runtime().block_on(async { + let guard = gateway.lock().await; + guard.is_active() + }) + } + + /// Stop the gateway transport. + pub fn stop(&self) -> Result<(), FfiError> { + let gateway = self.gateway.clone(); + global_runtime() + .block_on(async { + let mut guard = gateway.lock().await; + guard.stop().await + }) + .map_err(FfiError::from) + } +} + +/// A proxy that connects a local MCP client to a remote Nostr MCP server. +#[derive(uniffi::Object)] +pub struct Proxy { + proxy: Arc>, + receiver: Arc< + tokio::sync::Mutex>, + >, +} + +#[uniffi::export] +impl Proxy { + /// Create and start a proxy transport. + #[uniffi::constructor] + pub fn new(keys: &Keys, config: &ClientConfig) -> Result { + let sdk_config = build_sdk_client_config_from_fields(ClientConfigParts { + relay_urls: config.relay_urls.clone(), + server_pubkey: config.server_pubkey.clone(), + encryption_mode: sdk_encryption_mode(config.encryption_mode), + gift_wrap_mode: sdk_gift_wrap_mode(config.gift_wrap_mode), + is_stateless: config.is_stateless, + timeout_secs: config.timeout_secs, + discovery_relay_urls: config.discovery_relay_urls.clone(), + fallback_operational_relay_urls: config.fallback_operational_relay_urls.clone(), + }); + let proxy_config = contextvm_sdk::proxy::ProxyConfig::new(sdk_config); + + global_runtime() + .block_on(async { + let mut proxy = + contextvm_sdk::proxy::NostrMCPProxy::new(keys.inner.clone(), proxy_config) + .await?; + let receiver = proxy.start().await?; + Ok::<_, contextvm_sdk::Error>(Self { + proxy: Arc::new(tokio::sync::Mutex::new(proxy)), + receiver: Arc::new(tokio::sync::Mutex::new(receiver)), + }) + }) + .map_err(FfiError::from) + } + + /// Send a JSON-RPC message through the proxy. + pub fn send(&self, payload_json: &str) -> Result<(), FfiError> { + let message = parse_json_rpc(payload_json)?; + let proxy = self.proxy.clone(); + global_runtime() + .block_on(async { + let guard = proxy.lock().await; + guard.send(&message).await + }) + .map_err(FfiError::from) + } + + /// Receive the next response or notification. + pub fn recv(&self) -> Result { + recv_message(self.receiver.clone()) + } + + /// Receive the next response or notification, timing out after `timeout_secs`. + pub fn recv_timeout(&self, timeout_secs: u64) -> Result { + recv_message_timeout(self.receiver.clone(), timeout_secs) + } + + /// Return the next response or notification if one is already buffered. + pub fn recv_try(&self) -> Result, FfiError> { + recv_message_try(self.receiver.clone()) + } + + /// Check if the proxy is active. + pub fn is_active(&self) -> bool { + let proxy = self.proxy.clone(); + global_runtime().block_on(async { + let guard = proxy.lock().await; + guard.is_active() + }) + } + + /// Stop the proxy transport. + pub fn stop(&self) -> Result<(), FfiError> { + let proxy = self.proxy.clone(); + global_runtime() + .block_on(async { + let mut guard = proxy.lock().await; + guard.stop().await + }) + .map_err(FfiError::from) + } +} + +/// Discovery functions. +#[derive(uniffi::Object)] +pub struct Discovery; + +#[uniffi::export] +impl Discovery { + #[uniffi::constructor] + pub fn new() -> Self { + Self + } + + /// Discover MCP servers on the given relay URLs. + pub fn discover_servers( + &self, + pool: &RelayPool, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + contextvm_sdk::discovery::discover_servers(client, &relay_urls).await + }) + .map(|announcements| { + announcements + .into_iter() + .map(|a| ServerAnnouncement { + pubkey: a.pubkey, + name: a.server_info.name, + version: a.server_info.version, + picture: a.server_info.picture, + about: a.server_info.about, + website: a.server_info.website, + event_id: a.event_id.to_hex(), + }) + .collect() + }) + .map_err(FfiError::from) + } + + /// Discover MCP tools published by a specific provider. + pub fn discover_tools( + &self, + pool: &RelayPool, + provider_pubkey: String, + provider_display_name: Option, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + crate::discovery::discover_tools( + client, + &provider_pubkey, + provider_display_name, + &relay_urls, + ) + .await + }) + .map(|tools| tools.into_iter().map(tool_to_uniffi).collect()) + .map_err(FfiError::from) + } + + /// Discover server announcements, tools, and provider profiles in one pass. + pub fn discover_all_tools( + &self, + pool: &RelayPool, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { crate::discovery::discover_all_tools(client, &relay_urls).await }) + .map(|tools| tools.into_iter().map(tool_to_uniffi).collect()) + .map_err(FfiError::from) + } + + /// Fetch Nostr kind-0 provider profiles for a set of provider pubkeys. + pub fn fetch_provider_profiles( + &self, + pool: &RelayPool, + provider_pubkeys: Vec, + relay_urls: Vec, + ) -> Result, FfiError> { + let client = pool.inner.client(); + global_runtime() + .block_on(async { + crate::discovery::fetch_provider_profiles(client, &provider_pubkeys, &relay_urls) + .await + }) + .map(|profiles| profiles.into_values().map(profile_to_uniffi).collect()) + .map_err(FfiError::from) + } +} + +impl Default for Discovery { + fn default() -> Self { + Self::new() + } +} + +// ─── Top-level functions ─────────────────────────────────────────────── + +/// Get the library version. +#[uniffi::export] +pub fn version() -> String { + env!("CARGO_PKG_VERSION").to_string() +} + +/// Convert a hex public key to npub bech32. +#[uniffi::export] +pub fn pubkey_hex_to_npub(pubkey_hex: String) -> Result { + crate::discovery::pubkey_hex_to_npub(&pubkey_hex).map_err(FfiError::from) +} + +/// Helper: build a JSON-RPC request as a JSON string. +#[uniffi::export] +pub fn make_request(id: String, method: String, params: Option) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Request(contextvm_sdk::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + method, + params: params.and_then(|p| serde_json::from_str(&p).ok()), + }); + serde_json::to_string(&msg).unwrap_or_default() +} + +/// Helper: build a JSON-RPC notification as a JSON string. +#[uniffi::export] +pub fn make_notification(method: String, params: Option) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Notification(contextvm_sdk::JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method, + params: params.and_then(|p| serde_json::from_str(&p).ok()), + }); + serde_json::to_string(&msg).unwrap_or_default() +} + +/// Helper: build a JSON-RPC response as a JSON string. +#[uniffi::export] +pub fn make_response(id: String, result: String) -> String { + let msg = contextvm_sdk::JsonRpcMessage::Response(contextvm_sdk::JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + result: serde_json::from_str(&result).unwrap_or(serde_json::json!(null)), + }); + serde_json::to_string(&msg).unwrap_or_default() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> contextvm_sdk::JsonRpcMessage { + contextvm_sdk::JsonRpcMessage::Request(contextvm_sdk::JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("1"), + method: "ping".to_string(), + params: None, + }) + } + + #[test] + fn recv_message_try_returns_none_then_buffered_message() { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + + assert!(recv_message_try(rx.clone()).unwrap().is_none()); + tx.send(request()).unwrap(); + + let msg = recv_message_try(rx).unwrap().unwrap(); + assert_eq!(msg.method, "ping"); + } + + #[test] + fn recv_message_timeout_reports_timeout() { + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let err = recv_message_timeout(Arc::new(tokio::sync::Mutex::new(rx)), 0).unwrap_err(); + + assert_eq!(err.code, crate::error::ErrorCode::Timeout); + } + + #[test] + fn recv_message_timeout_includes_lock_wait() { + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + let _guard = global_runtime().block_on(rx.lock()); + + let err = recv_message_timeout(rx.clone(), 0).unwrap_err(); + + assert_eq!(err.code, crate::error::ErrorCode::Timeout); + } + + #[test] + fn recv_message_try_does_not_wait_for_lock() { + let (_tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let rx = Arc::new(tokio::sync::Mutex::new(rx)); + let _guard = global_runtime().block_on(rx.lock()); + + assert!(recv_message_try(rx.clone()).unwrap().is_none()); + } +} diff --git a/contextvm-ffi/tests/e2e_ffi_crypto.rs b/contextvm-ffi/tests/e2e_ffi_crypto.rs new file mode 100644 index 0000000..d63c5a5 --- /dev/null +++ b/contextvm-ffi/tests/e2e_ffi_crypto.rs @@ -0,0 +1,191 @@ +//! Pure-function tests for the crypto, key, and encoding surface. +//! +//! These exercise `cvm_encrypt_nip44` / `cvm_decrypt_nip44`, secret-key +//! restore, and npub conversion directly — no relay required. + +use contextvm_ffi::{ + cvm_decrypt_nip44, cvm_encrypt_nip44, cvm_error_code, cvm_error_free, cvm_error_message, + cvm_keys_free, cvm_keys_from_secret_key, cvm_keys_generate, cvm_keys_public_key, + cvm_keys_secret_key, cvm_pubkey_hex_to_npub, cvm_string_free, + error::{ErrorCode, FfiError}, +}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +fn to_c_str(s: &str) -> *mut c_char { + CString::new(s).unwrap().into_raw() +} + +unsafe fn from_c_str(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + CStr::from_ptr(ptr).to_str().unwrap_or("").to_string() +} + +/// NIP-44 encrypt then decrypt must round-trip between two keypairs. +#[test] +fn test_encrypt_decrypt_roundtrip() { + let mut error: *mut FfiError = ptr::null_mut(); + + let alice = cvm_keys_generate(&mut error); + let bob = cvm_keys_generate(&mut error); + assert!(alice.id > 0 && bob.id > 0); + + let bob_pub = cvm_keys_public_key(bob, &mut error); + let bob_pub = unsafe { from_c_str(bob_pub) }; + assert_eq!(bob_pub.len(), 64); + + let plaintext = "the quick brown fox 🦊"; + let ciphertext = cvm_encrypt_nip44(alice, to_c_str(&bob_pub), to_c_str(plaintext), &mut error); + assert!(!ciphertext.is_null(), "encrypt must succeed"); + + let ciphertext = unsafe { from_c_str(ciphertext) }; + assert!(!ciphertext.is_empty(), "ciphertext must be non-empty"); + assert_ne!( + ciphertext, plaintext, + "ciphertext must differ from plaintext" + ); + + // Decrypt with the recipient keys; sender is alice's pubkey. + let alice_pub = cvm_keys_public_key(alice, &mut error); + let alice_pub = unsafe { from_c_str(alice_pub) }; + + let recovered = cvm_decrypt_nip44(bob, to_c_str(&alice_pub), to_c_str(&ciphertext), &mut error); + assert!(!recovered.is_null(), "decrypt must succeed"); + assert_eq!( + unsafe { from_c_str(recovered) }, + plaintext, + "roundtrip must recover plaintext" + ); + + // Cleanup + cvm_string_free(recovered); + cvm_keys_free(alice); + cvm_keys_free(bob); +} + +/// Encrypting to a bogus recipient pubkey must report a validation error, +/// not crash or return garbage. +#[test] +fn test_encrypt_rejects_invalid_recipient() { + let mut error: *mut FfiError = ptr::null_mut(); + let keys = cvm_keys_generate(&mut error); + + let result = cvm_encrypt_nip44(keys, to_c_str("not-a-pubkey"), to_c_str("hi"), &mut error); + + assert!(result.is_null(), "encrypt with bad pubkey must return null"); + assert!(!error.is_null(), "an error must be reported"); + assert_eq!(unsafe { (*error).code }, ErrorCode::Validation); + + cvm_error_free(error); + cvm_keys_free(keys); +} + +/// Calling crypto with an invalid keys handle must fail cleanly. +#[test] +fn test_encrypt_rejects_invalid_keys_handle() { + let mut error: *mut FfiError = ptr::null_mut(); + let bogus = contextvm_ffi::handle::FfiHandle { id: 999_999 }; + + let result = cvm_encrypt_nip44( + bogus, + to_c_str("0000000000000000000000000000000000000000000000000000000000000001"), + to_c_str("hi"), + &mut error, + ); + + assert!(result.is_null()); + assert!(!error.is_null()); + assert_eq!(unsafe { (*error).code }, ErrorCode::Other); + cvm_error_free(error); +} + +/// A secret key exported from one keypair must restore to the same pubkey. +#[test] +fn test_secret_key_restore_roundtrip() { + let mut error: *mut FfiError = ptr::null_mut(); + + let original = cvm_keys_generate(&mut error); + let original_pub = cvm_keys_public_key(original, &mut error); + let original_pub = unsafe { from_c_str(original_pub) }; + + let secret = cvm_keys_secret_key(original, &mut error); + let secret = unsafe { from_c_str(secret) }; + assert_eq!(secret.len(), 64, "secret key hex is 64 chars"); + + let restored = cvm_keys_from_secret_key(to_c_str(&secret), &mut error); + assert!(restored.id > 0, "restore must produce a valid handle"); + + let restored_pub = cvm_keys_public_key(restored, &mut error); + assert_eq!( + unsafe { from_c_str(restored_pub) }, + original_pub, + "restored pubkey must match original" + ); + + cvm_keys_free(original); + cvm_keys_free(restored); +} + +/// `cvm_keys_from_secret_key` must reject malformed input. +#[test] +fn test_from_secret_key_rejects_garbage() { + let mut error: *mut FfiError = ptr::null_mut(); + let handle = cvm_keys_from_secret_key(to_c_str("definitely-not-a-key"), &mut error); + + assert_eq!(handle.id, 0, "no handle for garbage secret key"); + assert!(!error.is_null()); + cvm_error_free(error); +} + +/// A hex pubkey must convert to a bech32 npub. +#[test] +fn test_pubkey_hex_to_npub() { + let mut error: *mut FfiError = ptr::null_mut(); + let keys = cvm_keys_generate(&mut error); + let hex = cvm_keys_public_key(keys, &mut error); + let hex = unsafe { from_c_str(hex) }; + + let npub = cvm_pubkey_hex_to_npub(to_c_str(&hex), &mut error); + assert!(!npub.is_null(), "npub conversion must succeed"); + let npub_str = unsafe { from_c_str(npub) }; + assert!( + npub_str.starts_with("npub1"), + "npub must have bech32 prefix: {npub_str}" + ); + + cvm_string_free(npub); + cvm_keys_free(keys); +} + +/// `cvm_pubkey_hex_to_npub` must reject bad hex. +#[test] +fn test_pubkey_hex_to_npub_rejects_bad_hex() { + let mut error: *mut FfiError = ptr::null_mut(); + let result = cvm_pubkey_hex_to_npub(to_c_str("zz"), &mut error); + + assert!(result.is_null()); + assert!(!error.is_null()); + assert_eq!(unsafe { (*error).code }, ErrorCode::Validation); + cvm_error_free(error); +} + +/// `cvm_error_code` / `cvm_error_message` must read a populated error and +/// tolerate a null pointer. +#[test] +fn test_error_accessors() { + // Null pointer tolerance. + assert_eq!(cvm_error_code(ptr::null()), ErrorCode::Ok); + + // Populated error: borrow an error from a known-failing call. + let mut error: *mut FfiError = ptr::null_mut(); + let _ = cvm_keys_from_secret_key(to_c_str("garbage"), &mut error); + assert!(!error.is_null()); + assert_eq!(cvm_error_code(error), ErrorCode::Other); + let msg = cvm_error_message(error); + assert!(!unsafe { from_c_str(msg) }.is_empty()); + cvm_string_free(msg); + cvm_error_free(error); +} diff --git a/contextvm-ffi/tests/e2e_ffi_happy_path.rs b/contextvm-ffi/tests/e2e_ffi_happy_path.rs new file mode 100644 index 0000000..04dc69d --- /dev/null +++ b/contextvm-ffi/tests/e2e_ffi_happy_path.rs @@ -0,0 +1,293 @@ +//! Lifecycle and API-surface tests for the ContextVM FFI bindings. +//! +//! These cover the non-network parts of the FFI contract: keys generation, +//! config struct layout, message struct conversion, constant values, and +//! memory-management edge cases (null-safe frees, multiple handles). Real +//! message flow is exercised in `e2e_ffi_nak_relay.rs`. + +use contextvm_ffi::error::{ErrorCode, FfiError}; +use contextvm_ffi::handle::FfiHandle; +use contextvm_ffi::types::*; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// Helper to convert Rust string to C string +fn to_c_str(s: &str) -> *mut c_char { + CString::new(s).unwrap().into_raw() +} + +// Helper to safely get string from C pointer +unsafe fn from_c_str(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + CStr::from_ptr(ptr).to_str().unwrap_or("").to_string() +} + +/// Test full lifecycle: keys generation and free +#[test] +fn test_e2e_keys_creation_and_free() { + unsafe { + let mut error: *mut FfiError = ptr::null_mut(); + let keys_handle = cvm_keys_generate(&mut error); + + assert!(keys_handle.id > 0, "Keys handle should be valid"); + + // Get public key + let pubkey = cvm_keys_public_key(keys_handle, &mut error); + assert!(!pubkey.is_null(), "Should get public key"); + + let pubkey_str = from_c_str(pubkey); + assert_eq!(pubkey_str.len(), 64, "Public key should be 64 hex chars"); + + // Free strings + cvm_string_free(pubkey); + + // Free keys (no return value) + cvm_keys_free(keys_handle); + } +} + +/// Test error handling lifecycle +#[test] +fn test_e2e_error_handling() { + // Test with invalid handle + let invalid_handle = FfiHandle { id: 99999 }; + let mut error: *mut FfiError = ptr::null_mut(); + + let result = cvm_keys_public_key(invalid_handle, &mut error); + assert!(result.is_null(), "Should return null for invalid handle"); + + // The FFI doesn't set error for this case, but we verify no crash +} + +/// Test server announcement configuration parsing +#[test] +fn test_e2e_server_config_lifecycle() { + // Create minimal server config + let relay_urls: Vec<*mut c_char> = vec![to_c_str("wss://test.relay")]; + let config = FfiServerConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: ptr::null_mut(), + server_version: ptr::null_mut(), + server_picture: ptr::null_mut(), + server_about: ptr::null_mut(), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + }; + + // We can't actually start a server without a real relay in this test, + // but we verify the config structure is correct + assert_eq!(config.relay_url_count, 1); + assert_eq!(config.encryption_mode, ENCRYPTION_MODE_OPTIONAL); + + // Clean up + for url in relay_urls { + cvm_string_free(url); + } +} + +/// Test client configuration parsing +#[test] +fn test_e2e_client_config_lifecycle() { + // Create minimal client config + let server_pubkey = + to_c_str("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + + let relay_urls: Vec<*mut c_char> = vec![to_c_str("wss://test.relay")]; + + let config = FfiClientConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + server_pubkey, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + }; + + assert_eq!(config.relay_url_count, 1); + assert_eq!(config.timeout_secs, 30); + + // Clean up + cvm_string_free(server_pubkey); + for url in relay_urls { + cvm_string_free(url); + } +} + +/// Test full FFI message roundtrip (request -> response conversion) +#[test] +fn test_e2e_message_conversion_roundtrip() { + unsafe { + // Create a JSON-RPC request + let id = to_c_str("req-123"); + let payload = to_c_str(r#"{"jsonrpc":"2.0","id":"req-123","method":"tools/list"}"#); + let method = to_c_str("tools/list"); + + let msg = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id, + payload_json: payload, + method, + }; + + // Verify message structure + assert_eq!(msg.msg_type, JSON_RPC_TYPE_REQUEST); + + let id_str = from_c_str(msg.id); + assert_eq!(id_str, "req-123"); + + let payload_str = from_c_str(msg.payload_json); + assert!(payload_str.contains("tools/list")); + + // Free the message + cvm_message_free(msg); + + // Verify pointers are freed (no crash) + } +} + +/// Test error code constants match expected values +#[test] +fn test_e2e_error_code_constants() { + // These must match the C header values + assert_eq!(ErrorCode::Ok as i32, 0); + assert_eq!(ErrorCode::Transport as i32, 1); + assert_eq!(ErrorCode::Encryption as i32, 2); + assert_eq!(ErrorCode::Decryption as i32, 3); + assert_eq!(ErrorCode::Timeout as i32, 4); + assert_eq!(ErrorCode::Validation as i32, 5); + assert_eq!(ErrorCode::Unauthorized as i32, 6); + assert_eq!(ErrorCode::Serialization as i32, 7); + assert_eq!(ErrorCode::Other as i32, 99); +} + +/// Test encryption mode constants +#[test] +fn test_e2e_encryption_mode_constants() { + assert_eq!(ENCRYPTION_MODE_DISABLED, 2); + assert_eq!(ENCRYPTION_MODE_OPTIONAL, 0); + assert_eq!(ENCRYPTION_MODE_REQUIRED, 1); +} + +/// Test gift wrap mode constants +#[test] +fn test_e2e_gift_wrap_mode_constants() { + assert_eq!(GIFT_WRAP_MODE_OPTIONAL, 0); + assert_eq!(GIFT_WRAP_MODE_EPHEMERAL, 1); + assert_eq!(GIFT_WRAP_MODE_PERSISTENT, 2); +} + +/// Test JSON-RPC type constants +#[test] +fn test_e2e_json_rpc_type_constants() { + assert_eq!(JSON_RPC_TYPE_REQUEST, 0); + assert_eq!(JSON_RPC_TYPE_RESPONSE, 1); + assert_eq!(JSON_RPC_TYPE_ERROR_RESPONSE, 2); + assert_eq!(JSON_RPC_TYPE_NOTIFICATION, 3); +} + +/// Test memory safety: double free should not crash (we test by verifying +/// the free functions handle edge cases) +#[test] +fn test_e2e_memory_safety_edge_cases() { + // Free null pointers - should not crash + cvm_string_free(ptr::null_mut()); + cvm_error_free(ptr::null_mut()); + + // Create and free a message + let msg = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id: ptr::null_mut(), + payload_json: ptr::null_mut(), + method: ptr::null_mut(), + }; + cvm_message_free(msg); + + // Create and free an incoming request + let req = FfiIncomingRequest { + message: FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id: ptr::null_mut(), + payload_json: ptr::null_mut(), + method: ptr::null_mut(), + }, + client_pubkey: ptr::null_mut(), + event_id: ptr::null_mut(), + is_encrypted: false, + }; + cvm_incoming_request_free(req); +} + +/// Test multiple keys generation and cleanup +#[test] +fn test_e2e_multiple_keys_stress() { + let mut handles = Vec::new(); + + // Generate multiple key pairs + for _ in 0..10 { + let mut error: *mut FfiError = ptr::null_mut(); + let keys = cvm_keys_generate(&mut error); + assert!(keys.id > 0); + handles.push(keys); + } + + // Verify all handles are unique + let unique_ids: std::collections::HashSet<_> = handles.iter().map(|h| h.id).collect(); + assert_eq!( + unique_ids.len(), + handles.len(), + "All handles should be unique" + ); + + // Clean up all keys + for keys in handles { + cvm_keys_free(keys); + } +} + +/// Test string utility functions +#[test] +fn test_e2e_string_utilities() { + unsafe { + // Test with various string contents + let test_strings = vec![ + "simple string", + "string with spaces", + "string-with-dashes", + "string_with_underscores", + "1234567890", + "", // empty + ]; + + for s in test_strings { + let c_str = to_c_str(s); + let retrieved = from_c_str(c_str); + assert_eq!(retrieved, s, "String roundtrip failed for: {}", s); + cvm_string_free(c_str); + } + } +} diff --git a/contextvm-ffi/tests/e2e_ffi_mock_relay.rs b/contextvm-ffi/tests/e2e_ffi_mock_relay.rs new file mode 100644 index 0000000..2650cc9 --- /dev/null +++ b/contextvm-ffi/tests/e2e_ffi_mock_relay.rs @@ -0,0 +1,476 @@ +//! Structure and validation tests for ContextVM FFI types. +//! +//! These verify the layout and round-trip conversion of the FFI structs +//! (configs, messages, announcements, profiles) plus channel handle error +//! paths — without any relay or network. Live message flow lives in +//! `e2e_ffi_nak_relay.rs`. + +use contextvm_ffi::{ + cvm_client_ch_recv_timeout, cvm_gateway_ch_recv_timeout, cvm_proxy_ch_recv_timeout, + cvm_server_ch_recv_timeout, + error::{ErrorCode, FfiError}, + handle::FfiHandle, + types::*, +}; +use std::ffi::{CStr, CString}; +use std::os::raw::c_char; +use std::ptr; + +// Helper to convert Rust string to C string (returns raw pointer) +fn to_c_str(s: &str) -> *mut c_char { + CString::new(s).unwrap().into_raw() +} + +// Helper to safely get string from C pointer +unsafe fn from_c_str(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + CStr::from_ptr(ptr).to_str().unwrap_or("").to_string() +} + +/// Test that we can generate keys and they have valid format +#[test] +fn test_mock_relay_keys_generation() { + unsafe { + let mut error: *mut FfiError = ptr::null_mut(); + let keys = cvm_keys_generate(&mut error); + assert!(keys.id > 0, "Should generate valid keys handle"); + + let pubkey = cvm_keys_public_key(keys, &mut error); + assert!(!pubkey.is_null(), "Should return public key"); + + let pubkey_str = from_c_str(pubkey); + assert_eq!( + pubkey_str.len(), + 64, + "Public key should be 64 hex characters" + ); + + // Verify it's valid hex + assert!( + pubkey_str.chars().all(|c| c.is_ascii_hexdigit()), + "Public key should be valid hex" + ); + + // Cleanup + cvm_string_free(pubkey); + cvm_keys_free(keys); + } +} + +/// Test server config validation +#[test] +fn test_mock_relay_server_config_validation() { + // Create a server config with valid relay URLs + let relay_urls = vec![ + to_c_str("wss://mock.relay.1"), + to_c_str("wss://mock.relay.2"), + ]; + + let config = FfiServerConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 2, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: ptr::null_mut(), + server_version: ptr::null_mut(), + server_picture: ptr::null_mut(), + server_about: ptr::null_mut(), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 0, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + }; + + // Verify config is correctly structured + assert_eq!(config.relay_url_count, 2); + assert_eq!(config.encryption_mode, ENCRYPTION_MODE_OPTIONAL); + assert_eq!(config.session_timeout_secs, 300); + + // Cleanup + for url in relay_urls { + cvm_string_free(url); + } +} + +/// Test client config validation +#[test] +fn test_mock_relay_client_config_validation() { + let server_pubkey = + to_c_str("000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"); + let relay_urls = vec![to_c_str("wss://mock.relay")]; + + let config = FfiClientConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + server_pubkey, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + }; + + assert_eq!(config.relay_url_count, 1); + assert_eq!(config.timeout_secs, 30); + + // Cleanup + cvm_string_free(server_pubkey); + for url in relay_urls { + cvm_string_free(url); + } +} + +/// Test message structure conversion +#[test] +fn test_mock_relay_message_structure() { + unsafe { + // Test JSON-RPC request message + let id = to_c_str("req-test-123"); + let payload = + to_c_str(r#"{"jsonrpc":"2.0","id":"req-test-123","method":"tools/list","params":{}}"#); + let method = to_c_str("tools/list"); + + let msg = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id, + payload_json: payload, + method, + }; + + assert_eq!(msg.msg_type, JSON_RPC_TYPE_REQUEST); + + let id_str = from_c_str(msg.id); + assert_eq!(id_str, "req-test-123"); + + let payload_str = from_c_str(msg.payload_json); + assert!(payload_str.contains("tools/list")); + assert!(payload_str.contains("jsonrpc")); + + // Cleanup + cvm_message_free(msg); + + // Test JSON-RPC response message + let id2 = to_c_str("42"); + let payload2 = to_c_str(r#"{"jsonrpc":"2.0","id":42,"result":{"tools":[]}}"#); + + let msg2 = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_RESPONSE, + id: id2, + payload_json: payload2, + method: ptr::null_mut(), + }; + + assert_eq!(msg2.msg_type, JSON_RPC_TYPE_RESPONSE); + cvm_message_free(msg2); + + // Test JSON-RPC error response + let id3 = to_c_str("99"); + let payload3 = to_c_str( + r#"{"jsonrpc":"2.0","id":99,"error":{"code":-32600,"message":"Invalid Request"}}"#, + ); + + let msg3 = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_ERROR_RESPONSE, + id: id3, + payload_json: payload3, + method: ptr::null_mut(), + }; + + assert_eq!(msg3.msg_type, JSON_RPC_TYPE_ERROR_RESPONSE); + cvm_message_free(msg3); + + // Test notification (no id) + let payload4 = + to_c_str(r#"{"jsonrpc":"2.0","method":"notifications/initialized","params":{}}"#); + let method4 = to_c_str("notifications/initialized"); + + let msg4 = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_NOTIFICATION, + id: ptr::null_mut(), + payload_json: payload4, + method: method4, + }; + + assert_eq!(msg4.msg_type, JSON_RPC_TYPE_NOTIFICATION); + cvm_message_free(msg4); + } +} + +/// Test incoming request structure +#[test] +fn test_mock_relay_incoming_request_structure() { + unsafe { + let client_pubkey = + to_c_str("aabbccdd00112233aabbccdd00112233aabbccdd00112233aabbccdd00112233"); + let event_id = to_c_str("event123456789"); + + let msg = FfiJsonRpcMessage { + msg_type: JSON_RPC_TYPE_REQUEST, + id: to_c_str("1"), + payload_json: to_c_str(r#"{"jsonrpc":"2.0","id":1,"method":"test"}"#), + method: to_c_str("test"), + }; + + let req = FfiIncomingRequest { + message: msg, + client_pubkey, + event_id, + is_encrypted: true, + }; + + assert!(req.is_encrypted); + + let client_str = from_c_str(req.client_pubkey); + assert_eq!(client_str.len(), 64); + + // Cleanup + cvm_incoming_request_free(req); + } +} + +/// Test error handling with various error codes +#[test] +fn test_mock_relay_error_handling() { + // Test error codes + let error_codes = vec![ + (ErrorCode::Ok as i32, "OK"), + (ErrorCode::Transport as i32, "Transport"), + (ErrorCode::Encryption as i32, "Encryption"), + (ErrorCode::Decryption as i32, "Decryption"), + (ErrorCode::Timeout as i32, "Timeout"), + (ErrorCode::Validation as i32, "Validation"), + (ErrorCode::Unauthorized as i32, "Unauthorized"), + (ErrorCode::Serialization as i32, "Serialization"), + (ErrorCode::Other as i32, "Other"), + ]; + + for (code, name) in error_codes { + println!("Error code {}: {}", code, name); + } + + // Test freeing null error (should not crash) + cvm_error_free(ptr::null_mut()); +} + +/// Test channel handle invalidation (timeout functions with bad handles) +#[test] +fn test_mock_relay_channel_invalid_handles() { + let invalid_handle = FfiHandle { id: 99999 }; + + // Test all channel recv_timeout functions with invalid handle + let mut error: *mut FfiError = ptr::null_mut(); + let mut out_req = std::mem::MaybeUninit::::uninit(); + let mut out_msg = std::mem::MaybeUninit::::uninit(); + + // Server channel + let result = cvm_server_ch_recv_timeout(invalid_handle, 1, out_req.as_mut_ptr(), &mut error); + assert!(!result); + assert!(!error.is_null()); + cvm_error_free(error); + error = ptr::null_mut(); + + // Client channel + let result = cvm_client_ch_recv_timeout(invalid_handle, 1, out_msg.as_mut_ptr(), &mut error); + assert!(!result); + assert!(!error.is_null()); + cvm_error_free(error); + error = ptr::null_mut(); + + // Gateway channel + let result = cvm_gateway_ch_recv_timeout(invalid_handle, 1, out_req.as_mut_ptr(), &mut error); + assert!(!result); + assert!(!error.is_null()); + cvm_error_free(error); + error = ptr::null_mut(); + + // Proxy channel + let result = cvm_proxy_ch_recv_timeout(invalid_handle, 1, out_msg.as_mut_ptr(), &mut error); + assert!(!result); + assert!(!error.is_null()); + cvm_error_free(error); +} + +/// Test peer capabilities structure +#[test] +fn test_mock_relay_peer_capabilities() { + // Create a peer capabilities structure + let caps = FfiPeerCapabilities { + supports_encryption: true, + supports_ephemeral_encryption: true, + supports_oversized_transfer: false, + }; + + assert!(caps.supports_encryption); + assert!(caps.supports_ephemeral_encryption); + assert!(!caps.supports_oversized_transfer); + + // Copy is safe (no pointers) + let caps2 = caps; + assert!(caps2.supports_encryption); +} + +/// Test multiple sequential operations +#[test] +fn test_mock_relay_sequential_operations() { + unsafe { + // Generate multiple keys + let mut keys = Vec::new(); + for _ in 0..5 { + let mut error: *mut FfiError = ptr::null_mut(); + let k = cvm_keys_generate(&mut error); + assert!(k.id > 0); + keys.push(k); + } + + // Verify all handles are unique + let unique_count = keys + .iter() + .map(|k| k.id) + .collect::>() + .len(); + assert_eq!(unique_count, keys.len()); + + // Get pubkeys for all + let mut pubkeys = Vec::new(); + for k in &keys { + let mut error: *mut FfiError = ptr::null_mut(); + let pk = cvm_keys_public_key(*k, &mut error); + assert!(!pk.is_null()); + pubkeys.push(pk); + } + + // Verify all pubkeys are unique + let pubkey_strings: Vec = pubkeys.iter().map(|&pk| from_c_str(pk)).collect(); + let unique_pubkeys = pubkey_strings + .iter() + .collect::>() + .len(); + assert_eq!(unique_pubkeys, pubkey_strings.len()); + + // Cleanup + for pk in pubkeys { + cvm_string_free(pk); + } + for k in keys { + cvm_keys_free(k); + } + } +} + +/// Test discovered tool structure +#[test] +fn test_mock_relay_discovered_tool_structure() { + unsafe { + let provider_pubkey = + to_c_str("0011223300112233001122330011223300112233001122330011223300112233"); + let provider_display_name = to_c_str("Test Provider"); + let tool_name = to_c_str("echo"); + let description = to_c_str("Echo a message back"); + let schema_json = + to_c_str(r#"{"type":"object","properties":{"message":{"type":"string"}}}"#); + + let tool = FfiDiscoveredTool { + provider_pubkey, + provider_display_name, + provider_name: ptr::null_mut(), + provider_about: ptr::null_mut(), + provider_picture: ptr::null_mut(), + provider_nip05: ptr::null_mut(), + tool_name, + description, + schema_json, + }; + + let name_str = from_c_str(tool.tool_name); + assert_eq!(name_str, "echo"); + + let desc_str = from_c_str(tool.description); + assert_eq!(desc_str, "Echo a message back"); + + // Cleanup - manually free strings (don't use cvm_discovered_tools_free on stack data) + cvm_string_free(tool.provider_pubkey); + cvm_string_free(tool.provider_display_name); + cvm_string_free(tool.tool_name); + cvm_string_free(tool.description); + cvm_string_free(tool.schema_json); + } +} + +/// Test server announcement structure +#[test] +fn test_mock_relay_server_announcement_structure() { + unsafe { + let pubkey = to_c_str("0011223300112233001122330011223300112233001122330011223300112233"); + let name = to_c_str("Test Server"); + let version = to_c_str("1.0.0"); + let event_id = to_c_str("event12345"); + + let announcement = FfiServerAnnouncement { + pubkey, + name, + version, + picture: ptr::null_mut(), + about: ptr::null_mut(), + website: ptr::null_mut(), + event_id, + }; + + let name_str = from_c_str(announcement.name); + assert_eq!(name_str, "Test Server"); + + let version_str = from_c_str(announcement.version); + assert_eq!(version_str, "1.0.0"); + + // Cleanup - manually free strings + cvm_string_free(announcement.pubkey); + cvm_string_free(announcement.name); + cvm_string_free(announcement.version); + cvm_string_free(announcement.event_id); + } +} + +/// Test provider profile structure +#[test] +fn test_mock_relay_provider_profile_structure() { + unsafe { + let pubkey = to_c_str("0011223300112233001122330011223300112233001122330011223300112233"); + let name = to_c_str("Test Provider"); + let about = to_c_str("A test provider for ContextVM"); + + let profile = FfiProviderProfile { + pubkey, + name, + about, + picture: ptr::null_mut(), + nip05: ptr::null_mut(), + }; + + let name_str = from_c_str(profile.name); + assert_eq!(name_str, "Test Provider"); + + let about_str = from_c_str(profile.about); + assert_eq!(about_str, "A test provider for ContextVM"); + + // Cleanup - manually free strings + cvm_string_free(profile.pubkey); + cvm_string_free(profile.name); + cvm_string_free(profile.about); + } +} diff --git a/contextvm-ffi/tests/e2e_ffi_nak_relay.rs b/contextvm-ffi/tests/e2e_ffi_nak_relay.rs new file mode 100644 index 0000000..e6bd0e3 --- /dev/null +++ b/contextvm-ffi/tests/e2e_ffi_nak_relay.rs @@ -0,0 +1,496 @@ +//! End-to-end tests driven by a real `nak serve` relay. +//! +//! These tests spawn the `nak` CLI (https://github.com/nbd-wtf/nostr-cli) to run +//! an actual in-process relay and exercise real message flow through the FFI +//! bindings. They are gated behind the `nak-tests` feature and additionally +//! no-op when the `nak` binary is not on PATH, so they are safe under +//! `cargo test --all --all-features` without `nak` installed. + +use contextvm_ffi::{ + cvm_client_ch_close, cvm_client_ch_new, cvm_client_ch_recv_timeout, cvm_client_ch_send, + cvm_keys_free, cvm_keys_generate, cvm_keys_public_key, cvm_server_ch_announce, + cvm_server_ch_close, cvm_server_ch_new, cvm_server_ch_recv_timeout, cvm_string_free, + error::FfiError, types::*, +}; +use std::ffi::{CStr, CString}; +use std::net::TcpStream; +use std::os::raw::c_char; +use std::process::{Child, Command, Stdio}; +use std::ptr; +use std::thread; +use std::time::Duration; + +// Helper to convert Rust string to C string +fn to_c_str(s: &str) -> *mut c_char { + CString::new(s).unwrap().into_raw() +} + +// Helper to safely get string from C pointer +unsafe fn from_c_str(ptr: *const c_char) -> String { + if ptr.is_null() { + return String::new(); + } + CStr::from_ptr(ptr).to_str().unwrap_or("").to_string() +} + +/// Manages a nak relay process for testing +struct NakRelay { + process: Child, + url: String, +} + +/// Returns true if the `nak` binary is available on PATH. +/// +/// The nak-tests target is feature-gated, but CI runs `cargo test --all-features`, +/// which enables it. To stay green without `nak` installed, tests call this and +/// no-op when the binary is absent. +fn nak_available() -> bool { + Command::new("nak") + .arg("--version") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .is_ok() +} + +/// Skip helper for tests that need `nak`. Prints a note and returns true if the +/// test should be skipped (binary absent), false if it should proceed. +fn skip_if_no_nak() -> bool { + if !nak_available() { + eprintln!( + "nak binary not found on PATH; skipping nak e2e test \ + (enable `nak-tests` and install nak to run it)" + ); + return true; + } + false +} + +impl NakRelay { + /// Start a nak relay on a random available port. + /// + /// Panics with an actionable message if `nak` cannot be launched. + fn start() -> Self { + // Try ports in a range to find an available one + for port in 33333..33400 { + let url = format!("ws://127.0.0.1:{}", port); + + // Start nak serve + let child = Command::new("nak") + .args([ + "serve", + "--hostname", + "127.0.0.1", + "--port", + &port.to_string(), + ]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .spawn(); + + match child { + Ok(mut process) => { + // Probe the listener with a short TCP connect retry loop so + // we only proceed once nak is actually accepting sockets. + if Self::wait_for_relay(&url, Duration::from_secs(5)) { + return NakRelay { process, url }; + } else { + // Relay didn't come up on this port; clean up and try next. + let _ = process.kill(); + let _ = process.wait(); + continue; + } + } + Err(e) => { + // Most likely `nak` is not installed at all — surface that + // clearly rather than masking it as a port conflict. + if e.kind() == std::io::ErrorKind::NotFound { + panic!( + "`nak` binary not found on PATH; install it \ + (https://github.com/nbd-wtf/nostr-cli) or run without \ + the `nak-tests` feature" + ); + } + continue; // Transient launch failure; try next port. + } + } + } + + panic!("Could not start nak relay on any port in 33333..33400"); + } + + /// Poll the relay address with a TCP connect until it succeeds or times out. + fn wait_for_relay(url: &str, deadline: Duration) -> bool { + let addr = url.trim_start_matches("ws://"); + let start = std::time::Instant::now(); + while start.elapsed() < deadline { + if TcpStream::connect_timeout( + &addr + .parse() + .unwrap_or_else(|_| "127.0.0.1:0".parse().unwrap()), + Duration::from_millis(200), + ) + .is_ok() + { + return true; + } + thread::sleep(Duration::from_millis(100)); + } + false + } + + fn url(&self) -> &str { + &self.url + } +} + +impl Drop for NakRelay { + fn drop(&mut self) { + // Kill the nak process + let _ = self.process.kill(); + let _ = self.process.wait(); + } +} + +/// Test that nak relay can be started and stopped +#[test] +fn test_nak_relay_lifecycle() { + if skip_if_no_nak() { + return; + } + let relay = NakRelay::start(); + println!("Started nak relay at: {}", relay.url()); + // Relay will be stopped when dropped +} + +/// Test server channel creation with nak relay +#[test] +fn test_nak_server_channel_creation() { + if skip_if_no_nak() { + return; + } + let relay = NakRelay::start(); + let relay_url = relay.url(); + + unsafe { + let mut error: *mut FfiError = ptr::null_mut(); + + // Generate server keys + let server_keys = cvm_keys_generate(&mut error); + assert!(server_keys.id > 0, "Should generate server keys"); + + let server_pubkey = cvm_keys_public_key(server_keys, &mut error); + assert!(!server_pubkey.is_null()); + println!("Server pubkey: {}", from_c_str(server_pubkey)); + cvm_string_free(server_pubkey); + + // Create server config with nak relay + let relay_urls = vec![to_c_str(relay_url)]; + + let config = FfiServerConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: to_c_str("Test Server"), + server_version: to_c_str("1.0.0"), + server_picture: ptr::null_mut(), + server_about: to_c_str("Test server for nak E2E"), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 30, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + }; + + // Create server channel + let server_handle = cvm_server_ch_new(server_keys, config, &mut error); + + // Note: server_ch_new might fail if relay is not ready, that's OK for this test + if server_handle.id > 0 { + println!( + "Server channel created successfully: handle {}", + server_handle.id + ); + + // Try to announce + let announce_result = cvm_server_ch_announce(server_handle, &mut error); + println!("Server announce result: {}", announce_result); + + // Close the server channel + let close_result = cvm_server_ch_close(server_handle, &mut error); + assert!(close_result, "Should close server channel"); + } else { + println!("Server channel creation returned invalid handle (relay might not be ready)"); + } + + // Cleanup + for url in relay_urls { + cvm_string_free(url); + } + cvm_keys_free(server_keys); + } +} + +/// Test client channel creation with nak relay +#[test] +fn test_nak_client_channel_creation() { + if skip_if_no_nak() { + return; + } + let relay = NakRelay::start(); + let relay_url = relay.url(); + + let mut error: *mut FfiError = ptr::null_mut(); + + // Generate client keys + let client_keys = cvm_keys_generate(&mut error); + assert!(client_keys.id > 0, "Should generate client keys"); + + // Generate a mock server pubkey + let server_pubkey_str = "000102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f"; + + // Create client config + let relay_urls = vec![to_c_str(relay_url)]; + let server_pubkey = to_c_str(server_pubkey_str); + + let config = FfiClientConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + server_pubkey, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + }; + + // Create client channel + let client_handle = cvm_client_ch_new(client_keys, config, &mut error); + + if client_handle.id > 0 { + println!( + "Client channel created successfully: handle {}", + client_handle.id + ); + + // Try to send a message + let payload = to_c_str(r#"{"jsonrpc":"2.0","id":1,"method":"initialize","params":{}}"#); + let send_result = cvm_client_ch_send(client_handle, payload, &mut error); + println!("Client send result: {}", send_result); + cvm_string_free(payload); + + // Try to receive with short timeout + let mut out_msg = std::mem::MaybeUninit::::uninit(); + let recv_result = + cvm_client_ch_recv_timeout(client_handle, 2, out_msg.as_mut_ptr(), &mut error); + println!( + "Client recv result: {} (expected false - no server)", + recv_result + ); + + // Close the client channel + let close_result = cvm_client_ch_close(client_handle, &mut error); + assert!(close_result, "Should close client channel"); + } else { + println!("Client channel creation returned invalid handle"); + } + + // Cleanup + for url in relay_urls { + cvm_string_free(url); + } + cvm_string_free(server_pubkey); + cvm_keys_free(client_keys); +} + +/// Test full server-client message flow through nak relay +#[test] +fn test_nak_server_client_message_flow() { + if skip_if_no_nak() { + return; + } + let relay = NakRelay::start(); + let relay_url = relay.url(); + + unsafe { + let mut error: *mut FfiError = ptr::null_mut(); + + // Generate keys for both sides + let server_keys = cvm_keys_generate(&mut error); + let client_keys = cvm_keys_generate(&mut error); + + assert!(server_keys.id > 0, "Should generate server keys"); + assert!(client_keys.id > 0, "Should generate client keys"); + + let server_pubkey = cvm_keys_public_key(server_keys, &mut error); + let server_pubkey_str = from_c_str(server_pubkey); + println!("Server pubkey: {}", server_pubkey_str); + + // Create server config + let relay_urls = vec![to_c_str(relay_url)]; + let server_config = FfiServerConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: true, + server_name: to_c_str("E2E Test Server"), + server_version: to_c_str("1.0.0"), + server_picture: ptr::null_mut(), + server_about: to_c_str("Server for E2E test"), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 30, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + }; + + // Create client config + let server_pubkey_c = to_c_str(&server_pubkey_str); + let client_config = FfiClientConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + server_pubkey: server_pubkey_c, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_stateless: false, + timeout_secs: 30, + discovery_relay_urls: ptr::null_mut(), + discovery_relay_url_count: 0, + fallback_operational_relay_urls: ptr::null_mut(), + fallback_operational_relay_url_count: 0, + }; + + // Create channels + let server_handle = cvm_server_ch_new(server_keys, server_config, &mut error); + let client_handle = cvm_client_ch_new(client_keys, client_config, &mut error); + + println!( + "Server handle: {}, Client handle: {}", + server_handle.id, client_handle.id + ); + + if server_handle.id > 0 && client_handle.id > 0 { + // Announce server + let announce_result = cvm_server_ch_announce(server_handle, &mut error); + println!("Server announce: {}", announce_result); + + // Give time for announcement + thread::sleep(Duration::from_millis(1000)); + + // Try server recv (might get initialization request) + let mut out_req = std::mem::MaybeUninit::::uninit(); + let recv_result = + cvm_server_ch_recv_timeout(server_handle, 2, out_req.as_mut_ptr(), &mut error); + println!("Server recv: {}", recv_result); + + if recv_result { + // We got a request, could send response + println!("Server received request!"); + } + + // Close channels + let _ = cvm_server_ch_close(server_handle, &mut error); + let _ = cvm_client_ch_close(client_handle, &mut error); + } + + // Cleanup + for url in relay_urls { + cvm_string_free(url); + } + cvm_string_free(server_pubkey); + cvm_string_free(server_pubkey_c); + cvm_keys_free(server_keys); + cvm_keys_free(client_keys); + } +} + +/// Test multiple concurrent connections to nak relay +#[test] +fn test_nak_multiple_connections() { + if skip_if_no_nak() { + return; + } + let relay = NakRelay::start(); + let relay_url = relay.url(); + + let mut error: *mut FfiError = ptr::null_mut(); + + // Create multiple server channels + let mut handles = Vec::new(); + + for i in 0..3 { + let keys = cvm_keys_generate(&mut error); + assert!(keys.id > 0); + + let relay_urls = vec![to_c_str(relay_url)]; + let config = FfiServerConfig { + relay_urls: relay_urls.as_ptr() as *mut *mut c_char, + relay_url_count: 1, + encryption_mode: ENCRYPTION_MODE_OPTIONAL, + gift_wrap_mode: GIFT_WRAP_MODE_OPTIONAL, + is_announced_server: false, + server_name: to_c_str(&format!("Test Server {}", i)), + server_version: to_c_str("1.0.0"), + server_picture: ptr::null_mut(), + server_about: ptr::null_mut(), + server_website: ptr::null_mut(), + allowed_pubkeys: ptr::null_mut(), + allowed_pubkey_count: 0, + session_timeout_secs: 300, + cleanup_interval_secs: 60, + excluded_capabilities: ptr::null_mut(), + excluded_capability_count: 0, + max_sessions: 0, + request_timeout_secs: 30, + relay_list_urls: ptr::null_mut(), + relay_list_url_count: 0, + bootstrap_relay_urls: ptr::null_mut(), + bootstrap_relay_url_count: 0, + publish_relay_list: false, + profile_metadata_json: ptr::null_mut(), + }; + + let handle = cvm_server_ch_new(keys, config, &mut error); + if handle.id > 0 { + handles.push((handle, keys, relay_urls)); + println!("Created server {}: handle {}", i, handle.id); + } + } + + // Close all channels + for (handle, keys, urls) in handles { + let _ = cvm_server_ch_close(handle, &mut error); + cvm_keys_free(keys); + for url in urls { + cvm_string_free(url); + } + } + + println!("Successfully tested {} concurrent server connections", 3); +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..6f4439d --- /dev/null +++ b/docs/README.md @@ -0,0 +1,44 @@ +# Rust SDK Docs + +This directory contains the in-repo Rust SDK documentation for `contextvm-sdk`. + +## Start here + +The main mental model is: + +1. build an `rmcp` server or client +2. attach a ContextVM transport +3. run MCP over Nostr + +For most native Rust applications, the primary entry points are `NostrServerTransport` and `NostrClientTransport`, used together with `rmcp` services via `ServiceExt`. + +## Guides + +### Native ContextVM applications + +- Overview: architecture, API selection, and protocol model +- Native server guide: server setup over Nostr +- Native client guide: client setup over Nostr +- Encryption guide: plaintext, encrypted, and gift-wrap behavior +- Stateless mode guide: client-side initialize emulation and when to use it +- Discovery guide: public discovery helpers and event kinds +- Oversized transfer guide: CEP-22 fragmentation, the three-timer model, and progress-aware request options + +### Bridging existing MCP applications + +- Gateway guide: expose an existing server-side MCP flow over ContextVM +- Proxy guide: connect to a remote ContextVM server with a lighter client bridge + +### Integration notes + +- RMCP integration guide: how the optional `rmcp` integration layer fits in +- Transport guide: lower-level transport behavior and direct usage +- Transport modes guide: encryption mode, gift-wrap mode, and stateless mode as one reference + +## Documentation goals + +The docs here are concise and implementation-driven. + +They are derived from the public crate APIs, the `rmcp` service APIs, the repository examples, and the transport and conformance tests in this repository. + +They are intended to remain usable on their own, without depending on external documentation pages. diff --git a/docs/client-transport.md b/docs/client-transport.md new file mode 100644 index 0000000..4fa01bd --- /dev/null +++ b/docs/client-transport.md @@ -0,0 +1,171 @@ +# Native Client Guide + +Use this path when you are building a native ContextVM client in Rust. + +The recommended architecture is: + +- define an `rmcp` client handler or use a lightweight client info object +- create a `NostrClientTransport` +- attach the transport with `rmcp`'s `ServiceExt` + +This follows the same pattern as the standard `rmcp` client examples, except the transport is ContextVM over Nostr instead of HTTP. + +## The high-level shape + +In `rmcp`, a client is typically started with `client_info.serve(transport)`. + +For ContextVM, the transport becomes `NostrClientTransport`. In the current SDK API, you pass that transport directly to `ServiceExt`; there is no extra adapter step in the public workflow. + +## Loading an existing private key + +The signer helper is not limited to ephemeral keys. If you already have a private key, load it with `from_sk()`. + +It accepts either: + +- a 64-character hex secret key +- an `nsec` bech32 secret key + +```rust +use contextvm_sdk::signer; + +let signer = signer::from_sk("")?; +println!("client pubkey: {}", signer.public_key().to_hex()); +``` + +Use `generate()` only when you explicitly want a new random identity for a short-lived client or test flow. + +## Example + +```rust +use anyhow::Context; +use contextvm_sdk::transport::client::{ + NostrClientTransport, NostrClientTransportConfig, +}; +use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode}; +use rmcp::{ + model::{CallToolRequestParams, CallToolResult}, + ClientHandler, ServiceExt, +}; + +const RELAY_URL: &str = "wss://relay.contextvm.org"; + +#[derive(Clone, Default)] +struct DemoClient; + +impl ClientHandler for DemoClient {} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let server_pubkey = std::env::args() + .nth(1) + .context("Usage: native_echo_client ")?; + + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("contextvm_sdk=info".parse()?) + .add_directive("rmcp=warn".parse()?), + ) + .init(); + + let signer = signer::generate(); + + println!("Native ContextVM echo client starting"); + println!("Relay: {RELAY_URL}"); + println!("Client pubkey: {}", signer.public_key().to_hex()); + println!("Target server pubkey: {server_pubkey}"); + + let transport = NostrClientTransport::new( + signer, + NostrClientTransportConfig::default() + .with_relay_urls(vec![RELAY_URL.to_string()]) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional), + ) + .await?; + + let client = DemoClient.serve(transport).await?; + + let peer_info = client + .peer_info() + .expect("server did not provide peer info after initialize"); + println!("Connected to: {:?}", peer_info.server_info.name); + + let tools = client.list_all_tools().await?; + println!("Discovered {} tool(s):", tools.len()); + for tool in &tools { + println!("- {}", tool.name); + } + + let result = client + .call_tool(CallToolRequestParams { + name: "echo".into(), + arguments: serde_json::from_value(serde_json::json!({ + "message": "hello from native contextvm client" + })) + .ok(), + meta: None, + task: None, + }) + .await?; + + println!("Echo result: {}", first_text(&result)); + client.cancel().await?; + Ok(()) +} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|content| { + if let rmcp::model::RawContent::Text(text) = &content.raw { + Some(text.text.clone()) + } else { + None + } + }) + .unwrap_or_default() +} +``` + +This is the ContextVM equivalent of the usual `rmcp` client workflow, but using `NostrClientTransport` directly. + +## What the transport adds + +`NostrClientTransport` adds ContextVM-specific client behavior on top of `rmcp` client semantics: + +- relay connection management via `NostrClientTransport::new()` +- target server selection through `server_pubkey` in `NostrClientTransportConfig` +- request and response correlation via `send()` +- server capability learning from discovery tags +- optional stateless behavior via `is_stateless` in `NostrClientTransportConfig` +- encrypted message reception and gift-wrap deduplication during notification handling + +## Configuration fields that matter first + +Start with these fields in `NostrClientTransportConfig`: + +- `relay_urls`: relays the client uses to reach the server (empty = use relay resolution) +- `server_pubkey`: the target server's public key (hex, npub, or nprofile with relay hints) +- `encryption_mode`: whether plaintext is allowed +- `gift_wrap_mode`: whether to use persistent or ephemeral wrapping +- `is_stateless`: whether initialize is emulated locally for stateless workflows +- `timeout`: how long request correlation waits for a response +- `discovery_relay_urls`: bootstrap relays for CEP-17 kind 10002 relay-list discovery (defaults to `DEFAULT_BOOTSTRAP_RELAY_URLS`) +- `fallback_operational_relay_urls`: non-authoritative relays probed in parallel with CEP-17 discovery + +## When to use this instead of the proxy + +Use this page's approach when you are writing a new Rust MCP client that should speak ContextVM natively. + +Use the proxy guide when you want a simpler message-oriented bridge and do not want the full `rmcp` running client model. + +## Behavioral notes + +- The client-side `rmcp` handshake is driven by `ServiceExt::serve()` on the client handler. +- The initialize request is sent automatically as part of the running client startup sequence. +- Stateless initialization behavior is covered by the conformance tests. +- Capability learning and gift-wrap handling happen inside the client transport implementation. +- When `relay_urls` is empty, `start()` runs 6-stage relay resolution before connecting: configured relays > nprofile hints > CEP-17 kind 10002 discovery > fallback probing > bootstrap defaults. Callers can set `server_pubkey` to an nprofile and omit `relay_urls` entirely. diff --git a/docs/discovery.md b/docs/discovery.md new file mode 100644 index 0000000..a6887f8 --- /dev/null +++ b/docs/discovery.md @@ -0,0 +1,86 @@ +# Discovery Guide + +The Rust SDK exposes public discovery helpers for finding announced servers and their published capabilities. + +These functions query public announcement events so clients can find servers before opening a direct session. + +## What is discoverable + +The current implementation supports: + +- servers via `discover_servers()` +- tools via `discover_tools()` +- resources via `discover_resources()` +- prompts via `discover_prompts()` +- resource templates via `discover_resource_templates()` + +When the `rmcp` feature is enabled, typed variants are also available for tools, resources, prompts, and resource templates. + +## Minimal example + +This follows the repository discovery example. + +```rust +use contextvm_sdk::discovery; +use contextvm_sdk::relay::RelayPool; +use contextvm_sdk::signer; + +#[tokio::main] +async fn main() -> contextvm_sdk::Result<()> { + let keys = signer::generate(); + let relays = vec!["wss://relay.damus.io".to_string()]; + + let relay_pool = RelayPool::new(keys).await?; + relay_pool.connect(&relays).await?; + let client = relay_pool.client(); + + let servers = discovery::discover_servers(client, &relays).await?; + for server in &servers { + println!("server: {:?}", server.server_info.name); + + let tools = discovery::discover_tools(client, &server.pubkey_parsed, &relays).await?; + println!("tools: {}", tools.len()); + } + + relay_pool.disconnect().await?; + Ok(()) +} +``` + +## Discovery event model + +The event kinds follow the public announcement model summarized in the repository root README: + +- `11316`: server announcement +- `11317`: tools list +- `11318`: resources list +- `11319`: resource templates +- `11320`: prompts list + +These event kinds are the SDK's public discovery model for server metadata and advertised MCP capabilities. + +## CEP-17 automatic relay resolution + +Clients can discover which relays a server uses without hardcoding relay URLs. Set `server_pubkey` to an nprofile (which embeds relay hints) and leave `relay_urls` empty: + +```rust +NostrClientTransportConfig::default() + .with_server_pubkey("nprofile1...") // contains pubkey + relay hints +``` + +When `start()` is called with empty `relay_urls`, the transport runs a 6-stage resolution pipeline: + +1. **Configured relays** -- if `relay_urls` is non-empty, use them directly +2. **nprofile hints** -- relay URLs embedded in the nprofile identity +3. **CEP-17 discovery** -- fetch kind 10002 relay-list events from bootstrap relays +4. **Fallback probing** -- probe `fallback_operational_relay_urls` in parallel with discovery +5. **Sequential fallback** -- if the race winner returned empty, await the other +6. **Bootstrap default** -- fall back to `DEFAULT_BOOTSTRAP_RELAY_URLS` as last resort + +The `discovery_relay_urls` and `fallback_operational_relay_urls` config fields customize stages 3-4. + +## Important limitations + +- discovery is public metadata, not a replacement for direct transport negotiation +- the current helpers fetch and parse latest public lists, but they do not replace direct session learning +- direct session learning still matters for encryption preferences, gift-wrap support, and first-message capability hints on an active connection diff --git a/docs/encryption.md b/docs/encryption.md new file mode 100644 index 0000000..9abbb61 --- /dev/null +++ b/docs/encryption.md @@ -0,0 +1,76 @@ +# Encryption Guide + +ContextVM encryption in this SDK is controlled by `EncryptionMode` and `GiftWrapMode`. + +At a high level, direct traffic can be sent as plaintext ContextVM events or as encrypted NIP-44 payloads wrapped in gift-wrap events, depending on the configured policy on both peers. + +## Encryption modes + +`EncryptionMode` has three modes: + +- `Optional`: accept both plaintext and encrypted traffic +- `Required`: require encrypted traffic +- `Disabled`: reject encrypted traffic and use plaintext only + +These semantics are not only conceptual; they are also exercised by the transport integration tests. + +## Gift-wrap modes + +`GiftWrapMode` controls which outer encrypted event kind is used: + +- `Optional`: accept both modes and default to persistent wrapping until ephemeral support is learned from the peer +- `Ephemeral`: use kind `21059` +- `Persistent`: use kind `1059` + +The helper methods `allows_kind()` and `supports_ephemeral()` show the expected policy behavior. + +## Practical rules + +### Plaintext transport + +Plaintext ContextVM messages use kind `25910` and keep the MCP JSON-RPC payload in the event content. + +### Encrypted transport + +Encrypted ContextVM messages: + +1. serialize the MCP payload to JSON +2. encrypt it with NIP-44 +3. wrap it as a gift-wrap event using kind `1059` or `21059` + +The implementation details live in the SDK encryption module. + +## Response mirroring + +One important implementation detail is that server responses mirror the client’s inbound encryption format when policy allows it. + +This behavior is verified by the transport integration tests and is important for interoperable mixed-mode deployments. + +## Deduplication + +Both client and server transports deduplicate encrypted outer gift-wrap event ids before delivering them. + +This is covered by the deduplication and encrypted transport integration tests. + +## Example configuration + +```rust +use contextvm_sdk::core::types::{EncryptionMode, GiftWrapMode}; +use contextvm_sdk::transport::client::NostrClientTransportConfig; + +let config = NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://relay.damus.io".to_string()]) + .with_server_pubkey("") + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional); +``` + +## Discovery tags + +Encryption support is also surfaced through discovery tags and first-message capability learning. + +In practice, this matters for: + +- public announcements +- the first direct server-to-client message +- stateless operation diff --git a/docs/gateway.md b/docs/gateway.md new file mode 100644 index 0000000..73cf2dc --- /dev/null +++ b/docs/gateway.md @@ -0,0 +1,131 @@ +# Gateway Guide + +`NostrMCPGateway` is the simplest way to expose an MCP server through ContextVM. + +It wraps `NostrServerTransport`, receives incoming ContextVM requests from Nostr, and lets your application send responses back using the inbound event id. + +For native Rust applications, this is usually not the primary path. Most users should build an `rmcp` server and attach `NostrServerTransport` directly, as described in the native server guide. + +## When to use it + +Use the gateway when: + +- you already have MCP request handling logic +- you want a straightforward server loop +- you want optional public announcements without building directly on the transport layer + +Do not start here if you are writing a new native Rust MCP server from scratch. + +## Minimal example + +This follows the shape of the repository gateway example. + +```rust +use contextvm_sdk::core::types::{ + JsonRpcError, JsonRpcErrorResponse, JsonRpcMessage, JsonRpcResponse, ServerInfo, +}; +use contextvm_sdk::gateway::{GatewayConfig, NostrMCPGateway}; +use contextvm_sdk::signer; +use contextvm_sdk::transport::server::NostrServerTransportConfig; + +#[tokio::main] +async fn main() -> contextvm_sdk::Result<()> { + let keys = signer::generate(); + + let nostr_config = NostrServerTransportConfig::default() + .with_relay_urls(vec!["wss://relay.damus.io".to_string()]) + .with_server_info( + ServerInfo::default() + .with_name("Echo Server".to_string()) + .with_about("A simple ContextVM server".to_string()), + ) + .with_announced_server(true); + + let config = GatewayConfig::new(nostr_config); + + let mut gateway = NostrMCPGateway::new(keys, config).await?; + let mut rx = gateway.start().await?; + gateway.announce().await?; + + while let Some(req) = rx.recv().await { + let response = match &req.message { + JsonRpcMessage::Request(request) if request.method == "tools/list" => { + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + result: serde_json::json!({ + "tools": [{ + "name": "echo", + "description": "Echo a message", + "inputSchema": { + "type": "object", + "properties": { + "message": { "type": "string" } + }, + "required": ["message"] + } + }] + }), + }) + } + JsonRpcMessage::Request(request) => JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: request.id.clone(), + error: JsonRpcError { + code: -32601, + message: "Method not found".to_string(), + data: None, + }, + }), + _ => continue, + }; + + gateway.send_response(&req.event_id, response).await?; + } + + Ok(()) +} +``` + +## What the gateway gives you + +- a message channel of `IncomingRequest` +- automatic routing of responses by original Nostr event id through `send_response()` +- optional public announcements through `announce()` + +## When not to use it + +Prefer the native server transport path when: + +- your application is already modeled as an `rmcp` `ServerHandler` +- you want the normal `rmcp` running service lifecycle through `ServiceExt` +- you want docs and examples that match the broader `rmcp` ecosystem + +## Important server config + +The main operational knobs live on `NostrServerTransportConfig`: + +- `relay_urls`: relays to connect to +- `encryption_mode`: plaintext vs encrypted session policy +- `gift_wrap_mode`: choose between persistent and ephemeral gift wraps +- `server_info`: metadata used in public announcements +- `is_announced_server`: auto-publish announcements on start (CEP-6) +- `allowed_public_keys`: static client allowlist +- `excluded_capabilities`: allow public access to specific methods or capability names +- `max_sessions`, `cleanup_interval`, `session_timeout`: server-side session lifecycle +- `relay_list_urls`: relay URLs advertised in kind 10002 (CEP-17); defaults to `relay_urls` +- `bootstrap_relay_urls`: additional relays for publishing announcements (CEP-6/17) +- `publish_relay_list`: whether to publish kind 10002 relay list metadata; default `true` +- `profile_metadata`: optional profile metadata for kind 0 publication (CEP-23) + +## Behavioral notes + +- responses are routed using the inbound request event id, not just the JSON-RPC id +- for announced servers, public metadata publication is part of the supported flow +- authorization and allowlist bypass behavior are also exercised by the integration tests + +## rmcp path + +If your server already uses `rmcp`, the gateway also exposes the associated function `NostrMCPGateway::serve_handler()` so you can attach a handler directly without manually running the request loop. + +That said, the preferred native architecture is still `rmcp` service first and ContextVM transport second. diff --git a/docs/oversized-transfer.md b/docs/oversized-transfer.md new file mode 100644 index 0000000..9a35362 --- /dev/null +++ b/docs/oversized-transfer.md @@ -0,0 +1,128 @@ +# Oversized Transfer Guide (CEP-22) + +Nostr relays cap event sizes (commonly around 64 KiB on the wire, with a +65,535-byte NIP-44 plaintext ceiling for encrypted payloads), while MCP +results — file contents, tool output, resource reads — routinely exceed that. +CEP-22 closes the gap: a JSON-RPC message whose published form would not fit +in a single event is split into an ordered sequence of frames carried inside +MCP `notifications/progress` events, then reassembled and validated +(byte-length + SHA-256) by the receiver before it surfaces as one ordinary +message. Fragmentation is transparent to MCP consumers in both directions: +client→server requests and server→client responses. + +## Enabled by default + +Oversized transfer is **enabled by default** (matching the TypeScript SDK). +The negotiation gates make this safe with peers that don't support it: + +- the **client** fragments only requests that carry a `progressToken` (rmcp + stamps one into every outgoing request), and advertises + `support_oversized_transfer` on its first message; +- the **server** fragments responses only for clients that advertised the + capability, and advertises it on announcements and its first response. + +A peer without CEP-22 support just sees one extra discovery tag. To opt out: + +```rust +// Whole-config form: +let config = NostrClientTransportConfig::default() + .with_oversized_enabled(false); +// Same builder exists on NostrServerTransportConfig. +``` + +## Configuration + +`OversizedTransferConfig` is attached to both transport configs via +`with_oversized_transfer(..)` (or the `with_oversized_enabled(..)` shorthand): + +| Field | Default | Description | +|----------------------------|--------------|-----------------------------------------------------------------------------| +| `enabled` | `true` | Master gate: advertise + activate the capability | +| `threshold` | `48_000` | Published byte size at/above which the sender fragments | +| `chunk_size` | `48_000` | Upper bound on per-chunk payload bytes (shrunk automatically so every published frame stays under `threshold`) | +| `max_transfer_bytes` | `104_857_600` (100 MiB) | Receiver cap on a reassembled payload | +| `max_transfer_chunks` | `10_000` | Receiver cap on chunk count | +| `max_concurrent_transfers` | `64` | Receiver cap on simultaneously active transfers | +| `transfer_timeout_ms` | `300_000` | Receiver-side hard deadline per transfer, from admission; `0` disables the watchdog | +| `max_out_of_order_window` | `21` | How far ahead of the contiguous frontier a chunk may arrive and still be buffered | +| `max_out_of_order_chunks` | `42` | Cap on buffered out-of-order chunks | +| `accept_timeout_ms` | `30_000` | How long an uploading client waits for the server's `accept` handshake | + +The decision to fragment is made on the **final published event size** +(signed, JSON-escaped, and gift-wrapped when encryption is on), so `threshold` +is a real wire budget, not a payload-length heuristic. + +## The three timers + +Three independent timers govern a transfer; knowing who owns each one makes +timeout behavior predictable: + +1. **Requester idle timeout** (rmcp, per request — opt-in). Fails the call if + no progress arrives for `idle`. The transports forward every inbound + transfer frame to the requester as a plain progress notification, so a + *live* transfer resets this timer chunk by chunk while a *stalled* one + fails after `idle`. +2. **Requester max-total timeout** (rmcp, per request — opt-in). Hard cap on + the whole call regardless of progress — a trickling transfer cannot hold a + request open forever. +3. **Receiver watchdog** (`transfer_timeout_ms`, transport-owned). A hard + memory bound on inbound reassembly state, measured from `start` admission + and never refreshed by activity. Reaping is local-only — no abort frame is + emitted; the requester's own timers fail the other side. A reaped token is + re-admittable by a fresh `start`. + +The first two exist only when you pass request options — **a plain rmcp +`call_tool` has no timeout at all** (infinite await). That is the main +consumer footgun this SDK papers over: + +## Recommended MCP client usage + +```rust +use std::time::Duration; +use contextvm_sdk::{progress_aware_options, PeerRequestOptionsExt}; + +let result = running_client + .peer() + .call_tool_with_options( + params, + progress_aware_options(Duration::from_secs(60), Duration::from_secs(300)), + ) + .await?; +``` + +`progress_aware_options(idle, max_total)` builds +`PeerRequestOptions::with_timeout(idle).reset_timeout_on_progress().with_max_total_timeout(max_total)`. +The defaults `DEFAULT_OVERSIZED_IDLE_TIMEOUT` (60 s) and +`DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT` (300 s) intentionally mirror the +transport's numbers (60 s covers the worst-case accept wait; 300 s matches the +receiver watchdog so both sides give up in the same window) — but the peer +layer cannot read transport config, so re-align them manually if you tune +`OversizedTransferConfig`. + +Unlike the TypeScript SDK's low-level `client.request()` path, rmcp's timer +reset is not tied to registering an `onprogress` callback — the reset is wired +through request options alone. + +For request types beyond `call_tool`, the generic form is two lines on public +rmcp API: + +```rust +let handle = peer.send_cancellable_request(request, options).await?; +let response = handle.await_response().await?; +``` + +### Upload (client→server) caveat + +A fragmented *request* receives at most **one** inbound reset: the server's +`accept` handshake frame — and it reaches the rmcp service loop only after the +whole upload send returns. Size `idle` and `max_total` to cover the full +upload duration, not just inter-frame gaps. + +## Synthetic progress notifications + +Because transfer frames are forwarded to the requester (stripped of their +`cvm` payload, with the request's original `progressToken` restored), +consumers with a custom progress handler observe chunk-granular progress for +oversized responses — usable as transfer-progress UX. Default rmcp handlers +ignore progress for tokens they didn't register, so no action is needed if you +don't want it. Nothing extra goes on the wire; the forwarding is in-process. diff --git a/docs/overview.md b/docs/overview.md new file mode 100644 index 0000000..add9879 --- /dev/null +++ b/docs/overview.md @@ -0,0 +1,103 @@ +# ContextVM Rust SDK Overview + +The Rust SDK implements ContextVM: MCP over Nostr. + +In practice, it lets you transport MCP JSON-RPC messages through Nostr events, add server discovery through announcement events, and optionally encrypt direct traffic with NIP-44 plus gift wrapping. + +## The main mental model + +For native Rust applications, ContextVM is primarily a transport for `rmcp`. + +That means the usual shape is: + +1. define an `rmcp` server or client +2. create a ContextVM Nostr transport +3. attach the transport with `ServiceExt` + +This is the same pattern shown by the `rmcp` server and client examples. The only difference is that this SDK replaces stdio, HTTP, or raw sockets with Nostr transports. + +## Choose the right API + +Most users should start with one of these entry points: + +| Use case | Start with | +|---|---| +| Build a native ContextVM server | `NostrServerTransport` + `rmcp` `ServiceExt` | +| Build a native ContextVM client | `NostrClientTransport` + `rmcp` `ServiceExt` | +| Expose an already-existing MCP server on Nostr | `NostrMCPGateway` | +| Connect to a remote ContextVM server with a simpler bridge | `NostrMCPProxy` | +| Discover public servers and capabilities | `discover_servers()` and related helpers | +| Work directly with the optional bridge layer | `NostrMCPGateway::serve_handler()` or `NostrMCPProxy::serve_client_handler()` | + +## Architecture + +The crate is organized in layers: + +- `core`: protocol types, validation, serialization, errors +- `relay`: relay pool abstraction +- `signer`: key generation and signer helpers +- `encryption`: NIP-44 and gift-wrap helpers +- `transport`: native ContextVM client and server transports +- `gateway`: wrapper for exposing an existing MCP server flow on Nostr +- `proxy`: wrapper for connecting to a remote server without the full `rmcp` client model +- `discovery`: announcement and capability discovery + +The application-facing `rmcp` layer provides the `ServiceExt` integration point together with the usual server and client startup flow. + +## Protocol model + +ContextVM keeps MCP semantics intact and uses Nostr only as the transport envelope. + +- MCP payloads are represented by `JsonRpcMessage` +- direct plaintext ContextVM traffic uses kind `25910` +- encrypted traffic uses gift-wrap kinds `1059` or `21059` +- public discovery uses kinds `11316` through `11320` +- routing is done with `p` tags and request/response correlation with `e` tags, as reflected in the repository root README + +## Core types you should know + +- `EncryptionMode`: `Optional`, `Required`, `Disabled` +- `GiftWrapMode`: `Optional`, `Ephemeral`, `Persistent` +- `contextvm_sdk::ServerInfo`: announcement metadata +- `CapabilityExclusion`: allowlist bypass rules for specific methods or capabilities + +## Typical workflows + +### Build a native server + +1. generate keys with `signer::generate()` or load an existing private key with `from_sk()` +2. configure `NostrServerTransportConfig` +3. create `NostrServerTransport` +4. attach it to an `rmcp` server with `ServiceExt` +5. optionally publish announcements with `announce()` + +### Build a native client + +1. generate keys with `signer::generate()` or load an existing private key with `from_sk()` +2. configure `NostrClientTransportConfig` +3. create `NostrClientTransport` +4. attach it to an `rmcp` client with `ServiceExt` + +### Bridge an existing server or client + +If you are not building a native `rmcp` service directly, use the wrapper layer: + +- `NostrMCPGateway` for server-side bridging +- `NostrMCPProxy` for client-side bridging + +### Discover servers + +1. create a `RelayPool` +2. query `discover_servers()` +3. fetch public tools, resources, and prompts with the discovery helpers + +## What is important in this implementation + +The Rust SDK already implements behavior that users should rely on: + +- stateless client initialization behavior +- announcement publication and deletion +- encryption negotiation and response mirroring +- rmcp conversion and routing flow + +Use the task-oriented pages in this directory for those details. Start with the native server and native client guides if you are building ContextVM applications directly. diff --git a/docs/proxy.md b/docs/proxy.md new file mode 100644 index 0000000..d1c3469 --- /dev/null +++ b/docs/proxy.md @@ -0,0 +1,114 @@ +# Proxy Guide + +`NostrMCPProxy` is the simplest way to talk to a remote ContextVM server from Rust. + +It wraps `NostrClientTransport`, gives you a receiver for responses and notifications, and handles transport startup and shutdown. + +For native Rust applications, this is usually not the primary path. Most users should build an `rmcp` client and attach `NostrClientTransport` directly, as described in the native client guide. + +## When to use it + +Use the proxy when: + +- you already know the target server pubkey +- you want a lightweight request/response flow +- you do not need low-level transport hooks + +Do not start here if you are writing a new native Rust MCP client from scratch. + +## Loading an existing private key + +Like the native client transport, the proxy can reuse an existing Nostr identity instead of generating a new one. Load the signer with `from_sk()`: + +```rust +use contextvm_sdk::signer; + +let signer = signer::from_sk("")?; +``` + +Pass that signer to `NostrMCPProxy::new()` exactly as you would pass a freshly generated keypair. + +## Minimal example + +This follows the repository proxy example. + +```rust +use contextvm_sdk::core::types::{JsonRpcMessage, JsonRpcRequest}; +use contextvm_sdk::proxy::{NostrMCPProxy, ProxyConfig}; +use contextvm_sdk::signer; +use contextvm_sdk::transport::client::NostrClientTransportConfig; + +#[tokio::main] +async fn main() -> contextvm_sdk::Result<()> { + let keys = signer::from_sk("")?; + + let nostr_config = NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://relay.damus.io".to_string()]) + .with_server_pubkey(""); + + let config = ProxyConfig::new(nostr_config); + + let mut proxy = NostrMCPProxy::new(keys, config).await?; + let mut rx = proxy.start().await?; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: None, + }); + + proxy.send(&request).await?; + + if let Some(message) = rx.recv().await { + println!("{}", serde_json::to_string_pretty(&message)?); + } + + proxy.stop().await?; + Ok(()) +} +``` + +## Client config + +The main options live on `NostrClientTransportConfig`: + +- `relay_urls`: relays used for direct transport +- `server_pubkey`: target server identity in hex form +- `encryption_mode`: client encryption policy +- `gift_wrap_mode`: preferred gift-wrap kind policy +- `is_stateless`: emulate the initialize response locally +- `timeout`: pending request correlation retention + +## Stateless mode + +`is_stateless` is a major behavior switch. + +When enabled, the client can emulate initialize handling locally instead of waiting for a network roundtrip. This behavior is covered by the conformance tests. + +Use it when: + +- you want faster startup for short-lived clients +- you control the server behavior and know stateless operation is acceptable + +Avoid assuming that every server workflow depends only on stateless behavior. + +## Behavioral notes + +- responses are correlated at the transport level, not just by raw receive order +- the client learns peer capabilities from discovery tags on inbound messages +- encrypted traffic is deduplicated by outer gift-wrap event id before delivery + +## When not to use it + +Prefer the native client transport path when: + +- your application is already modeled as an `rmcp` `ClientHandler` +- you want the normal running-client workflow from `ServiceExt` +- you want examples that match the rest of the `rmcp` client ecosystem + +## rmcp path + +If you are building on `rmcp`, use the associated function `NostrMCPProxy::serve_client_handler()` instead of manually sending and receiving raw `JsonRpcMessage` values. + +That said, the preferred native architecture is still `rmcp` client first and ContextVM transport second. diff --git a/docs/rmcp.md b/docs/rmcp.md new file mode 100644 index 0000000..cf611c6 --- /dev/null +++ b/docs/rmcp.md @@ -0,0 +1,44 @@ +# RMCP Integration Guide + +For native Rust applications, `rmcp` is the main application layer and ContextVM is the transport layer. + +The Rust SDK exposes that integration behind the `rmcp` feature and re-exports the `rmcp` crate when that feature is enabled. The bridge lives in the SDK's rmcp transport layer. + +## Recommended mental model + +Use `rmcp` to define your server or client behavior, then attach ContextVM transports. + +That mirrors the transport-agnostic `rmcp` model, especially `ServiceExt` and the standard `handler.serve(transport)` pattern. + +The native entry points in this SDK are therefore: + +- `NostrServerTransport` for servers +- `NostrClientTransport` for clients + +For that workflow, use the native server and native client guides in this directory. + +## Server-side integration + +Use the associated function `NostrMCPGateway::serve_handler()` to serve an `rmcp` server handler directly over ContextVM. + +## Client-side integration + +Use the associated function `NostrMCPProxy::serve_client_handler()` to connect an `rmcp` client handler through the ContextVM client worker. + +## Why this still exists as a separate page + +The base SDK does not require `rmcp`. The core message model is represented by `JsonRpcMessage` and related internal JSON-RPC types. + +That separation keeps the transport usable as a lower-level protocol layer, but most application authors will want the `rmcp` path. + +The gateway and proxy APIs are convenience layers on top of this broader model, not the primary architecture for native apps. + +## Behavioral confidence + +The conversion pipeline is covered by the SDK test suite, which tests: + +- JSON-RPC parsing into internal message types +- internal-to-rmcp conversion +- rmcp-to-internal conversion +- request id preservation through the bridge +- event-id based routing assumptions used by the server worker diff --git a/docs/server-transport.md b/docs/server-transport.md new file mode 100644 index 0000000..147ae52 --- /dev/null +++ b/docs/server-transport.md @@ -0,0 +1,178 @@ +# Native Server Guide + +Use this path when you are building a native ContextVM server in Rust. + +The recommended architecture is: + +- define an `rmcp` server handler +- create a `NostrServerTransport` +- attach the transport to the handler with `rmcp`'s `ServiceExt` + +This is the same model used by the standard `rmcp` server examples, except the transport is Nostr instead of stdio. + +## The high-level shape + +In `rmcp`, a native server is normally started with `YourHandler.serve(transport)`. + +For ContextVM, the transport becomes `NostrServerTransport`. In the current SDK API, you pass that transport directly to `ServiceExt`; there is no extra adapter step in the public workflow. + +## Loading an existing private key + +If the server should run under a stable Nostr identity, load the signer from an existing private key with `from_sk()`: + +```rust +use contextvm_sdk::signer; + +let signer = signer::from_sk("")?; +println!("server pubkey: {}", signer.public_key().to_hex()); +``` + +This is the right choice for long-lived servers, announced servers, and deployments where clients must recognize the same public key across restarts. + +## Example + +```rust +use contextvm_sdk::transport::server::{ + NostrServerTransport, NostrServerTransportConfig, +}; +use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode, ServerInfo}; +use rmcp::{ + ServerHandler, ServiceExt, + handler::server::{router::tool::ToolRouter, wrapper::Parameters}, + model::*, + schemars, tool, tool_handler, tool_router, +}; + +const RELAY_URL: &str = "wss://relay.contextvm.org"; + +#[derive(Clone)] +struct DemoServer { + tool_router: ToolRouter, +} + +impl DemoServer { + fn new() -> Self { + Self { + tool_router: Self::tool_router(), + } + } +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +#[tool_router] +impl DemoServer { + #[tool(description = "Echo a message back unchanged")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(format!( + "Echo: {message}" + ))])) + } +} + +#[tool_handler] +impl ServerHandler for DemoServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo { + protocol_version: ProtocolVersion::LATEST, + capabilities: ServerCapabilities::builder().enable_tools().build(), + server_info: Implementation { + name: "contextvm-native-echo".to_string(), + title: Some("ContextVM Native Echo Server".to_string()), + version: "0.1.0".to_string(), + description: Some("Native rmcp echo server over ContextVM/Nostr".to_string()), + icons: None, + website_url: None, + }, + instructions: Some("Call the echo tool with a message string".to_string()), + } + } +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("contextvm_sdk=info".parse()?) + .add_directive("rmcp=warn".parse()?), + ) + .init(); + + let signer = signer::generate(); + let pubkey = signer.public_key().to_hex(); + + println!("Native ContextVM echo server starting"); + println!("Relay: {RELAY_URL}"); + println!("Server pubkey: {pubkey}"); + + let transport = NostrServerTransport::new( + signer, + NostrServerTransportConfig::default() + .with_relay_urls(vec![RELAY_URL.to_string()]) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_announced_server(false) + .with_server_info( + ServerInfo::default() + .with_name("contextvm-native-echo".to_string()) + .with_about("Native rmcp echo server example".to_string()), + ), + ) + .await?; + + let service = DemoServer::new().serve(transport).await?; + println!("Server ready. Press Ctrl+C to stop."); + service.waiting().await?; + Ok(()) +} +``` + +This follows the same flow as the repository's native echo server example. + +## What the transport adds + +`NostrServerTransport` is not just a byte stream adapter. It adds ContextVM-specific behavior on top of `rmcp` server semantics: + +- Nostr relay connectivity via `NostrServerTransport::new()` +- public announcements via `announce()` +- publication of tools, resources, prompts, and resource templates +- client authorization via `allowed_public_keys` in `NostrServerTransportConfig` +- capability exclusions via `CapabilityExclusion` +- encryption negotiation and response mirroring via `send_response()` +- session management and request routing inside the server event loop + +## Configuration fields that matter first + +Start with these fields in `NostrServerTransportConfig`: + +- `relay_urls`: relays the server will publish to and listen on +- `is_announced_server`: whether the server should participate in public discovery +- `encryption_mode`: plaintext vs encrypted policy +- `gift_wrap_mode`: persistent vs ephemeral wrapping policy +- `allowed_public_keys`: allowlist for private or restricted servers +- `excluded_capabilities`: allow specific methods without fully opening the server +- `relay_list_urls`: relay URLs advertised in kind 10002 (CEP-17); defaults to `relay_urls` +- `bootstrap_relay_urls`: additional relays for publishing announcements (CEP-6/17); merged with `relay_list_urls` +- `publish_relay_list`: whether to publish kind 10002 relay list metadata; default `true` +- `profile_metadata`: optional profile metadata for kind 0 publication (CEP-23) + +## When to use this instead of the gateway + +Use this page's approach when you are writing a new Rust MCP server. + +Use the gateway guide when you already have a request loop or existing local MCP service abstraction and want a thinner bridge. + +## Behavioral notes + +- The `rmcp` server handshake follows `ServiceExt::serve()` on the server handler. +- `rmcp` accepts pre-init ping and enters the main loop immediately after initialization completes. +- ContextVM response routing depends on request event ids. +- Encryption mirroring and announcement behavior are covered by the integration tests. +- When `is_announced_server` is `true`, the transport auto-publishes all announcement events on `start()` via synthetic MCP requests: kind 11316 (server announcement), kinds 11317-11320 (tools, resources, templates, prompts), kind 10002 (relay list), and kind 0 (profile metadata if configured). diff --git a/docs/stateless.md b/docs/stateless.md new file mode 100644 index 0000000..7fb2c48 --- /dev/null +++ b/docs/stateless.md @@ -0,0 +1,102 @@ +# Stateless Mode Guide + +Stateless mode is a client-side transport behavior enabled through `NostrClientTransportConfig::with_stateless()`. + +It is designed for flows where the client should behave as if initialization succeeded without waiting for the server to answer over the network. + +## What stateless mode actually does + +When `is_stateless` is enabled on `NostrClientTransportConfig`, the client transport intercepts two parts of the normal MCP startup sequence inside `NostrClientTransport::send()`: + +- an outgoing `initialize` request +- an outgoing `notifications/initialized` notification + +For the `initialize` request, the transport locally emulates a successful initialize response instead of publishing the request to the relay network. + +For the `notifications/initialized` notification, the transport simply swallows the notification and does not send it over the network. + +## What does not change + +Stateless mode does not make the whole transport local-only. + +After initialization is emulated, normal requests are still serialized and sent through Nostr. + +That means stateless mode changes startup semantics, not the rest of the request/response transport model. + +## When to use it + +Use stateless mode when: + +- you want faster startup for short-lived clients +- you control both sides and do not need a server-provided initialize payload +- you are using the proxy or native client flow mainly for direct tool calls after startup + +Avoid it when: + +- you need the server's real initialize response +- your workflow depends on server-specific initialize metadata +- you want startup behavior to strictly follow the network exchange + +## Example + +```rust +use contextvm_sdk::transport::client::{ + NostrClientTransport, NostrClientTransportConfig, +}; +use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode}; +use rmcp::{ClientHandler, ServiceExt}; + +#[derive(Clone, Default)] +struct DemoClient; + +impl ClientHandler for DemoClient {} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + let signer = signer::generate(); + + let transport = NostrClientTransport::new( + signer, + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://relay.contextvm.org".to_string()]) + .with_server_pubkey("") + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_stateless(true), + ) + .await?; + + let client = DemoClient.serve(transport).await?; + + // Initialize completed locally; subsequent requests still go over Nostr. + let tools = client.list_all_tools().await?; + println!("Discovered {} tool(s)", tools.len()); + + client.cancel().await?; + Ok(()) +} +``` + +## Emulated initialize shape + +The emulated initialize response includes: + +- `protocolVersion` +- `serverInfo` +- `capabilities` + +This is verified by the transport tests in the repository. + +The placeholder `serverInfo.name` used by the emulated response is currently `Emulated-Stateless-Server`. + +## Relationship to discovery and learned capabilities + +Stateless mode does not disable peer capability learning. + +The client still advertises its own capabilities through discovery tags, and it still learns server capabilities from inbound tags later in the session. + +So even in stateless mode, encryption and ephemeral gift-wrap support can still be learned from later inbound traffic. + +## Practical limitation + +Because the initialize roundtrip is skipped, stateless mode should be treated as an optimization for compatible workflows, not as a universal replacement for normal MCP startup. diff --git a/docs/transport-modes.md b/docs/transport-modes.md new file mode 100644 index 0000000..e058096 --- /dev/null +++ b/docs/transport-modes.md @@ -0,0 +1,116 @@ +# Transport Modes Guide + +This page focuses on the transport behavior switches that are spread across the SDK APIs: + +- `EncryptionMode` +- `GiftWrapMode` +- stateless mode via `NostrClientTransportConfig::with_stateless()` + +The existing guides mention these knobs in context. This page collects them into one operational reference. + +## Encryption modes + +`EncryptionMode` controls whether the transport accepts or emits plaintext vs encrypted direct traffic. + +The three modes are: + +- `Optional`: mirror mode; encrypted traffic is allowed and plaintext is also allowed +- `Required`: reject plaintext direct traffic and require encrypted direct traffic +- `Disabled`: reject encrypted direct traffic and use plaintext only + +The exact enforcement is implemented in the client and server transport layers. + +### What `Optional` really means + +`Optional` does not mean "always plaintext is fine" or "always encrypt everything". + +At the base transport level, the outgoing encryption choice for direct traffic is mirror-oriented: + +- `EncryptionMode::Required` always encrypts direct traffic +- `EncryptionMode::Disabled` never encrypts direct traffic +- `EncryptionMode::Optional` uses the known encryption state of the peer interaction when available + +## Gift-wrap modes + +`GiftWrapMode` only matters when traffic is encrypted. + +The three modes are: + +- `Optional`: accept both persistent and ephemeral gift wraps; prefer persistent until ephemeral support is known +- `Ephemeral`: require kind `21059` +- `Persistent`: require kind `1059` + +The acceptance rules are defined by `GiftWrapMode::allows_kind()`, and whether a mode can advertise or choose ephemeral wrapping is defined by `GiftWrapMode::supports_ephemeral()`. + +### Client outbound behavior + +Client selection follows this behavior: + +- `Persistent` always uses persistent gift wrap +- `Ephemeral` always uses ephemeral gift wrap +- `Optional` uses persistent first, then switches to ephemeral once peer support is learned + +### Server outbound behavior + +Server response and notification selection follow the current transport implementation. + +Important server rules: + +- if a correlated encrypted request used a valid wrap kind, the server mirrors it when possible +- for notifications, learned client support for ephemeral gift wrap can change the fallback behavior +- `Optional` still falls back to persistent when no stronger signal exists + +## Stateless mode + +Stateless mode is client-only and is controlled by `NostrClientTransportConfig::with_stateless()`. + +It changes initialization behavior only: + +- outgoing `initialize` is emulated locally +- outgoing `notifications/initialized` is suppressed +- later requests still go through the normal Nostr transport path + +See the dedicated stateless mode guide in this docs directory. + +## Capability tags and mode signaling + +Mode choices affect discovery and capability tags. + +### Client-side signaling + +Client tags follow this behavior: + +- if encryption is not disabled, the client advertises encryption support +- if gift-wrap mode is not persistent, the client also advertises ephemeral gift-wrap support + +### Server-side signaling + +Server announcement tags follow this behavior: + +- if encryption is not disabled, the server advertises encryption support +- if gift-wrap mode supports ephemeral wrapping, the server also advertises ephemeral support + +This is why `GiftWrapMode::Optional` and `GiftWrapMode::Ephemeral` both advertise ephemeral support, while `GiftWrapMode::Persistent` does not. + +## Example configurations + +```rust +use contextvm_sdk::{EncryptionMode, GiftWrapMode}; +use contextvm_sdk::transport::client::NostrClientTransportConfig; + +let interoperable = NostrClientTransportConfig::default() + .with_server_pubkey("") + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional); + +let strict_encrypted = NostrClientTransportConfig::default() + .with_server_pubkey("") + .with_encryption_mode(EncryptionMode::Required) + .with_gift_wrap_mode(GiftWrapMode::Persistent); + +let stateless_client = NostrClientTransportConfig::default() + .with_server_pubkey("") + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_stateless(true); +``` diff --git a/docs/transports.md b/docs/transports.md new file mode 100644 index 0000000..f007221 --- /dev/null +++ b/docs/transports.md @@ -0,0 +1,137 @@ +# Transport Guide + +Use this page when you want to understand the transport layer itself. + +If you are building a normal native server or client, start with the dedicated native server or native client guide first. + +- `NostrClientTransport`: client-side direct transport +- `NostrServerTransport`: server-side direct transport + +## Why use the transport layer directly + +Use transports directly when you need to: + +- integrate with your own request loop +- control announcement timing yourself +- tune authorization and session behavior +- embed the transport in higher-level abstractions + +## Signer choice + +All transport constructors accept an existing signer, not just a newly generated one. + +- Use `generate()` for temporary identities in examples, tests, and short-lived sessions. +- Use `from_sk()` when you need a stable identity backed by a pre-existing hex or `nsec` private key. + +```rust +use contextvm_sdk::signer; + +let signer = signer::from_sk("")?; +``` + +## Low-level client transport example + +```rust +use contextvm_sdk::core::types::{JsonRpcMessage, JsonRpcRequest}; +use contextvm_sdk::signer; +use contextvm_sdk::transport::client::{ + NostrClientTransport, NostrClientTransportConfig, +}; + +#[tokio::main] +async fn main() -> contextvm_sdk::Result<()> { + let keys = signer::generate(); + let mut transport = NostrClientTransport::new( + keys, + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://relay.damus.io".to_string()]) + .with_server_pubkey(""), + ) + .await?; + + transport.start().await?; + let mut rx = transport.take_message_receiver().expect("receiver available"); + + transport + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: None, + })) + .await?; + + if let Some(message) = rx.recv().await { + println!("received: {:?}", message); + } + + transport.close().await?; + Ok(()) +} +``` + +## Low-level server transport example + +```rust +use contextvm_sdk::core::types::{JsonRpcMessage, JsonRpcResponse}; +use contextvm_sdk::signer; +use contextvm_sdk::transport::server::{ + NostrServerTransport, NostrServerTransportConfig, +}; + +#[tokio::main] +async fn main() -> contextvm_sdk::Result<()> { + let keys = signer::generate(); + let mut transport = NostrServerTransport::new( + keys, + NostrServerTransportConfig::default().with_announced_server(true), + ) + .await?; + + transport.start().await?; + let mut rx = transport.take_message_receiver().expect("receiver available"); + + while let Some(req) = rx.recv().await { + if let Some(id) = req.message.id() { + transport + .send_response( + &req.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: id.clone(), + result: serde_json::json!({"ok": true}), + }), + ) + .await?; + } + } + + Ok(()) +} +``` + +## Server-side semantics to understand + +The server transport does more than relay bytes. + +It manages: + +- multi-client session state +- request route storage +- authorization via `allowed_public_keys` +- allowlist bypasses via `CapabilityExclusion` +- announcement publication +- encryption negotiation and response mirroring + +Those behaviors are part of the server transport implementation and are exercised heavily by the integration tests. + +## What the server receives + +Incoming traffic is delivered as `IncomingRequest`, which includes: + +- the parsed `JsonRpcMessage` +- the client pubkey +- the original Nostr request event id +- whether the incoming message was encrypted + +That extra metadata is what allows correct response routing and encryption mirroring. diff --git a/examples/discovery.rs b/examples/discovery.rs index 166cb3f..edff853 100644 --- a/examples/discovery.rs +++ b/examples/discovery.rs @@ -53,8 +53,7 @@ async fn main() -> contextvm_sdk::Result<()> { println!(" Resources: {} found", resources.len()); } - let prompts = - discovery::discover_prompts(client, &server.pubkey_parsed, &relays).await?; + let prompts = discovery::discover_prompts(client, &server.pubkey_parsed, &relays).await?; if !prompts.is_empty() { println!(" Prompts: {} found", prompts.len()); } diff --git a/examples/gateway.rs b/examples/gateway.rs index ee6b1c5..efe8500 100644 --- a/examples/gateway.rs +++ b/examples/gateway.rs @@ -2,6 +2,8 @@ //! //! This demonstrates how to create a ContextVM gateway that receives //! MCP requests over Nostr and responds to them. +//! +//! Usage: cargo run --example gateway use contextvm_sdk::core::types::*; use contextvm_sdk::gateway::{GatewayConfig, NostrMCPGateway}; @@ -17,18 +19,14 @@ async fn main() -> contextvm_sdk::Result<()> { println!("Server pubkey: {}", keys.public_key().to_hex()); // Configure the gateway - let config = GatewayConfig { - nostr_config: NostrServerTransportConfig { - relay_urls: vec!["wss://relay.damus.io".to_string()], - server_info: Some(ServerInfo { - name: Some("Echo Server".to_string()), - about: Some("A simple echo tool exposed via ContextVM".to_string()), - ..Default::default() - }), - is_public_server: true, - ..Default::default() - }, - }; + let nostr_config = NostrServerTransportConfig::default() + .with_server_info( + ServerInfo::default() + .with_name("Echo Server") + .with_about("A simple echo tool exposed via ContextVM"), + ) + .with_announced_server(true); + let config = GatewayConfig::new(nostr_config); let mut gateway = NostrMCPGateway::new(keys, config).await?; let mut rx = gateway.start().await?; diff --git a/examples/native_echo_client.rs b/examples/native_echo_client.rs new file mode 100644 index 0000000..1068a40 --- /dev/null +++ b/examples/native_echo_client.rs @@ -0,0 +1,93 @@ +//! Example: Native rmcp client over ContextVM/Nostr. +//! +//! Usage: +//! cargo run --example native_echo_client -- + +use anyhow::{Context, Result}; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode}; +use rmcp::{ + model::{CallToolRequestParams, CallToolResult}, + ClientHandler, ServiceExt, +}; + +const RELAY_URL: &str = "wss://relay.contextvm.org"; + +#[derive(Clone, Default)] +struct EchoClient; + +impl ClientHandler for EchoClient {} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("contextvm_sdk=info".parse()?) + .add_directive("rmcp=warn".parse()?), + ) + .init(); + + let server_pubkey = std::env::args() + .nth(1) + .context("Usage: native_echo_client ")?; + + let signer = signer::generate(); + println!("Native ContextVM echo client starting"); + println!("Relay: {RELAY_URL}"); + println!("Client pubkey: {}", signer.public_key().to_hex()); + println!("Target server pubkey: {server_pubkey}"); + + let transport = NostrClientTransport::new( + signer, + NostrClientTransportConfig::default() + .with_relay_urls(vec![RELAY_URL.to_string()]) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional), + ) + .await?; + + let client = EchoClient.serve(transport).await?; + + let peer_info = client + .peer_info() + .context("server did not provide peer info after initialize")?; + println!("Connected to: {:?}", peer_info.server_info.name); + + let tools = client.list_all_tools().await?; + println!("Discovered {} tool(s):", tools.len()); + for tool in &tools { + println!("- {}", tool.name); + } + + let result = client + .call_tool( + CallToolRequestParams::new("echo").with_arguments( + serde_json::from_value(serde_json::json!({ + "message": "hello from native contextvm client" + })) + .unwrap(), + ), + ) + .await?; + + println!("Echo result: {}", first_text(&result)); + + client.cancel().await?; + Ok(()) +} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|content| { + if let rmcp::model::RawContent::Text(text) = &content.raw { + Some(text.text.clone()) + } else { + None + } + }) + .unwrap_or_default() +} diff --git a/examples/native_echo_server.rs b/examples/native_echo_server.rs new file mode 100644 index 0000000..b731864 --- /dev/null +++ b/examples/native_echo_server.rs @@ -0,0 +1,92 @@ +//! Example: Native rmcp echo server over ContextVM/Nostr. +//! +//! Usage: +//! cargo run --example native_echo_server + +use anyhow::Result; +use contextvm_sdk::transport::server::{NostrServerTransport, NostrServerTransportConfig}; +use contextvm_sdk::{signer, EncryptionMode, GiftWrapMode, ServerInfo}; +use rmcp::{ + handler::server::wrapper::Parameters, model::*, schemars, tool, tool_handler, tool_router, + ServerHandler, ServiceExt, +}; + +const RELAY_URL: &str = "wss://relay.contextvm.org"; + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +#[derive(Clone)] +struct EchoServer {} + +impl EchoServer { + fn new() -> Self { + Self {} + } +} + +#[tool_router] +impl EchoServer { + #[tool(description = "Echo a message back unchanged")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(format!( + "Echo: {message}" + ))])) + } +} + +#[tool_handler] +impl ServerHandler for EchoServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info( + Implementation::new("contextvm-native-echo", "0.1.0") + .with_title("ContextVM Native Echo Server") + .with_description("Native rmcp echo server over ContextVM/Nostr"), + ) + .with_instructions("Call the echo tool with a message string") + } +} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("contextvm_sdk=info".parse()?) + .add_directive("rmcp=warn".parse()?), + ) + .init(); + + let signer = signer::generate(); + let pubkey = signer.public_key().to_hex(); + + println!("Native ContextVM echo server starting"); + println!("Relay: {RELAY_URL}"); + println!("Server pubkey: {pubkey}"); + + let transport = NostrServerTransport::new( + signer, + NostrServerTransportConfig::default() + .with_relay_urls(vec![RELAY_URL.to_string()]) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_announced_server(false) + .with_server_info( + ServerInfo::default() + .with_name("contextvm-native-echo".to_string()) + .with_about("Native rmcp echo server example".to_string()), + ), + ) + .await?; + + let service = EchoServer::new().serve(transport).await?; + println!("Server ready. Press Ctrl+C to stop."); + service.waiting().await?; + Ok(()) +} diff --git a/examples/proxy.rs b/examples/proxy.rs index a10663a..4aea3e3 100644 --- a/examples/proxy.rs +++ b/examples/proxy.rs @@ -6,6 +6,7 @@ use contextvm_sdk::core::types::*; use contextvm_sdk::proxy::{NostrMCPProxy, ProxyConfig}; use contextvm_sdk::signer; use contextvm_sdk::transport::client::NostrClientTransportConfig; + #[tokio::main] async fn main() -> contextvm_sdk::Result<()> { tracing_subscriber::fmt::init(); @@ -17,14 +18,10 @@ async fn main() -> contextvm_sdk::Result<()> { let keys = signer::generate(); println!("Client pubkey: {}", keys.public_key().to_hex()); - let config = ProxyConfig { - nostr_config: NostrClientTransportConfig { - relay_urls: vec!["wss://relay.damus.io".to_string()], - server_pubkey: server_pubkey_hex, - encryption_mode: EncryptionMode::Optional, - ..Default::default() - }, - }; + let nostr_config = NostrClientTransportConfig::default() + .with_server_pubkey(server_pubkey_hex) + .with_encryption_mode(EncryptionMode::Optional); + let config = ProxyConfig::new(nostr_config); let mut proxy = NostrMCPProxy::new(keys, config).await?; let mut rx = proxy.start().await?; @@ -42,7 +39,10 @@ async fn main() -> contextvm_sdk::Result<()> { // Wait for response if let Some(response) = rx.recv().await { - println!("Response: {}", serde_json::to_string_pretty(&response).unwrap()); + println!( + "Response: {}", + serde_json::to_string_pretty(&response).unwrap() + ); } proxy.stop().await?; diff --git a/examples/python/.gitignore b/examples/python/.gitignore new file mode 100644 index 0000000..a105440 --- /dev/null +++ b/examples/python/.gitignore @@ -0,0 +1,8 @@ +# Native library and generated binding are release artifacts, not source. +# Obtain them per README.md and drop them in this directory to run the examples. +/libcontextvm_ffi.so +/libcontextvm_ffi.dylib +/contextvm_ffi.dll +/contextvm_ffi.py +__pycache__/ +*.pyc diff --git a/examples/python/01_offline_check.py b/examples/python/01_offline_check.py new file mode 100644 index 0000000..8a62727 --- /dev/null +++ b/examples/python/01_offline_check.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Offline sanity check for the contextvm_ffi Python binding. + +Run this first to confirm the binding and native library are installed and +matched. It exercises only the non-network APIs, so it needs no internet: + + python3 01_offline_check.py + +If this fails with `InternalError("UniFFI ... mismatch")`, the generated +`contextvm_ffi.py` and the native library were taken from different releases +(see README.md). +""" + +import contextvm_ffi as cvm + + +def main() -> None: + print(f"contextvm-ffi version: {cvm.version()}") + + # --- key management -------------------------------------------------- + keys = cvm.Keys.generate() + pubkey = keys.public_key() # 64-char hex + secret = keys.secret_key() # 64-char hex + npub = cvm.pubkey_hex_to_npub(pubkey) # bech32 (npub1...) + + print(f"public key (hex): {pubkey}") + print(f"public key (npub): {npub}") + print(f"secret key (hex): {secret[:16]}...") + + # Round-trip: reconstruct the keypair from its secret hex. + restored = cvm.Keys.from_secret_key(secret) + assert restored.public_key() == pubkey, "key reconstruction failed" + print("key reconstruction from secret: OK") + + # --- JSON-RPC message helpers --------------------------------------- + # These build the JSON strings accepted by Client.send() / + # Server.send_response() / Proxy.send(). + print("\nbuilt request: ", cvm.make_request("1", "tools/list", None)) + print("built tool call: ", cvm.make_request("2", "tools/call", '{"name": "echo"}')) + print("built notification: ", cvm.make_notification("notifications/initialized", None)) + print("built response: ", cvm.make_response("1", '{"tools": []}')) + + # --- config records (construction only; no network) ------------------ + client_config = cvm.ClientConfig( + relay_urls=["wss://relay.damus.io"], + server_pubkey=pubkey, + encryption_mode=cvm.EncryptionMode.OPTIONAL, + gift_wrap_mode=cvm.GiftWrapMode.OPTIONAL, + is_stateless=False, + timeout_secs=30, + discovery_relay_urls=[], + fallback_operational_relay_urls=[], + ) + print( + "\nclient config: server=%s... encryption=%s" + % (client_config.server_pubkey[:16], client_config.encryption_mode) + ) + + print("\nAll offline checks passed. The binding is installed correctly.") + + +if __name__ == "__main__": + main() diff --git a/examples/python/README.md b/examples/python/README.md new file mode 100644 index 0000000..0afb17b --- /dev/null +++ b/examples/python/README.md @@ -0,0 +1,92 @@ +# Python examples for the ContextVM FFI binding + +These examples use the **UniFFI-generated** Python binding (`contextvm_ffi.py`) +plus the platform native library (`libcontextvm_ffi.so` / `.dylib` / +`contextvm_ffi.dll`). They are the Python equivalents of the Rust examples in +[`../`](../) (`discovery.rs`, `proxy.rs`). + +## The one rule: `.py` and native lib must come from the same release + +UniFFI embeds a **contract version** and a **per-function checksum** into both +the compiled native library and the generated `contextvm_ffi.py`. At import +time the binding recomputes them against the library and aborts with +`InternalError("UniFFI ... mismatch")` if they differ. + +So you must take the native library and the Python binding from the **same +GitHub Release / same CI run**. Never mix an old `.py` with a new `.so`. + +## Get the artifacts + +### Option A — from a GitHub Release + +Download **two** artifacts from the release page: + +1. `contextvm-ffi-bindings.tar.gz` → contains `python/contextvm_ffi.py` +2. The native archive for **your platform**: + - Linux x86_64 → `contextvm-ffi-x86_64-unknown-linux-gnu.tar.gz` + (`contextvm-ffi/lib/libcontextvm_ffi.so`) + - macOS Apple Silicon → `contextvm-ffi-aarch64-apple-darwin.tar.gz` + (`contextvm-ffi/lib/libcontextvm_ffi.dylib`) + - macOS universal → `contextvm-ffi-universal-apple-darwin.tar.gz` + - Windows x86_64 → `contextvm-ffi-x86_64-pc-windows-msvc.zip` + (`contextvm-ffi/lib/contextvm_ffi.dll`) + +### Option B — build locally + +```bash +# from the repo root +cargo build -p contextvm-ffi # -> target/debug/libcontextvm_ffi.so +cargo install --git https://github.com/mozilla/uniffi-rs \ + --tag v0.31.2 uniffi-bindgen-cli --locked # bindgen matching the runtime `uniffi` version +uniffi-bindgen-cli generate target/debug/libcontextvm_ffi.so \ + --library --language python --out-dir examples/python/ +``` + +## Lay them out side by side + +The generated `contextvm_ffi.py` looks for the native library **next to itself** +(`os.path.dirname(__file__)` + `libcontextvm_ffi.so`/`.dylib`/`.dll`). Put the +two files in this directory: + +```bash +# from examples/python/ +cp /path/to/contextvm-ffi/lib/libcontextvm_ffi.so . +# (place contextvm_ffi.py here too, from either Option A or B) +``` + +The `.gitignore` in this directory keeps both of these out of version control — +they are build/release artifacts, not source. + +## Run the examples + +```bash +cd examples/python + +# 1. Offline sanity check (no network). Run this first to confirm the install. +python3 01_offline_check.py + +# 2. Discover announced servers + their tools (needs internet). +python3 discover_servers.py +python3 discover_servers.py wss://relay.damus.io wss://nos.lol + +# 3. Talk to a specific server (needs internet + a known server pubkey). +python3 proxy_tools_list.py +``` + +## API surface + +The binding exposes these top-level helpers and objects: + +| Category | Names | +|----------|-------| +| Functions | `version()`, `pubkey_hex_to_npub(hex)`, `make_request(id, method, params)`, `make_response(id, result)`, `make_notification(method, params)` | +| Objects | `Keys`, `RelayPool`, `Discovery`, `Server`, `Client`, `Gateway`, `Proxy` | +| Configs | `ServerConfig(...)`, `ClientConfig(...)` (keyword-only fields) | +| Enums | `EncryptionMode.{OPTIONAL,REQUIRED,DISABLED}`, `GiftWrapMode.{OPTIONAL,EPHEMERAL,PERSISTENT}` | +| Records | `JsonRpcMessage`, `IncomingRequest`, `ServerAnnouncement`, `DiscoveredTool`, `ProviderProfile`, `PeerCapabilities` | + +All async SDK work runs on an internal Tokio runtime, so every method here is +**blocking** — no `async`/`await` needed on the Python side. + +See `contextvm-ffi/README.md` and `contextvm-ffi/src/uniffi_types.rs` for the +full, authoritative API. diff --git a/examples/python/discover_servers.py b/examples/python/discover_servers.py new file mode 100644 index 0000000..7e1bc92 --- /dev/null +++ b/examples/python/discover_servers.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 +"""Discover announced ContextVM MCP servers and their published tools. + +The Python equivalent of examples/discovery.rs. Requires internet access to +Nostr relays. + + python3 discover_servers.py [relay_url ...] + +Defaults to wss://relay.damus.io and wss://nos.lol. +""" + +import sys + +import contextvm_ffi as cvm + +DEFAULT_RELAYS = ["wss://relay.damus.io", "wss://nos.lol"] + + +def main() -> int: + relays = sys.argv[1:] or DEFAULT_RELAYS + + keys = cvm.Keys.generate() + pool = cvm.RelayPool(keys) + print(f"Connecting to {len(relays)} relay(s)...") + pool.connect(relays) + + discovery = cvm.Discovery() + + print("Discovering announced MCP servers...\n") + servers = discovery.discover_servers(pool, relays) + if not servers: + print("No servers found. Try other relays, or wait a moment and re-run.") + pool.disconnect() + return 0 + + for s in servers: + name = s.name or "unnamed" + print(f"Server: {name} (npub: {cvm.pubkey_hex_to_npub(s.pubkey)[:24]}...)") + if s.about: + print(f" about: {s.about}") + + # Tools published by this specific provider. + tools = discovery.discover_tools(pool, s.pubkey, name, relays) + if tools: + print(f" tools ({len(tools)}):") + for t in tools: + desc = t.description.replace("\n", " ")[:80] + print(f" - {t.tool_name}: {desc}") + print() + + # One-shot aggregate discovery across all providers on the relays. + print("All discoverable tools across providers:") + all_tools = discovery.discover_all_tools(pool, relays) + for t in all_tools: + provider = t.provider_display_name or t.provider_name or t.provider_pubkey[:8] + desc = t.description.replace("\n", " ")[:60] + print(f" [{provider}] {t.tool_name}: {desc}") + print(f"\nTotal: {len(all_tools)} tool(s) from {len(servers)} server(s).") + + pool.disconnect() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/python/proxy_tools_list.py b/examples/python/proxy_tools_list.py new file mode 100644 index 0000000..a312a23 --- /dev/null +++ b/examples/python/proxy_tools_list.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Connect to a remote ContextVM MCP server as a client and call tools/list. + +The Python equivalent of examples/proxy.rs. Requires internet access and a +known server pubkey. + + python3 proxy_tools_list.py [relay_url ...] + +The server pubkey is the 64-char hex public key of an announced ContextVM +server (discover one with discover_servers.py). +""" + +import sys + +import contextvm_ffi as cvm + +DEFAULT_RELAYS = ["wss://relay.damus.io"] + + +def main() -> int: + if len(sys.argv) < 2: + print(__doc__) + return 2 + server_pubkey = sys.argv[1] + relays = sys.argv[2:] or DEFAULT_RELAYS + + keys = cvm.Keys.generate() + print(f"Client npub: {cvm.pubkey_hex_to_npub(keys.public_key())}") + + config = cvm.ClientConfig( + relay_urls=relays, + server_pubkey=server_pubkey, + encryption_mode=cvm.EncryptionMode.OPTIONAL, + gift_wrap_mode=cvm.GiftWrapMode.OPTIONAL, + is_stateless=False, + timeout_secs=30, + discovery_relay_urls=[], + fallback_operational_relay_urls=[], + ) + + client = cvm.Client(keys, config) + + # MCP initialize handshake (sent on the underlying transport as soon as the + # client learns the server's relay list / capabilities). + init = cvm.make_request( + "1", + "initialize", + '{"protocolVersion":"2025-06-18","capabilities":{},' + '"clientInfo":{"name":"py-example","version":"0.1.0"}}', + ) + print("Sending initialize...") + client.send(init) + init_reply = client.recv_timeout(15) + print(f" -> id={init_reply.id}") + + # Query the server's tools. + print("Sending tools/list...") + client.send(cvm.make_request("2", "tools/list", None)) + reply = client.recv_timeout(15) + print(f" -> id={reply.id}") + print(reply.payload_json) + + client.close() + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/examples/rmcp_integration_test.rs b/examples/rmcp_integration_test.rs new file mode 100644 index 0000000..c611625 --- /dev/null +++ b/examples/rmcp_integration_test.rs @@ -0,0 +1,708 @@ +//! Comprehensive rmcp integration matrix for ContextVM SDK. +//! +//! This example validates three scenarios: +//! 1) local rmcp transport (in-process duplex) +//! 2) hybrid relay mode (rmcp server + legacy JSON-RPC client) +//! 3) full rmcp over relays (rmcp server + rmcp client) +//! +//! Run: +//! cargo run --example rmcp_integration_test --features rmcp +//! cargo run --example rmcp_integration_test --features rmcp -- local +//! cargo run --example rmcp_integration_test --features rmcp -- hybrid +//! cargo run --example rmcp_integration_test --features rmcp -- relay-rmcp +//! cargo run --example rmcp_integration_test --features rmcp -- all +//! +//! Optional relay override: +//! CTXVM_RELAY_URL=wss://relay.primal.net cargo run --example rmcp_integration_test --features rmcp -- all +//! cargo run --example rmcp_integration_test --features rmcp -- all wss://relay.primal.net + +use anyhow::{anyhow, bail, Context, Result}; +use contextvm_sdk::core::constants::mcp_protocol_version; +use contextvm_sdk::core::types::{ + EncryptionMode, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, + ServerInfo as CtxServerInfo, +}; +use contextvm_sdk::gateway::{GatewayConfig, NostrMCPGateway}; +use contextvm_sdk::proxy::{NostrMCPProxy, ProxyConfig}; +use contextvm_sdk::signer; +use contextvm_sdk::transport::client::NostrClientTransportConfig; +use contextvm_sdk::transport::server::NostrServerTransportConfig; +use rmcp::{ + handler::server::wrapper::Parameters, model::*, schemars, service::RequestContext, tool, + tool_handler, tool_router, ClientHandler, RoleServer, ServerHandler, ServiceExt, +}; +use std::sync::Arc; +use std::time::Duration; +use tokio::sync::Mutex; +use tokio::time::{sleep, timeout}; + +const DEFAULT_RELAY_URL: &str = "wss://relay.primal.net"; +const IO_TIMEOUT: Duration = Duration::from_secs(30); +const RELAY_WARMUP: Duration = Duration::from_secs(2); +const STARTUP_TIMEOUT: Duration = Duration::from_secs(20); + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Mode { + Local, + Hybrid, + RelayRmcp, + All, +} + +impl Mode { + fn parse(value: Option<&str>) -> Result { + match value.unwrap_or("all") { + "local" => Ok(Self::Local), + "hybrid" => Ok(Self::Hybrid), + "relay-rmcp" => Ok(Self::RelayRmcp), + "all" => Ok(Self::All), + other => bail!("Unknown mode '{other}'. Use one of: local | hybrid | relay-rmcp | all"), + } + } + + fn run_local(self) -> bool { + matches!(self, Self::Local | Self::All) + } + + fn run_hybrid(self) -> bool { + matches!(self, Self::Hybrid | Self::All) + } + + fn run_relay_rmcp(self) -> bool { + matches!(self, Self::RelayRmcp | Self::All) + } +} + +// Parameter structs with JSON schema for tools/list. +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i64, + b: i64, +} + +#[derive(Clone)] +struct DemoServer { + echo_count: Arc>, +} + +impl DemoServer { + fn new() -> Self { + Self { + echo_count: Arc::new(Mutex::new(0)), + } + } +} + +#[tool_router] +impl DemoServer { + #[tool(description = "Echo a message back unchanged")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + let mut n = self.echo_count.lock().await; + *n += 1; + Ok(CallToolResult::success(vec![Content::text(format!( + "Echo #{n}: {message}" + ))])) + } + + #[tool(description = "Add two integers and return their sum")] + fn add( + &self, + Parameters(AddParams { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(format!( + "{a} + {b} = {}", + a + b + ))])) + } + + #[tool(description = "Return the total number of echo calls made so far")] + async fn get_echo_count(&self) -> Result { + let n = self.echo_count.lock().await; + Ok(CallToolResult::success(vec![Content::text(format!( + "Total echo calls: {n}" + ))])) + } +} + +#[tool_handler] +impl ServerHandler for DemoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info( + Implementation::new("contextvm-demo", "0.1.0") + .with_title("ContextVM Demo Server") + .with_description("Demonstrates rmcp integration over ContextVM"), + ) + .with_instructions("Try: echo, add, get_echo_count") + } + + async fn list_resources( + &self, + _req: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("demo://readme", "Demo README".to_string()).no_annotation() + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + req: ReadResourceRequestParams, + _ctx: RequestContext, + ) -> Result { + match req.uri.as_str() { + "demo://readme" => Ok(ReadResourceResult::new(vec![ResourceContents::text( + "This server demonstrates the ContextVM rmcp integration.", + req.uri, + )])), + other => Err(ErrorData::resource_not_found( + "not_found", + Some(serde_json::json!({ "uri": other })), + )), + } + } +} + +#[derive(Clone, Default)] +struct DemoClient; +impl ClientHandler for DemoClient {} + +#[derive(Clone, Default)] +struct RelayRmcpClient; +impl ClientHandler for RelayRmcpClient {} + +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter( + tracing_subscriber::EnvFilter::from_default_env() + .add_directive("rmcp=warn".parse()?) + .add_directive("contextvm_sdk=info".parse()?), + ) + .init(); + + let args: Vec = std::env::args().skip(1).collect(); + let mode = Mode::parse(args.first().map(String::as_str))?; + let relay_url = args + .get(1) + .cloned() + .or_else(|| std::env::var("CTXVM_RELAY_URL").ok()) + .unwrap_or_else(|| DEFAULT_RELAY_URL.to_string()); + + println!("========================================"); + println!("ContextVM SDK rmcp integration matrix"); + println!("mode: {:?}", mode); + println!("relay: {relay_url}"); + println!("========================================\n"); + + if mode.run_local() { + run_local_rmcp_case().await?; + } + + if mode.run_hybrid() { + run_hybrid_relay_case(&relay_url).await?; + } + + if mode.run_relay_rmcp() { + run_relay_rmcp_case(&relay_url).await?; + } + + println!("\nAll selected integration scenarios passed."); + Ok(()) +} + +async fn run_local_rmcp_case() -> Result<()> { + println!("[local-rmcp] start"); + + let (server_io, client_io) = tokio::io::duplex(65536); + + let server_handle = tokio::spawn(async move { + DemoServer::new() + .serve(server_io) + .await + .expect("server serve failed") + .waiting() + .await + .expect("server error"); + }); + + let client = DemoClient.serve(client_io).await?; + + let tools = client.list_all_tools().await?; + assert_eq!(tools.len(), 3, "expected 3 tools in local rmcp case"); + + let add_result = client + .call_tool(call_params( + "add", + Some(serde_json::json!({ "a": 7, "b": 5 })), + )) + .await?; + let add_text = first_text(&add_result); + assert!(add_text.contains("12"), "expected add result to include 12"); + + let resources = client.list_all_resources().await?; + assert_eq!( + resources.len(), + 1, + "expected one resource in local rmcp case" + ); + + match client.call_tool(call_params("no_such_tool", None)).await { + Err(_) => {} + Ok(r) if r.is_error.unwrap_or(false) => {} + Ok(_) => bail!("expected unknown tool to fail in local rmcp case"), + } + + client.cancel().await?; + server_handle.abort(); + + println!("[local-rmcp] pass"); + Ok(()) +} + +async fn run_hybrid_relay_case(relay_url: &str) -> Result<()> { + println!("[relay-hybrid] start (rmcp server + legacy client)"); + + let server_keys = signer::generate(); + let server_pubkey_hex = server_keys.public_key().to_hex(); + + println!("[relay-hybrid] stage: spawning rmcp server task"); + let relay_url_owned = relay_url.to_string(); + let server_task = tokio::spawn(async move { + let server = NostrMCPGateway::serve_handler( + server_keys, + server_config(&relay_url_owned), + DemoServer::new(), + ) + .await + .with_context(|| format!("failed to start rmcp server on relay {relay_url_owned}"))?; + + let _ = server + .waiting() + .await + .map_err(|e| anyhow!("rmcp server exited with error: {e}"))?; + + Err(anyhow!("rmcp server stopped unexpectedly")) + }); + + sleep(RELAY_WARMUP).await; + + if server_task.is_finished() { + let res = server_task + .await + .map_err(|e| anyhow!("rmcp server task join error: {e}"))?; + return res.context("rmcp server task ended before client startup"); + } + + let outcome: Result<()> = async { + println!("[relay-hybrid] stage: creating legacy proxy client"); + + let mut proxy = timeout( + STARTUP_TIMEOUT, + NostrMCPProxy::new( + signer::generate(), + client_config(relay_url, server_pubkey_hex.clone()), + ), + ) + .await + .with_context(|| { + format!( + "timed out creating legacy proxy client after {:?}", + STARTUP_TIMEOUT + ) + })? + .context("failed to create legacy proxy client")?; + + println!("[relay-hybrid] stage: starting legacy proxy transport"); + let mut rx = timeout(STARTUP_TIMEOUT, proxy.start()) + .await + .with_context(|| { + format!( + "timed out starting legacy proxy transport after {:?}", + STARTUP_TIMEOUT + ) + })? + .context("failed to start legacy proxy")?; + println!("[relay-hybrid] stage: legacy proxy started"); + + let init_id = serde_json::json!(1); + let init_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: init_id.clone(), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": { + "tools": {}, + "resources": {} + }, + "clientInfo": { + "name": "legacy-hybrid-client", + "version": "0.1.0" + } + })), + }); + + let init_response = + send_legacy_request_and_wait(&proxy, &mut rx, init_request, &init_id).await?; + assert_initialize_shape(&init_response)?; + + proxy + .send(&JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + })) + .await + .context("failed to send initialized notification")?; + + let tools_id = serde_json::json!(2); + let tools_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: tools_id.clone(), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + + let tools_response = + send_legacy_request_and_wait(&proxy, &mut rx, tools_request, &tools_id).await?; + let tools = extract_tools_list(&tools_response)?; + assert!( + tools + .iter() + .any(|t| t.get("name") == Some(&serde_json::json!("echo"))), + "expected echo tool in hybrid case" + ); + + let call_id = serde_json::json!(3); + let call_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: call_id.clone(), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "name": "echo", + "arguments": { "message": "legacy-client-hello" } + })), + }); + + let call_response = send_legacy_request_and_wait(&proxy, &mut rx, call_request, &call_id) + .await + .context("tools/call failed in hybrid case")?; + let echo_text = extract_first_content_text(&call_response)?; + assert!( + echo_text.contains("legacy-client-hello"), + "unexpected echo output in hybrid case: {echo_text}" + ); + + let unknown_id = serde_json::json!(4); + let unknown_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: unknown_id.clone(), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "name": "no_such_tool", + "arguments": {} + })), + }); + + let unknown_response = + send_legacy_request_and_wait(&proxy, &mut rx, unknown_request, &unknown_id).await?; + assert_error_response(&unknown_response)?; + + proxy.stop().await.context("failed to stop legacy proxy")?; + + Ok(()) + } + .await; + + server_task.abort(); + + if server_task.is_finished() { + let _ = server_task.await; + } + + outcome?; + + println!("[relay-hybrid] pass"); + Ok(()) +} + +async fn run_relay_rmcp_case(relay_url: &str) -> Result<()> { + println!("[relay-rmcp] start (rmcp server + rmcp client)"); + + let server_keys = signer::generate(); + let server_pubkey_hex = server_keys.public_key().to_hex(); + + println!("[relay-rmcp] stage: spawning rmcp server task"); + let relay_url_owned = relay_url.to_string(); + let server_task = tokio::spawn(async move { + let server = NostrMCPGateway::serve_handler( + server_keys, + server_config(&relay_url_owned), + DemoServer::new(), + ) + .await + .with_context(|| format!("failed to start rmcp server on relay {relay_url_owned}"))?; + + let _ = server + .waiting() + .await + .map_err(|e| anyhow!("rmcp server exited with error: {e}"))?; + + Err(anyhow!("rmcp server stopped unexpectedly")) + }); + + sleep(RELAY_WARMUP).await; + + if server_task.is_finished() { + let res = server_task + .await + .map_err(|e| anyhow!("rmcp server task join error: {e}"))?; + return res.context("rmcp server task ended before rmcp client startup"); + } + + let outcome: Result<()> = async { + println!("[relay-rmcp] stage: starting rmcp relay client worker"); + + let client = timeout( + STARTUP_TIMEOUT, + NostrMCPProxy::serve_client_handler( + signer::generate(), + client_config(relay_url, server_pubkey_hex), + RelayRmcpClient, + ), + ) + .await + .with_context(|| { + format!( + "timed out starting rmcp relay client worker after {:?}", + STARTUP_TIMEOUT + ) + })? + .context("failed to start rmcp relay client")?; + println!("[relay-rmcp] stage: rmcp relay client started"); + + let peer = client + .peer_info() + .ok_or_else(|| anyhow!("rmcp relay client did not receive peer info"))?; + let negotiated = peer.protocol_version.to_string(); + assert!( + is_supported_protocol(&negotiated), + "unexpected negotiated protocol version: {negotiated}" + ); + + let tools = client.list_all_tools().await?; + assert!( + tools.iter().any(|t| t.name == "echo"), + "expected echo tool in rmcp relay case" + ); + + let echo = client + .call_tool(call_params( + "echo", + Some(serde_json::json!({ "message": "rmcp-relay-hello" })), + )) + .await?; + let echo_text = first_text(&echo); + assert!( + echo_text.contains("rmcp-relay-hello"), + "unexpected rmcp relay echo output: {echo_text}" + ); + + let resources = client.list_all_resources().await?; + assert!( + resources.iter().any(|r| r.uri.as_str() == "demo://readme"), + "expected demo://readme resource in rmcp relay case" + ); + + match client.call_tool(call_params("no_such_tool", None)).await { + Err(_) => {} + Ok(r) if r.is_error.unwrap_or(false) => {} + Ok(_) => bail!("expected unknown tool to fail in rmcp relay case"), + } + + client + .cancel() + .await + .context("failed to cancel rmcp relay client")?; + + Ok(()) + } + .await; + + server_task.abort(); + + if server_task.is_finished() { + let _ = server_task.await; + } + + outcome?; + + println!("[relay-rmcp] pass"); + Ok(()) +} + +fn server_config(relay_url: &str) -> GatewayConfig { + let nostr_config = NostrServerTransportConfig::default() + .with_relay_urls(vec![relay_url.to_string()]) + .with_encryption_mode(EncryptionMode::Optional) + .with_server_info( + CtxServerInfo::default() + .with_name("rmcp-matrix-server") + .with_about("rmcp matrix coverage server"), + ) + .with_announced_server(false); + GatewayConfig::new(nostr_config) +} + +fn client_config(relay_url: &str, server_pubkey: String) -> ProxyConfig { + let nostr_config = NostrClientTransportConfig::default() + .with_relay_urls(vec![relay_url.to_string()]) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(EncryptionMode::Optional); + ProxyConfig::new(nostr_config) +} + +async fn send_legacy_request_and_wait( + proxy: &NostrMCPProxy, + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + request: JsonRpcMessage, + expected_id: &serde_json::Value, +) -> Result { + proxy.send(&request).await?; + + loop { + let maybe_msg = timeout(IO_TIMEOUT, rx.recv()) + .await + .context("timed out waiting for legacy response")?; + + let msg = maybe_msg.ok_or_else(|| anyhow!("legacy response channel closed"))?; + + if msg.id() == Some(expected_id) { + return Ok(msg); + } + + if msg.is_notification() { + continue; + } + } +} + +fn extract_tools_list(response: &JsonRpcMessage) -> Result<&Vec> { + let JsonRpcMessage::Response(resp) = response else { + bail!("expected tools/list response, got {response:?}"); + }; + + resp.result + .get("tools") + .and_then(|v| v.as_array()) + .ok_or_else(|| anyhow!("tools/list response missing tools array")) +} + +fn extract_first_content_text(response: &JsonRpcMessage) -> Result { + let JsonRpcMessage::Response(resp) = response else { + bail!("expected tools/call response, got {response:?}"); + }; + + let text = resp + .result + .get("content") + .and_then(|v| v.as_array()) + .and_then(|items| items.first()) + .and_then(|item| item.get("text")) + .and_then(|text| text.as_str()) + .ok_or_else(|| anyhow!("tools/call response missing content[0].text"))?; + + Ok(text.to_string()) +} + +fn assert_initialize_shape(response: &JsonRpcMessage) -> Result<()> { + let JsonRpcMessage::Response(resp) = response else { + bail!("expected initialize response, got {response:?}"); + }; + let expected_protocol = mcp_protocol_version(); + let protocol = resp + .result + .get("protocolVersion") + .and_then(|v| v.as_str()) + .ok_or_else(|| anyhow!("initialize response missing protocolVersion"))?; + + if !is_supported_protocol(protocol) { + bail!( + "unexpected protocolVersion in initialize response: expected one of [{expected_protocol}, {}], got {protocol}", + ProtocolVersion::LATEST + ); + } + + if resp.result.get("serverInfo").is_none() { + bail!("initialize response missing serverInfo"); + } + + Ok(()) +} + +fn is_supported_protocol(protocol: &str) -> bool { + protocol == mcp_protocol_version() || protocol == ProtocolVersion::LATEST.to_string() +} + +fn assert_error_response(response: &JsonRpcMessage) -> Result<()> { + match response { + JsonRpcMessage::ErrorResponse(err) => { + if err.error.code >= 0 { + bail!( + "expected negative JSON-RPC error code, got {}", + err.error.code + ); + } + Ok(()) + } + JsonRpcMessage::Response(resp) => { + if resp.result.get("isError") == Some(&serde_json::json!(true)) { + Ok(()) + } else { + bail!("expected error response but received success result") + } + } + _ => bail!("expected error response, got {response:?}"), + } +} + +fn call_params(name: &'static str, args: Option) -> CallToolRequestParams { + let mut params = CallToolRequestParams::new(name); + if let Some(v) = args.and_then(|v| serde_json::from_value(v).ok()) { + params = params.with_arguments(v); + } + params +} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|content| { + if let RawContent::Text(t) = &content.raw { + Some(t.text.clone()) + } else { + None + } + }) + .unwrap_or_default() +} diff --git a/rust-sdk b/rust-sdk new file mode 160000 index 0000000..3bf5298 --- /dev/null +++ b/rust-sdk @@ -0,0 +1 @@ +Subproject commit 3bf5298972d34e88bc3666ad601c8752718fc605 diff --git a/sdk b/sdk new file mode 160000 index 0000000..572926d --- /dev/null +++ b/sdk @@ -0,0 +1 @@ +Subproject commit 572926d78adb11ec488aaedd4a377ea2ea4070bd diff --git a/src/core/constants.rs b/src/core/constants.rs index 3748a3b..e06af99 100644 --- a/src/core/constants.rs +++ b/src/core/constants.rs @@ -9,6 +9,15 @@ pub const CTXVM_MESSAGES_KIND: u16 = 25910; /// Encrypted messages using NIP-59 Gift Wrap (kind 1059) pub const GIFT_WRAP_KIND: u16 = 1059; +/// Ephemeral variant of NIP-59 Gift Wrap (kind 21059, CEP-19) +/// +/// Same structure and semantics as kind 1059, but in NIP-01's ephemeral range. +/// Relays are not expected to store ephemeral events beyond transient forwarding. +pub const EPHEMERAL_GIFT_WRAP_KIND: u16 = 21059; + +/// Replaceable relay list metadata event following NIP-65 (CEP-17) +pub const RELAY_LIST_METADATA_KIND: u16 = 10002; + /// Server announcement (addressable, kind 11316) pub const SERVER_ANNOUNCEMENT_KIND: u16 = 11316; @@ -29,6 +38,9 @@ pub mod tags { /// Public key tag pub const PUBKEY: &str = "p"; + /// Relay URL tag (CEP-17) + pub const RELAY: &str = "r"; + /// Event ID tag for correlation pub const EVENT_ID: &str = "e"; @@ -49,11 +61,52 @@ pub mod tags { /// Support encryption tag pub const SUPPORT_ENCRYPTION: &str = "support_encryption"; + + /// Support ephemeral gift wrap kind (21059) for encrypted messages (CEP-19) + pub const SUPPORT_ENCRYPTION_EPHEMERAL: &str = "support_encryption_ephemeral"; + + /// Support CEP-22 oversized payload transfer via notifications/progress framing + pub const SUPPORT_OVERSIZED_TRANSFER: &str = "support_oversized_transfer"; + + /// Support CEP-41 open-ended streaming via notifications/progress framing + pub const SUPPORT_OPEN_STREAM: &str = "support_open_stream"; } /// Maximum message size (1MB) pub const MAX_MESSAGE_SIZE: usize = 1024 * 1024; +/// Default LRU cache size for deduplication +pub const DEFAULT_LRU_SIZE: usize = 5000; + +/// Default timeout for network/relay operations (30 seconds) +pub const DEFAULT_TIMEOUT_MS: u64 = 30_000; + +/// Default relay targets for discoverability publication (CEP-17). +/// +/// These are used as additional publication targets for server metadata, +/// even when they are not part of the server's operational relay list. +pub const DEFAULT_BOOTSTRAP_RELAY_URLS: &[&str] = &[ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nos.lol", + "wss://relay.snort.social/", + "wss://nostr.mom/", + "wss://nostr.oxtr.dev/", +]; + +/// MCP protocol method for the initialization request +pub const INITIALIZE_METHOD: &str = "initialize"; + +/// MCP protocol method for the initialized notification +pub const NOTIFICATIONS_INITIALIZED_METHOD: &str = "notifications/initialized"; + +/// Sentinel request ID for the announcement auto-publish flow. +/// +/// Synthetic initialize and capability-list requests use this ID so the +/// worker routes responses to the announcement handler rather than the +/// normal client response path. +pub const ANNOUNCEMENT_REQUEST_ID: &str = "announcement"; + /// Kinds that should never be encrypted (public announcements) pub const UNENCRYPTED_KINDS: &[u16] = &[ SERVER_ANNOUNCEMENT_KIND, @@ -62,3 +115,131 @@ pub const UNENCRYPTED_KINDS: &[u16] = &[ RESOURCETEMPLATES_LIST_KIND, PROMPTS_LIST_KIND, ]; + +/// Return the latest MCP protocol version string +#[cfg(feature = "rmcp")] +pub fn mcp_protocol_version() -> &'static str { + use std::sync::OnceLock; + static VERSION: OnceLock = OnceLock::new(); + VERSION + .get_or_init(|| rmcp::model::ProtocolVersion::LATEST.to_string()) + .as_str() +} + +/// Return the latest MCP protocol version string +#[cfg(not(feature = "rmcp"))] +pub const fn mcp_protocol_version() -> &'static str { + "2025-11-25" +} + +// Compile-time range checks (NIP-01 kind ranges). +// Placed at module level so violations are caught in every build, not just `cargo test`. +const _: () = { + // Ephemeral events: 20000 <= kind < 30000 + assert!(EPHEMERAL_GIFT_WRAP_KIND >= 20000); + assert!(EPHEMERAL_GIFT_WRAP_KIND < 30000); + assert!(CTXVM_MESSAGES_KIND >= 20000); + assert!(CTXVM_MESSAGES_KIND < 30000); + // Replaceable events: 10000 <= kind < 20000 + assert!(RELAY_LIST_METADATA_KIND >= 10000); + assert!(RELAY_LIST_METADATA_KIND < 20000); +}; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_event_kind_values_match_spec() { + assert_eq!(CTXVM_MESSAGES_KIND, 25910); + assert_eq!(GIFT_WRAP_KIND, 1059); + assert_eq!(EPHEMERAL_GIFT_WRAP_KIND, 21059); + assert_eq!(RELAY_LIST_METADATA_KIND, 10002); + assert_eq!(SERVER_ANNOUNCEMENT_KIND, 11316); + assert_eq!(TOOLS_LIST_KIND, 11317); + assert_eq!(RESOURCES_LIST_KIND, 11318); + assert_eq!(RESOURCETEMPLATES_LIST_KIND, 11319); + assert_eq!(PROMPTS_LIST_KIND, 11320); + } + + #[test] + fn test_tag_values_match_ts_sdk() { + assert_eq!(tags::PUBKEY, "p"); + assert_eq!(tags::RELAY, "r"); + assert_eq!(tags::EVENT_ID, "e"); + assert_eq!(tags::CAPABILITY, "cap"); + assert_eq!(tags::NAME, "name"); + assert_eq!(tags::WEBSITE, "website"); + assert_eq!(tags::PICTURE, "picture"); + assert_eq!(tags::ABOUT, "about"); + assert_eq!(tags::SUPPORT_ENCRYPTION, "support_encryption"); + assert_eq!( + tags::SUPPORT_ENCRYPTION_EPHEMERAL, + "support_encryption_ephemeral" + ); + assert_eq!( + tags::SUPPORT_OVERSIZED_TRANSFER, + "support_oversized_transfer" + ); + } + + #[test] + fn test_announcement_kinds_in_addressable_range() { + // NIP-01: addressable events are 30000 <= kind < 40000 + // However, the spec uses 11316-11320 which are in the replaceable range. + // These are parameterized replaceable events per the ContextVM spec. + for &kind in UNENCRYPTED_KINDS { + assert!(kind >= 11316); + assert!(kind <= 11320); + } + } + + #[test] + fn test_bootstrap_relays_are_wss() { + for url in DEFAULT_BOOTSTRAP_RELAY_URLS { + assert!( + url.starts_with("wss://"), + "Bootstrap relay must use wss: {url}" + ); + } + } + + #[test] + fn test_bootstrap_relays_nonempty() { + assert!( + !DEFAULT_BOOTSTRAP_RELAY_URLS.is_empty(), + "Must have at least one bootstrap relay" + ); + } + + #[test] + fn test_mcp_method_constants() { + assert_eq!(INITIALIZE_METHOD, "initialize"); + assert_eq!( + NOTIFICATIONS_INITIALIZED_METHOD, + "notifications/initialized" + ); + } + + #[test] + fn test_announcement_request_id() { + assert_eq!(ANNOUNCEMENT_REQUEST_ID, "announcement"); + // Must differ from the stateless synthetic sentinel used by the worker + assert_ne!(ANNOUNCEMENT_REQUEST_ID, "contextvm-stateless-init"); + } + + #[test] + fn test_unencrypted_kinds_contains_all_announcements() { + assert!(UNENCRYPTED_KINDS.contains(&SERVER_ANNOUNCEMENT_KIND)); + assert!(UNENCRYPTED_KINDS.contains(&TOOLS_LIST_KIND)); + assert!(UNENCRYPTED_KINDS.contains(&RESOURCES_LIST_KIND)); + assert!(UNENCRYPTED_KINDS.contains(&RESOURCETEMPLATES_LIST_KIND)); + assert!(UNENCRYPTED_KINDS.contains(&PROMPTS_LIST_KIND)); + } + + #[test] + fn test_gift_wrap_not_in_unencrypted() { + assert!(!UNENCRYPTED_KINDS.contains(&GIFT_WRAP_KIND)); + assert!(!UNENCRYPTED_KINDS.contains(&EPHEMERAL_GIFT_WRAP_KIND)); + } +} diff --git a/src/core/error.rs b/src/core/error.rs index fe8891a..5a70a81 100644 --- a/src/core/error.rs +++ b/src/core/error.rs @@ -34,6 +34,14 @@ pub enum Error { #[error("Serialization error: {0}")] Serialization(#[from] serde_json::Error), + /// CEP-22 oversized payload transfer error (framing/reassembly) + #[error("Oversized transfer error: {0}")] + OversizedTransfer(#[from] crate::transport::oversized_transfer::OversizedTransferError), + + /// CEP-41 open-stream error (sequencing/policy/abort) + #[error("Open stream error: {0}")] + OpenStream(#[from] crate::transport::open_stream::OpenStreamError), + /// Generic error #[error("{0}")] Other(String), diff --git a/src/core/serializers.rs b/src/core/serializers.rs index 3d641fd..69c519c 100644 --- a/src/core/serializers.rs +++ b/src/core/serializers.rs @@ -50,7 +50,7 @@ pub fn get_tag_value_from_slice(tags: &[Tag], name: &str) -> Option { #[cfg(test)] mod tests { use super::*; - use crate::core::types::{JsonRpcRequest, JsonRpcMessage}; + use crate::core::types::{JsonRpcMessage, JsonRpcRequest}; #[test] fn test_roundtrip() { diff --git a/src/core/types.rs b/src/core/types.rs index cb10773..b790e8a 100644 --- a/src/core/types.rs +++ b/src/core/types.rs @@ -4,16 +4,19 @@ use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::time::Instant; +use crate::core::constants::{EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND}; + // ── Encryption mode ───────────────────────────────────────────────── /// Encryption mode for transport communication. /// /// Controls whether MCP messages are sent as plaintext kind 25910 events /// or wrapped in NIP-59 gift wraps (kind 1059) for end-to-end encryption. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] #[serde(rename_all = "lowercase")] pub enum EncryptionMode { /// Encrypt responses only when the incoming request was encrypted (mirror mode). + #[default] Optional, /// Enforce encryption for all messages; reject plaintext. Required, @@ -21,9 +24,35 @@ pub enum EncryptionMode { Disabled, } -impl Default for EncryptionMode { - fn default() -> Self { - Self::Optional +/// Gift-wrap policy for encrypted transport communication (CEP-19) +/// +/// Controls whether encrypted messages use persistent gift wraps (kind `1059`), +/// ephemeral gift wraps (kind `21059`), or adapt based on peer support. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum GiftWrapMode { + /// Prefer persistent gift wraps until ephemeral support is explicitly chosen or learned. + #[default] + Optional, + /// Force the ephemeral gift-wrap kind (`21059`) for encrypted messages. + Ephemeral, + /// Force the persistent gift-wrap kind (`1059`) for encrypted messages. + Persistent, +} + +impl GiftWrapMode { + /// Returns whether this mode accepts the given encrypted outer event kind. + pub fn allows_kind(self, kind: u16) -> bool { + match self { + Self::Optional => kind == GIFT_WRAP_KIND || kind == EPHEMERAL_GIFT_WRAP_KIND, + Self::Ephemeral => kind == EPHEMERAL_GIFT_WRAP_KIND, + Self::Persistent => kind == GIFT_WRAP_KIND, + } + } + + /// Returns whether this mode supports sending and advertising ephemeral gift wraps. + pub fn supports_ephemeral(self) -> bool { + !matches!(self, Self::Persistent) } } @@ -34,6 +63,7 @@ impl Default for EncryptionMode { /// Published as the content of a replaceable Nostr event so that clients /// can discover the server's identity and metadata. #[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[non_exhaustive] pub struct ServerInfo { /// Human-readable server name. #[serde(skip_serializing_if = "Option::is_none")] @@ -52,15 +82,129 @@ pub struct ServerInfo { pub about: Option, } +impl ServerInfo { + /// Set the server name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + /// Set the server version. + pub fn with_version(mut self, version: impl Into) -> Self { + self.version = Some(version.into()); + self + } + /// Set the server picture URL. + pub fn with_picture(mut self, picture: impl Into) -> Self { + self.picture = Some(picture.into()); + self + } + /// Set the server website URL. + pub fn with_website(mut self, website: impl Into) -> Self { + self.website = Some(website.into()); + self + } + /// Set the server description. + pub fn with_about(mut self, about: impl Into) -> Self { + self.about = Some(about.into()); + self + } +} + +// ── Profile metadata ──────────────────────────────────────────────── + +/// Nostr profile metadata for server identity (NIP-01 kind 0 / CEP-23). +/// +/// Opt-in profile that servers can publish so clients see a human-friendly +/// identity on the Nostr network. Serialized as the content of a kind 0 event. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[non_exhaustive] +pub struct ProfileMetadata { + /// Display name. + #[serde(skip_serializing_if = "Option::is_none")] + pub name: Option, + /// Short description or bio. + #[serde(skip_serializing_if = "Option::is_none")] + pub about: Option, + /// Avatar / profile picture URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub picture: Option, + /// Banner image URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub banner: Option, + /// Website URL. + #[serde(skip_serializing_if = "Option::is_none")] + pub website: Option, + /// NIP-05 verification identifier (e.g. `user@example.com`). + #[serde(skip_serializing_if = "Option::is_none")] + pub nip05: Option, + /// Lightning address for payments (LUD-16). + #[serde(skip_serializing_if = "Option::is_none")] + pub lud16: Option, + /// Arbitrary additional fields preserved across round-trips. + #[serde(flatten)] + pub extra: HashMap, +} + +impl ProfileMetadata { + /// Set the display name. + pub fn with_name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); + self + } + /// Set the description / bio. + pub fn with_about(mut self, about: impl Into) -> Self { + self.about = Some(about.into()); + self + } + /// Set the avatar URL. + pub fn with_picture(mut self, picture: impl Into) -> Self { + self.picture = Some(picture.into()); + self + } + /// Set the banner image URL. + pub fn with_banner(mut self, banner: impl Into) -> Self { + self.banner = Some(banner.into()); + self + } + /// Set the website URL. + pub fn with_website(mut self, website: impl Into) -> Self { + self.website = Some(website.into()); + self + } + /// Set the NIP-05 verification identifier. + pub fn with_nip05(mut self, nip05: impl Into) -> Self { + self.nip05 = Some(nip05.into()); + self + } + /// Set the Lightning address (LUD-16). + pub fn with_lud16(mut self, lud16: impl Into) -> Self { + self.lud16 = Some(lud16.into()); + self + } +} + // ── Client session ────────────────────────────────────────────────── /// Client session state tracked by the server transport. -#[derive(Debug)] +#[derive(Debug, Clone)] pub struct ClientSession { /// Whether the client has completed MCP initialization. pub is_initialized: bool, /// Whether the client's messages were encrypted. pub is_encrypted: bool, + /// Whether server discovery tags have been sent to this client (one-shot flag). + pub has_sent_common_tags: bool, + /// Whether the client has demonstrated support for ephemeral gift wraps (CEP-19). + pub supports_ephemeral_gift_wrap: bool, + /// Learned from client discovery tags: peer supports NIP-44 encryption. + pub supports_encryption: bool, + /// Learned from client discovery tags: peer supports ephemeral gift wraps (CEP-19). + pub supports_ephemeral_encryption: bool, + /// Learned from client discovery tags: peer supports CEP-22 oversized transfer. + pub supports_oversized_transfer: bool, + /// Learned from client discovery tags: peer supports CEP-41 open streams + /// (learned, gated by server config; data only until PR2 activation). + pub supports_open_stream: bool, /// Last activity timestamp. pub last_activity: Instant, /// Pending requests: event_id → original request ID. @@ -75,6 +219,12 @@ impl ClientSession { Self { is_initialized: false, is_encrypted, + has_sent_common_tags: false, + supports_ephemeral_gift_wrap: false, + supports_encryption: false, + supports_ephemeral_encryption: false, + supports_oversized_transfer: false, + supports_open_stream: false, last_activity: Instant::now(), pending_requests: HashMap::new(), event_to_progress_token: HashMap::new(), @@ -220,10 +370,393 @@ impl JsonRpcMessage { // ── Capability exclusion ──────────────────────────────────────────── /// A capability exclusion pattern that bypasses pubkey whitelisting. -#[derive(Debug, Clone)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct CapabilityExclusion { /// The JSON-RPC method to exclude (e.g., "tools/call", "tools/list"). pub method: String, /// Optional capability name for method-specific exclusions (e.g., "get_weather"). pub name: Option, } + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::constants::{EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND}; + use serde_json::json; + use std::thread; + use std::time::Duration; + + #[test] + fn test_encryption_mode_serde_roundtrip_optional() { + let mode = EncryptionMode::Optional; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"optional\""); + let parsed: EncryptionMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_encryption_mode_serde_roundtrip_required() { + let mode = EncryptionMode::Required; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"required\""); + let parsed: EncryptionMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_encryption_mode_serde_roundtrip_disabled() { + let mode = EncryptionMode::Disabled; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"disabled\""); + let parsed: EncryptionMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_gift_wrap_mode_serde_roundtrip_optional() { + let mode = GiftWrapMode::Optional; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"optional\""); + let parsed: GiftWrapMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_gift_wrap_mode_serde_roundtrip_ephemeral() { + let mode = GiftWrapMode::Ephemeral; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"ephemeral\""); + let parsed: GiftWrapMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_gift_wrap_mode_serde_roundtrip_persistent() { + let mode = GiftWrapMode::Persistent; + let s = serde_json::to_string(&mode).unwrap(); + assert_eq!(s, "\"persistent\""); + let parsed: GiftWrapMode = serde_json::from_str(&s).unwrap(); + assert_eq!(parsed, mode); + } + + #[test] + fn test_gift_wrap_mode_policy_helpers() { + // Optional accepts both kinds + assert!(GiftWrapMode::Optional.allows_kind(GIFT_WRAP_KIND)); + assert!(GiftWrapMode::Optional.allows_kind(EPHEMERAL_GIFT_WRAP_KIND)); + // Ephemeral only accepts 21059 + assert!(GiftWrapMode::Ephemeral.allows_kind(EPHEMERAL_GIFT_WRAP_KIND)); + assert!(!GiftWrapMode::Ephemeral.allows_kind(GIFT_WRAP_KIND)); + // Persistent only accepts 1059 + assert!(GiftWrapMode::Persistent.allows_kind(GIFT_WRAP_KIND)); + assert!(!GiftWrapMode::Persistent.allows_kind(EPHEMERAL_GIFT_WRAP_KIND)); + // supports_ephemeral check + assert!(GiftWrapMode::Optional.supports_ephemeral()); + assert!(GiftWrapMode::Ephemeral.supports_ephemeral()); + assert!(!GiftWrapMode::Persistent.supports_ephemeral()); + } + + fn assert_json_rpc_roundtrip(msg: &JsonRpcMessage) { + let wire = serde_json::to_string(msg).unwrap(); + let parsed: JsonRpcMessage = serde_json::from_str(&wire).unwrap(); + let before = serde_json::to_value(msg).unwrap(); + let after = serde_json::to_value(&parsed).unwrap(); + assert_eq!(before, after); + } + + #[test] + fn test_json_rpc_message_serde_roundtrip_request() { + let msg = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!(42), + method: "tools/list".to_string(), + params: Some(json!({ "cursor": null })), + }); + assert_json_rpc_roundtrip(&msg); + } + + #[test] + fn test_json_rpc_message_serde_roundtrip_request_without_params() { + let msg = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!("req-id"), + method: "ping".to_string(), + params: None, + }); + assert_json_rpc_roundtrip(&msg); + } + + #[test] + fn test_json_rpc_message_serde_roundtrip_response() { + let msg = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: json!(1), + result: json!({ "tools": [] }), + }); + assert_json_rpc_roundtrip(&msg); + } + + #[test] + fn test_json_rpc_message_serde_roundtrip_error_response() { + let msg = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: json!(99), + error: JsonRpcError { + code: -32600, + message: "Invalid Request".to_string(), + data: Some(json!({ "hint": "fix it" })), + }, + }); + assert_json_rpc_roundtrip(&msg); + } + + #[test] + fn test_json_rpc_message_serde_roundtrip_notification() { + let msg = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + assert_json_rpc_roundtrip(&msg); + } + + #[test] + fn test_json_rpc_message_type_predicates() { + let req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!(1), + method: "m".to_string(), + params: None, + }); + let res = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: json!(1), + result: json!(null), + }); + let err = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: json!(1), + error: JsonRpcError { + code: -1, + message: "e".to_string(), + data: None, + }, + }); + let notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "n".to_string(), + params: None, + }); + + assert!(req.is_request()); + assert!(res.is_response()); + assert!(err.is_error()); + assert!(notif.is_notification()); + } + + #[test] + fn test_json_rpc_error_data_none_omitted() { + let err = JsonRpcError { + code: -32600, + message: "bad".to_string(), + data: None, + }; + let json_str = serde_json::to_string(&err).unwrap(); + let value: serde_json::Value = serde_json::from_str(&json_str).unwrap(); + let obj = value.as_object().expect("error object"); + assert!( + !obj.contains_key("data"), + "expected data omitted when None, got: {json_str}" + ); + } + + #[test] + fn test_json_rpc_message_method() { + let req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!(0), + method: "tools/call".to_string(), + params: None, + }); + let res = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: json!(0), + result: json!(null), + }); + let err = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: json!(0), + error: JsonRpcError { + code: 0, + message: "e".to_string(), + data: None, + }, + }); + let notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: None, + }); + + assert_eq!(req.method(), Some("tools/call")); + assert_eq!(res.method(), None); + assert_eq!(err.method(), None); + assert_eq!(notif.method(), Some("notifications/progress")); + } + + #[test] + fn test_json_rpc_message_id() { + let req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!("abc"), + method: "m".to_string(), + params: None, + }); + let res = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: json!(7), + result: json!(null), + }); + let err = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: json!([1, 2]), + error: JsonRpcError { + code: 0, + message: "e".to_string(), + data: None, + }, + }); + let notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "n".to_string(), + params: None, + }); + + assert_eq!(req.id(), Some(&json!("abc"))); + assert_eq!(res.id(), Some(&json!(7))); + assert_eq!(err.id(), Some(&json!([1, 2]))); + assert_eq!(notif.id(), None); + } + + #[test] + fn test_server_info_serde_all_fields_present() { + let info = ServerInfo { + name: Some("Test Server".to_string()), + version: Some("1.0.0".to_string()), + picture: Some("https://example.com/p.png".to_string()), + website: Some("https://example.com".to_string()), + about: Some("About text".to_string()), + }; + let json_str = serde_json::to_string(&info).unwrap(); + let parsed: ServerInfo = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed.name, info.name); + assert_eq!(parsed.version, info.version); + assert_eq!(parsed.picture, info.picture); + assert_eq!(parsed.website, info.website); + assert_eq!(parsed.about, info.about); + } + + #[test] + fn test_server_info_serde_optional_fields_omitted() { + let info = ServerInfo { + name: None, + version: None, + picture: None, + website: None, + about: None, + }; + let json_str = serde_json::to_string(&info).unwrap(); + assert_eq!(json_str, "{}"); + } + + #[test] + fn test_client_session_new_initial_state_encrypted() { + let session = ClientSession::new(true); + assert!(!session.is_initialized); + assert!(session.is_encrypted); + assert!(session.pending_requests.is_empty()); + assert!(session.event_to_progress_token.is_empty()); + } + + #[test] + fn test_client_session_new_initial_state_plaintext() { + let session = ClientSession::new(false); + assert!(!session.is_initialized); + assert!(!session.is_encrypted); + assert!(session.pending_requests.is_empty()); + assert!(session.event_to_progress_token.is_empty()); + } + + #[test] + fn test_client_session_update_activity() { + let mut session = ClientSession::new(false); + let first = session.last_activity; + thread::sleep(Duration::from_millis(10)); + session.update_activity(); + assert!(session.last_activity > first); + } + + #[test] + fn test_profile_metadata_serde_roundtrip() { + let meta = ProfileMetadata { + name: Some("My Server".to_string()), + about: Some("Does things".to_string()), + picture: Some("https://example.com/pic.png".to_string()), + banner: Some("https://example.com/banner.png".to_string()), + website: Some("https://example.com".to_string()), + nip05: Some("server@example.com".to_string()), + lud16: Some("server@getalby.com".to_string()), + extra: HashMap::new(), + }; + let json_str = serde_json::to_string(&meta).unwrap(); + let parsed: ProfileMetadata = serde_json::from_str(&json_str).unwrap(); + assert_eq!(parsed.name, meta.name); + assert_eq!(parsed.about, meta.about); + assert_eq!(parsed.picture, meta.picture); + assert_eq!(parsed.banner, meta.banner); + assert_eq!(parsed.website, meta.website); + assert_eq!(parsed.nip05, meta.nip05); + assert_eq!(parsed.lud16, meta.lud16); + } + + #[test] + fn test_profile_metadata_default_serializes_empty() { + let meta = ProfileMetadata::default(); + let json_str = serde_json::to_string(&meta).unwrap(); + assert_eq!(json_str, "{}"); + } + + #[test] + fn test_profile_metadata_preserves_custom_fields() { + let json_str = r#"{"name":"Srv","custom_flag":true,"rank":42}"#; + let parsed: ProfileMetadata = serde_json::from_str(json_str).unwrap(); + assert_eq!(parsed.name, Some("Srv".to_string())); + assert_eq!(parsed.extra.get("custom_flag"), Some(&json!(true))); + assert_eq!(parsed.extra.get("rank"), Some(&json!(42))); + + let reserialized = serde_json::to_string(&parsed).unwrap(); + let reparsed: serde_json::Value = serde_json::from_str(&reserialized).unwrap(); + assert_eq!(reparsed["custom_flag"], json!(true)); + assert_eq!(reparsed["rank"], json!(42)); + } + + #[test] + fn test_profile_metadata_builder() { + let meta = ProfileMetadata::default() + .with_name("Test") + .with_about("Bio") + .with_picture("https://pic.url") + .with_banner("https://banner.url") + .with_website("https://web.url") + .with_nip05("user@example.com") + .with_lud16("user@getalby.com"); + assert_eq!(meta.name.as_deref(), Some("Test")); + assert_eq!(meta.about.as_deref(), Some("Bio")); + assert_eq!(meta.picture.as_deref(), Some("https://pic.url")); + assert_eq!(meta.banner.as_deref(), Some("https://banner.url")); + assert_eq!(meta.website.as_deref(), Some("https://web.url")); + assert_eq!(meta.nip05.as_deref(), Some("user@example.com")); + assert_eq!(meta.lud16.as_deref(), Some("user@getalby.com")); + } +} diff --git a/src/core/validation.rs b/src/core/validation.rs index d409b7b..355c60d 100644 --- a/src/core/validation.rs +++ b/src/core/validation.rs @@ -8,6 +8,40 @@ pub fn validate_message_size(content: &str) -> bool { content.len() <= MAX_MESSAGE_SIZE } +/// Validate size and structure, then parse into a [`JsonRpcMessage`]. +pub fn validate_and_parse(content: &str) -> Option { + if !validate_message_size(content) { + tracing::warn!("Message size validation failed: {} bytes", content.len()); + return None; + } + + let value: serde_json::Value = serde_json::from_str(content).ok()?; + validate_message(&value) +} + +/// Validate size against a custom byte bound, then parse into a [`JsonRpcMessage`]. +/// +/// Unlike [`validate_and_parse`], which enforces the 1 MB per-event cap +/// ([`MAX_MESSAGE_SIZE`]), this bounds `content` to `max_bytes` — the receiver +/// policy's `maxTransferBytes` (100 MiB default). It is the dedicated entrypoint +/// for **reassembled** CEP-22 oversized payloads, which legitimately exceed the +/// per-event cap (the very limit the transfer profile works around). The bound +/// keeps a single ceiling on reassembled-payload memory rather than leaving it +/// unbounded. +pub fn validate_and_parse_oversized(content: &str, max_bytes: usize) -> Option { + if content.len() > max_bytes { + tracing::warn!( + "Oversized message exceeds max transfer bytes: {} > {}", + content.len(), + max_bytes + ); + return None; + } + + let value: serde_json::Value = serde_json::from_str(content).ok()?; + validate_message(&value) +} + /// Validate that a JSON value is a well-formed JSON-RPC 2.0 message. /// /// Checks: @@ -61,4 +95,61 @@ mod tests { let big = "x".repeat(MAX_MESSAGE_SIZE + 1); assert!(!validate_message_size(&big)); } + + #[test] + fn test_validate_and_parse_valid_request() { + let content = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + let msg = validate_and_parse(content).unwrap(); + assert!(msg.is_request()); + assert_eq!(msg.method(), Some("tools/list")); + } + + #[test] + fn test_validate_and_parse_rejects_oversized() { + let padding = "x".repeat(MAX_MESSAGE_SIZE); + let content = format!(r#"{{"jsonrpc":"2.0","id":1,"method":"{}"}}"#, padding); + assert!(validate_and_parse(&content).is_none()); + } + + #[test] + fn test_validate_and_parse_rejects_invalid_version() { + let content = r#"{"jsonrpc":"1.0","id":1,"method":"test"}"#; + assert!(validate_and_parse(content).is_none()); + } + + #[test] + fn test_validate_and_parse_rejects_invalid_json() { + assert!(validate_and_parse("not json").is_none()); + } + + #[test] + fn test_validate_and_parse_oversized_bypasses_1mb_cap() { + // A valid message larger than the 1 MB per-event cap. + let big_value = "m".repeat(MAX_MESSAGE_SIZE + 1024); + let content = format!(r#"{{"jsonrpc":"2.0","id":1,"result":{{"value":"{big_value}"}}}}"#); + assert!(content.len() > MAX_MESSAGE_SIZE); + + // The standard entrypoint rejects it on size... + assert!(validate_and_parse(&content).is_none()); + // ...but the oversized entrypoint accepts it under a higher bound. + let parsed = validate_and_parse_oversized(&content, 100 * 1024 * 1024).unwrap(); + assert!(parsed.is_response()); + } + + #[test] + fn test_validate_and_parse_oversized_enforces_its_own_bound() { + let content = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + // Rejected when the content exceeds the supplied max_bytes. + assert!(validate_and_parse_oversized(content, 8).is_none()); + // Accepted under a sufficient bound. + assert!(validate_and_parse_oversized(content, 1024) + .unwrap() + .is_request()); + } + + #[test] + fn test_validate_and_parse_oversized_rejects_invalid_version() { + let content = r#"{"jsonrpc":"1.0","id":1,"method":"test"}"#; + assert!(validate_and_parse_oversized(content, 1024).is_none()); + } } diff --git a/src/discovery/mod.rs b/src/discovery/mod.rs index eb0e876..87e76aa 100644 --- a/src/discovery/mod.rs +++ b/src/discovery/mod.rs @@ -42,12 +42,18 @@ pub struct ServerAnnouncement { pub pubkey: String, /// Parsed public key. pub pubkey_parsed: PublicKey, - /// Server information from the announcement content. + /// Server information extracted from the announcement content. pub server_info: ServerInfo, /// The Nostr event ID of the announcement. pub event_id: EventId, /// When the announcement was created. pub created_at: Timestamp, + /// MCP protocol version (present when content is a full `InitializeResult`). + pub protocol_version: Option, + /// Server capabilities (present when content is a full `InitializeResult`). + pub capabilities: Option, + /// Human-readable instructions (present when content is a full `InitializeResult`). + pub instructions: Option, } /// Discover MCP servers by fetching kind 11316 announcement events from relays. @@ -64,14 +70,17 @@ pub async fn discover_servers( let mut announcements = Vec::new(); for event in events { - let server_info: ServerInfo = - serde_json::from_str(&event.content).unwrap_or_default(); + let (server_info, protocol_version, capabilities, instructions) = + parse_announcement_content(&event.content); announcements.push(ServerAnnouncement { pubkey: event.pubkey.to_hex(), pubkey_parsed: event.pubkey, server_info, event_id: event.id, created_at: event.created_at, + protocol_version, + capabilities, + instructions, }); } @@ -120,8 +129,118 @@ pub async fn discover_resource_templates( .await } +/// Discover tools and parse them into rmcp typed descriptors. +#[cfg(feature = "rmcp")] +pub async fn discover_tools_typed( + client: &Arc, + server_pubkey: &PublicKey, + relay_urls: &[String], +) -> Result> { + let raw = discover_tools(client, server_pubkey, relay_urls).await?; + parse_typed_list(raw) +} + +/// Discover resources and parse them into rmcp typed descriptors. +#[cfg(feature = "rmcp")] +pub async fn discover_resources_typed( + client: &Arc, + server_pubkey: &PublicKey, + relay_urls: &[String], +) -> Result> { + let raw = discover_resources(client, server_pubkey, relay_urls).await?; + parse_typed_list(raw) +} + +/// Discover prompts and parse them into rmcp typed descriptors. +#[cfg(feature = "rmcp")] +pub async fn discover_prompts_typed( + client: &Arc, + server_pubkey: &PublicKey, + relay_urls: &[String], +) -> Result> { + let raw = discover_prompts(client, server_pubkey, relay_urls).await?; + parse_typed_list(raw) +} + +/// Discover resource templates and parse them into rmcp typed descriptors. +#[cfg(feature = "rmcp")] +pub async fn discover_resource_templates_typed( + client: &Arc, + server_pubkey: &PublicKey, + relay_urls: &[String], +) -> Result> { + let raw = discover_resource_templates(client, server_pubkey, relay_urls).await?; + parse_typed_list(raw) +} + // ── Internal ──────────────────────────────────────────────────────── +/// Parse kind 11316 event content, supporting two formats: +/// +/// - **New (InitializeResult):** `{ "protocolVersion": "…", "capabilities": {…}, +/// "serverInfo": {…}, "instructions": "…" }` — used when the server publishes +/// the full MCP InitializeResult as content. +/// - **Legacy (ServerInfo):** `{ "name": "…", "version": "…", … }` — the original +/// rs-sdk format where content is just `ServerInfo`. +fn parse_announcement_content( + content: &str, +) -> ( + ServerInfo, + Option, + Option, + Option, +) { + let Ok(value) = serde_json::from_str::(content) else { + return (ServerInfo::default(), None, None, None); + }; + + // Detect new format by the presence of "protocolVersion" (camelCase from rmcp). + if value.get("protocolVersion").is_some() { + let server_info = value + .get("serverInfo") + .map(server_info_from_implementation) + .unwrap_or_default(); + let protocol_version = value + .get("protocolVersion") + .and_then(|v| v.as_str()) + .map(String::from); + let capabilities = value.get("capabilities").cloned(); + let instructions = value + .get("instructions") + .and_then(|v| v.as_str()) + .map(String::from); + (server_info, protocol_version, capabilities, instructions) + } else { + // Legacy: content is a flat ServerInfo object. + let server_info = serde_json::from_value::(value).unwrap_or_default(); + (server_info, None, None, None) + } +} + +/// Map an rmcp `Implementation` JSON object to our `ServerInfo`. +/// +/// Field mapping: `name`→`name`, `version`→`version`, +/// `websiteUrl`→`website`, `description`→`about`. The `picture` field has no +/// equivalent in `Implementation` so it is left `None`. +fn server_info_from_implementation(val: &serde_json::Value) -> ServerInfo { + ServerInfo { + name: val.get("name").and_then(|v| v.as_str()).map(String::from), + version: val + .get("version") + .and_then(|v| v.as_str()) + .map(String::from), + website: val + .get("websiteUrl") + .and_then(|v| v.as_str()) + .map(String::from), + about: val + .get("description") + .and_then(|v| v.as_str()) + .map(String::from), + picture: None, + } +} + async fn fetch_list( client: &Arc, server_pubkey: &PublicKey, @@ -153,6 +272,20 @@ async fn fetch_list( .unwrap_or_default()) } +#[cfg(feature = "rmcp")] +fn parse_typed_list(raw: Vec) -> Result> +where + T: serde::de::DeserializeOwned, +{ + let mut parsed = Vec::new(); + for item in raw { + let value = serde_json::from_value(item) + .map_err(|e| Error::Other(format!("Failed to parse typed discovery item: {e}")))?; + parsed.push(value); + } + Ok(parsed) +} + #[cfg(test)] mod tests { use super::*; @@ -175,7 +308,10 @@ mod tests { assert_eq!(parsed.version, Some("1.0.0".to_string())); assert_eq!(parsed.about, Some("A test MCP server".to_string())); assert_eq!(parsed.website, Some("https://example.com".to_string())); - assert_eq!(parsed.picture, Some("https://example.com/pic.png".to_string())); + assert_eq!( + parsed.picture, + Some("https://example.com/pic.png".to_string()) + ); } #[test] @@ -222,11 +358,93 @@ mod tests { }, event_id: EventId::from_hex( "0000000000000000000000000000000000000000000000000000000000000001", - ).unwrap(), + ) + .unwrap(), created_at: Timestamp::now(), + protocol_version: None, + capabilities: None, + instructions: None, }; assert_eq!(announcement.pubkey, pubkey.to_hex()); assert_eq!(announcement.server_info.name, Some("Test".to_string())); } + + #[test] + fn test_parse_announcement_content_legacy_format() { + let content = r#"{"name":"Legacy Server","version":"0.1.0","about":"Old format"}"#; + let (info, pv, caps, instr) = super::parse_announcement_content(content); + assert_eq!(info.name.as_deref(), Some("Legacy Server")); + assert_eq!(info.version.as_deref(), Some("0.1.0")); + assert_eq!(info.about.as_deref(), Some("Old format")); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_parse_announcement_content_initialize_result_format() { + let content = r#"{ + "protocolVersion": "2025-03-26", + "capabilities": { + "tools": { "listChanged": true }, + "resources": { "subscribe": false, "listChanged": false } + }, + "serverInfo": { + "name": "NewServer", + "version": "2.0.0", + "description": "Full InitializeResult", + "websiteUrl": "https://example.com" + }, + "instructions": "Use tool X for Y" + }"#; + let (info, pv, caps, instr) = super::parse_announcement_content(content); + + assert_eq!(info.name.as_deref(), Some("NewServer")); + assert_eq!(info.version.as_deref(), Some("2.0.0")); + assert_eq!(info.about.as_deref(), Some("Full InitializeResult")); + assert_eq!(info.website.as_deref(), Some("https://example.com")); + assert!(info.picture.is_none()); + + assert_eq!(pv.as_deref(), Some("2025-03-26")); + assert!(caps.is_some()); + let caps = caps.unwrap(); + assert!(caps.get("tools").is_some()); + assert_eq!(instr.as_deref(), Some("Use tool X for Y")); + } + + #[test] + fn test_parse_announcement_content_invalid_json() { + let (info, pv, caps, instr) = super::parse_announcement_content("not json"); + assert!(info.name.is_none()); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_parse_announcement_content_empty_object() { + let (info, pv, caps, instr) = super::parse_announcement_content("{}"); + assert!(info.name.is_none()); + assert!(pv.is_none()); + assert!(caps.is_none()); + assert!(instr.is_none()); + } + + #[test] + fn test_server_info_from_implementation() { + let val = serde_json::json!({ + "name": "TestImpl", + "version": "3.0", + "title": "Fancy Title", + "description": "Impl description", + "websiteUrl": "https://impl.example.com" + }); + let info = super::server_info_from_implementation(&val); + assert_eq!(info.name.as_deref(), Some("TestImpl")); + assert_eq!(info.version.as_deref(), Some("3.0")); + assert_eq!(info.website.as_deref(), Some("https://impl.example.com")); + assert_eq!(info.about.as_deref(), Some("Impl description")); + assert!(info.picture.is_none()); + } } diff --git a/src/encryption/mod.rs b/src/encryption/mod.rs index dadf331..913031d 100644 --- a/src/encryption/mod.rs +++ b/src/encryption/mod.rs @@ -3,7 +3,7 @@ //! Provides NIP-44 encryption/decryption and NIP-59 gift wrapping. //! The actual gift wrapping is done via nostr-sdk's Client for full NIP-59 compliance. -use crate::core::constants::GIFT_WRAP_KIND; +use crate::core::constants::{EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND}; use crate::core::error::{Error, Result}; use nostr_sdk::prelude::*; @@ -37,16 +37,8 @@ where .map_err(|e| Error::Decryption(e.to_string())) } -/// Decrypt a single-layer NIP-44 gift wrap (kind 1059). -/// -/// This matches the ContextVM JS/TS SDK's encryption scheme: -/// - The gift wrap event has NIP-44 encrypted content (single layer) -/// - Decrypt using recipient's key + event's pubkey (ephemeral sender) -/// - Returns the decrypted plaintext content string -pub async fn decrypt_gift_wrap_single_layer( - signer: &T, - event: &Event, -) -> Result +/// Decrypt a single-layer NIP-44 gift wrap (kind 1059) +pub async fn decrypt_gift_wrap_single_layer(signer: &T, event: &Event) -> Result where T: NostrSigner, { @@ -54,13 +46,7 @@ where decrypt_nip44(signer, &sender_pubkey, &event.content).await } -/// Create a single-layer NIP-44 gift wrap (kind 1059). -/// -/// Matches the ContextVM JS/TS SDK's `encryptMessage`: -/// 1. Generate ephemeral keypair -/// 2. NIP-44 encrypt plaintext using ephemeral_secret + recipient_pubkey -/// 3. Build kind 1059 event with `p` tag pointing to recipient -/// 4. Sign with ephemeral key +/// Create a single-layer NIP-44 gift wrap (kind 1059) pub async fn gift_wrap_single_layer( _signer: &T, recipient: &PublicKey, @@ -73,8 +59,39 @@ where let encrypted = encrypt_nip44(&ephemeral, recipient, plaintext).await?; - let builder = EventBuilder::new(Kind::Custom(GIFT_WRAP_KIND), encrypted) - .tag(Tag::public_key(*recipient)); + let builder = + EventBuilder::new(Kind::Custom(GIFT_WRAP_KIND), encrypted).tag(Tag::public_key(*recipient)); + + builder + .sign_with_keys(&ephemeral) + .map_err(|e| Error::Encryption(e.to_string())) +} + +/// Create a single-layer NIP-44 gift wrap using the provided outer event kind. +/// +/// Only ContextVM's supported persistent (`1059`) and ephemeral (`21059`) gift-wrap +/// kinds are accepted here. +pub async fn gift_wrap_single_layer_with_kind( + _signer: &T, + recipient: &PublicKey, + plaintext: &str, + gift_wrap_kind: u16, +) -> Result +where + T: NostrSigner, +{ + if gift_wrap_kind != GIFT_WRAP_KIND && gift_wrap_kind != EPHEMERAL_GIFT_WRAP_KIND { + return Err(Error::Encryption(format!( + "Unsupported gift-wrap kind for single-layer encryption: {gift_wrap_kind}" + ))); + } + + let ephemeral = Keys::generate(); + + let encrypted = encrypt_nip44(&ephemeral, recipient, plaintext).await?; + + let builder = + EventBuilder::new(Kind::Custom(gift_wrap_kind), encrypted).tag(Tag::public_key(*recipient)); builder .sign_with_keys(&ephemeral) @@ -114,6 +131,8 @@ pub async fn gift_wrap( #[cfg(test)] mod tests { + use crate::core::constants::{EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND}; + use super::*; #[tokio::test] @@ -142,10 +161,7 @@ mod tests { /// 2. NIP-44 encrypt the plaintext using ephemeral_secret + recipient_pubkey /// 3. Build kind 1059 event with encrypted content, `p` tag = recipient /// 4. Sign with ephemeral key - async fn create_js_style_gift_wrap( - plaintext: &str, - recipient: &PublicKey, - ) -> (Event, Keys) { + async fn create_simple_gift_wrap(plaintext: &str, recipient: &PublicKey) -> (Event, Keys) { let ephemeral = Keys::generate(); // Single-layer NIP-44 encrypt @@ -154,7 +170,7 @@ mod tests { .unwrap(); // Build kind 1059 event - let builder = EventBuilder::new(Kind::Custom(1059), encrypted) + let builder = EventBuilder::new(Kind::from(GIFT_WRAP_KIND), encrypted) .tag(Tag::public_key(*recipient)); let event = builder.sign_with_keys(&ephemeral).unwrap(); @@ -183,7 +199,7 @@ mod tests { // Step 3: Encrypt as a gift wrap let (gift_wrap, _ephemeral) = - create_js_style_gift_wrap(&inner_json, &server_keys.public_key()).await; + create_simple_gift_wrap(&inner_json, &server_keys.public_key()).await; assert_eq!(gift_wrap.kind, Kind::Custom(1059)); @@ -265,4 +281,93 @@ mod tests { // (it uses an ephemeral key, like the JS SDK) assert_ne!(gift_wrap_event.pubkey, sender_keys.public_key()); } + + /// Regression: gift-wrapped inner events with a tampered pubkey must be + /// caught by `Event::verify()`. + #[tokio::test] + async fn test_forged_inner_event_detected_by_verify() { + let real_sender = Keys::generate(); + let impersonated = Keys::generate(); + let recipient = Keys::generate(); + + let mcp_content = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + + // Step 1: build a legitimately signed inner event + let inner_event = EventBuilder::new(Kind::Custom(25910), mcp_content) + .tag(Tag::public_key(recipient.public_key())) + .sign_with_keys(&real_sender) + .unwrap(); + + // Step 2: tamper the pubkey (keep original, now-invalid, signature) + let mut forged_json: serde_json::Value = serde_json::to_value(&inner_event).unwrap(); + forged_json["pubkey"] = serde_json::Value::String(impersonated.public_key().to_hex()); + let forged_str = serde_json::to_string(&forged_json).unwrap(); + + // Step 3: gift-wrap the forged payload + let (gift_wrap, _) = create_simple_gift_wrap(&forged_str, &recipient.public_key()).await; + + // Decrypt + parse both succeed — the forgery is syntactically valid + let decrypted = decrypt_gift_wrap_single_layer(&recipient, &gift_wrap) + .await + .unwrap(); + let parsed: Event = serde_json::from_str(&decrypted).unwrap(); + assert_eq!(parsed.pubkey, impersonated.public_key()); + + // Signature verification catches the tampered pubkey + assert!( + parsed.verify().is_err(), + "forged inner event must fail signature verification" + ); + } + + #[tokio::test] + async fn test_ephemeral_gift_wrap_roundtrip_single_layer() { + let sender_keys = Keys::generate(); + let recipient_keys = Keys::generate(); + + let mcp_content = r#"{"jsonrpc":"2.0","id":1,"method":"tools/list"}"#; + let inner_event = EventBuilder::new(Kind::Custom(25910), mcp_content) + .tag(Tag::public_key(recipient_keys.public_key())) + .sign_with_keys(&sender_keys) + .unwrap(); + let inner_json = serde_json::to_string(&inner_event).unwrap(); + + let gift_wrap_event = gift_wrap_single_layer_with_kind( + &sender_keys, + &recipient_keys.public_key(), + &inner_json, + EPHEMERAL_GIFT_WRAP_KIND, + ) + .await + .unwrap(); + + assert_eq!(gift_wrap_event.kind, Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND)); + + let decrypted = decrypt_gift_wrap_single_layer(&recipient_keys, &gift_wrap_event) + .await + .unwrap(); + let parsed: Event = serde_json::from_str(&decrypted).unwrap(); + assert_eq!(parsed.pubkey, sender_keys.public_key()); + assert_eq!(parsed.content, mcp_content); + } + + #[tokio::test] + async fn test_invalid_gift_wrap_kind_rejected() { + let sender_keys = Keys::generate(); + let recipient_keys = Keys::generate(); + + let error = gift_wrap_single_layer_with_kind( + &sender_keys, + &recipient_keys.public_key(), + "test", + 4242, + ) + .await + .unwrap_err(); + + assert!( + error.to_string().contains("Unsupported gift-wrap kind"), + "unexpected error: {error}" + ); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index d907611..5027d5b 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -8,11 +8,20 @@ use crate::core::types::JsonRpcMessage; use crate::transport::server::{IncomingRequest, NostrServerTransport, NostrServerTransportConfig}; /// Configuration for the gateway. +#[derive(Debug, Clone)] +#[non_exhaustive] pub struct GatewayConfig { /// Nostr server transport configuration. pub nostr_config: NostrServerTransportConfig, } +impl GatewayConfig { + /// Create a new gateway configuration. + pub fn new(nostr_config: NostrServerTransportConfig) -> Self { + Self { nostr_config } + } +} + /// Gateway that bridges a local MCP server to Nostr. /// /// The gateway listens for incoming MCP requests via Nostr, forwards them @@ -40,14 +49,13 @@ impl NostrMCPGateway { /// /// The caller is responsible for processing requests and calling /// `send_response` for each one. - pub async fn start( - &mut self, - ) -> Result> { + pub async fn start(&mut self) -> Result> { if self.is_running { return Err(Error::Other("Gateway already running".to_string())); } self.transport.start().await?; + self.transport.spawn_discoverability_publication(); self.is_running = true; self.transport @@ -81,6 +89,32 @@ impl NostrMCPGateway { } } +#[cfg(feature = "rmcp")] +impl NostrMCPGateway { + /// Start a gateway directly from an rmcp server handler. + /// + /// This additive API keeps the existing `new/start/send_response` flow intact, + /// while also allowing direct `handler.serve(transport)` style usage. + pub async fn serve_handler( + signer: T, + config: GatewayConfig, + handler: H, + ) -> Result> + where + T: nostr_sdk::prelude::IntoNostrSigner, + H: rmcp::ServerHandler, + { + use crate::NostrServerTransport; + use rmcp::ServiceExt; + + let transport = NostrServerTransport::new(signer, config.nostr_config).await?; + handler + .serve(transport) + .await + .map_err(|e| Error::Other(format!("rmcp server initialization failed: {e}"))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -93,25 +127,50 @@ mod tests { let nostr_config = NostrServerTransportConfig { relay_urls: vec!["wss://relay.example.com".to_string()], encryption_mode: EncryptionMode::Required, + gift_wrap_mode: GiftWrapMode::Optional, server_info: Some(ServerInfo { name: Some("Test Gateway".to_string()), version: Some("1.0.0".to_string()), ..Default::default() }), - is_public_server: true, + is_announced_server: true, allowed_public_keys: vec!["abc123".to_string()], excluded_capabilities: vec![], + max_sessions: 1000, cleanup_interval: Duration::from_secs(120), session_timeout: Duration::from_secs(600), + request_timeout: Duration::from_secs(60), + relay_list_urls: None, + bootstrap_relay_urls: None, + publish_relay_list: true, + profile_metadata: None, + oversized_transfer: Default::default(), + open_stream: Default::default(), }; let config = GatewayConfig { nostr_config }; - assert_eq!(config.nostr_config.relay_urls, vec!["wss://relay.example.com"]); - assert_eq!(config.nostr_config.encryption_mode, EncryptionMode::Required); - assert!(config.nostr_config.is_public_server); + assert_eq!( + config.nostr_config.relay_urls, + vec!["wss://relay.example.com"] + ); + assert_eq!( + config.nostr_config.encryption_mode, + EncryptionMode::Required + ); + assert!(config.nostr_config.is_announced_server); assert_eq!(config.nostr_config.allowed_public_keys.len(), 1); - assert!(config.nostr_config.server_info.as_ref().unwrap().name.as_ref().unwrap() == "Test Gateway"); + assert!( + config + .nostr_config + .server_info + .as_ref() + .unwrap() + .name + .as_ref() + .unwrap() + == "Test Gateway" + ); } #[test] @@ -119,7 +178,10 @@ mod tests { let config = GatewayConfig { nostr_config: NostrServerTransportConfig::default(), }; - assert_eq!(config.nostr_config.encryption_mode, EncryptionMode::Optional); - assert!(!config.nostr_config.is_public_server); + assert_eq!( + config.nostr_config.encryption_mode, + EncryptionMode::Optional + ); + assert!(!config.nostr_config.is_announced_server); } } diff --git a/src/lib.rs b/src/lib.rs index becd92e..c231863 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,3 +1,4 @@ +#![warn(missing_docs)] //! # ContextVM SDK for Rust //! //! A complete Rust implementation of the [ContextVM protocol](https://contextvm.org), @@ -36,22 +37,63 @@ //! use contextvm_sdk::signer; //! ``` +/// Core types, constants, serializers, and validation pub mod core; +/// Server and capability discovery on the Nostr network pub mod discovery; +/// NIP-44 encryption and NIP-59 gift wrapping pub mod encryption; +/// Gateway bridging a local MCP server to Nostr pub mod gateway; +/// Proxy connecting to a remote MCP server via Nostr pub mod proxy; +/// Nostr relay pool management pub mod relay; +/// Nostr signer utilities and key management pub mod signer; +/// Client and server MCP-over-Nostr transports pub mod transport; -// Re-export commonly used types +/// rmcp Worker integration for ContextVM transports +#[cfg(feature = "rmcp")] +pub mod rmcp_transport; +// ── Core types and error handling ──────────────────────────────────── pub use core::error::{Error, Result}; pub use core::types::{ - CapabilityExclusion, ClientSession, EncryptionMode, JsonRpcError, JsonRpcErrorResponse, - JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, ServerInfo, + CapabilityExclusion, ClientSession, EncryptionMode, GiftWrapMode, JsonRpcError, + JsonRpcErrorResponse, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + ProfileMetadata, ServerInfo, }; + +// ── Discovery ──────────────────────────────────────────────────────── pub use discovery::ServerAnnouncement; -pub use relay::RelayPool; -pub use transport::client::{NostrClientTransport, NostrClientTransportConfig}; -pub use transport::server::{IncomingRequest, NostrServerTransport, NostrServerTransportConfig}; + +// ── Relay pool ─────────────────────────────────────────────────────── +#[cfg(any(test, feature = "test-utils"))] +pub use relay::mock::MockRelayPool; +pub use relay::{RelayPool, RelayPoolTrait}; + +// ── Transport (client) ────────────────────────────────────────────── +pub use transport::client::{ + ClientCorrelationStore, NostrClientTransport, NostrClientTransportConfig, +}; + +// ── Transport (discovery tags) ────────────────────────────────────── +pub use transport::discovery_tags::{DiscoveredPeerCapabilities, PeerCapabilities}; + +// ── Transport (server) ────────────────────────────────────────────── +pub use transport::server::{ + IncomingRequest, NostrServerTransport, NostrServerTransportConfig, RouteEntry, + ServerEventRouteStore, SessionSnapshot, SessionStore, +}; + +// ── rmcp re-export ────────────────────────────────────────────────── +#[cfg(feature = "rmcp")] +pub use rmcp; + +// ── CEP-22 progress-aware request helpers ─────────────────────────── +#[cfg(feature = "rmcp")] +pub use rmcp_transport::progress::{ + progress_aware_options, PeerRequestOptionsExt, DEFAULT_OVERSIZED_IDLE_TIMEOUT, + DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT, +}; diff --git a/src/proxy/mod.rs b/src/proxy/mod.rs index fab56d7..c1bcecc 100644 --- a/src/proxy/mod.rs +++ b/src/proxy/mod.rs @@ -8,11 +8,20 @@ use crate::core::types::JsonRpcMessage; use crate::transport::client::{NostrClientTransport, NostrClientTransportConfig}; /// Configuration for the proxy. +#[derive(Debug, Clone)] +#[non_exhaustive] pub struct ProxyConfig { /// Nostr client transport configuration. pub nostr_config: NostrClientTransportConfig, } +impl ProxyConfig { + /// Create a new proxy configuration. + pub fn new(nostr_config: NostrClientTransportConfig) -> Self { + Self { nostr_config } + } +} + /// Proxy that connects to a remote MCP server via Nostr. pub struct NostrMCPProxy { transport: NostrClientTransport, @@ -34,9 +43,7 @@ impl NostrMCPProxy { } /// Start the proxy. Returns a receiver for incoming responses/notifications. - pub async fn start( - &mut self, - ) -> Result> { + pub async fn start(&mut self) -> Result> { if self.is_running { return Err(Error::Other("Proxy already running".to_string())); } @@ -70,6 +77,32 @@ impl NostrMCPProxy { } } +#[cfg(feature = "rmcp")] +impl NostrMCPProxy { + /// Start a proxy directly from an rmcp client handler. + /// + /// This additive API keeps the existing `new/start/send` flow intact, + /// while also allowing direct `handler.serve(transport)` style usage. + pub async fn serve_client_handler( + signer: T, + config: ProxyConfig, + handler: H, + ) -> Result> + where + T: nostr_sdk::prelude::IntoNostrSigner, + H: rmcp::ClientHandler, + { + use crate::NostrClientTransport; + use rmcp::ServiceExt; + + let transport = NostrClientTransport::new(signer, config.nostr_config).await?; + handler + .serve(transport) + .await + .map_err(|e| Error::Other(format!("rmcp client initialization failed: {e}"))) + } +} + #[cfg(test)] mod tests { use super::*; @@ -86,15 +119,26 @@ mod tests { relay_urls: vec!["wss://relay.example.com".to_string()], server_pubkey: server_pubkey.clone(), encryption_mode: EncryptionMode::Required, + gift_wrap_mode: GiftWrapMode::Optional, is_stateless: true, timeout: Duration::from_secs(60), + discovery_relay_urls: None, + fallback_operational_relay_urls: None, + oversized_transfer: Default::default(), + open_stream: Default::default(), }; let config = ProxyConfig { nostr_config }; - assert_eq!(config.nostr_config.relay_urls, vec!["wss://relay.example.com"]); + assert_eq!( + config.nostr_config.relay_urls, + vec!["wss://relay.example.com"] + ); assert_eq!(config.nostr_config.server_pubkey, server_pubkey); - assert_eq!(config.nostr_config.encryption_mode, EncryptionMode::Required); + assert_eq!( + config.nostr_config.encryption_mode, + EncryptionMode::Required + ); assert!(config.nostr_config.is_stateless); assert_eq!(config.nostr_config.timeout, Duration::from_secs(60)); } @@ -105,6 +149,9 @@ mod tests { nostr_config: NostrClientTransportConfig::default(), }; assert!(!config.nostr_config.is_stateless); - assert_eq!(config.nostr_config.encryption_mode, EncryptionMode::Optional); + assert_eq!( + config.nostr_config.encryption_mode, + EncryptionMode::Optional + ); } } diff --git a/src/relay/mock.rs b/src/relay/mock.rs new file mode 100644 index 0000000..0b0b26e --- /dev/null +++ b/src/relay/mock.rs @@ -0,0 +1,562 @@ +//! In-memory mock relay pool for network-free testing. +//! +//! Mirrors the design of the TypeScript SDK's `MockRelayHub`: +//! - `publish_event` stores the event and delivers it only to pools whose +//! `subscribe()` filters match it — like a real relay, a subscriber sees only +//! the events its subscription selected. A pool that never subscribed (or +//! subscribed with no matching filter) receives nothing from live publishes. +//! - `subscribe` registers a pool's filters and immediately replays matching +//! stored events through that pool's channel, so listeners that called +//! `notifications()` before `subscribe()` see the replay. +//! - `connect` / `disconnect` are no-ops — no sockets are opened. +//! - Signing uses a freshly generated ephemeral `Keys`; `signer()` returns it wrapped in `Arc` +//! so encryption code can call it without any real relay connection. + +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use tokio::sync::Mutex; + +use nostr_sdk::prelude::*; + +use crate::core::error::{Error, Result}; +use crate::relay::RelayPoolTrait; + +/// Process-global source of unique pool ids, so each pool can key its own +/// subscription slot in the shared store without locking at construction time. +static NEXT_POOL_ID: AtomicU64 = AtomicU64::new(0); + +// ── Internal state ──────────────────────────────────────────────────────────── + +/// One linked pool's delivery slot: its broadcast sender plus the filters it +/// registered via `subscribe()`. A live publish is delivered through `tx` only +/// when one of `filters` matches the event. +struct Subscriber { + tx: tokio::sync::broadcast::Sender, + filters: Vec, +} + +struct MockRelayInner { + events: Vec, + /// Per-pool subscription slots, keyed by `MockRelayPool::pool_id`. Populated + /// lazily on a pool's first `subscribe()` call. + subscribers: HashMap, +} + +impl MockRelayInner { + fn new() -> Self { + Self { + events: Vec::new(), + subscribers: HashMap::new(), + } + } +} + +// ── Public struct ───────────────────────────────────────────────────────────── + +/// In-memory relay pool for deterministic, network-free testing. +/// +/// Create one with [`MockRelayPool::new`] and pass it (wrapped in `Arc`) wherever +/// an `Arc` is expected. +pub struct MockRelayPool { + inner: Arc>, + /// This pool's own broadcast sender. `notifications()` receivers read from + /// it, and a live publish reaches it only when this pool's registered + /// `subscribe()` filters match the event. + notification_tx: tokio::sync::broadcast::Sender, + /// Unique id keying this pool's subscription slot in the shared store. + pool_id: u64, + /// Ephemeral key used for signing in `publish` / `sign` / `signer`. + keys: Keys, +} + +impl MockRelayPool { + /// Create a new mock relay pool with a freshly generated ephemeral signing key. + pub fn new() -> Self { + Self::with_keys(Keys::generate()) + } + + /// The ephemeral public key used by this mock for signing. + pub fn mock_public_key(&self) -> PublicKey { + self.keys.public_key() + } + + /// The ephemeral signing keys (for manual event injection in tests). + pub fn mock_keys(&self) -> Keys { + self.keys.clone() + } + + /// Like [`new`](Self::new) but with caller-provided signing keys. + pub fn with_keys(keys: Keys) -> Self { + Self::linked(Arc::new(Mutex::new(MockRelayInner::new())), keys) + } + + /// Build a pool bound to an existing shared store, with its own channel, + /// pool id, and signing keys. + fn linked(inner: Arc>, keys: Keys) -> Self { + let (tx, _rx) = tokio::sync::broadcast::channel(1024); + Self { + inner, + notification_tx: tx, + pool_id: NEXT_POOL_ID.fetch_add(1, Ordering::Relaxed), + keys, + } + } + + /// Create a pair of linked mock relay pools with different signing keys. + /// + /// Both pools share the same event store. Each has its own notification + /// channel; an event published by one reaches the other only when the + /// other's `subscribe()` filters match it (as a real relay would). + pub fn create_pair() -> (Self, Self) { + let inner = Arc::new(Mutex::new(MockRelayInner::new())); + let a = Self::linked(Arc::clone(&inner), Keys::generate()); + let b = Self::linked(inner, Keys::generate()); + (a, b) + } + + /// Create `n` linked mock relay pools with different signing keys. + /// + /// All pools share the same event store; each gets its own notification + /// channel and delivery is scoped to each pool's `subscribe()` filters. + /// Useful for multi-client integration tests. + pub fn create_linked_group(n: usize) -> Vec { + assert!(n > 0, "group must have at least one pool"); + let inner = Arc::new(Mutex::new(MockRelayInner::new())); + (0..n) + .map(|_| Self::linked(Arc::clone(&inner), Keys::generate())) + .collect() + } + + /// Clone of all events published so far (useful for assertions in tests). + pub async fn stored_events(&self) -> Vec { + self.inner.lock().await.events.clone() + } + + /// Inject an externally-built event into the store without broadcasting. + /// + /// Useful for seeding kind 10002 relay-list events for `fetch_events()` tests. + pub async fn inject_event(&self, event: Event) { + self.inner.lock().await.events.push(event); + } +} + +impl Default for MockRelayPool { + fn default() -> Self { + Self::new() + } +} + +// ── RelayPoolTrait impl ─────────────────────────────────────────────────────── + +#[async_trait] +impl RelayPoolTrait for MockRelayPool { + /// No-op: the mock has no sockets to open. + async fn connect(&self, _relay_urls: &[String]) -> Result<()> { + Ok(()) + } + + /// No-op: the mock has no sockets to close. + async fn disconnect(&self) -> Result<()> { + Ok(()) + } + + /// Store the event and deliver it to every linked pool whose registered + /// `subscribe()` filters match it, mirroring a real relay: a subscriber sees + /// only the events its subscription selected. Pools with no matching filter + /// (or no subscription at all) receive nothing. + async fn publish_event(&self, event: &Event) -> Result { + let event_id = event.id; + + let mut inner = self.inner.lock().await; + inner.events.push(event.clone()); + + for subscriber in inner.subscribers.values() { + let matches = subscriber + .filters + .iter() + .any(|f| f.match_event(event, MatchEventOptions::default())); + if matches { + // Ignore send errors: they just mean this pool has no active receiver. + let _ = subscriber.tx.send(make_notification(event.clone())); + } + } + + Ok(event_id) + } + + /// Sign `builder` with the ephemeral key, then call `publish_event`. + async fn publish(&self, builder: EventBuilder) -> Result { + let event = sign_with_keys(builder, &self.keys)?; + let id = event.id; + self.publish_event(&event).await?; + Ok(id) + } + + /// Sign `builder` with the ephemeral key and return the event without publishing. + async fn sign(&self, builder: EventBuilder) -> Result { + sign_with_keys(builder, &self.keys) + } + + /// Return the ephemeral key as a signer. + async fn signer(&self) -> Result> { + Ok(Arc::new(self.keys.clone()) as Arc) + } + + /// Return a new broadcast receiver for this pool. It only sees events that + /// match the filters this pool registers via `subscribe()` — events + /// published *after* this call that match, plus any replayed by a subsequent + /// `subscribe()`. Without a matching subscription it sees nothing. + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + self.notification_tx.subscribe() + } + + /// Return the ephemeral public key. + async fn public_key(&self) -> Result { + Ok(self.keys.public_key()) + } + + /// Register this pool's filters (accumulating across calls, like multiple + /// active REQs) and immediately replay any already-stored events that match + /// the new filters through this pool's channel, mirroring a real relay that + /// sends historical events before EOSE. + async fn subscribe(&self, filters: Vec) -> Result<()> { + let replay = { + let mut inner = self.inner.lock().await; + + // Snapshot events before touching the subscriber slot to keep the + // borrows disjoint. + let events_snapshot = inner.events.clone(); + + // Upsert this pool's slot (created on first subscribe), binding it to + // this pool's own channel and accumulating the new filters. + let tx = self.notification_tx.clone(); + let subscriber = inner + .subscribers + .entry(self.pool_id) + .or_insert_with(|| Subscriber { + tx, + filters: Vec::new(), + }); + subscriber.filters.extend(filters.iter().cloned()); + + // Replay only events matching the newly-added filters, as a real + // relay replays historical events per REQ. + events_snapshot + .into_iter() + .filter(|e| { + filters + .iter() + .any(|f| f.match_event(e, MatchEventOptions::default())) + }) + .collect::>() + }; + + for event in replay { + let _ = self.notification_tx.send(make_notification(event)); + } + + Ok(()) + } + + /// Mock ignores target URLs — delegates to `publish()`. + async fn publish_to(&self, _urls: &[String], builder: EventBuilder) -> Result { + self.publish(builder).await + } + + /// Return stored events that match the given filters' kind and author constraints. + async fn fetch_events(&self, filters: Vec, _timeout: Duration) -> Result> { + let inner = self.inner.lock().await; + let matched: Vec = inner + .events + .iter() + .filter(|e| { + filters + .iter() + .any(|f| f.match_event(e, MatchEventOptions::default())) + }) + .cloned() + .collect(); + Ok(matched) + } +} + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +fn sign_with_keys(builder: EventBuilder, keys: &Keys) -> Result { + builder + .sign_with_keys(keys) + .map_err(|e| Error::Transport(e.to_string())) +} + +fn make_notification(event: Event) -> RelayPoolNotification { + RelayPoolNotification::Event { + relay_url: RelayUrl::parse("wss://mock.relay").expect("hardcoded URL"), + subscription_id: SubscriptionId::generate(), + event: Box::new(event), + } +} + +// ── Unit tests ──────────────────────────────────────────────────────────────── + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::constants::CTXVM_MESSAGES_KIND; + + #[tokio::test] + async fn connect_and_disconnect_are_noops() { + let pool = MockRelayPool::new(); + assert!(pool.connect(&["wss://unused".to_string()]).await.is_ok()); + assert!(pool.disconnect().await.is_ok()); + } + + #[tokio::test] + async fn publish_event_stores_and_delivers_to_matching_subscriber() { + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + // A live publish is only delivered to a pool that subscribed for it. + pool.subscribe(vec![Filter::new().kind(Kind::TextNote)]) + .await + .unwrap(); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::TextNote, "hello") + .sign_with_keys(&keys) + .unwrap(); + + pool.publish_event(&event).await.unwrap(); + + assert_eq!(pool.stored_events().await.len(), 1); + let notif = rx.try_recv().unwrap(); + if let RelayPoolNotification::Event { event: e, .. } = notif { + assert_eq!(e.id, event.id); + } else { + panic!("expected Event notification"); + } + } + + #[tokio::test] + async fn publish_without_subscription_delivers_nothing() { + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::TextNote, "unheard") + .sign_with_keys(&keys) + .unwrap(); + pool.publish_event(&event).await.unwrap(); + + // Stored, but never delivered live without a matching subscription. + assert_eq!(pool.stored_events().await.len(), 1); + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn live_publish_respects_subscriber_filters() { + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + pool.subscribe(vec![Filter::new().kind(Kind::TextNote)]) + .await + .unwrap(); + + let keys = Keys::generate(); + let matching = EventBuilder::new(Kind::TextNote, "keep") + .sign_with_keys(&keys) + .unwrap(); + let other = EventBuilder::new(Kind::Custom(9999), "drop") + .sign_with_keys(&keys) + .unwrap(); + pool.publish_event(&matching).await.unwrap(); + pool.publish_event(&other).await.unwrap(); + + // Only the kind that matches the subscription is delivered. + let notif = rx.try_recv().unwrap(); + if let RelayPoolNotification::Event { event: e, .. } = notif { + assert_eq!(e.id, matching.id); + } else { + panic!("expected Event notification"); + } + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn subscribe_accumulates_filters_across_calls() { + // Multiple subscribe() calls behave like multiple active REQs: every + // registered filter stays live, so events matching any of them deliver. + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + pool.subscribe(vec![Filter::new().kind(Kind::TextNote)]) + .await + .unwrap(); + pool.subscribe(vec![Filter::new().kind(Kind::Custom(7777))]) + .await + .unwrap(); + + let keys = Keys::generate(); + let kind_a = EventBuilder::new(Kind::TextNote, "a") + .sign_with_keys(&keys) + .unwrap(); + let kind_b = EventBuilder::new(Kind::Custom(7777), "b") + .sign_with_keys(&keys) + .unwrap(); + let neither = EventBuilder::new(Kind::Custom(9999), "c") + .sign_with_keys(&keys) + .unwrap(); + + pool.publish_event(&kind_a).await.unwrap(); + pool.publish_event(&kind_b).await.unwrap(); + pool.publish_event(&neither).await.unwrap(); + + // Both accumulated filters remain active; the unmatched kind is dropped. + let first = rx.try_recv().unwrap(); + let second = rx.try_recv().unwrap(); + let received: Vec = [first, second] + .into_iter() + .map(|n| match n { + RelayPoolNotification::Event { event, .. } => event.id, + _ => panic!("expected Event notification"), + }) + .collect(); + assert!(received.contains(&kind_a.id), "kind A must be delivered"); + assert!(received.contains(&kind_b.id), "kind B must be delivered"); + assert!( + !received.contains(&neither.id), + "unmatched kind must not be delivered" + ); + assert!( + rx.try_recv().is_err(), + "only the two matching events deliver" + ); + } + + #[tokio::test] + async fn linked_pools_only_receive_their_subscribed_events() { + // The fix that makes EncryptionMode::Disabled e2e tests work: a pool + // subscribed to its own p-tag never receives an event addressed to a peer. + let (a, b) = MockRelayPool::create_pair(); + let a_pubkey = a.mock_public_key(); + let b_pubkey = b.mock_public_key(); + let mut a_rx = a.notifications(); + let mut b_rx = b.notifications(); + + // Each pool subscribes for events p-tagged to itself. + a.subscribe(vec![Filter::new() + .kind(Kind::Custom(CTXVM_MESSAGES_KIND)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::P), + a_pubkey.to_hex(), + )]) + .await + .unwrap(); + b.subscribe(vec![Filter::new() + .kind(Kind::Custom(CTXVM_MESSAGES_KIND)) + .custom_tag( + SingleLetterTag::lowercase(Alphabet::P), + b_pubkey.to_hex(), + )]) + .await + .unwrap(); + + // `a` publishes a response addressed to `b` (p-tag = b). + let keys = Keys::generate(); + let response = EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), "to-b") + .tag(Tag::public_key(b_pubkey)) + .sign_with_keys(&keys) + .unwrap(); + a.publish_event(&response).await.unwrap(); + + // `b` receives it; `a` does NOT echo its own peer-addressed event. + let notif = b_rx.try_recv().unwrap(); + if let RelayPoolNotification::Event { event: e, .. } = notif { + assert_eq!(e.id, response.id); + } else { + panic!("expected Event notification for b"); + } + assert!( + a_rx.try_recv().is_err(), + "a must not receive its own b-addressed event" + ); + } + + #[tokio::test] + async fn publish_signs_and_stores() { + let pool = MockRelayPool::new(); + let builder = EventBuilder::new(Kind::TextNote, "signed"); + pool.publish(builder).await.unwrap(); + let stored = pool.stored_events().await; + assert_eq!(stored.len(), 1); + assert_eq!(stored[0].pubkey, pool.mock_public_key()); + } + + #[tokio::test] + async fn sign_does_not_publish() { + let pool = MockRelayPool::new(); + let builder = EventBuilder::new(Kind::TextNote, "unsigned"); + let event = pool.sign(builder).await.unwrap(); + assert_eq!(event.pubkey, pool.mock_public_key()); + assert!(pool.stored_events().await.is_empty()); + } + + #[tokio::test] + async fn signer_uses_same_key_as_publish() { + let pool = MockRelayPool::new(); + let signer = pool.signer().await.unwrap(); + let expected_pubkey = pool.mock_public_key(); + assert_eq!(signer.get_public_key().await.unwrap(), expected_pubkey); + } + + #[tokio::test] + async fn subscribe_replays_matching_stored_events() { + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + + // Pre-publish two events + let keys = Keys::generate(); + let e1 = EventBuilder::new(Kind::TextNote, "one") + .sign_with_keys(&keys) + .unwrap(); + let e2 = EventBuilder::new(Kind::Custom(9999), "two") + .sign_with_keys(&keys) + .unwrap(); + pool.publish_event(&e1).await.unwrap(); + pool.publish_event(&e2).await.unwrap(); + + // No subscription yet, so nothing was delivered live. + assert!(rx.try_recv().is_err()); + + // Subscribe for TextNote only — e1 should be replayed, e2 not + let filter = Filter::new().kind(Kind::TextNote); + pool.subscribe(vec![filter]).await.unwrap(); + + let replayed = rx.try_recv().unwrap(); + if let RelayPoolNotification::Event { event, .. } = replayed { + assert_eq!(event.id, e1.id); + } else { + panic!("expected replayed Event notification"); + } + // e2 should not be replayed + assert!(rx.try_recv().is_err()); + } + + #[tokio::test] + async fn notifications_receives_future_publishes() { + let pool = MockRelayPool::new(); + let mut rx = pool.notifications(); + pool.subscribe(vec![Filter::new().kind(Kind::TextNote)]) + .await + .unwrap(); + + let keys = Keys::generate(); + let event = EventBuilder::new(Kind::TextNote, "future") + .sign_with_keys(&keys) + .unwrap(); + pool.publish_event(&event).await.unwrap(); + + let notif = rx.try_recv().unwrap(); + assert!(matches!(notif, RelayPoolNotification::Event { .. })); + } +} diff --git a/src/relay/mod.rs b/src/relay/mod.rs index dedc94f..7db9da5 100644 --- a/src/relay/mod.rs +++ b/src/relay/mod.rs @@ -2,9 +2,44 @@ //! //! Wraps nostr-sdk's Client for relay connection, event publishing, and subscription. +#[cfg(any(test, feature = "test-utils"))] +pub mod mock; +#[cfg(any(test, feature = "test-utils"))] +pub use mock::MockRelayPool; + +use async_trait::async_trait; + use crate::core::error::{Error, Result}; use nostr_sdk::prelude::*; use std::sync::Arc; +use std::time::Duration; + +/// Trait abstracting relay pool operations, enabling dependency injection and testing. +#[async_trait] +pub trait RelayPoolTrait: Send + Sync { + /// Connect to the given relay URLs. + async fn connect(&self, relay_urls: &[String]) -> Result<()>; + /// Disconnect from all relays. + async fn disconnect(&self) -> Result<()>; + /// Publish a pre-built event to relays. + async fn publish_event(&self, event: &Event) -> Result; + /// Build, sign, and publish an event from a builder. + async fn publish(&self, builder: EventBuilder) -> Result; + /// Sign an event builder without publishing. + async fn sign(&self, builder: EventBuilder) -> Result; + /// Get the signer associated with this relay pool. + async fn signer(&self) -> Result>; + /// Get notifications receiver for event streaming. + fn notifications(&self) -> tokio::sync::broadcast::Receiver; + /// Get the public key of the signer. + async fn public_key(&self) -> Result; + /// Subscribe to events matching filters. + async fn subscribe(&self, filters: Vec) -> Result<()>; + /// Sign and publish an event to specific relay URLs. + async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result; + /// Fetch events matching filters from connected relays. + async fn fetch_events(&self, filters: Vec, timeout: Duration) -> Result>; +} /// Relay pool wrapper for managing Nostr relay connections. pub struct RelayPool { @@ -105,4 +140,82 @@ impl RelayPool { } Ok(()) } + + /// Sign and publish an event to specific relay URLs. + pub async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result { + let output = self + .client + .send_event_builder_to(urls, builder) + .await + .map_err(|e| Error::Transport(e.to_string()))?; + Ok(output.val) + } + + /// Fetch events matching filters from connected relays. + pub async fn fetch_events( + &self, + filters: Vec, + timeout: Duration, + ) -> Result> { + let mut all_events = Vec::new(); + for filter in filters { + let events = self + .client + .fetch_events(filter, timeout) + .await + .map_err(|e| Error::Transport(e.to_string()))?; + all_events.extend(events); + } + Ok(all_events) + } +} + +#[async_trait] +impl RelayPoolTrait for RelayPool { + async fn connect(&self, relay_urls: &[String]) -> Result<()> { + RelayPool::connect(self, relay_urls).await + } + + async fn disconnect(&self) -> Result<()> { + RelayPool::disconnect(self).await + } + + async fn publish_event(&self, event: &Event) -> Result { + RelayPool::publish_event(self, event).await + } + + async fn publish(&self, builder: EventBuilder) -> Result { + RelayPool::publish(self, builder).await + } + + async fn sign(&self, builder: EventBuilder) -> Result { + RelayPool::sign(self, builder).await + } + + async fn signer(&self) -> Result> { + self.client + .signer() + .await + .map_err(|e| Error::Other(e.to_string())) + } + + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + RelayPool::notifications(self) + } + + async fn public_key(&self) -> Result { + RelayPool::public_key(self).await + } + + async fn subscribe(&self, filters: Vec) -> Result<()> { + RelayPool::subscribe(self, filters).await + } + + async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result { + RelayPool::publish_to(self, urls, builder).await + } + + async fn fetch_events(&self, filters: Vec, timeout: Duration) -> Result> { + RelayPool::fetch_events(self, filters, timeout).await + } } diff --git a/src/rmcp_transport/convert.rs b/src/rmcp_transport/convert.rs new file mode 100644 index 0000000..7df0783 --- /dev/null +++ b/src/rmcp_transport/convert.rs @@ -0,0 +1,247 @@ +//! Conversion boundary between internal JSON-RPC messages and rmcp message types. +//! +//! These helpers intentionally convert via serde JSON to preserve wire-level +//! compatibility and avoid fragile hand-mapping between evolving type systems. + +use crate::core::types::JsonRpcMessage; + +const LOG_TARGET: &str = "contextvm_sdk::rmcp_transport::convert"; + +/// Convert internal JSON-RPC message into rmcp server RX message. +/// +/// Role mapping: +/// - RoleServer RX receives client-originated messages. +pub fn internal_to_rmcp_server_rx( + msg: &JsonRpcMessage, +) -> Option> { + let direction = "internal_to_rmcp_server_rx"; + let value = match serde_json::to_value(msg) { + Ok(value) => value, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + "Failed to serialize message into intermediate JSON" + ); + return None; + } + }; + + match serde_json::from_value(value.clone()) { + Ok(parsed) => Some(parsed), + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + payload = ?value, + "Failed to parse converted JSON payload" + ); + None + } + } +} + +/// Convert internal JSON-RPC message into rmcp client RX message. +/// +/// Role mapping: +/// - RoleClient RX receives server-originated messages. +pub fn internal_to_rmcp_client_rx( + msg: &JsonRpcMessage, +) -> Option> { + let direction = "internal_to_rmcp_client_rx"; + let value = match serde_json::to_value(msg) { + Ok(value) => value, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + "Failed to serialize message into intermediate JSON" + ); + return None; + } + }; + + match serde_json::from_value(value.clone()) { + Ok(parsed) => Some(parsed), + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + payload = ?value, + "Failed to parse converted JSON payload" + ); + None + } + } +} + +/// Convert rmcp server TX message back into internal JSON-RPC. +pub fn rmcp_server_tx_to_internal( + msg: rmcp::service::TxJsonRpcMessage, +) -> Option { + let direction = "rmcp_server_tx_to_internal"; + let value = match serde_json::to_value(msg) { + Ok(value) => value, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + "Failed to serialize message into intermediate JSON" + ); + return None; + } + }; + + match serde_json::from_value(value.clone()) { + Ok(parsed) => Some(parsed), + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + payload = ?value, + "Failed to parse converted JSON payload" + ); + None + } + } +} + +/// Convert rmcp client TX message back into internal JSON-RPC. +pub fn rmcp_client_tx_to_internal( + msg: rmcp::service::TxJsonRpcMessage, +) -> Option { + let direction = "rmcp_client_tx_to_internal"; + let value = match serde_json::to_value(msg) { + Ok(value) => value, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + "Failed to serialize message into intermediate JSON" + ); + return None; + } + }; + + match serde_json::from_value(value.clone()) { + Ok(parsed) => Some(parsed), + Err(error) => { + tracing::error!( + target: LOG_TARGET, + direction = direction, + error = %error, + payload = ?value, + "Failed to parse converted JSON payload" + ); + None + } + } +} + +#[cfg(all(test, feature = "rmcp"))] +mod tests { + use super::*; + use crate::core::types::{JsonRpcRequest, JsonRpcResponse}; + + #[test] + fn test_internal_request_to_rmcp_server_rx_ping() { + let internal = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "ping".to_string(), + params: None, + }); + + let rmcp_msg = internal_to_rmcp_server_rx(&internal) + .expect("expected conversion to rmcp server rx message"); + let value = serde_json::to_value(rmcp_msg).expect("serialize rmcp message to JSON"); + + assert_eq!(value.get("method"), Some(&serde_json::json!("ping"))); + assert_eq!(value.get("id"), Some(&serde_json::json!(1))); + } + + #[test] + fn test_internal_response_to_rmcp_client_rx_empty_result() { + let internal = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + result: serde_json::json!({}), + }); + + let rmcp_msg = internal_to_rmcp_client_rx(&internal) + .expect("expected conversion to rmcp client rx message"); + let value = serde_json::to_value(rmcp_msg).expect("serialize rmcp message to JSON"); + + assert_eq!(value.get("id"), Some(&serde_json::json!(42))); + assert_eq!(value.get("result"), Some(&serde_json::json!({}))); + } + + #[test] + fn test_rmcp_server_tx_to_internal_response() { + let rmcp_msg = rmcp::model::ServerJsonRpcMessage::response( + rmcp::model::ServerResult::empty(()), + rmcp::model::RequestId::Number(7), + ); + + let internal = rmcp_server_tx_to_internal(rmcp_msg) + .expect("expected conversion from rmcp server tx to internal JSON-RPC"); + + match internal { + JsonRpcMessage::Response(resp) => { + assert_eq!(resp.id, serde_json::json!(7)); + assert_eq!(resp.result, serde_json::json!({})); + } + other => panic!("expected internal response, got {other:?}"), + } + } + + #[test] + fn test_rmcp_client_tx_to_internal_response() { + let rmcp_msg = rmcp::model::ClientJsonRpcMessage::response( + rmcp::model::ClientResult::empty(()), + rmcp::model::RequestId::Number(9), + ); + + let internal = rmcp_client_tx_to_internal(rmcp_msg) + .expect("expected conversion from rmcp client tx to internal JSON-RPC"); + + match internal { + JsonRpcMessage::Response(resp) => { + assert_eq!(resp.id, serde_json::json!(9)); + assert_eq!(resp.result, serde_json::json!({})); + } + other => panic!("expected internal response, got {other:?}"), + } + } + + #[test] + fn test_server_rx_roundtrip_preserves_wire_shape() { + let internal = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("abc"), + method: "ping".to_string(), + params: None, + }); + + let rmcp_msg = internal_to_rmcp_server_rx(&internal) + .expect("expected conversion to rmcp server rx message"); + let value = serde_json::to_value(rmcp_msg).expect("serialize rmcp message to JSON"); + let roundtrip_internal: JsonRpcMessage = + serde_json::from_value(value).expect("deserialize back to internal JSON-RPC"); + + match roundtrip_internal { + JsonRpcMessage::Request(req) => { + assert_eq!(req.id, serde_json::json!("abc")); + assert_eq!(req.method, "ping"); + } + other => panic!("expected internal request, got {other:?}"), + } + } +} diff --git a/src/rmcp_transport/mod.rs b/src/rmcp_transport/mod.rs new file mode 100644 index 0000000..8cc1211 --- /dev/null +++ b/src/rmcp_transport/mod.rs @@ -0,0 +1,22 @@ +//! rmcp integration for ContextVM Nostr transports. +//! +//! This module contains the conversion helpers and worker bridge that let raw +//! ContextVM transports plug directly into rmcp service APIs. + +pub mod convert; +pub mod progress; +pub mod transport; +pub mod worker; + +#[cfg(test)] +mod pipeline_tests; + +pub use convert::{ + internal_to_rmcp_client_rx, internal_to_rmcp_server_rx, rmcp_client_tx_to_internal, + rmcp_server_tx_to_internal, +}; +pub use progress::{ + progress_aware_options, PeerRequestOptionsExt, DEFAULT_OVERSIZED_IDLE_TIMEOUT, + DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT, +}; +pub use worker::{NostrClientWorker, NostrServerWorker}; diff --git a/src/rmcp_transport/pipeline_tests.rs b/src/rmcp_transport/pipeline_tests.rs new file mode 100644 index 0000000..ea1d2cb --- /dev/null +++ b/src/rmcp_transport/pipeline_tests.rs @@ -0,0 +1,486 @@ +//! End-to-end pipeline tests for the rmcp ↔ Nostr transport integration. +//! +//! These tests verify every step of the message journey without requiring a live +//! relay connection: +//! +//! ```text +//! Nostr event content (JSON string) +//! → serializers::nostr_event_to_mcp_message [Layer 1: deserialise] +//! → internal_to_rmcp_server_rx [Layer 2: type bridge] +//! → (rmcp handler processes it) [Layer 3: rmcp dispatch – simulated] +//! → rmcp_server_tx_to_internal [Layer 4: type bridge back] +//! → send_response (event_id correlation) [Layer 5: route back to Nostr – mocked] +//! ``` + +#[cfg(all(test, feature = "rmcp"))] +mod tests { + use std::sync::Arc; + + use rmcp::model::{ + CallToolRequestParams, CallToolResult, ClientJsonRpcMessage, ClientResult, ErrorData, + Implementation, RequestId, ServerCapabilities, ServerInfo, ServerJsonRpcMessage, + ServerResult, + }; + use rmcp::{ + handler::server::wrapper::Parameters, schemars, tool, tool_handler, tool_router, + ClientHandler, ServerHandler, ServiceExt, + }; + + use crate::core::serializers; + use crate::core::types::{EncryptionMode, GiftWrapMode}; + use crate::core::types::{ + JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + }; + use crate::relay::mock::MockRelayPool; + use crate::relay::RelayPoolTrait; + use crate::rmcp_transport::convert::{ + internal_to_rmcp_client_rx, internal_to_rmcp_server_rx, rmcp_client_tx_to_internal, + rmcp_server_tx_to_internal, + }; + use crate::transport::{ + client::{NostrClientTransport, NostrClientTransportConfig}, + server::{NostrServerTransport, NostrServerTransportConfig}, + }; + + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] + struct EchoParams { + message: String, + } + + #[derive(Clone)] + struct StatelessTestServer {} + + impl StatelessTestServer { + fn new() -> Self { + Self {} + } + } + + #[tool_router] + impl StatelessTestServer { + #[tool(description = "Echo a message back unchanged")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![rmcp::model::Content::text( + format!("Echo: {message}"), + )])) + } + } + + #[tool_handler] + impl ServerHandler for StatelessTestServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info( + Implementation::new("stateless-test-server", "0.1.0") + .with_title("Stateless Test Server") + .with_description("Stateless rmcp regression test server"), + ) + .with_instructions("Use the echo tool") + } + } + + #[derive(Clone, Default)] + struct StatelessTestClient; + impl ClientHandler for StatelessTestClient {} + + // ── Layer 1: Nostr event content → JsonRpcMessage ────────────────────── + + #[test] + fn layer1_nostr_content_to_internal_request() { + let content = r#"{"jsonrpc":"2.0","id":1,"method":"ping","params":{}}"#; + let msg = serializers::nostr_event_to_mcp_message(content) + .expect("valid MCP request should parse"); + + assert!(msg.is_request()); + assert_eq!(msg.method(), Some("ping")); + assert_eq!(msg.id(), Some(&serde_json::json!(1))); + } + + #[test] + fn layer1_nostr_content_to_internal_tools_list() { + let content = r#"{"jsonrpc":"2.0","id":"abc","method":"tools/list","params":{}}"#; + let msg = serializers::nostr_event_to_mcp_message(content).unwrap(); + assert_eq!(msg.method(), Some("tools/list")); + assert_eq!(msg.id(), Some(&serde_json::json!("abc"))); + } + + #[test] + fn layer1_nostr_content_to_internal_notification() { + let content = r#"{"jsonrpc":"2.0","method":"notifications/initialized"}"#; + let msg = serializers::nostr_event_to_mcp_message(content).unwrap(); + assert!(!msg.is_request()); + assert_eq!(msg.method(), Some("notifications/initialized")); + } + + #[test] + fn layer1_nostr_content_invalid_json_returns_none() { + assert!(serializers::nostr_event_to_mcp_message("not json").is_none()); + } + + #[test] + fn layer1_nostr_event_to_mcp_message_no_version_check() { + // DESIGN NOTE: nostr_event_to_mcp_message uses raw serde deserialization — + // it does NOT reject invalid jsonrpc versions. Version enforcement happens + // one layer up in base.rs via validate_message(), which IS tested separately + // in core::validation::tests::test_invalid_version and + // transport::base::tests::test_convert_event_to_mcp_invalid_jsonrpc_version. + // + // A message with jsonrpc "1.0" will parse successfully at the serializer + // layer because JsonRpcRequest accepts any String for the jsonrpc field. + let content = r#"{"jsonrpc":"1.0","id":1,"method":"ping"}"#; + // It parses — the struct captures jsonrpc as a plain String. + let msg = serializers::nostr_event_to_mcp_message(content); + // We don't assert None here; rejection happens in base.rs, not here. + // What we DO assert: if it parsed, the method and id are intact. + if let Some(msg) = msg { + assert_eq!(msg.method(), Some("ping")); + } + // The real rejection path is covered by: + // transport::base::tests::test_convert_event_to_mcp_invalid_jsonrpc_version + } + + // ── Layer 2: JsonRpcMessage → rmcp RxJsonRpcMessage (server) ─────────── + + #[test] + fn layer2_internal_request_converts_to_rmcp_server_rx() { + let msg = make_request("ping", serde_json::json!(1), None); + let rmcp = internal_to_rmcp_server_rx(&msg).expect("ping should convert"); + + let v = serde_json::to_value(&rmcp).unwrap(); + assert_eq!(v["method"], "ping"); + assert_eq!(v["id"], serde_json::json!(1)); + assert_eq!(v["jsonrpc"], "2.0"); + } + + #[test] + fn layer2_string_id_preserved_through_bridge() { + let msg = make_request("tools/list", serde_json::json!("req-xyz"), None); + let rmcp = internal_to_rmcp_server_rx(&msg).unwrap(); + + let v = serde_json::to_value(&rmcp).unwrap(); + assert_eq!(v["id"], serde_json::json!("req-xyz")); + } + + #[test] + fn layer2_notification_converts_to_rmcp_server_rx() { + let msg = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + let rmcp = + internal_to_rmcp_server_rx(&msg).expect("initialized notification should convert"); + let v = serde_json::to_value(&rmcp).unwrap(); + assert_eq!(v["method"], "notifications/initialized"); + } + + #[test] + fn layer2_tools_list_with_params_converts() { + let msg = make_request( + "tools/list", + serde_json::json!(7), + Some(serde_json::json!({"cursor": "next-page"})), + ); + let rmcp = internal_to_rmcp_server_rx(&msg).unwrap(); + let v = serde_json::to_value(&rmcp).unwrap(); + assert_eq!(v["method"], "tools/list"); + assert_eq!(v["params"]["cursor"], "next-page"); + } + + // ── Layer 3+4: Simulated handler → rmcp response → internal ──────────── + + #[test] + fn layer4_rmcp_ping_response_roundtrip_number_id() { + // Simulate rmcp handler producing a ping response + let rmcp_response = + ServerJsonRpcMessage::response(ServerResult::empty(()), RequestId::Number(42)); + let internal = + rmcp_server_tx_to_internal(rmcp_response).expect("ping response should convert back"); + + match internal { + JsonRpcMessage::Response(r) => { + assert_eq!(r.id, serde_json::json!(42)); + assert_eq!(r.jsonrpc, "2.0"); + } + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn layer4_rmcp_ping_response_roundtrip_string_id() { + let rmcp_response = ServerJsonRpcMessage::response( + ServerResult::empty(()), + RequestId::String(std::sync::Arc::from("req-xyz")), + ); + let internal = rmcp_server_tx_to_internal(rmcp_response).unwrap(); + + match internal { + JsonRpcMessage::Response(r) => { + assert_eq!(r.id, serde_json::json!("req-xyz")); + } + other => panic!("expected Response, got {other:?}"), + } + } + + // ── Full roundtrip: internal → rmcp → internal ────────────────────────── + + #[test] + fn full_server_roundtrip_request_id_preserved() { + // Layer 2: convert incoming request to rmcp + let original = make_request("ping", serde_json::json!(99), None); + let rmcp_rx = internal_to_rmcp_server_rx(&original).unwrap(); + + // Extract the ID that rmcp sees + let rmcp_value = serde_json::to_value(&rmcp_rx).unwrap(); + let id_seen_by_rmcp = rmcp_value["id"].clone(); + assert_eq!(id_seen_by_rmcp, serde_json::json!(99)); + + // Layer 4: rmcp produces a response with the same ID echoed back + let rmcp_tx = + ServerJsonRpcMessage::response(ServerResult::empty(()), RequestId::Number(99)); + let response = rmcp_server_tx_to_internal(rmcp_tx).unwrap(); + + // The response ID must equal the original request ID + assert_eq!(response.id(), Some(&serde_json::json!(99))); + } + + #[test] + fn full_client_roundtrip_response_id_preserved() { + // Client side: rmcp produces an outbound request + let rmcp_tx = ClientJsonRpcMessage::response(ClientResult::empty(()), RequestId::Number(7)); + let internal = rmcp_client_tx_to_internal(rmcp_tx).unwrap(); + assert_eq!(internal.id(), Some(&serde_json::json!(7))); + + // And an incoming server response converts to rmcp correctly + let incoming_response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(7), + result: serde_json::json!({"tools": []}), + }); + let rmcp_rx = internal_to_rmcp_client_rx(&incoming_response).unwrap(); + let v = serde_json::to_value(&rmcp_rx).unwrap(); + assert_eq!(v["id"], serde_json::json!(7)); + assert_eq!(v["result"]["tools"], serde_json::json!([])); + } + + // ── Layer 5: event_id-based request correlation (mirrors NostrServerWorker) ── + + #[test] + fn layer5_worker_uses_event_id_as_request_id() { + // Simulate the worker rewriting req.id to the Nostr event_id. + let event_id = "abc123def456"; + let mut req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + method: "tools/list".to_string(), + params: None, + }; + + // Worker inbound path: rewrite id to event_id + req.id = serde_json::json!(event_id); + assert_eq!(req.id, serde_json::json!("abc123def456")); + + // Convert through rmcp bridge — ID must survive the roundtrip + let msg = JsonRpcMessage::Request(req); + let rmcp_rx = internal_to_rmcp_server_rx(&msg).unwrap(); + let v = serde_json::to_value(&rmcp_rx).unwrap(); + assert_eq!(v["id"], serde_json::json!("abc123def456")); + + // Simulate rmcp handler echoing the event_id back in the response + let rmcp_tx = ServerJsonRpcMessage::response( + ServerResult::empty(()), + RequestId::String(std::sync::Arc::from(event_id)), + ); + let response = rmcp_server_tx_to_internal(rmcp_tx).unwrap(); + + // The response ID is the event_id — worker passes it directly to send_response + match response { + JsonRpcMessage::Response(r) => { + assert_eq!(r.id.as_str(), Some(event_id)); + } + other => panic!("expected Response, got {other:?}"), + } + } + + #[test] + fn layer5_worker_two_clients_no_collision() { + // Two clients both send requests with id: 1. The worker rewrites each + // to its unique Nostr event_id, so no collision occurs. + let event_id_a = "aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111aaaa1111"; + let event_id_b = "bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222bbbb2222"; + + let mut req_a = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: None, + }; + let mut req_b = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: None, + }; + + // Worker rewrites both to their respective event IDs + req_a.id = serde_json::json!(event_id_a); + req_b.id = serde_json::json!(event_id_b); + + // After rewrite, the IDs are distinct even though both clients sent id: 1 + assert_ne!(req_a.id, req_b.id); + assert_eq!(req_a.id.as_str(), Some(event_id_a)); + assert_eq!(req_b.id.as_str(), Some(event_id_b)); + + // Responses echo back the event_id — each routes to the correct client + let rmcp_resp_a = ServerJsonRpcMessage::response( + ServerResult::empty(()), + RequestId::String(std::sync::Arc::from(event_id_a)), + ); + let rmcp_resp_b = ServerJsonRpcMessage::response( + ServerResult::empty(()), + RequestId::String(std::sync::Arc::from(event_id_b)), + ); + + let resp_a = rmcp_server_tx_to_internal(rmcp_resp_a).unwrap(); + let resp_b = rmcp_server_tx_to_internal(rmcp_resp_b).unwrap(); + + // Each response carries its own event_id — no cross-wiring + assert_eq!(resp_a.id().unwrap().as_str(), Some(event_id_a)); + assert_eq!(resp_b.id().unwrap().as_str(), Some(event_id_b)); + } + + #[test] + fn layer5_error_response_carries_event_id() { + // Error responses also carry the event_id for routing. + let event_id = "deadbeef"; + let mut req = JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(5), + method: "tools/call".to_string(), + params: None, + }; + req.id = serde_json::json!(event_id); + + // rmcp handler returns an error with the rewritten event_id + let rmcp_err = ServerJsonRpcMessage::error( + rmcp::model::ErrorData { + code: rmcp::model::ErrorCode::METHOD_NOT_FOUND, + message: "Method not found".into(), + data: None, + }, + Some(RequestId::String(std::sync::Arc::from(event_id))), + ); + let internal = rmcp_server_tx_to_internal(rmcp_err).unwrap(); + + match internal { + JsonRpcMessage::ErrorResponse(r) => { + assert_eq!(r.id.as_str(), Some(event_id)); + } + other => panic!("expected ErrorResponse, got {other:?}"), + } + } + + #[tokio::test] + async fn stateless_rmcp_roundtrip_over_mock_relay_preserves_correlation() { + let (server_pool, client_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool + .public_key() + .await + .expect("server mock relay pubkey") + .to_hex(); + + let server_transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_relay_urls(vec!["mock://relay".to_string()]) + .with_encryption_mode(EncryptionMode::Disabled) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::new(server_pool), + ) + .await + .expect("server transport"); + + let server_task = tokio::spawn(async move { + StatelessTestServer::new() + .serve(server_transport) + .await + .expect("server should start") + .waiting() + .await + .expect("server should keep running until aborted"); + }); + + let client_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["mock://relay".to_string()]) + .with_server_pubkey(server_pubkey) + .with_encryption_mode(EncryptionMode::Disabled) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_stateless(true), + Arc::new(client_pool), + ) + .await + .expect("client transport"); + + let client = StatelessTestClient + .serve(client_transport) + .await + .expect("stateless client should start"); + + let peer_info = client + .peer_info() + .expect("peer info from emulated initialize"); + assert_eq!(peer_info.server_info.name, "Emulated-Stateless-Server"); + + let tools = client + .list_all_tools() + .await + .expect("tools/list should succeed"); + assert!( + tools.iter().any(|tool| tool.name == "echo"), + "expected echo tool from server" + ); + + let result = client + .call_tool( + CallToolRequestParams::new("echo").with_arguments( + serde_json::from_value(serde_json::json!({ + "message": "hello from stateless test" + })) + .unwrap(), + ), + ) + .await + .expect("tools/call should succeed"); + + let echoed = result + .content + .iter() + .find_map(|content| match &content.raw { + rmcp::model::RawContent::Text(text) => Some(text.text.clone()), + _ => None, + }) + .expect("echo response text"); + assert_eq!(echoed, "Echo: hello from stateless test"); + + client.cancel().await.expect("client cancel"); + server_task.abort(); + } + + // ── Helper ────────────────────────────────────────────────────────────── + + fn make_request( + method: &str, + id: serde_json::Value, + params: Option, + ) -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id, + method: method.to_string(), + params, + }) + } +} diff --git a/src/rmcp_transport/progress.rs b/src/rmcp_transport/progress.rs new file mode 100644 index 0000000..07914d0 --- /dev/null +++ b/src/rmcp_transport/progress.rs @@ -0,0 +1,170 @@ +//! CEP-22: progress-aware request options for rmcp consumers. +//! +//! Under the rmcp fork, plain [`Peer`] calls such as `call_tool` use +//! `PeerRequestOptions::no_options()` — **no timeout at all**: a stalled +//! oversized response hangs the caller forever. This module closes that gap +//! the way the TS SDK's docs do — by passing request options on the existing +//! call — via an extension trait carrying an options-taking `call_tool` +//! variant, plus a constructor for the recommended progress-aware settings. +//! +//! With CEP-22 enabled, the Nostr transports forward each inbound transfer +//! frame to the requester as a plain progress notification, so an idle +//! timeout built by [`progress_aware_options`] resets on every chunk: a live +//! transfer can run long, a stalled one fails after `idle`, and +//! `max_total` caps the call regardless of progress. +//! +//! ```ignore +//! use contextvm_sdk::{progress_aware_options, PeerRequestOptionsExt}; +//! +//! let result = running_service +//! .peer() +//! .call_tool_with_options( +//! params, +//! progress_aware_options( +//! contextvm_sdk::DEFAULT_OVERSIZED_IDLE_TIMEOUT, +//! contextvm_sdk::DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT, +//! ), +//! ) +//! .await?; +//! ``` + +use std::time::Duration; + +use rmcp::model::{ + CallToolRequest, CallToolRequestParams, CallToolResult, ClientRequest, ServerResult, +}; +use rmcp::service::{Peer, PeerRequestOptions, ServiceError}; +use rmcp::RoleClient; + +/// Default requester-side idle timeout for requests that may trigger a CEP-22 +/// oversized transfer: 60 s. +/// +/// TS parity twice over: equals the upstream TS SDK's blanket per-request +/// timeout (`DEFAULT_REQUEST_TIMEOUT_MSEC`), and exceeds the worst-case +/// inter-chunk gap including the 30 s accept wait +/// ([`DEFAULT_ACCEPT_TIMEOUT_MS`](crate::transport::oversized_transfer::DEFAULT_ACCEPT_TIMEOUT_MS)). +pub const DEFAULT_OVERSIZED_IDLE_TIMEOUT: Duration = Duration::from_secs(60); + +/// Default requester-side max-total timeout: 300 s. +/// +/// Aligned with the receiver-side watchdog default +/// ([`DEFAULT_TRANSFER_TIMEOUT_MS`](crate::transport::oversized_transfer::DEFAULT_TRANSFER_TIMEOUT_MS)) +/// so the requester gives up in the same window the receiver reaps state — +/// symmetric failure. (Upstream TS sets no max-total default; here it stays +/// opt-in via [`progress_aware_options`], never baked into plain calls.) +pub const DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT: Duration = Duration::from_secs(300); + +/// Build the recommended [`PeerRequestOptions`] for requests whose responses +/// may arrive as CEP-22 oversized transfers: an `idle` timeout that resets on +/// every progress notification, capped by `max_total`. +/// +/// Equivalent to +/// `PeerRequestOptions::with_timeout(idle).reset_timeout_on_progress().with_max_total_timeout(max_total)`. +/// Named after the mechanism (progress-aware timeouts), not the oversized use +/// case — mirroring the TS SDK, where "oversized" appears in docs but never in +/// the API surface. +/// +/// Sizing notes: +/// - `reset_timeout_on_progress` without an idle timeout is a **no-op** — the +/// fork registers a progress watcher only when *both* are set, which is why +/// this constructor takes `idle` rather than making it optional. +/// - The client→server upload direction receives at most **one** inbound +/// reset (the server's `accept` handshake frame) — and it reaches the rmcp +/// service loop only after the whole upload send returns. Size `idle` and +/// `max_total` to cover the full upload duration, not just the gaps. +/// - Sensible defaults: [`DEFAULT_OVERSIZED_IDLE_TIMEOUT`] / +/// [`DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT`]. They intentionally mirror the +/// transport's `OversizedTransferConfig` numbers but are not read from it — +/// the peer layer has no access to transport config by design; align them +/// manually if you tune the transport. +pub fn progress_aware_options(idle: Duration, max_total: Duration) -> PeerRequestOptions { + PeerRequestOptions::with_timeout(idle) + .reset_timeout_on_progress() + .with_max_total_timeout(max_total) +} + +/// Options-taking call variants for [`Peer`] — the rs-side analog +/// of passing `RequestOptions` inline to the TS SDK's `client.callTool`. +/// +/// Without these, high-level fork calls (`peer.call_tool(..)` etc.) run with +/// `PeerRequestOptions::no_options()`: **no timeout, infinite await**. Pair +/// with [`progress_aware_options`] for any call whose response may be large +/// (CEP-22 fragments every rmcp request's response once the peer advertises +/// support — rmcp stamps a progress token into every outgoing request). +/// +/// For request types without a dedicated variant here, the generic path is +/// two lines on public fork API — no wrapper needed: +/// +/// ```ignore +/// let handle = peer.send_cancellable_request(request, options).await?; +/// let response = handle.await_response().await?; +/// ``` +/// +/// On timeout the call fails with `ServiceError::Timeout { timeout }` (the +/// value identifies which timer fired: `idle` vs `max_total`) and rmcp +/// publishes a `notifications/cancelled` for the request. +pub trait PeerRequestOptionsExt { + /// `call_tool` with explicit [`PeerRequestOptions`] — the direct analog of + /// TS `client.callTool(params, schema, options)`. + fn call_tool_with_options( + &self, + params: CallToolRequestParams, + options: PeerRequestOptions, + ) -> impl std::future::Future> + Send; +} + +impl PeerRequestOptionsExt for Peer { + async fn call_tool_with_options( + &self, + params: CallToolRequestParams, + options: PeerRequestOptions, + ) -> Result { + // Mirrors the fork's `method!` expansion for `call_tool` + // (service/client.rs), with options threaded through + // `send_cancellable_request` instead of the option-less default. + let result = self + .send_cancellable_request( + ClientRequest::CallToolRequest(CallToolRequest::new(params)), + options, + ) + .await? + .await_response() + .await?; + match result { + ServerResult::CallToolResult(result) => Ok(result), + _ => Err(ServiceError::UnexpectedResponse), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Pins the `progress_aware_options` wiring: the constructor must (a) set the + /// idle `timeout`, (b) enable `reset_timeout_on_progress` — without which the + /// fork registers no progress watcher (`send_request_with_option` only arms + /// one when *both* the flag and a timeout are set), so inbound chunks would + /// never reset the timer — and (c) populate `max_total_timeout`. Distinct + /// idle/max-total values also catch an accidental argument swap in the + /// builder chain. End-to-end behavior is covered by the timeout tests in + /// `tests/oversized_timeout_e2e.rs`; this is the fast unit guard on the flags. + #[test] + fn progress_aware_options_sets_reset_and_both_timeouts() { + let idle = Duration::from_millis(250); + let max_total = Duration::from_secs(42); + + let options = progress_aware_options(idle, max_total); + + assert_eq!(options.timeout, Some(idle), "idle timeout must be set"); + assert!( + options.reset_timeout_on_progress, + "reset_timeout_on_progress must be enabled or the fork arms no progress watcher" + ); + assert_eq!( + options.max_total_timeout, + Some(max_total), + "max_total_timeout must be set" + ); + } +} diff --git a/src/rmcp_transport/transport.rs b/src/rmcp_transport/transport.rs new file mode 100644 index 0000000..29e8fb4 --- /dev/null +++ b/src/rmcp_transport/transport.rs @@ -0,0 +1,31 @@ +//! rmcp transport integration for raw ContextVM Nostr transports. + +use crate::{ + core::error::Error, + rmcp_transport::worker::{NostrClientWorker, NostrServerWorker}, + transport::{client::NostrClientTransport, server::NostrServerTransport}, +}; + +impl rmcp::transport::IntoTransport + for NostrServerTransport +{ + /// Convert the raw server transport into rmcp's transport model via the + /// worker bridge. + fn into_transport( + self, + ) -> impl rmcp::transport::Transport + 'static { + NostrServerWorker::from_transport(self).into_transport() + } +} + +impl rmcp::transport::IntoTransport + for NostrClientTransport +{ + /// Convert the raw client transport into rmcp's transport model via the + /// worker bridge. + fn into_transport( + self, + ) -> impl rmcp::transport::Transport + 'static { + NostrClientWorker::from_transport(self).into_transport() + } +} diff --git a/src/rmcp_transport/worker.rs b/src/rmcp_transport/worker.rs new file mode 100644 index 0000000..da83b79 --- /dev/null +++ b/src/rmcp_transport/worker.rs @@ -0,0 +1,572 @@ +//! rmcp worker adapters. +//! +//! This file defines wrapper types that bind existing ContextVM Nostr +//! transports to rmcp's worker abstraction. + +use crate::core::constants::ANNOUNCEMENT_REQUEST_ID; +use crate::core::error::Result; +use crate::core::types::{JsonRpcMessage, JsonRpcNotification, JsonRpcRequest}; +use crate::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use crate::transport::server::{NostrServerTransport, NostrServerTransportConfig}; +use rmcp::transport::worker::{Worker, WorkerContext, WorkerQuitReason}; +use std::collections::HashSet; + +use super::convert::{ + internal_to_rmcp_client_rx, internal_to_rmcp_server_rx, rmcp_client_tx_to_internal, + rmcp_server_tx_to_internal, +}; + +const LOG_TARGET: &str = "contextvm_sdk::rmcp_transport::worker"; +const STATELESS_SYNTHETIC_EVENT_ID: &str = "contextvm-stateless-init"; + +fn synthetic_initialize_message() -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(STATELESS_SYNTHETIC_EVENT_ID), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": crate::core::constants::mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { + "name": "contextvm-stateless-client", + "version": "0.1.0" + } + })), + }) +} + +fn synthetic_initialized_notification() -> JsonRpcMessage { + JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }) +} + +fn should_inject_stateless_bootstrap( + initialized_clients: &HashSet, + client_pubkey: &str, + message: &JsonRpcMessage, +) -> bool { + if initialized_clients.contains(client_pubkey) { + return false; + } + + matches!(message, JsonRpcMessage::Request(req) if req.method != "initialize") +} + +fn is_synthetic_initialize_message(message: &JsonRpcMessage) -> bool { + matches!( + message, + JsonRpcMessage::Request(req) + if req.method == "initialize" + && req.id == serde_json::json!(STATELESS_SYNTHETIC_EVENT_ID) + ) +} + +/// rmcp server worker wrapper for ContextVM Nostr server transport. +/// +/// Multiplexes all connected clients through a single rmcp service instance. +/// Inbound requests have their JSON-RPC `id` rewritten to the Nostr `event_id` +/// before being forwarded to the rmcp handler. Since event IDs are globally +/// unique (SHA-256 hashes), this eliminates collisions when different clients +/// use the same JSON-RPC request IDs. The transport's event-route store +/// handles response routing back to the originating client; server-initiated +/// notifications are broadcast to all initialized clients. +pub struct NostrServerWorker { + transport: NostrServerTransport, +} + +impl NostrServerWorker { + /// Create a new server worker from existing server transport config. + pub async fn new(signer: T, config: NostrServerTransportConfig) -> Result + where + T: nostr_sdk::prelude::IntoNostrSigner, + { + let transport = NostrServerTransport::new(signer, config).await?; + Ok(Self { transport }) + } + + /// Create a worker from an already-constructed raw transport. + pub fn from_transport(transport: NostrServerTransport) -> Self { + Self { transport } + } + + /// Access the wrapped transport. + pub fn transport(&self) -> &NostrServerTransport { + &self.transport + } +} + +impl Worker for NostrServerWorker { + type Error = crate::core::error::Error; + type Role = rmcp::RoleServer; + + fn err_closed() -> Self::Error { + Self::Error::Transport("rmcp worker channel closed".to_string()) + } + + fn err_join(e: tokio::task::JoinError) -> Self::Error { + Self::Error::Other(format!("rmcp worker join error: {e}")) + } + + async fn run( + mut self, + mut context: WorkerContext, + ) -> std::result::Result<(), WorkerQuitReason> { + self.transport + .start() + .await + .map_err(WorkerQuitReason::fatal_context("starting server transport"))?; + + // CEP-6: Spawn auto-publish after start() so the worker's select loop + // is running when synthetic messages arrive through message_tx. + self.transport.spawn_announcements(); + + let mut rx = self.transport.take_message_receiver().ok_or_else(|| { + WorkerQuitReason::fatal( + Self::Error::Other("server message receiver already taken".to_string()), + "taking server message receiver", + ) + })?; + + let cancellation_token = context.cancellation_token.clone(); + let mut initialized_clients = HashSet::new(); + + let quit_reason = loop { + tokio::select! { + _ = cancellation_token.cancelled() => { + break WorkerQuitReason::Cancelled; + } + incoming = rx.recv() => { + let Some(incoming) = incoming else { + break WorkerQuitReason::TransportClosed; + }; + + let crate::transport::server::IncomingRequest { + mut message, + event_id, + client_pubkey, + .. + } = incoming; + + let should_inject_bootstrap = should_inject_stateless_bootstrap( + &initialized_clients, + &client_pubkey, + &message, + ); + + if should_inject_bootstrap { + let synthetic_init = synthetic_initialize_message(); + let Some(rmcp_init) = internal_to_rmcp_server_rx(&synthetic_init) else { + break WorkerQuitReason::fatal( + Self::Error::Validation( + "failed converting synthetic initialize request to rmcp format".to_string(), + ), + "converting synthetic initialize request", + ); + }; + + if let Err(reason) = context.send_to_handler(rmcp_init).await { + break reason; + } + + let initialized = synthetic_initialized_notification(); + let Some(rmcp_initialized) = internal_to_rmcp_server_rx(&initialized) else { + break WorkerQuitReason::fatal( + Self::Error::Validation( + "failed converting synthetic initialized notification to rmcp format".to_string(), + ), + "converting synthetic initialized notification", + ); + }; + + if let Err(reason) = context.send_to_handler(rmcp_initialized).await { + break reason; + } + + initialized_clients.insert(client_pubkey.clone()); + } + + if matches!(&message, JsonRpcMessage::Request(req) if req.method == "initialize") + || matches!(&message, JsonRpcMessage::Notification(n) if n.method == "notifications/initialized") + { + initialized_clients.insert(client_pubkey.clone()); + } + + // Rewrite real wire requests to the Nostr event_id. + // Synthetic stateless bootstrap messages must retain their + // sentinel ID so their responses can be dropped before they + // ever touch transport correlation. + if !is_synthetic_initialize_message(&message) { + if let JsonRpcMessage::Request(ref mut req) = message { + req.id = serde_json::json!(event_id); + } + } + + if let Some(rmcp_msg) = internal_to_rmcp_server_rx(&message) { + if let Err(reason) = context.send_to_handler(rmcp_msg).await { + break reason; + } + } else { + tracing::warn!( + target: LOG_TARGET, + "Failed to convert incoming server-side message to rmcp format" + ); + } + } + outbound = context.recv_from_handler() => { + let outbound = match outbound { + Ok(outbound) => outbound, + Err(reason) => break reason, + }; + + let result = if let Some(internal_msg) = rmcp_server_tx_to_internal(outbound.message) { + self.forward_server_internal(internal_msg).await + } else { + Err(Self::Error::Validation( + "failed converting rmcp server message to internal JSON-RPC".to_string(), + )) + }; + + let _ = outbound.responder.send(result); + } + } + }; + + if let Err(e) = self.transport.close().await { + tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to close server transport cleanly" + ); + } + + Err(quit_reason) + } +} + +/// rmcp client worker wrapper for ContextVM Nostr client transport. +pub struct NostrClientWorker { + transport: NostrClientTransport, +} + +impl NostrClientWorker { + /// Create a new client worker from existing client transport config. + pub async fn new(signer: T, config: NostrClientTransportConfig) -> Result + where + T: nostr_sdk::prelude::IntoNostrSigner, + { + let transport = NostrClientTransport::new(signer, config).await?; + Ok(Self { transport }) + } + + /// Create a worker from an already-constructed raw transport. + pub fn from_transport(transport: NostrClientTransport) -> Self { + Self { transport } + } + + /// Access the wrapped transport. + pub fn transport(&self) -> &NostrClientTransport { + &self.transport + } +} + +impl Worker for NostrClientWorker { + type Error = crate::core::error::Error; + type Role = rmcp::RoleClient; + + fn err_closed() -> Self::Error { + Self::Error::Transport("rmcp worker channel closed".to_string()) + } + + fn err_join(e: tokio::task::JoinError) -> Self::Error { + Self::Error::Other(format!("rmcp worker join error: {e}")) + } + + async fn run( + mut self, + mut context: WorkerContext, + ) -> std::result::Result<(), WorkerQuitReason> { + self.transport + .start() + .await + .map_err(WorkerQuitReason::fatal_context("starting client transport"))?; + + let mut rx = self.transport.take_message_receiver().ok_or_else(|| { + WorkerQuitReason::fatal( + Self::Error::Other("client message receiver already taken".to_string()), + "taking client message receiver", + ) + })?; + + let cancellation_token = context.cancellation_token.clone(); + + let quit_reason = loop { + tokio::select! { + _ = cancellation_token.cancelled() => { + break WorkerQuitReason::Cancelled; + } + incoming = rx.recv() => { + let Some(incoming) = incoming else { + break WorkerQuitReason::TransportClosed; + }; + + if let Some(rmcp_msg) = internal_to_rmcp_client_rx(&incoming) { + if let Err(reason) = context.send_to_handler(rmcp_msg).await { + break reason; + } + } else { + tracing::warn!( + target: LOG_TARGET, + "Failed to convert incoming client-side message to rmcp format" + ); + } + } + outbound = context.recv_from_handler() => { + let outbound = match outbound { + Ok(outbound) => outbound, + Err(reason) => break reason, + }; + + let result = if let Some(internal_msg) = rmcp_client_tx_to_internal(outbound.message) { + self.transport.send(&internal_msg).await + } else { + Err(Self::Error::Validation( + "failed converting rmcp client message to internal JSON-RPC".to_string(), + )) + }; + + let _ = outbound.responder.send(result); + } + } + }; + + if let Err(e) = self.transport.close().await { + tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to close client transport cleanly" + ); + } + + Err(quit_reason) + } +} + +impl NostrServerWorker { + /// Forward an outbound message from the rmcp handler to the Nostr transport. + /// + /// Response IDs carry the Nostr event_id set during ingest. The transport's + /// `send_response` uses this to look up the route (client_pubkey + + /// original_request_id) and deliver the response to the correct client. + /// Notifications and server-initiated requests are broadcast to all + /// initialized clients. + async fn forward_server_internal(&mut self, message: JsonRpcMessage) -> Result<()> { + match message { + JsonRpcMessage::Response(resp) => { + let event_id = resp.id.as_str().map(str::to_owned).ok_or_else(|| { + crate::core::error::Error::Validation( + "rmcp server response id is not a string event_id".to_string(), + ) + })?; + + if event_id == ANNOUNCEMENT_REQUEST_ID { + tracing::debug!( + target: LOG_TARGET, + "Routing announcement response to handler" + ); + return self + .transport + .handle_announcement_response(JsonRpcMessage::Response(resp)) + .await; + } + + if event_id == STATELESS_SYNTHETIC_EVENT_ID { + tracing::debug!( + target: LOG_TARGET, + event_id = %event_id, + "Dropping synthetic initialize response before wire transport" + ); + return Ok(()); + } + + self.transport + .send_response(&event_id, JsonRpcMessage::Response(resp)) + .await + } + JsonRpcMessage::ErrorResponse(resp) => { + let event_id = resp.id.as_str().map(str::to_owned).ok_or_else(|| { + crate::core::error::Error::Validation( + "rmcp server error response id is not a string event_id".to_string(), + ) + })?; + + if event_id == ANNOUNCEMENT_REQUEST_ID { + tracing::debug!( + target: LOG_TARGET, + "Routing announcement error to handler" + ); + return self + .transport + .handle_announcement_response(JsonRpcMessage::ErrorResponse(resp)) + .await; + } + + if event_id == STATELESS_SYNTHETIC_EVENT_ID { + tracing::debug!( + target: LOG_TARGET, + event_id = %event_id, + "Dropping synthetic initialize error before wire transport" + ); + return Ok(()); + } + + self.transport + .send_response(&event_id, JsonRpcMessage::ErrorResponse(resp)) + .await + } + JsonRpcMessage::Notification(notification) => { + let message = JsonRpcMessage::Notification(notification); + self.transport.broadcast_notification(&message).await + } + JsonRpcMessage::Request(request) => { + let message = JsonRpcMessage::Request(request); + self.transport.broadcast_notification(&message).await + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::JsonRpcResponse; + + #[test] + fn test_should_inject_stateless_bootstrap_for_first_non_initialize_request() { + let initialized_clients = HashSet::new(); + let message = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + + assert!(should_inject_stateless_bootstrap( + &initialized_clients, + "client-a", + &message, + )); + } + + #[test] + fn test_should_not_inject_stateless_bootstrap_for_real_initialize() { + let initialized_clients = HashSet::new(); + let message = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: Some(serde_json::json!({})), + }); + + assert!(!should_inject_stateless_bootstrap( + &initialized_clients, + "client-a", + &message, + )); + } + + #[test] + fn test_synthetic_initialize_keeps_sentinel_id() { + let message = synthetic_initialize_message(); + + match message { + JsonRpcMessage::Request(req) => { + assert_eq!(req.id, serde_json::json!(STATELESS_SYNTHETIC_EVENT_ID)); + assert_eq!(req.method, "initialize"); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn test_real_request_is_rewritten_to_event_id() { + let mut message = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + + if let JsonRpcMessage::Request(ref mut req) = message { + req.id = serde_json::json!("real-event-id"); + } + + match message { + JsonRpcMessage::Request(req) => { + assert_eq!(req.id, serde_json::json!("real-event-id")); + } + other => panic!("expected request, got {other:?}"), + } + } + + #[test] + fn test_synthetic_initialize_response_uses_sentinel_for_drop() { + let message = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(STATELESS_SYNTHETIC_EVENT_ID), + result: serde_json::json!({}), + }); + + match message { + JsonRpcMessage::Response(resp) => { + assert_eq!(resp.id.as_str(), Some(STATELESS_SYNTHETIC_EVENT_ID)); + } + other => panic!("expected response, got {other:?}"), + } + } + + #[test] + fn test_synthetic_initialized_notification_shape() { + let message = synthetic_initialized_notification(); + match message { + JsonRpcMessage::Notification(notification) => { + assert_eq!(notification.method, "notifications/initialized"); + } + other => panic!("expected notification, got {other:?}"), + } + } + + #[test] + fn test_is_synthetic_initialize_message_detects_sentinel() { + assert!(is_synthetic_initialize_message( + &synthetic_initialize_message() + )); + } + + #[test] + fn test_announcement_sentinel_differs_from_stateless_sentinel() { + assert_ne!(ANNOUNCEMENT_REQUEST_ID, STATELESS_SYNTHETIC_EVENT_ID); + } + + #[test] + fn test_announcement_response_id_detected() { + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "test" } + }), + }); + + if let JsonRpcMessage::Response(ref resp) = response { + let event_id = resp.id.as_str().unwrap(); + assert_eq!(event_id, ANNOUNCEMENT_REQUEST_ID); + // Must not be confused with the stateless synthetic sentinel + assert!(!is_synthetic_initialize_message(&response)); + } + } +} diff --git a/src/transport/base.rs b/src/transport/base.rs index 0c19a4a..bacb9c0 100644 --- a/src/transport/base.rs +++ b/src/transport/base.rs @@ -9,7 +9,9 @@ use crate::core::serializers; use crate::core::types::{EncryptionMode, JsonRpcMessage}; use crate::core::validation; use crate::encryption; -use crate::relay::RelayPool; +use crate::relay::RelayPoolTrait; + +const LOG_TARGET: &str = "contextvm_sdk::transport::base"; /// Shared transport logic for both client and server. /// @@ -18,7 +20,7 @@ use crate::relay::RelayPool; /// and [`NostrServerTransport`](super::server::NostrServerTransport). pub struct BaseTransport { /// The relay pool for publishing and subscribing to Nostr events. - pub relay_pool: Arc, + pub relay_pool: Arc, /// The encryption policy for outgoing messages. pub encryption_mode: EncryptionMode, /// Whether the transport is currently connected to relays. @@ -53,39 +55,39 @@ impl BaseTransport { /// Subscribe to events targeting a pubkey (both regular and encrypted). /// - /// Uses two filters: one for ephemeral ContextVM messages (kind 25910) - /// with `since: now()`, and one for NIP-59 gift wraps (kind 1059) without - /// a `since` constraint. Gift wraps use randomized timestamps per NIP-59, - /// so a `since: now()` filter would reject most incoming encrypted messages. + /// Uses three filters: one for ephemeral ContextVM messages (kind 25910) + /// and two for NIP-59 gift wraps (kinds 1059 and 21059). pub async fn subscribe_for_pubkey(&self, pubkey: &PublicKey) -> Result<()> { let p_tag = pubkey.to_hex(); + let now = Timestamp::now(); - // Ephemeral ContextVM messages — safe to use since:now() let ephemeral_filter = Filter::new() .kind(Kind::Custom(CTXVM_MESSAGES_KIND)) .custom_tag(SingleLetterTag::lowercase(Alphabet::P), p_tag.clone()) - .since(Timestamp::now()); + .since(now); - // NIP-59 gift wraps — timestamps are randomized (up to ±48h or more), - // so we must NOT use since:now(). Limit to recent window instead. - let two_days_ago = Timestamp::from(Timestamp::now().as_u64().saturating_sub(2 * 24 * 3600)); let gift_wrap_filter = Filter::new() .kind(Kind::Custom(GIFT_WRAP_KIND)) - .custom_tag(SingleLetterTag::lowercase(Alphabet::P), p_tag) - .since(two_days_ago); + .custom_tag(SingleLetterTag::lowercase(Alphabet::P), p_tag.clone()) + .since(now); - self.relay_pool.subscribe(vec![ephemeral_filter, gift_wrap_filter]).await + let ephemeral_gift_wrap_filter = Filter::new() + .kind(Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND)) + .custom_tag(SingleLetterTag::lowercase(Alphabet::P), p_tag) + .since(now); + + self.relay_pool + .subscribe(vec![ + ephemeral_filter, + gift_wrap_filter, + ephemeral_gift_wrap_filter, + ]) + .await } /// Convert a Nostr event to an MCP message with validation. pub fn convert_event_to_mcp(&self, content: &str) -> Option { - if !validation::validate_message_size(content) { - tracing::warn!("Message size validation failed: {} bytes", content.len()); - return None; - } - - let value: serde_json::Value = serde_json::from_str(content).ok()?; - validation::validate_message(&value) + validation::validate_and_parse(content) } /// Create a signed Nostr event for an MCP message. @@ -99,9 +101,62 @@ impl BaseTransport { self.relay_pool.sign(builder).await } + /// Prepare an MCP message for publishing without actually publishing it. + /// + /// Signs (and optionally gift-wraps) the event, returning the inner signed + /// event ID together with the final event that should be published to relays. + pub async fn prepare_mcp_message( + &self, + message: &JsonRpcMessage, + recipient: &PublicKey, + kind: u16, + tags: Vec, + is_encrypted: Option, + gift_wrap_kind: Option, + ) -> Result<(EventId, Event)> { + let should_encrypt = self.should_encrypt(kind, is_encrypted); + + let event = self.create_signed_event(message, kind, tags).await?; + let signed_event_id = event.id; + + if should_encrypt { + let event_json = + serde_json::to_string(&event).map_err(|e| Error::Encryption(e.to_string()))?; + let signer = self + .relay_pool + .signer() + .await + .map_err(|e| Error::Encryption(e.to_string()))?; + let selected_gift_wrap_kind = gift_wrap_kind.unwrap_or(GIFT_WRAP_KIND); + let gift_wrap_event = encryption::gift_wrap_single_layer_with_kind( + &signer, + recipient, + &event_json, + selected_gift_wrap_kind, + ) + .await?; + tracing::debug!( + target: LOG_TARGET, + signed_event_id = %signed_event_id, + envelope_id = %gift_wrap_event.id, + gift_wrap_kind = selected_gift_wrap_kind, + "Prepared encrypted MCP message" + ); + Ok((signed_event_id, gift_wrap_event)) + } else { + tracing::debug!( + target: LOG_TARGET, + signed_event_id = %signed_event_id, + "Prepared unencrypted MCP message" + ); + Ok((signed_event_id, event)) + } + } + /// Send an MCP message to a recipient, optionally encrypting. /// - /// Returns the event ID of the published event. + /// Returns the signed MCP event ID. + /// When encrypted, this is the inner signed event ID. pub async fn send_mcp_message( &self, message: &JsonRpcMessage, @@ -109,29 +164,49 @@ impl BaseTransport { kind: u16, tags: Vec, is_encrypted: Option, + gift_wrap_kind: Option, ) -> Result { let should_encrypt = self.should_encrypt(kind, is_encrypted); let event = self.create_signed_event(message, kind, tags).await?; + let signed_event_id = event.id; if should_encrypt { // Single-layer gift wrap: JSON.stringify(signedEvent) → NIP-44 encrypt // This matches the JS/TS SDK's encryptMessage(JSON.stringify(event), recipient) - let event_json = serde_json::to_string(&event) + let event_json = + serde_json::to_string(&event).map_err(|e| Error::Encryption(e.to_string()))?; + let signer = self + .relay_pool + .signer() + .await .map_err(|e| Error::Encryption(e.to_string()))?; - let signer = self.relay_pool.client().signer().await - .map_err(|e| Error::Encryption(e.to_string()))?; - let gift_wrap_event = encryption::gift_wrap_single_layer( - &signer, recipient, &event_json, - ).await?; - let event_id = self.relay_pool.publish_event(&gift_wrap_event).await?; - tracing::debug!(event_id = %event_id, "Sent encrypted MCP message"); - Ok(event_id) + let selected_gift_wrap_kind = gift_wrap_kind.unwrap_or(GIFT_WRAP_KIND); + let gift_wrap_event = encryption::gift_wrap_single_layer_with_kind( + &signer, + recipient, + &event_json, + selected_gift_wrap_kind, + ) + .await?; + self.relay_pool.publish_event(&gift_wrap_event).await?; + tracing::debug!( + target: LOG_TARGET, + signed_event_id = %signed_event_id, + envelope_id = %gift_wrap_event.id, + gift_wrap_kind = selected_gift_wrap_kind, + "Sent encrypted MCP message" + ); } else { - let event_id = self.relay_pool.publish_event(&event).await?; - tracing::debug!(event_id = %event_id, "Sent unencrypted MCP message"); - Ok(event_id) + self.relay_pool.publish_event(&event).await?; + tracing::debug!( + target: LOG_TARGET, + signed_event_id = %signed_event_id, + "Sent unencrypted MCP message" + ); } + + Ok(signed_event_id) } /// Determine whether a message should be encrypted. @@ -157,13 +232,27 @@ impl BaseTransport { pub fn create_response_tags(pubkey: &PublicKey, event_id: &EventId) -> Vec { vec![Tag::public_key(*pubkey), Tag::event(*event_id)] } + + /// Compose outbound event tags in canonical order: + /// routing (p, e) -> discovery (one-shot caps) -> negotiation (pmi, persistent). + pub fn compose_outbound_tags( + base_tags: &[Tag], + discovery_tags: &[Tag], + negotiation_tags: &[Tag], + ) -> Vec { + let mut tags = + Vec::with_capacity(base_tags.len() + discovery_tags.len() + negotiation_tags.len()); + tags.extend_from_slice(base_tags); + tags.extend_from_slice(discovery_tags); + tags.extend_from_slice(negotiation_tags); + tags + } } #[cfg(test)] mod tests { use super::*; use crate::core::types::*; - use nostr_sdk::prelude::*; // Test should_encrypt logic without constructing full BaseTransport fn should_encrypt(mode: EncryptionMode, kind: u16, is_encrypted: Option) -> bool { @@ -179,24 +268,60 @@ mod tests { #[test] fn test_should_encrypt_disabled_mode() { - assert!(!should_encrypt(EncryptionMode::Disabled, CTXVM_MESSAGES_KIND, None)); - assert!(!should_encrypt(EncryptionMode::Disabled, CTXVM_MESSAGES_KIND, Some(true))); - assert!(!should_encrypt(EncryptionMode::Disabled, CTXVM_MESSAGES_KIND, Some(false))); + assert!(!should_encrypt( + EncryptionMode::Disabled, + CTXVM_MESSAGES_KIND, + None + )); + assert!(!should_encrypt( + EncryptionMode::Disabled, + CTXVM_MESSAGES_KIND, + Some(true) + )); + assert!(!should_encrypt( + EncryptionMode::Disabled, + CTXVM_MESSAGES_KIND, + Some(false) + )); } #[test] fn test_should_encrypt_required_mode() { - assert!(should_encrypt(EncryptionMode::Required, CTXVM_MESSAGES_KIND, None)); - assert!(should_encrypt(EncryptionMode::Required, CTXVM_MESSAGES_KIND, Some(false))); - assert!(should_encrypt(EncryptionMode::Required, CTXVM_MESSAGES_KIND, Some(true))); + assert!(should_encrypt( + EncryptionMode::Required, + CTXVM_MESSAGES_KIND, + None + )); + assert!(should_encrypt( + EncryptionMode::Required, + CTXVM_MESSAGES_KIND, + Some(false) + )); + assert!(should_encrypt( + EncryptionMode::Required, + CTXVM_MESSAGES_KIND, + Some(true) + )); } #[test] fn test_should_encrypt_optional_mode() { // Default (None) → true - assert!(should_encrypt(EncryptionMode::Optional, CTXVM_MESSAGES_KIND, None)); - assert!(should_encrypt(EncryptionMode::Optional, CTXVM_MESSAGES_KIND, Some(true))); - assert!(!should_encrypt(EncryptionMode::Optional, CTXVM_MESSAGES_KIND, Some(false))); + assert!(should_encrypt( + EncryptionMode::Optional, + CTXVM_MESSAGES_KIND, + None + )); + assert!(should_encrypt( + EncryptionMode::Optional, + CTXVM_MESSAGES_KIND, + Some(true) + )); + assert!(!should_encrypt( + EncryptionMode::Optional, + CTXVM_MESSAGES_KIND, + Some(false) + )); } #[test] @@ -224,10 +349,9 @@ mod tests { let keys = Keys::generate(); let pubkey = keys.public_key(); // Create a dummy event ID - let event_id = EventId::from_hex( - "0000000000000000000000000000000000000000000000000000000000000001", - ) - .unwrap(); + let event_id = + EventId::from_hex("0000000000000000000000000000000000000000000000000000000000000001") + .unwrap(); let tags = BaseTransport::create_response_tags(&pubkey, &event_id); assert_eq!(tags.len(), 2); @@ -287,4 +411,57 @@ mod tests { let big = "x".repeat(MAX_MESSAGE_SIZE + 1); assert!(!crate::core::validation::validate_message_size(&big)); } + + // ── compose_outbound_tags ────────────────────────────────── + + fn make_custom_tag(name: &str) -> Tag { + Tag::custom(TagKind::Custom(name.into()), Vec::::new()) + } + + #[test] + fn compose_outbound_tags_ordering() { + let keys = Keys::generate(); + let base = vec![Tag::public_key(keys.public_key())]; + let discovery = vec![make_custom_tag("support_encryption")]; + let negotiation = vec![make_custom_tag("pmi")]; + + let result = BaseTransport::compose_outbound_tags(&base, &discovery, &negotiation); + assert_eq!(result.len(), 3); + assert_eq!(result[0].clone().to_vec()[0], "p"); + assert_eq!(result[1].clone().to_vec()[0], "support_encryption"); + assert_eq!(result[2].clone().to_vec()[0], "pmi"); + } + + #[test] + fn compose_outbound_tags_empty_discovery() { + let keys = Keys::generate(); + let base = vec![Tag::public_key(keys.public_key())]; + let negotiation = vec![make_custom_tag("pmi")]; + + let result = BaseTransport::compose_outbound_tags(&base, &[], &negotiation); + assert_eq!(result.len(), 2); + assert_eq!(result[0].clone().to_vec()[0], "p"); + assert_eq!(result[1].clone().to_vec()[0], "pmi"); + } + + #[test] + fn compose_outbound_tags_all_empty() { + let result = BaseTransport::compose_outbound_tags(&[], &[], &[]); + assert!(result.is_empty()); + } + + #[test] + fn compose_outbound_tags_preserves_all_elements() { + let discovery = vec![ + make_custom_tag("support_encryption"), + make_custom_tag("support_encryption_ephemeral"), + ]; + let result = BaseTransport::compose_outbound_tags(&[], &discovery, &[]); + assert_eq!(result.len(), 2); + assert_eq!(result[0].clone().to_vec()[0], "support_encryption"); + assert_eq!( + result[1].clone().to_vec()[0], + "support_encryption_ephemeral" + ); + } } diff --git a/src/transport/client.rs b/src/transport/client.rs deleted file mode 100644 index 7dffcf5..0000000 --- a/src/transport/client.rs +++ /dev/null @@ -1,318 +0,0 @@ -//! Client-side Nostr transport for ContextVM. -//! -//! Connects to a remote MCP server over Nostr. Sends JSON-RPC requests as -//! kind 25910 events, correlates responses via `e` tag. - -use std::collections::HashSet; -use std::sync::Arc; -use std::time::Duration; - -use nostr_sdk::prelude::*; -use tokio::sync::RwLock; - -use crate::core::constants::*; -use crate::core::error::{Error, Result}; -use crate::core::serializers; -use crate::core::types::*; -use crate::encryption; -use crate::relay::RelayPool; -use crate::transport::base::BaseTransport; - -/// Configuration for the client transport. -pub struct NostrClientTransportConfig { - /// Relay URLs to connect to. - pub relay_urls: Vec, - /// The server's public key (hex). - pub server_pubkey: String, - /// Encryption mode. - pub encryption_mode: EncryptionMode, - /// Stateless mode: emulate initialize response locally. - pub is_stateless: bool, - /// Response timeout (default: 30s). - pub timeout: Duration, -} - -impl Default for NostrClientTransportConfig { - fn default() -> Self { - Self { - relay_urls: vec!["wss://relay.damus.io".to_string()], - server_pubkey: String::new(), - encryption_mode: EncryptionMode::Optional, - is_stateless: false, - timeout: Duration::from_secs(30), - } - } -} - -/// Client-side Nostr transport for sending MCP requests and receiving responses. -pub struct NostrClientTransport { - base: BaseTransport, - config: NostrClientTransportConfig, - server_pubkey: PublicKey, - /// Pending request event IDs awaiting responses. - pending_requests: Arc>>, - /// Channel for receiving processed MCP messages from the event loop. - message_tx: tokio::sync::mpsc::UnboundedSender, - message_rx: Option>, -} - -impl NostrClientTransport { - /// Create a new client transport. - pub async fn new(signer: T, config: NostrClientTransportConfig) -> Result - where - T: IntoNostrSigner, - { - let server_pubkey = PublicKey::from_hex(&config.server_pubkey) - .map_err(|e| Error::Other(format!("Invalid server pubkey: {e}")))?; - - let relay_pool = Arc::new(RelayPool::new(signer).await?); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - - Ok(Self { - base: BaseTransport { - relay_pool, - encryption_mode: config.encryption_mode, - is_connected: false, - }, - config, - server_pubkey, - pending_requests: Arc::new(RwLock::new(HashSet::new())), - message_tx: tx, - message_rx: Some(rx), - }) - } - - /// Connect and start listening for responses. - pub async fn start(&mut self) -> Result<()> { - self.base.connect(&self.config.relay_urls).await?; - - let pubkey = self.base.get_public_key().await?; - tracing::info!(pubkey = %pubkey.to_hex(), "Client transport started"); - - self.base.subscribe_for_pubkey(&pubkey).await?; - - // Spawn event loop - let client = self.base.relay_pool.client().clone(); - let pending = self.pending_requests.clone(); - let server_pubkey = self.server_pubkey; - let tx = self.message_tx.clone(); - let encryption_mode = self.config.encryption_mode; - - tokio::spawn(async move { - Self::event_loop(client, pending, server_pubkey, tx, encryption_mode).await; - }); - - Ok(()) - } - - /// Close the transport. - pub async fn close(&mut self) -> Result<()> { - self.base.disconnect().await - } - - /// Send a JSON-RPC message to the server. - pub async fn send(&self, message: &JsonRpcMessage) -> Result<()> { - // Stateless mode: emulate initialize response - if self.config.is_stateless { - if let JsonRpcMessage::Request(ref req) = message { - if req.method == "initialize" { - self.emulate_initialize_response(&req.id); - return Ok(()); - } - } - if let JsonRpcMessage::Notification(ref n) = message { - if n.method == "notifications/initialized" { - return Ok(()); - } - } - } - - let tags = BaseTransport::create_recipient_tags(&self.server_pubkey); - let event_id = self - .base - .send_mcp_message(message, &self.server_pubkey, CTXVM_MESSAGES_KIND, tags, None) - .await?; - - self.pending_requests - .write() - .await - .insert(event_id.to_hex()); - - Ok(()) - } - - /// Take the message receiver for consuming incoming messages. - pub fn take_message_receiver( - &mut self, - ) -> Option> { - self.message_rx.take() - } - - fn emulate_initialize_response(&self, request_id: &serde_json::Value) { - let response = JsonRpcMessage::Response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: request_id.clone(), - result: serde_json::json!({ - "protocolVersion": "2025-03-26", - "serverInfo": { - "name": "Emulated-Stateless-Server", - "version": "1.0.0" - }, - "capabilities": { - "tools": { "listChanged": true }, - "prompts": { "listChanged": true }, - "resources": { "subscribe": true, "listChanged": true } - } - }), - }); - let _ = self.message_tx.send(response); - } - - async fn event_loop( - client: Arc, - pending: Arc>>, - server_pubkey: PublicKey, - tx: tokio::sync::mpsc::UnboundedSender, - _encryption_mode: EncryptionMode, - ) { - let mut notifications = client.notifications(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Event { event, .. } = notification { - // Handle gift-wrapped events - let (actual_event_content, actual_pubkey, e_tag) = - if event.kind == Kind::Custom(GIFT_WRAP_KIND) { - // Single-layer NIP-44 decrypt (matches JS/TS SDK) - let signer = match client.signer().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get signer: {e}"); - continue; - } - }; - match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await { - Ok(decrypted_json) => { - match serde_json::from_str::(&decrypted_json) { - Ok(inner) => { - let e_tag = serializers::get_tag_value(&inner.tags, "e"); - (inner.content, inner.pubkey, e_tag) - } - Err(e) => { - tracing::error!("Failed to parse inner event: {e}"); - continue; - } - } - } - Err(e) => { - tracing::error!("Failed to decrypt gift wrap: {e}"); - continue; - } - } - } else { - let e_tag = serializers::get_tag_value(&event.tags, "e"); - (event.content.clone(), event.pubkey, e_tag) - }; - - // Verify it's from our server - if actual_pubkey != server_pubkey { - tracing::debug!("Skipping event from unexpected pubkey"); - continue; - } - - // Correlate response - if let Some(ref correlated_id) = e_tag { - let is_pending = pending.read().await.contains(correlated_id.as_str()); - if !is_pending { - tracing::warn!(e_tag = %correlated_id, "Response for unknown request"); - continue; - } - } - - // Parse MCP message - if let Some(mcp_msg) = - serializers::nostr_event_to_mcp_message(&actual_event_content) - { - // Clean up pending request - if let Some(ref correlated_id) = e_tag { - pending.write().await.remove(correlated_id.as_str()); - } - let _ = tx.send(mcp_msg); - } - } - } - } -} - -#[cfg(test)] -mod tests { - use super::*; - use crate::core::types::*; - - #[test] - fn test_config_defaults() { - let config = NostrClientTransportConfig::default(); - assert_eq!(config.relay_urls, vec!["wss://relay.damus.io".to_string()]); - assert!(config.server_pubkey.is_empty()); - assert_eq!(config.encryption_mode, EncryptionMode::Optional); - assert!(!config.is_stateless); - assert_eq!(config.timeout, Duration::from_secs(30)); - } - - #[test] - fn test_stateless_config() { - let config = NostrClientTransportConfig { - is_stateless: true, - ..Default::default() - }; - assert!(config.is_stateless); - } - - #[test] - fn test_stateless_emulated_initialize_response_shape() { - // Verify the emulated response has the expected structure - let request_id = serde_json::json!(1); - let response = JsonRpcMessage::Response(JsonRpcResponse { - jsonrpc: "2.0".to_string(), - id: request_id.clone(), - result: serde_json::json!({ - "protocolVersion": "2025-03-26", - "serverInfo": { - "name": "Emulated-Stateless-Server", - "version": "1.0.0" - }, - "capabilities": { - "tools": { "listChanged": true }, - "prompts": { "listChanged": true }, - "resources": { "subscribe": true, "listChanged": true } - } - }), - }); - assert!(response.is_response()); - assert_eq!(response.id(), Some(&serde_json::json!(1))); - - if let JsonRpcMessage::Response(r) = &response { - assert!(r.result.get("capabilities").is_some()); - assert!(r.result.get("serverInfo").is_some()); - let server_info = r.result.get("serverInfo").unwrap(); - assert_eq!(server_info.get("name").unwrap().as_str().unwrap(), "Emulated-Stateless-Server"); - } - } - - #[test] - fn test_stateless_mode_initialize_request_detection() { - let init_req = JsonRpcMessage::Request(JsonRpcRequest { - jsonrpc: "2.0".to_string(), - id: serde_json::json!(1), - method: "initialize".to_string(), - params: None, - }); - assert_eq!(init_req.method(), Some("initialize")); - - let init_notif = JsonRpcMessage::Notification(JsonRpcNotification { - jsonrpc: "2.0".to_string(), - method: "notifications/initialized".to_string(), - params: None, - }); - assert_eq!(init_notif.method(), Some("notifications/initialized")); - } -} diff --git a/src/transport/client/correlation_store.rs b/src/transport/client/correlation_store.rs new file mode 100644 index 0000000..6ee6558 --- /dev/null +++ b/src/transport/client/correlation_store.rs @@ -0,0 +1,229 @@ +//! Client-side correlation store for tracking pending request event IDs. + +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use lru::LruCache; +use tokio::sync::RwLock; + +use crate::core::constants::DEFAULT_LRU_SIZE; + +/// A pending request tracked by the correlation store. +#[derive(Debug, Clone)] +pub struct PendingRequest { + /// The original JSON-RPC request ID before event-ID replacement. + pub original_id: serde_json::Value, + /// Whether this request is an `initialize` handshake. + pub is_initialize: bool, + /// When the request was registered. + pub registered_at: Instant, +} + +/// Tracks pending request event IDs and their original request IDs on the client side. +/// +/// An optional capacity limit enables LRU eviction of the oldest entry when the +/// store is full. +#[derive(Clone)] +pub struct ClientCorrelationStore { + pending_requests: Arc>>, +} + +impl Default for ClientCorrelationStore { + fn default() -> Self { + Self::new() + } +} + +impl ClientCorrelationStore { + /// Create a new store with the default capacity + pub fn new() -> Self { + Self::with_max_pending(DEFAULT_LRU_SIZE) + } + + /// Create a store with an upper bound on pending requests. + /// When the limit is reached the oldest entry is evicted. + pub fn with_max_pending(max_pending: usize) -> Self { + Self { + pending_requests: Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(max_pending).unwrap_or(NonZeroUsize::new(1).unwrap()), + ))), + } + } + + /// Register a pending request with its original JSON-RPC request ID. + pub async fn register( + &self, + event_id: String, + original_id: serde_json::Value, + is_initialize: bool, + ) { + self.pending_requests.write().await.push( + event_id, + PendingRequest { + original_id, + is_initialize, + registered_at: Instant::now(), + }, + ); + } + + /// Check whether a given event ID corresponds to an `initialize` request. + pub async fn is_initialize_request(&self, event_id: &str) -> bool { + self.pending_requests + .read() + .await + .peek(event_id) + .is_some_and(|r| r.is_initialize) + } + + /// Check whether a pending request exists for the given event ID + pub async fn contains(&self, event_id: &str) -> bool { + self.pending_requests.read().await.contains(event_id) + } + + /// Remove a pending request. Returns `true` if the key existed. + pub async fn remove(&self, event_id: &str) -> bool { + self.pending_requests.write().await.pop(event_id).is_some() + } + + /// Retrieve the original request ID for a given event ID without removing it. + pub async fn get_original_id(&self, event_id: &str) -> Option { + self.pending_requests + .read() + .await + .peek(event_id) + .map(|r| r.original_id.clone()) + } + + /// Refresh a pending request's registration timestamp without disturbing its + /// `original_id`/`is_initialize`. Used by CEP-22 oversized transfers: each + /// inbound frame "touches" the entry so [`sweep_expired`](Self::sweep_expired) + /// does not evict it mid-transfer. Returns `true` if the entry existed. + pub async fn touch(&self, event_id: &str) -> bool { + let mut cache = self.pending_requests.write().await; + if let Some(entry) = cache.get_mut(event_id) { + entry.registered_at = Instant::now(); + true + } else { + false + } + } + + /// Number of pending requests currently tracked. + pub async fn count(&self) -> usize { + self.pending_requests.read().await.len() + } + + /// Remove all entries older than `timeout`. Returns the number of entries removed. + pub async fn sweep_expired(&self, timeout: Duration) -> usize { + let now = Instant::now(); + let mut cache = self.pending_requests.write().await; + let mut expired_keys = Vec::new(); + + for (key, entry) in cache.iter() { + if now.duration_since(entry.registered_at) >= timeout { + expired_keys.push(key.clone()); + } + } + + let count = expired_keys.len(); + for key in expired_keys { + cache.pop(&key); + } + count + } + + /// Remove all pending requests from the store + pub async fn clear(&self) { + self.pending_requests.write().await.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn remove_nonexistent_is_noop() { + let store = ClientCorrelationStore::new(); + assert!(!store.remove("nonexistent").await); + assert!(!store.contains("nonexistent").await); + } + + #[tokio::test] + async fn contains_after_clear() { + let store = ClientCorrelationStore::new(); + store + .register("e1".into(), serde_json::Value::Null, false) + .await; + store + .register("e2".into(), serde_json::Value::Null, false) + .await; + assert!(store.contains("e1").await); + store.clear().await; + assert!(!store.contains("e1").await); + assert!(!store.contains("e2").await); + } + + #[tokio::test] + async fn register_and_remove_roundtrip() { + let store = ClientCorrelationStore::new(); + store + .register("e1".into(), serde_json::Value::Null, false) + .await; + assert!(store.contains("e1").await); + assert!(store.remove("e1").await); + assert!(!store.contains("e1").await); + } + + #[tokio::test] + async fn default_store_is_bounded() { + let store = ClientCorrelationStore::new(); + for i in 0..=DEFAULT_LRU_SIZE { + store + .register(format!("e{i}"), serde_json::Value::Null, false) + .await; + } + + assert_eq!(store.count().await, DEFAULT_LRU_SIZE); + assert!(!store.contains("e0").await); + assert!(store.contains(&format!("e{DEFAULT_LRU_SIZE}")).await); + } + + #[tokio::test] + async fn sweep_expired_removes_only_stale_entries() { + let store = ClientCorrelationStore::new(); + + // Insert an entry that will be "old" by the time we sweep. + store + .register("old".into(), serde_json::json!(1), false) + .await; + + // Sleep so "old" entry ages past the threshold. + tokio::time::sleep(Duration::from_millis(20)).await; + + // Insert a fresh entry. + store + .register("fresh".into(), serde_json::json!(2), false) + .await; + + // Sweep with a 10ms timeout — "old" should be removed, "fresh" should remain. + let swept = store.sweep_expired(Duration::from_millis(10)).await; + assert_eq!(swept, 1); + assert!(!store.contains("old").await); + assert!(store.contains("fresh").await); + } + + #[tokio::test] + async fn sweep_expired_returns_zero_when_nothing_expired() { + let store = ClientCorrelationStore::new(); + store + .register("e1".into(), serde_json::Value::Null, false) + .await; + + let swept = store.sweep_expired(Duration::from_secs(60)).await; + assert_eq!(swept, 0); + assert!(store.contains("e1").await); + } +} diff --git a/src/transport/client/mod.rs b/src/transport/client/mod.rs new file mode 100644 index 0000000..8e0abf2 --- /dev/null +++ b/src/transport/client/mod.rs @@ -0,0 +1,2225 @@ +//! Client-side Nostr transport for ContextVM. +//! +//! Connects to a remote MCP server over Nostr. Sends JSON-RPC requests as +//! kind 25910 events, correlates responses via `e` tag. + +pub mod correlation_store; +pub mod relay_resolution; +pub mod server_identity; +pub mod server_relay_discovery; + +pub use correlation_store::ClientCorrelationStore; + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use lru::LruCache; +use nostr_sdk::prelude::*; +use tokio::sync::oneshot; +use tokio_util::sync::CancellationToken; + +use crate::core::constants::*; +use crate::core::error::Result; +use crate::core::serializers; +use crate::core::types::*; +use crate::core::validation; +use crate::encryption; +use crate::relay::{RelayPool, RelayPoolTrait}; +use crate::transport::base::BaseTransport; +use crate::transport::discovery_tags::{parse_discovered_peer_capabilities, PeerCapabilities}; +use crate::transport::open_stream::OpenStreamConfig; +use crate::transport::oversized_transfer::{ + build_oversized_frames, progress_token_string, resolve_safe_chunk_size, + send_oversized_transfer, OversizedFrame, OversizedSenderOptions, OversizedTransferConfig, + OversizedTransferReceiver, NOTIFICATIONS_PROGRESS_METHOD, +}; + +const LOG_TARGET: &str = "contextvm_sdk::transport::client"; + +/// Configuration for the client transport. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct NostrClientTransportConfig { + /// Relay URLs to connect to. + pub relay_urls: Vec, + /// The server's public key (hex, npub, or nprofile). + /// + /// When an nprofile is provided, embedded relay hints are extracted and used + /// during CEP-17 relay resolution. + pub server_pubkey: String, + /// Encryption mode. + pub encryption_mode: EncryptionMode, + /// Gift-wrap policy for encrypted messages. + pub gift_wrap_mode: GiftWrapMode, + /// Stateless mode: emulate initialize response locally. + pub is_stateless: bool, + /// Correlation-retention TTL for pending client requests (default: 30s). + /// + /// Stale pending entries older than this are swept from the correlation store. + /// This prevents leaks -- rmcp owns actual request timeout and cancellation. + /// Keep this value above your rmcp request timeout to avoid premature cleanup. + pub timeout: Duration, + /// Relay URLs used for CEP-17 relay-list discovery when operational relays are not configured. + /// Overrides `DEFAULT_BOOTSTRAP_RELAY_URLS` when provided. + pub discovery_relay_urls: Option>, + /// Non-authoritative operational relays probed in parallel with CEP-17 discovery. + pub fallback_operational_relay_urls: Option>, + /// CEP-22 oversized payload transfer configuration. Enabled by default. + pub oversized_transfer: OversizedTransferConfig, + /// CEP-41 open-stream configuration. Disabled by default (opt-in). + /// + /// **Data only in PR1** — the event loop does not consult it yet; activation + /// (capability advertisement, learning, `call_tool_stream`) lands in PR2. + pub open_stream: OpenStreamConfig, +} + +impl Default for NostrClientTransportConfig { + fn default() -> Self { + Self { + relay_urls: vec![], + server_pubkey: String::new(), + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + is_stateless: false, + timeout: Duration::from_secs(30), + discovery_relay_urls: None, + fallback_operational_relay_urls: None, + oversized_transfer: OversizedTransferConfig::default(), + open_stream: OpenStreamConfig::default(), + } + } +} + +impl NostrClientTransportConfig { + /// Set the server's public key (hex, npub, or nprofile). + pub fn with_server_pubkey(mut self, pubkey: impl Into) -> Self { + self.server_pubkey = pubkey.into(); + self + } + /// Set the encryption mode. + pub fn with_encryption_mode(mut self, mode: EncryptionMode) -> Self { + self.encryption_mode = mode; + self + } + /// Set the gift-wrap mode (CEP-19). + pub fn with_gift_wrap_mode(mut self, mode: GiftWrapMode) -> Self { + self.gift_wrap_mode = mode; + self + } + /// Enable or disable stateless mode. + pub fn with_stateless(mut self, stateless: bool) -> Self { + self.is_stateless = stateless; + self + } + /// Set the relay URLs to connect to. + pub fn with_relay_urls(mut self, urls: Vec) -> Self { + self.relay_urls = urls; + self + } + /// Set the correlation-retention TTL. + pub fn with_timeout(mut self, timeout: Duration) -> Self { + self.timeout = timeout; + self + } + /// Set relay URLs for CEP-17 relay-list discovery. + pub fn with_discovery_relay_urls(mut self, urls: Vec) -> Self { + self.discovery_relay_urls = Some(urls); + self + } + /// Set fallback operational relay URLs probed in parallel with discovery. + pub fn with_fallback_operational_relay_urls(mut self, urls: Vec) -> Self { + self.fallback_operational_relay_urls = Some(urls); + self + } + /// Set the full CEP-22 oversized payload transfer configuration. + pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self { + self.oversized_transfer = config; + self + } + /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default. + pub fn with_oversized_enabled(mut self, enabled: bool) -> Self { + self.oversized_transfer.enabled = enabled; + self + } + /// Set the full CEP-41 open-stream configuration. + /// + /// Data only in PR1: the event loop does not read this until PR2. + pub fn with_open_stream(mut self, config: OpenStreamConfig) -> Self { + self.open_stream = config; + self + } +} + +/// Client-side Nostr transport for sending MCP requests and receiving responses. +pub struct NostrClientTransport { + base: BaseTransport, + config: NostrClientTransportConfig, + server_pubkey: PublicKey, + /// Populated from nprofile relay hints; used by relay resolution in `start()` (CEP-17). + hinted_relay_urls: Vec, + /// Discovery relay URLs for CEP-17 kind 10002 lookup. + discovery_relay_urls: Vec, + /// Fallback operational relay URLs probed in parallel with discovery. + fallback_operational_relay_urls: Vec, + /// Pending request event IDs awaiting responses. + pending_requests: ClientCorrelationStore, + /// CEP-35: one-shot flag for client discovery tag emission. + has_sent_discovery_tags: AtomicBool, + /// CEP-35: learned server capabilities from inbound discovery tags. + discovered_server_capabilities: Arc>, + /// CEP-35: first inbound event carrying discovery tags (session baseline). + server_initialize_event: Arc>>, + /// Learned support for server-side ephemeral gift wraps. + server_supports_ephemeral: Arc, + /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`). + /// Duplicate outer ids are skipped before decrypt; ids are inserted only after success + /// so failed decrypt/verify can be retried on redelivery. + seen_gift_wrap_ids: Arc>>, + /// CEP-22: reassembly engine for inbound oversized responses from the server + /// (single peer). Cleared on [`close`](Self::close). + oversized_receiver: Arc>, + /// CEP-22: outstanding `accept` handshake waiters keyed by `progressToken`. A + /// `send()` awaiting the server's `accept` registers a one-shot here before + /// publishing `start`; the event loop fires it when the `accept` frame arrives. + accept_waiters: Arc>>>, + /// CEP-22: original `_meta.progressToken` JSON values of sent + /// oversized-eligible requests, keyed by their stringified form. Frames + /// stringify tokens on the wire (both SDKs), so the original value — + /// `Number` for rmcp-issued tokens — survives only here; progress forwarded + /// to the requester must restore it for rmcp's watcher lookup to match + /// (`Number(5)` ≠ `String("5")`). LRU-bounded; entries are dropped when + /// their transfer concludes and cleared on [`close`](Self::close). + original_progress_tokens: Arc>>, + /// Channel for receiving processed MCP messages from the event loop. + message_tx: Option>, + message_rx: Option>, + /// Token used to cancel the spawned event loop on close(). + cancellation_token: CancellationToken, + /// Handle for the spawned event loop task. + event_loop_handle: Option>, +} + +impl NostrClientTransport { + /// Create a new client transport. + pub async fn new(signer: T, config: NostrClientTransportConfig) -> Result + where + T: IntoNostrSigner, + { + let (server_pubkey, hinted_relay_urls) = + server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + server_pubkey = %config.server_pubkey, + "Invalid server pubkey" + ); + error + })?; + + let relay_pool: Arc = + Arc::new(RelayPool::new(signer).await.map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to initialize relay pool for client transport" + ); + error + })?); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + tracing::info!( + target: LOG_TARGET, + relay_count = config.relay_urls.len(), + stateless = config.is_stateless, + encryption_mode = ?config.encryption_mode, + "Created client transport" + ); + let discovery_relay_urls = config.discovery_relay_urls.clone().unwrap_or_else(|| { + DEFAULT_BOOTSTRAP_RELAY_URLS + .iter() + .map(|s| s.to_string()) + .collect() + }); + let fallback_operational_relay_urls = config + .fallback_operational_relay_urls + .clone() + .unwrap_or_default(); + + let oversized_receiver = Arc::new(Mutex::new(OversizedTransferReceiver::with_policy( + (&config.oversized_transfer).into(), + ))); + let accept_waiters = Arc::new(Mutex::new(HashMap::new())); + let original_progress_tokens = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + Ok(Self { + base: BaseTransport { + relay_pool, + encryption_mode: config.encryption_mode, + is_connected: false, + }, + oversized_receiver, + accept_waiters, + original_progress_tokens, + config, + server_pubkey, + hinted_relay_urls, + discovery_relay_urls, + fallback_operational_relay_urls, + pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), + server_supports_ephemeral: Arc::new(AtomicBool::new(false)), + seen_gift_wrap_ids, + message_tx: Some(tx), + message_rx: Some(rx), + cancellation_token: CancellationToken::new(), + event_loop_handle: None, + }) + } + + /// Like [`new`](Self::new) but accepts an existing relay pool. + pub async fn with_relay_pool( + config: NostrClientTransportConfig, + relay_pool: Arc, + ) -> Result { + let (server_pubkey, hinted_relay_urls) = + server_identity::parse_server_identity(&config.server_pubkey).map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + server_pubkey = %config.server_pubkey, + "Invalid server pubkey" + ); + error + })?; + + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + let discovery_relay_urls = config.discovery_relay_urls.clone().unwrap_or_else(|| { + DEFAULT_BOOTSTRAP_RELAY_URLS + .iter() + .map(|s| s.to_string()) + .collect() + }); + let fallback_operational_relay_urls = config + .fallback_operational_relay_urls + .clone() + .unwrap_or_default(); + + tracing::info!( + target: LOG_TARGET, + relay_count = config.relay_urls.len(), + stateless = config.is_stateless, + encryption_mode = ?config.encryption_mode, + "Created client transport (with_relay_pool)" + ); + let oversized_receiver = Arc::new(Mutex::new(OversizedTransferReceiver::with_policy( + (&config.oversized_transfer).into(), + ))); + let accept_waiters = Arc::new(Mutex::new(HashMap::new())); + let original_progress_tokens = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + Ok(Self { + base: BaseTransport { + relay_pool, + encryption_mode: config.encryption_mode, + is_connected: false, + }, + oversized_receiver, + accept_waiters, + original_progress_tokens, + config, + server_pubkey, + hinted_relay_urls, + discovery_relay_urls, + fallback_operational_relay_urls, + pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), + server_supports_ephemeral: Arc::new(AtomicBool::new(false)), + seen_gift_wrap_ids, + message_tx: Some(tx), + message_rx: Some(rx), + cancellation_token: CancellationToken::new(), + event_loop_handle: None, + }) + } + + /// Connect and start listening for responses. + pub async fn start(&mut self) -> Result<()> { + let resolved_urls = + relay_resolution::resolve_operational_relays(relay_resolution::RelayResolutionConfig { + configured_relay_urls: self.config.relay_urls.clone(), + hinted_relay_urls: self.hinted_relay_urls.clone(), + discovery_relay_urls: self.discovery_relay_urls.clone(), + fallback_operational_relay_urls: self.fallback_operational_relay_urls.clone(), + server_pubkey: self.server_pubkey, + signer: self.base.relay_pool.signer().await?, + timeout: Duration::from_millis(DEFAULT_TIMEOUT_MS), + }) + .await; + + let connect_urls = if resolved_urls.is_empty() { + &self.config.relay_urls + } else { + &resolved_urls + }; + + self.base.connect(connect_urls).await.map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to connect client transport to relays" + ); + error + })?; + + let pubkey = self.base.get_public_key().await.map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to fetch client transport public key" + ); + error + })?; + tracing::info!( + target: LOG_TARGET, + pubkey = %pubkey.to_hex(), + "Client transport started" + ); + + self.base + .subscribe_for_pubkey(&pubkey) + .await + .map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + pubkey = %pubkey.to_hex(), + "Failed to subscribe client transport for pubkey" + ); + error + })?; + + // Spawn event loop with cancellation support + let relay_pool = Arc::clone(&self.base.relay_pool); + let pending = self.pending_requests.clone(); + let server_pubkey = self.server_pubkey; + let tx = self + .message_tx + .as_ref() + .expect("message_tx must exist before start()") + .clone(); + let encryption_mode = self.config.encryption_mode; + let gift_wrap_mode = self.config.gift_wrap_mode; + let discovered_caps = self.discovered_server_capabilities.clone(); + let init_event = self.server_initialize_event.clone(); + let server_supports_ephemeral = self.server_supports_ephemeral.clone(); + let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone(); + let oversized_receiver = self.oversized_receiver.clone(); + let accept_waiters = self.accept_waiters.clone(); + let original_progress_tokens = self.original_progress_tokens.clone(); + let oversized_enabled = self.config.oversized_transfer.enabled; + let timeout = self.config.timeout; + let token = self.cancellation_token.child_token(); + + self.event_loop_handle = Some(tokio::spawn(async move { + Self::event_loop( + relay_pool, + pending, + server_pubkey, + tx, + encryption_mode, + gift_wrap_mode, + discovered_caps, + init_event, + server_supports_ephemeral, + seen_gift_wrap_ids, + oversized_receiver, + accept_waiters, + original_progress_tokens, + oversized_enabled, + timeout, + token, + ) + .await; + })); + + tracing::info!( + target: LOG_TARGET, + relay_count = self.config.relay_urls.len(), + "Client transport event loop spawned" + ); + Ok(()) + } + + /// Close the transport — cancels the event loop and disconnects from relays. + pub async fn close(&mut self) -> Result<()> { + self.cancellation_token.cancel(); + if let Some(handle) = self.event_loop_handle.take() { + let _ = handle.await; + } + self.message_tx.take(); + // CEP-22: release reassembly state and drop any accept waiters so an + // in-flight `send()` awaiter unblocks (cancelled) instead of hanging to + // its accept timeout. + { + let mut receiver = match self.oversized_receiver.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + receiver.clear(); + } + { + let mut waiters = match self.accept_waiters.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + waiters.clear(); + } + { + let mut originals = match self.original_progress_tokens.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + originals.clear(); + } + self.base.disconnect().await + } + + /// Send a JSON-RPC message to the server. + pub async fn send(&self, message: &JsonRpcMessage) -> Result<()> { + // Stateless mode: emulate initialize response + if self.config.is_stateless { + if let JsonRpcMessage::Request(ref req) = message { + if req.method == "initialize" { + self.emulate_initialize_response(&req.id); + return Ok(()); + } + } + if let JsonRpcMessage::Notification(ref n) = message { + if n.method == "notifications/initialized" { + return Ok(()); + } + } + } + + let is_request = message.is_request(); + let base_tags = BaseTransport::create_recipient_tags(&self.server_pubkey); + let discovery_tags = if is_request { + self.get_pending_client_discovery_tags() + } else { + vec![] + }; + let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]); + let gift_wrap_kind = self.choose_outbound_gift_wrap_kind(); + let discovery_sent = !discovery_tags.is_empty(); + + // CEP-22: only a request carrying a `progressToken` is eligible for oversized + // fragmentation (the token addresses the frames); extract it once up front. + // Tokens may be JSON strings or numbers (rmcp issues numbers): the + // stringified form keys all transport state, and the original value is + // recorded so progress forwarded to the requester can restore the token's + // wire type. + let oversized_token: Option = + if is_request && self.config.oversized_transfer.enabled { + let original = match message { + JsonRpcMessage::Request(req) => req + .params + .as_ref() + .and_then(|p| p.get("_meta")) + .and_then(|m| m.get("progressToken")), + _ => None, + }; + let token = original.and_then(progress_token_string); + if let (Some(token), Some(original)) = (token.as_deref(), original) { + self.record_original_progress_token(token, original); + } + token + } else { + None + }; + + // CEP-22: fragment when the message would not fit in a single Nostr event. + // Relay size limits apply to the *published* event, so the decision is made + // on the published byte size — not the raw payload — which is what actually + // grows under JSON escaping and gift-wrap encryption (mirrors TS + // `measurePublishedMcpMessageSize`). The raw serialized length is a cheap + // lower bound: when it already meets the threshold the message is + // conclusively oversized and we fragment without building a single event — + // an escape-heavy payload could otherwise overflow NIP-44's plaintext limit + // while we measure. + if let Some(token) = oversized_token.as_deref() { + let content = serde_json::to_string(message)?; + let threshold = self.config.oversized_transfer.threshold; + if content.len() >= threshold { + return self + .send_oversized_request( + message, + &content, + token, + base_tags, + tags, + discovery_sent, + ) + .await; + } + // Borderline: a sub-threshold payload can still cross the threshold once + // signed, JSON-escaped, and (when enabled) gift-wrapped. Build the single + // event once, measure its real published size, and reuse it if it fits. + match self + .base + .prepare_mcp_message( + message, + &self.server_pubkey, + CTXVM_MESSAGES_KIND, + tags.clone(), + None, + Some(gift_wrap_kind), + ) + .await + { + Ok((event_id, publishable_event)) => { + let published_len = serde_json::to_string(&publishable_event) + .map(|s| s.len()) + .unwrap_or(usize::MAX); + if published_len > threshold { + return self + .send_oversized_request( + message, + &content, + token, + base_tags, + tags, + discovery_sent, + ) + .await; + } + return self + .publish_single_event(message, event_id, publishable_event, discovery_sent) + .await; + } + Err(error) => { + // Could not build even one event (e.g. NIP-44 plaintext overflow + // from an escape-heavy payload) → it cannot be sent as a single + // event; fragment it. + tracing::debug!( + target: LOG_TARGET, + error = %error, + "Single-event build failed; sending as oversized transfer" + ); + return self + .send_oversized_request( + message, + &content, + token, + base_tags, + tags, + discovery_sent, + ) + .await; + } + } + } + + // Single-event path: not oversized-eligible (notification, feature disabled, + // or no progressToken). + let (event_id, publishable_event) = self + .base + .prepare_mcp_message( + message, + &self.server_pubkey, + CTXVM_MESSAGES_KIND, + tags, + None, + Some(gift_wrap_kind), + ) + .await + .map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + server_pubkey = %self.server_pubkey.to_hex(), + method = ?message.method(), + "Failed to prepare client message" + ); + error + })?; + + self.publish_single_event(message, event_id, publishable_event, discovery_sent) + .await + } + + /// Register (for requests) and publish one prepared MCP event, flipping the + /// one-shot discovery flag after a successful publish. Shared by the + /// non-oversized send paths so the event built for the CEP-22 size check is + /// reused for publishing rather than re-encrypted. + async fn publish_single_event( + &self, + message: &JsonRpcMessage, + event_id: EventId, + publishable_event: Event, + discovery_sent: bool, + ) -> Result<()> { + if let JsonRpcMessage::Request(ref req) = message { + let is_initialize = req.method == INITIALIZE_METHOD; + self.pending_requests + .register(event_id.to_hex(), req.id.clone(), is_initialize) + .await; + } + + if let Err(error) = self.base.relay_pool.publish_event(&publishable_event).await { + self.pending_requests.remove(&event_id.to_hex()).await; + tracing::error!( + target: LOG_TARGET, + error = %error, + server_pubkey = %self.server_pubkey.to_hex(), + method = ?message.method(), + "Failed to publish client message" + ); + return Err(error); + } + + // Flip one-shot flag only after successful publish + if discovery_sent { + self.has_sent_discovery_tags.store(true, Ordering::Relaxed); + } + + tracing::debug!( + target: LOG_TARGET, + event_id = %event_id.to_hex(), + method = ?message.method(), + "Sent client message" + ); + Ok(()) + } + + /// CEP-22: publish a request as an ordered oversized-transfer sequence. + /// + /// Builds `start → chunks… → end` frames, registers an `accept` waiter before + /// publishing `start` when the server's support is not yet known, drives the + /// [`send_oversized_transfer`] sequencer, and registers the pending request + /// against the **end** frame's event id (the value the server correlates its + /// response to). One-shot discovery tags ride the `start` frame only. + async fn send_oversized_request( + &self, + message: &JsonRpcMessage, + content: &str, + token: &str, + base_tags: Vec, + start_tags: Vec, + discovery_sent: bool, + ) -> Result<()> { + // The handshake is required until the server is known to support oversized + // transfer; once learned, chunks start immediately (no accept slot). + let needs_accept = !self + .discovered_server_capabilities() + .supports_oversized_transfer; + + let gift_wrap_kind = self.choose_outbound_gift_wrap_kind(); + // Effective encryption for these frames (the publish closure passes `None`, + // letting `should_encrypt` decide from the mode — resolve the same boolean + // here so the sizing measurement matches the real published frames). + let is_encrypted = self.base.should_encrypt(CTXVM_MESSAGES_KIND, None); + + // CEP-22: derive a per-chunk payload budget so every published frame stays + // under the threshold even after the JSON-RPC envelope, signature, and + // (when encrypted) gift-wrap expansion. Mirrors TS `resolveSafeOversizedChunkSize`. + // Continuation (chunk) frames carry the bare recipient `p`-tags (`base_tags`), + // so size against those. + let chunk_size = resolve_safe_chunk_size( + self.config.oversized_transfer.chunk_size, + &self.base, + &self.server_pubkey, + &base_tags, + is_encrypted, + Kind::Custom(gift_wrap_kind), + self.config.oversized_transfer.threshold, + ) + .await?; + + let options = OversizedSenderOptions::new(token) + .with_chunk_size(chunk_size) + .with_accept_handshake(needs_accept); + let frames = build_oversized_frames(content, &options)?; + + // Register the accept-waiter BEFORE publishing `start` so an early `accept` + // (decoded on the event-loop task) is never lost. + let await_accept = if needs_accept { + let (accept_tx, accept_rx) = oneshot::channel(); + { + let mut waiters = match self.accept_waiters.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + waiters.insert(token.to_string(), accept_tx); + } + Some(accept_rx) + } else { + None + }; + + // Per-frame publish: the start frame carries one-shot discovery tags; the + // rest carry bare recipient tags. Mirrors the prepare+publish pair in `send`. + let base = &self.base; + let server_pubkey = self.server_pubkey; + let mut start_tags = Some(start_tags); + let publish = move |frame: JsonRpcNotification| { + let tags = start_tags.take().unwrap_or_else(|| base_tags.clone()); + async move { + let msg = JsonRpcMessage::Notification(frame); + let (event_id, publishable) = base + .prepare_mcp_message( + &msg, + &server_pubkey, + CTXVM_MESSAGES_KIND, + tags, + None, + Some(gift_wrap_kind), + ) + .await?; + base.relay_pool.publish_event(&publishable).await?; + Ok::(event_id) + } + }; + + let accept_timeout = + Duration::from_millis(self.config.oversized_transfer.accept_timeout_ms); + let result = + send_oversized_transfer(frames, needs_accept, await_accept, accept_timeout, publish) + .await; + + // Drop the accept-waiter entry regardless of outcome. + if needs_accept { + let mut waiters = match self.accept_waiters.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + waiters.remove(token); + } + + let end_id = match result { + Ok(id) => id, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + server_pubkey = %self.server_pubkey.to_hex(), + method = ?message.method(), + "Failed to send oversized client request" + ); + return Err(error); + } + }; + + // Register the pending request against the END frame's event id. + if let JsonRpcMessage::Request(ref req) = message { + let is_initialize = req.method == INITIALIZE_METHOD; + self.pending_requests + .register(end_id.to_hex(), req.id.clone(), is_initialize) + .await; + } + + // Flip the one-shot discovery flag after a successful transfer. + if discovery_sent { + self.has_sent_discovery_tags.store(true, Ordering::Relaxed); + } + + tracing::debug!( + target: LOG_TARGET, + end_event_id = %end_id.to_hex(), + method = ?message.method(), + "Sent oversized client request" + ); + Ok(()) + } + + /// CEP-22: record the original `_meta.progressToken` value of an + /// outbound request under its stringified form, replacing any stale entry + /// for the same key. See [`Self::original_progress_tokens`]. + fn record_original_progress_token(&self, token: &str, original: &serde_json::Value) { + let mut originals = match self.original_progress_tokens.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + originals.push(token.to_string(), original.clone()); + } + + /// CEP-22: drop — and return — the original `progressToken` value + /// recorded for `token`, once its transfer concludes (delivered or failed). + fn remove_original_progress_token( + originals: &Mutex>, + token: Option<&str>, + ) -> Option { + let token = token?; + let mut originals = match originals.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + originals.pop(token) + } + + /// CEP-22: look up — without removing — the original `progressToken` + /// value recorded for `token`, promoting its LRU recency so an in-flight + /// transfer's record outlives idle ones. + fn original_progress_token( + originals: &Mutex>, + token: &str, + ) -> Option { + let mut originals = match originals.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + originals.get(token).cloned() + } + + /// CEP-22: build the plain `notifications/progress` forwarded to the + /// local consumer for an inbound oversized-transfer frame: `progress` is + /// copied verbatim (plus `total`/`message` when present), the `cvm` frame + /// payload is omitted, and `progressToken` is set to `original_token` — + /// the value recorded at send time, NOT the frame's wire token. The + /// wire stringifies every token, but rmcp's progress-watcher map is keyed + /// by exact JSON type (`Number(5)` ≠ `String("5")`), so only the recorded + /// original resets the requester's idle timer. Returns `None` when the + /// frame has no `progress` (malformed; nothing worth forwarding). + fn stripped_progress_notification( + params: &serde_json::Value, + original_token: &serde_json::Value, + ) -> Option { + let mut stripped = serde_json::Map::new(); + stripped.insert("progressToken".to_string(), original_token.clone()); + stripped.insert("progress".to_string(), params.get("progress")?.clone()); + for key in ["total", "message"] { + if let Some(value) = params.get(key) { + stripped.insert(key.to_string(), value.clone()); + } + } + Some(JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: NOTIFICATIONS_PROGRESS_METHOD.to_string(), + params: Some(serde_json::Value::Object(stripped)), + })) + } + + /// CEP-22: forward one stripped progress notification for + /// the oversized frame `notif` onto the consumer channel, restoring the + /// token recorded at send time. Falls back to the wire token for + /// transfers with no record (e.g. a transfer addressed to a token this + /// transport never sent); rmcp ignores tokens it never issued, so the + /// fallback forward is harmless. + fn forward_stripped_progress( + notif: &JsonRpcNotification, + token: &str, + originals: &Mutex>, + tx: &tokio::sync::mpsc::UnboundedSender, + ) { + let Some(params) = notif.params.as_ref() else { + return; + }; + let Some(original) = Self::original_progress_token(originals, token) + .or_else(|| params.get("progressToken").cloned()) + else { + return; + }; + if let Some(stripped) = Self::stripped_progress_notification(params, &original) { + let _ = tx.send(stripped); + } + } + + /// Take the message receiver for consuming incoming messages. + pub fn take_message_receiver( + &mut self, + ) -> Option> { + self.message_rx.take() + } + + fn emulate_initialize_response(&self, request_id: &serde_json::Value) { + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id.clone(), + result: serde_json::json!({ + "protocolVersion": crate::core::constants::mcp_protocol_version(), + "serverInfo": { + "name": "Emulated-Stateless-Server", + "version": "1.0.0" + }, + "capabilities": { + "tools": { "listChanged": true }, + "prompts": { "listChanged": true }, + "resources": { "subscribe": true, "listChanged": true } + } + }), + }); + if let Some(ref tx) = self.message_tx { + let _ = tx.send(response); + } + } + + #[allow(clippy::too_many_arguments)] + async fn event_loop( + relay_pool: Arc, + pending: ClientCorrelationStore, + server_pubkey: PublicKey, + tx: tokio::sync::mpsc::UnboundedSender, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + discovered_caps: Arc>, + init_event: Arc>>, + server_supports_ephemeral: Arc, + seen_gift_wrap_ids: Arc>>, + oversized_receiver: Arc>, + accept_waiters: Arc>>>, + original_progress_tokens: Arc>>, + oversized_enabled: bool, + timeout: Duration, + cancel: CancellationToken, + ) { + let mut notifications = relay_pool.notifications(); + // Sweep interval: half the timeout, clamped to [1s, 30s]. + let sweep_interval = (timeout / 2).clamp(Duration::from_secs(1), Duration::from_secs(30)); + let mut sweep_timer = + tokio::time::interval_at(tokio::time::Instant::now() + sweep_interval, sweep_interval); + + loop { + tokio::select! { + _ = cancel.cancelled() => { + tracing::info!( + target: LOG_TARGET, + "Client event loop cancelled" + ); + break; + } + result = notifications.recv() => { + let notification = match result { + Ok(n) => n, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + target: LOG_TARGET, + skipped = n, + "Relay broadcast lagged, skipping missed events" + ); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + }; + Self::handle_notification( + ¬ification, + &pending, + server_pubkey, + &tx, + encryption_mode, + gift_wrap_mode, + &discovered_caps, + &init_event, + &server_supports_ephemeral, + &seen_gift_wrap_ids, + &oversized_receiver, + &accept_waiters, + &original_progress_tokens, + &relay_pool, + ) + .await; + } + _ = sweep_timer.tick() => { + let swept = pending.sweep_expired(timeout).await; + if swept > 0 { + tracing::warn!( + target: LOG_TARGET, + swept, + timeout_ms = timeout.as_millis() as u64, + "Swept stale pending requests (rmcp handles timeout errors)" + ); + } + // CEP-22: reap inbound transfers past their hard deadline. + // Local-only (no abort frame is emitted): the requester's + // own timeout fails the call, and late frames are + // orphan-ignored. `remove_expired` no-ops when + // `transfer_timeout_ms` is 0; the sync guard is dropped + // before anything awaits. + if oversized_enabled { + let reaped = { + let mut receiver = match oversized_receiver.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + receiver.remove_expired() + }; + for token in reaped { + tracing::warn!( + target: LOG_TARGET, + token = %token, + "Oversized transfer reaped by watchdog" + ); + } + } + } + } + } + } + + // ── CEP-35 discovery tag helpers ────────────────────────────── + + /// Constructs client capability tags based on config. + fn get_client_capability_tags(&self) -> Vec { + let mut tags = Vec::new(); + if self.config.encryption_mode != EncryptionMode::Disabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + )); + if self.config.gift_wrap_mode != GiftWrapMode::Persistent { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )); + } + } + // CEP-22: advertise oversized-transfer support when enabled. + if self.config.oversized_transfer.enabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + )); + } + tags + } + + /// One-shot: returns capability tags if not yet sent, empty otherwise. + fn get_pending_client_discovery_tags(&self) -> Vec { + if self.has_sent_discovery_tags.load(Ordering::Relaxed) { + vec![] + } else { + self.get_client_capability_tags() + } + } + + /// Parses inbound event tags and updates learned server capabilities. + fn learn_server_discovery( + discovered_caps: &Mutex, + init_event: &Mutex>, + event: &Event, + ) { + let tag_vec: Vec = event.tags.clone().to_vec(); + let discovered = parse_discovered_peer_capabilities(&tag_vec); + if discovered.discovery_tags.is_empty() { + return; + } + + { + let mut caps = match discovered_caps.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + caps.supports_encryption |= discovered.capabilities.supports_encryption; + caps.supports_ephemeral_encryption |= + discovered.capabilities.supports_ephemeral_encryption; + caps.supports_oversized_transfer |= discovered.capabilities.supports_oversized_transfer; + } + + let mut stored = match init_event.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + match stored.as_ref() { + // First discovery-tag-carrying event becomes the session baseline. + None => *stored = Some(event.clone()), + // CEP-35 upgrade (mirrors TS `inbound-coordinator`): if the baseline was + // captured from a non-initialize event (e.g. the first discovery tags + // arrived on a notification) and this event carries a full + // `InitializeResult` (has `protocolVersion`), upgrade the baseline to the + // richer initialize response so `get_server_initialize_event` exposes the + // full server identity/capabilities. Never downgrades. + Some(existing) => { + if !Self::event_has_initialize_result(existing) + && Self::event_has_initialize_result(event) + { + *stored = Some(event.clone()); + } + } + } + } + + /// Returns `true` when the event's `content` parses to a JSON-RPC response + /// whose `result` is a full MCP `InitializeResult` (keyed on `protocolVersion`, + /// matching the TS `InitializeResultSchema` marker). + fn event_has_initialize_result(event: &Event) -> bool { + serde_json::from_str::(&event.content) + .ok() + .as_ref() + .and_then(|content| content.get("result")) + .and_then(|result| result.get("protocolVersion")) + .is_some() + } + + /// Returns a clone of the first inbound event that carried server discovery tags. + pub fn get_server_initialize_event(&self) -> Option { + let guard = match self.server_initialize_event.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + guard.clone() + } + + /// Returns a snapshot of the learned server capabilities from discovery tags. + pub fn discovered_server_capabilities(&self) -> PeerCapabilities { + let guard = match self.discovered_server_capabilities.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + *guard + } + + #[allow(clippy::too_many_arguments)] + async fn handle_notification( + notification: &RelayPoolNotification, + pending: &ClientCorrelationStore, + server_pubkey: PublicKey, + tx: &tokio::sync::mpsc::UnboundedSender, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + discovered_caps: &Arc>, + init_event: &Arc>>, + server_supports_ephemeral: &Arc, + seen_gift_wrap_ids: &Arc>>, + oversized_receiver: &Arc>, + accept_waiters: &Arc>>>, + original_progress_tokens: &Arc>>, + relay_pool: &Arc, + ) { + let event = match notification { + RelayPoolNotification::Event { event, .. } => event, + _ => return, + }; + + let is_gift_wrap = is_gift_wrap_kind(&event.kind); + let outer_kind = event.kind.as_u16(); + + // Enforce encryption mode before decrypt/parse. + if violates_encryption_policy(&event.kind, &encryption_mode) { + if is_gift_wrap { + tracing::warn!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + event_kind = outer_kind, + configured_mode = ?gift_wrap_mode, + "Skipping encrypted response because client encryption is disabled" + ); + } else { + tracing::warn!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + "Skipping plaintext response because client encryption is required" + ); + } + return; + } + + // Enforce CEP-19 gift-wrap-mode policy. + if is_gift_wrap && !gift_wrap_mode.allows_kind(outer_kind) { + tracing::warn!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + event_kind = outer_kind, + configured_mode = ?gift_wrap_mode, + "Skipping gift wrap due to CEP-19 policy" + ); + return; + } + + // Handle gift-wrapped events + let (actual_event_content, actual_pubkey, e_tag, verified_tags, source_event) = + if is_gift_wrap { + { + let guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if guard.contains(&event.id) { + tracing::debug!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + "Skipping duplicate gift-wrap (outer id)" + ); + return; + } + } + // Single-layer NIP-44 decrypt (matches JS/TS SDK) + let signer = match relay_pool.signer().await { + Ok(s) => s, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to get signer" + ); + return; + } + }; + match encryption::decrypt_gift_wrap_single_layer(&signer, event).await { + Ok(decrypted_json) => match serde_json::from_str::(&decrypted_json) { + Ok(inner) => { + if let Err(e) = inner.verify() { + tracing::warn!("Inner event signature verification failed: {e}"); + return; + } + { + let mut guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.put(event.id, ()); + } + let e_tag = serializers::get_tag_value(&inner.tags, "e"); + let inner_clone = inner.clone(); + (inner.content, inner.pubkey, e_tag, inner.tags, inner_clone) + } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to parse inner event" + ); + return; + } + }, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to decrypt gift wrap" + ); + return; + } + } + } else { + let e_tag = serializers::get_tag_value(&event.tags, "e"); + let event_clone: Event = (**event).clone(); + ( + event.content.clone(), + event.pubkey, + e_tag, + event.tags.clone(), + event_clone, + ) + }; + + // Verify it's from our server + if actual_pubkey != server_pubkey { + tracing::debug!( + target: LOG_TARGET, + event_pubkey = %actual_pubkey.to_hex(), + expected_pubkey = %server_pubkey.to_hex(), + "Skipping event from unexpected pubkey" + ); + return; + } + + // CEP-35: learn server capabilities from discovery tags + Self::learn_server_discovery(discovered_caps, init_event, &source_event); + + // CEP-19: learn ephemeral support from server + if Self::should_learn_ephemeral_support( + actual_pubkey, + server_pubkey, + if is_gift_wrap { Some(outer_kind) } else { None }, + &verified_tags, + ) { + server_supports_ephemeral.store(true, Ordering::Relaxed); + } + + // CEP-22: intercept oversized-transfer frames ABOVE the correlation gate + // below. This is mandatory: an `accept` is e-tagged to the start frame + // (not in `pending`), and chunk/end response frames must be reassembled + // rather than delivered raw. Plain `notifications/progress` (no `cvm`) and + // ordinary responses fall through untouched. + if let Ok(notif) = serde_json::from_str::(&actual_event_content) { + if notif.method == NOTIFICATIONS_PROGRESS_METHOD + && OversizedTransferReceiver::is_oversized_frame(¬if) + { + // Token extraction accepts string or number — defensive only: + // every known sender stringifies tokens into frames. + let token = notif + .params + .as_ref() + .and_then(|p| p.get("progressToken")) + .and_then(progress_token_string); + + // Route `accept` frames to the waiting sender by progressToken + // (their e-tag is the start-frame id, which is not in `pending`). + let is_accept = notif + .params + .as_ref() + .and_then(|p| p.get("cvm")) + .and_then(OversizedFrame::from_cvm_value) + .is_some_and(|f| matches!(f, OversizedFrame::Accept)); + if is_accept { + if let Some(ref token) = token { + let waiter = { + let mut waiters = match accept_waiters.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + waiters.remove(token) + }; + if let Some(waiter) = waiter { + let _ = waiter.send(()); + // The accept is the one inbound frame of a + // client→server upload — forward it (stripped) so + // the requester's idle timer re-arms for the + // response-wait phase. Only for a live waiter: a + // duplicate or stray accept must not poke the timer. + Self::forward_stripped_progress( + ¬if, + token, + original_progress_tokens, + tx, + ); + } + } + return; + } + + // Touch the pending entry so the sweep does not evict the + // request mid-transfer (chunks do not otherwise refresh it). + if let Some(ref correlated_id) = e_tag { + pending.touch(correlated_id.as_str()).await; + } + + // Feed the frame to the reassembler (process_frame is sync; the + // guard is dropped before any await or channel send). + let (outcome, tracked) = { + let mut receiver = match oversized_receiver.lock() { + Ok(g) => g, + Err(p) => p.into_inner(), + }; + let outcome = receiver.process_frame(¬if); + // Zombie guard: forward progress only for transfers still + // tracked after this frame — a late/orphan frame must not + // keep a dead request's idle timer alive. + let tracked = token + .as_deref() + .is_some_and(|token| receiver.is_tracking(token)); + (outcome, tracked) + }; + match outcome { + // start/chunk consumed — forward a stripped (cvm-less) + // progress notification carrying the original token so the + // requester's progress-aware idle timeout resets. + Ok(None) => { + if tracked { + if let Some(ref token) = token { + Self::forward_stripped_progress( + ¬if, + token, + original_progress_tokens, + tx, + ); + } + } + return; + } + // end frame: deliver the reassembled (already-validated, may + // exceed 1 MB) message and clear the pending entry. No extra + // progress forward — the response itself resolves the request. + Ok(Some(message)) => { + if let Some(ref correlated_id) = e_tag { + pending.remove(correlated_id.as_str()).await; + } else { + // Matches the TS SDK: an oversized response that + // reassembles without a correlation `e` tag is still + // delivered (rmcp matches it by JSON-RPC id), but the + // missing transport-level correlation is worth a warn. + tracing::warn!( + target: LOG_TARGET, + "Oversized transfer completed without a correlation `e` tag; \ + delivering the reassembled response uncorrelated" + ); + } + Self::remove_original_progress_token( + original_progress_tokens, + token.as_deref(), + ); + let _ = tx.send(message); + return; + } + // Failure: clean up locally, let the request time out. + Err(error) => { + tracing::warn!( + target: LOG_TARGET, + error = %error, + "Inbound oversized transfer failed" + ); + Self::remove_original_progress_token( + original_progress_tokens, + token.as_deref(), + ); + return; + } + } + } + } + + // Correlate response + if let Some(ref correlated_id) = e_tag { + let is_pending = pending.contains(correlated_id.as_str()).await; + if !is_pending { + tracing::warn!( + target: LOG_TARGET, + correlated_event_id = %correlated_id, + "Response for unknown request" + ); + return; + } + } + + // Parse MCP message + if let Some(mcp_msg) = validation::validate_and_parse(&actual_event_content) { + // Drop uncorrelated responses and server-to-client requests (matches TS SDK). + match &mcp_msg { + JsonRpcMessage::Response(_) | JsonRpcMessage::ErrorResponse(_) + if e_tag.is_none() => + { + tracing::warn!( + target: LOG_TARGET, + "Dropping response/error without correlation `e` tag" + ); + return; + } + JsonRpcMessage::Request(_) => { + tracing::warn!( + target: LOG_TARGET, + method = ?mcp_msg.method(), + "Dropping server-to-client request (invalid in MCP)" + ); + return; + } + _ => {} + } + + // Clean up pending request + if let Some(ref correlated_id) = e_tag { + pending.remove(correlated_id.as_str()).await; + } + let _ = tx.send(mcp_msg); + } + } + + fn choose_outbound_gift_wrap_kind(&self) -> u16 { + match self.config.gift_wrap_mode { + GiftWrapMode::Persistent => GIFT_WRAP_KIND, + GiftWrapMode::Ephemeral => EPHEMERAL_GIFT_WRAP_KIND, + GiftWrapMode::Optional => { + if self.server_supports_ephemeral.load(Ordering::Relaxed) { + EPHEMERAL_GIFT_WRAP_KIND + } else { + GIFT_WRAP_KIND + } + } + } + } + + fn has_support_ephemeral_tag(tags: &Tags) -> bool { + tags.iter().any(|tag| { + tag.kind() + == TagKind::Custom( + crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into(), + ) + }) + } + + fn should_learn_ephemeral_support( + actual_pubkey: PublicKey, + server_pubkey: PublicKey, + event_kind: Option, + tags: &Tags, + ) -> bool { + actual_pubkey == server_pubkey + && (event_kind == Some(EPHEMERAL_GIFT_WRAP_KIND) + || Self::has_support_ephemeral_tag(tags)) + } + + /// Returns whether the client has learned ephemeral gift-wrap support from the server. + pub fn server_supports_ephemeral_encryption(&self) -> bool { + self.server_supports_ephemeral.load(Ordering::Relaxed) + } +} + +#[inline] +fn is_gift_wrap_kind(kind: &Kind) -> bool { + *kind == Kind::Custom(GIFT_WRAP_KIND) || *kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND) +} + +/// Returns `true` when the inbound event kind violates the configured encryption +/// policy and must be dropped before any further processing. +#[inline] +fn violates_encryption_policy(kind: &Kind, mode: &EncryptionMode) -> bool { + let is_gift_wrap = is_gift_wrap_kind(kind); + (is_gift_wrap && *mode == EncryptionMode::Disabled) + || (!is_gift_wrap && *mode == EncryptionMode::Required) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_config_defaults() { + let config = NostrClientTransportConfig::default(); + assert!(config.relay_urls.is_empty()); + assert!(config.server_pubkey.is_empty()); + assert_eq!(config.encryption_mode, EncryptionMode::Optional); + assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional); + assert!(!config.is_stateless); + assert_eq!(config.timeout, Duration::from_secs(30)); + assert!(config.discovery_relay_urls.is_none()); + assert!(config.fallback_operational_relay_urls.is_none()); + } + + #[test] + fn test_stateless_config() { + let config = NostrClientTransportConfig { + is_stateless: true, + ..Default::default() + }; + assert!(config.is_stateless); + } + + #[test] + fn test_custom_timeout_config() { + let config = NostrClientTransportConfig { + timeout: Duration::from_secs(60), + ..Default::default() + }; + assert_eq!(config.timeout, Duration::from_secs(60)); + } + + #[test] + fn test_has_support_ephemeral_tag_detects_capability() { + let tags = Tags::from_list(vec![Tag::custom( + TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )]); + assert!(NostrClientTransport::has_support_ephemeral_tag(&tags)); + } + + #[test] + fn test_has_support_ephemeral_tag_absent() { + let tags = Tags::from_list(vec![Tag::custom( + TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + )]); + assert!(!NostrClientTransport::has_support_ephemeral_tag(&tags)); + } + + #[test] + fn test_should_learn_ephemeral_support_requires_matching_server_pubkey() { + let server_keys = Keys::generate(); + let other_keys = Keys::generate(); + let tags = Tags::from_list(vec![Tag::custom( + TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )]); + + assert!(!NostrClientTransport::should_learn_ephemeral_support( + other_keys.public_key(), + server_keys.public_key(), + Some(EPHEMERAL_GIFT_WRAP_KIND), + &tags, + )); + assert!(NostrClientTransport::should_learn_ephemeral_support( + server_keys.public_key(), + server_keys.public_key(), + Some(EPHEMERAL_GIFT_WRAP_KIND), + &tags, + )); + } + + #[test] + fn test_should_learn_from_ephemeral_kind_even_without_tag() { + let server_keys = Keys::generate(); + let empty_tags = Tags::from_list(vec![]); + + assert!(NostrClientTransport::should_learn_ephemeral_support( + server_keys.public_key(), + server_keys.public_key(), + Some(EPHEMERAL_GIFT_WRAP_KIND), + &empty_tags, + )); + } + + #[test] + fn test_should_learn_from_tag_without_ephemeral_kind() { + let server_keys = Keys::generate(); + let tags = Tags::from_list(vec![Tag::custom( + TagKind::Custom(crate::core::constants::tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )]); + + assert!(NostrClientTransport::should_learn_ephemeral_support( + server_keys.public_key(), + server_keys.public_key(), + Some(GIFT_WRAP_KIND), // persistent kind, but tag present + &tags, + )); + } + + #[test] + fn test_stateless_emulated_initialize_response_shape() { + let request_id = serde_json::json!(1); + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: request_id.clone(), + result: serde_json::json!({ + "protocolVersion": crate::core::constants::mcp_protocol_version(), + "serverInfo": { + "name": "Emulated-Stateless-Server", + "version": "1.0.0" + }, + "capabilities": { + "tools": { "listChanged": true }, + "prompts": { "listChanged": true }, + "resources": { "subscribe": true, "listChanged": true } + } + }), + }); + assert!(response.is_response()); + assert_eq!(response.id(), Some(&serde_json::json!(1))); + + if let JsonRpcMessage::Response(r) = &response { + assert!(r.result.get("capabilities").is_some()); + assert!(r.result.get("serverInfo").is_some()); + let server_info = r.result.get("serverInfo").unwrap(); + assert_eq!( + server_info.get("name").unwrap().as_str().unwrap(), + "Emulated-Stateless-Server" + ); + } + } + + #[test] + fn test_stateless_mode_initialize_request_detection() { + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + }); + assert_eq!(init_req.method(), Some("initialize")); + + let init_notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + assert_eq!(init_notif.method(), Some("notifications/initialized")); + } + + #[test] + fn test_gift_wrap_kind_detection() { + assert!(is_gift_wrap_kind(&Kind::Custom(GIFT_WRAP_KIND))); + assert!(is_gift_wrap_kind(&Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND))); + assert!(!is_gift_wrap_kind(&Kind::Custom(CTXVM_MESSAGES_KIND))); + } + + #[test] + fn test_required_mode_drops_plaintext() { + let plaintext_kind = Kind::Custom(CTXVM_MESSAGES_KIND); + assert!( + violates_encryption_policy(&plaintext_kind, &EncryptionMode::Required), + "Required mode must reject plaintext (non-gift-wrap) events" + ); + } + + #[test] + fn test_disabled_mode_drops_encrypted() { + assert!( + violates_encryption_policy(&Kind::Custom(GIFT_WRAP_KIND), &EncryptionMode::Disabled), + "Disabled mode must reject gift-wrap events" + ); + assert!( + violates_encryption_policy( + &Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND), + &EncryptionMode::Disabled + ), + "Disabled mode must reject ephemeral gift-wrap events" + ); + } + + #[test] + fn test_optional_mode_accepts_all() { + let plaintext = Kind::Custom(CTXVM_MESSAGES_KIND); + let gift_wrap = Kind::Custom(GIFT_WRAP_KIND); + let ephemeral = Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND); + assert!(!violates_encryption_policy( + &plaintext, + &EncryptionMode::Optional + )); + assert!(!violates_encryption_policy( + &gift_wrap, + &EncryptionMode::Optional + )); + assert!(!violates_encryption_policy( + &ephemeral, + &EncryptionMode::Optional + )); + } + + #[test] + fn test_required_mode_accepts_encrypted() { + assert!( + !violates_encryption_policy(&Kind::Custom(GIFT_WRAP_KIND), &EncryptionMode::Required), + "Required mode must accept gift-wrap events" + ); + assert!( + !violates_encryption_policy( + &Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND), + &EncryptionMode::Required + ), + "Required mode must accept ephemeral gift-wrap events" + ); + } + + #[test] + fn test_disabled_mode_accepts_plaintext() { + let plaintext = Kind::Custom(CTXVM_MESSAGES_KIND); + assert!( + !violates_encryption_policy(&plaintext, &EncryptionMode::Disabled), + "Disabled mode must accept plaintext events" + ); + } + + // ── CEP-35 client discovery tag emission ──────────────────── + + fn make_transport_for_tags( + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + ) -> NostrClientTransport { + let keys = Keys::generate(); + NostrClientTransport { + base: BaseTransport { + relay_pool: Arc::new(crate::relay::mock::MockRelayPool::new()), + encryption_mode, + is_connected: false, + }, + config: NostrClientTransportConfig { + encryption_mode, + gift_wrap_mode, + server_pubkey: Keys::generate().public_key().to_hex(), + ..Default::default() + }, + server_pubkey: keys.public_key(), + hinted_relay_urls: vec![], + discovery_relay_urls: vec![], + fallback_operational_relay_urls: vec![], + pending_requests: ClientCorrelationStore::new(), + has_sent_discovery_tags: AtomicBool::new(false), + discovered_server_capabilities: Arc::new(Mutex::new(PeerCapabilities::default())), + server_initialize_event: Arc::new(Mutex::new(None)), + server_supports_ephemeral: Arc::new(AtomicBool::new(false)), + seen_gift_wrap_ids: Arc::new(Mutex::new(LruCache::new(NonZeroUsize::new(10).unwrap()))), + oversized_receiver: Arc::new(Mutex::new(OversizedTransferReceiver::new())), + accept_waiters: Arc::new(Mutex::new(HashMap::new())), + original_progress_tokens: Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(10).unwrap(), + ))), + message_tx: Some(tokio::sync::mpsc::unbounded_channel().0), + message_rx: None, + cancellation_token: CancellationToken::new(), + event_loop_handle: None, + } + } + + fn make_tag(parts: &[&str]) -> Tag { + let kind = TagKind::Custom(parts[0].into()); + let values: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + Tag::custom(kind, values) + } + + fn tag_names(tags: &[Tag]) -> Vec { + tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect() + } + + #[test] + fn client_capability_tags_encryption_optional() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + let tags = t.get_client_capability_tags(); + let names = tag_names(&tags); + // The oversized tag (default-on) is pushed last. + assert_eq!( + names, + vec![ + "support_encryption", + "support_encryption_ephemeral", + "support_oversized_transfer" + ] + ); + } + + #[test] + fn client_capability_tags_encryption_disabled() { + let t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + let tags = t.get_client_capability_tags(); + // No encryption tags; the default-on oversized tag remains. + assert_eq!(tag_names(&tags), vec!["support_oversized_transfer"]); + } + + #[test] + fn client_capability_tags_persistent_gift_wrap() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Persistent); + let tags = t.get_client_capability_tags(); + let names = tag_names(&tags); + assert_eq!( + names, + vec!["support_encryption", "support_oversized_transfer"] + ); + } + + #[test] + fn client_capability_tags_oversized_enabled_by_default() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + assert!(t.config.oversized_transfer.enabled); + let names = tag_names(&t.get_client_capability_tags()); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "oversized tag must be advertised by default" + ); + } + + #[test] + fn client_capability_tags_oversized_opt_out() { + // The opt-out gate still works: disabling suppresses the tag. + let mut t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + t.config.oversized_transfer = OversizedTransferConfig::default().with_enabled(false); + let names = tag_names(&t.get_client_capability_tags()); + assert!( + !names.contains(&"support_oversized_transfer".to_string()), + "oversized tag must not be advertised when disabled" + ); + } + + #[test] + fn client_capability_tags_oversized_enabled() { + let mut t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + t.config.oversized_transfer.enabled = true; + let names = tag_names(&t.get_client_capability_tags()); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "oversized tag must be advertised when enabled" + ); + } + + #[test] + fn client_capability_tags_oversized_enabled_without_encryption() { + // Tag is emitted independently of the encryption capability tags. + let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + t.config.oversized_transfer.enabled = true; + let names = tag_names(&t.get_client_capability_tags()); + assert_eq!(names, vec!["support_oversized_transfer"]); + } + + #[test] + fn client_config_oversized_builders() { + let cfg = NostrClientTransportConfig::default().with_oversized_enabled(true); + assert!(cfg.oversized_transfer.enabled); + let cfg = NostrClientTransportConfig::default() + .with_oversized_transfer(OversizedTransferConfig::enabled().with_chunk_size(1024)); + assert!(cfg.oversized_transfer.enabled); + assert_eq!(cfg.oversized_transfer.chunk_size, 1024); + } + + // ── CEP-22 original progressToken record/restore ───────────── + + #[test] + fn original_progress_token_roundtrip_preserves_numeric_type() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + // rmcp stamps numeric tokens; the record keys them by stringified form. + t.record_original_progress_token("7", &serde_json::json!(7)); + let restored = NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("7"), + ); + assert_eq!(restored, Some(serde_json::json!(7))); + // Dropped on first take — the transfer concluded. + assert_eq!( + NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("7"), + ), + None + ); + } + + #[test] + fn original_progress_token_string_never_parsed_to_number() { + // A legitimate String("5") token must restore as a string — restoring + // by parsing numeric-looking wire strings would corrupt it. + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + t.record_original_progress_token("5", &serde_json::json!("5")); + assert_eq!( + NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("5"), + ), + Some(serde_json::json!("5")) + ); + } + + #[test] + fn remove_original_progress_token_handles_missing() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + assert_eq!( + NostrClientTransport::remove_original_progress_token(&t.original_progress_tokens, None,), + None + ); + assert_eq!( + NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("unknown"), + ), + None + ); + } + + /// `send()` must record the original token value for every + /// oversized-eligible request — including sub-threshold ones, whose + /// *responses* may still come back fragmented. + #[tokio::test] + async fn send_records_numeric_progress_token_original() { + let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + t.config.oversized_transfer.enabled = true; + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })), + }); + t.send(&request).await.expect("send small request"); + + let recorded = NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("7"), + ); + assert_eq!( + recorded, + Some(serde_json::json!(7)), + "numeric token must be recorded under its stringified form" + ); + } + + /// With oversized transfer disabled (explicit opt-out) nothing is recorded. + #[tokio::test] + async fn send_records_nothing_when_oversized_disabled() { + let mut t = make_transport_for_tags(EncryptionMode::Disabled, GiftWrapMode::Optional); + t.config.oversized_transfer = OversizedTransferConfig::default().with_enabled(false); + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })), + }); + t.send(&request).await.expect("send small request"); + + assert_eq!( + NostrClientTransport::remove_original_progress_token( + &t.original_progress_tokens, + Some("7"), + ), + None + ); + } + + // ── CEP-22 stripped progress construction ──────────────────── + + #[test] + fn stripped_progress_notification_strips_cvm_and_restores_token() { + let params = serde_json::json!({ + "progressToken": "7", + "progress": 3, + "total": 5, + "message": "transferring", + "cvm": { "type": "oversized-transfer", "frameType": "chunk", "data": "x" }, + }); + let stripped = + NostrClientTransport::stripped_progress_notification(¶ms, &serde_json::json!(7)) + .expect("frame carries progress"); + let JsonRpcMessage::Notification(n) = stripped else { + panic!("expected a notification"); + }; + assert_eq!(n.method, NOTIFICATIONS_PROGRESS_METHOD); + let p = n.params.expect("params"); + assert_eq!( + p["progressToken"], + serde_json::json!(7), + "token must be the restored original, not the wire string" + ); + assert_eq!(p["progress"], serde_json::json!(3)); + assert_eq!(p["total"], serde_json::json!(5)); + assert_eq!(p["message"], serde_json::json!("transferring")); + assert!(p.get("cvm").is_none(), "cvm payload must be stripped"); + } + + #[test] + fn stripped_progress_notification_requires_progress_and_omits_absent_fields() { + // No `progress` → nothing worth forwarding. + let malformed = serde_json::json!({ "progressToken": "7", "cvm": {} }); + assert!(NostrClientTransport::stripped_progress_notification( + &malformed, + &serde_json::json!(7) + ) + .is_none()); + + // Absent total/message are omitted, not nulled. + let minimal = serde_json::json!({ "progressToken": "7", "progress": 1 }); + let stripped = + NostrClientTransport::stripped_progress_notification(&minimal, &serde_json::json!("7")) + .expect("progress present"); + let JsonRpcMessage::Notification(n) = stripped else { + panic!("expected a notification"); + }; + let p = n.params.expect("params"); + let keys = p.as_object().expect("object params"); + assert_eq!(keys.len(), 2, "only progressToken + progress: {p}"); + assert_eq!(p["progressToken"], serde_json::json!("7")); + } + + #[test] + fn client_discovery_tags_sent_once() { + let t = make_transport_for_tags(EncryptionMode::Optional, GiftWrapMode::Optional); + let first = t.get_pending_client_discovery_tags(); + assert!(!first.is_empty()); + + t.has_sent_discovery_tags.store(true, Ordering::Relaxed); + let second = t.get_pending_client_discovery_tags(); + assert!(second.is_empty()); + } + + // ── CEP-35 client capability learning ─────────────────────── + + fn make_event_with_tags(tag_parts: &[&[&str]]) -> Event { + make_event_with_content_and_tags("{}", tag_parts) + } + + fn make_event_with_content_and_tags(content: &str, tag_parts: &[&[&str]]) -> Event { + let keys = Keys::generate(); + let tags: Vec = tag_parts.iter().map(|p| make_tag(p)).collect(); + let builder = EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), content).tags(tags); + let unsigned = builder.build(keys.public_key()); + unsigned.sign_with_keys(&keys).unwrap() + } + + /// A JSON-RPC response carrying a full `InitializeResult` (has `protocolVersion`). + fn initialize_result_content() -> String { + serde_json::json!({ + "jsonrpc": "2.0", + "id": 1, + "result": { + "protocolVersion": "2025-06-18", + "capabilities": {}, + "serverInfo": { "name": "UpgradedServer", "version": "1.0.0" } + } + }) + .to_string() + } + + #[test] + fn client_learn_server_discovery_sets_baseline() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + let event = make_event_with_tags(&[&["support_encryption"], &["name", "TestServer"]]); + + NostrClientTransport::learn_server_discovery(&caps, &init, &event); + + let c = caps.lock().unwrap(); + assert!(c.supports_encryption); + assert!(!c.supports_ephemeral_encryption); + + let stored = init.lock().unwrap(); + assert!(stored.is_some()); + assert_eq!(stored.as_ref().unwrap().id, event.id); + } + + #[test] + fn client_learn_server_discovery_or_assigns() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + + let event1 = make_event_with_tags(&[&["support_encryption"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event1); + + // Second event with different caps does NOT downgrade + let event2 = make_event_with_tags(&[&["support_encryption_ephemeral"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event2); + + let c = caps.lock().unwrap(); + assert!(c.supports_encryption, "must not downgrade"); + assert!(c.supports_ephemeral_encryption, "must learn new cap"); + } + + #[test] + fn client_baseline_not_replaced_on_later_events() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + + let event1 = make_event_with_tags(&[&["support_encryption"], &["name", "First"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event1); + let first_id = event1.id; + + let event2 = + make_event_with_tags(&[&["support_encryption_ephemeral"], &["name", "Second"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &event2); + + let stored = init.lock().unwrap(); + assert_eq!( + stored.as_ref().unwrap().id, + first_id, + "baseline must not be replaced" + ); + } + + #[test] + fn client_baseline_upgraded_to_initialize_result() { + let caps = Mutex::new(PeerCapabilities::default()); + let init = Mutex::new(None); + + // First discovery tags arrive on a non-initialize event (e.g. a notification). + let baseline = make_event_with_tags(&[&["support_encryption"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &baseline); + assert_eq!(init.lock().unwrap().as_ref().unwrap().id, baseline.id); + + // A later event carries a full InitializeResult → baseline is upgraded. + let init_event = make_event_with_content_and_tags( + &initialize_result_content(), + &[&["support_encryption"]], + ); + NostrClientTransport::learn_server_discovery(&caps, &init, &init_event); + assert_eq!( + init.lock().unwrap().as_ref().unwrap().id, + init_event.id, + "baseline must upgrade to the initialize-result event" + ); + + // A still-later non-initialize event must NOT downgrade the baseline. + let later = make_event_with_tags(&[&["support_encryption_ephemeral"]]); + NostrClientTransport::learn_server_discovery(&caps, &init, &later); + assert_eq!( + init.lock().unwrap().as_ref().unwrap().id, + init_event.id, + "baseline must not downgrade away from the initialize result" + ); + } +} diff --git a/src/transport/client/relay_resolution.rs b/src/transport/client/relay_resolution.rs new file mode 100644 index 0000000..8857424 --- /dev/null +++ b/src/transport/client/relay_resolution.rs @@ -0,0 +1,369 @@ +//! CEP-17 multi-stage relay resolution. +//! +//! Resolves operational relays via: config → nprofile hints → kind 10002 +//! discovery → fallback probing → bootstrap defaults. +//! Mirrors the TS SDK's `resolveOperationalRelays()` and +//! `connectFallbackOperationalRelays()`. + +use std::sync::Arc; +use std::time::Duration; + +use nostr_sdk::prelude::*; +use tokio::pin; + +use crate::relay::RelayPool; +use crate::transport::client::server_relay_discovery::{ + fetch_server_relay_list, select_operational_relay_urls, +}; + +const LOG_TARGET: &str = "contextvm_sdk::transport::client::relay_resolution"; + +/// Inputs for multi-stage relay resolution. +pub struct RelayResolutionConfig { + /// Explicitly configured relay URLs (stage 1). + pub configured_relay_urls: Vec, + /// Relay hints from nprofile identity (stage 2). + pub hinted_relay_urls: Vec, + /// Bootstrap relays for CEP-17 kind 10002 discovery (stage 4). + pub discovery_relay_urls: Vec, + /// Fallback relays probed in parallel with discovery (stage 4). + pub fallback_operational_relay_urls: Vec, + /// Server public key for kind 10002 filter. + pub server_pubkey: PublicKey, + /// Signer for temporary relay pool connections. + pub signer: Arc, + /// Timeout for discovery and fallback probing. + pub timeout: Duration, +} + +/// Resolve operational relay URLs through a multi-stage pipeline. +/// +/// Stages (returns on first non-empty result): +/// 1. Configured relays (explicit `relay_urls`) +/// 2. Hinted relays (from nprofile) +/// 3. If no discovery relays available: use fallback or return empty +/// 4. Race CEP-17 discovery vs fallback probing (`tokio::select!`) +/// 5. Sequential fallback if race winner was empty +/// 6. Last resort: use discovery relay URLs as operational +/// +/// Mirrors the TS SDK `resolveOperationalRelays()`. +pub async fn resolve_operational_relays(config: RelayResolutionConfig) -> Vec { + // Stage 1: configured relays + if !config.configured_relay_urls.is_empty() { + return config.configured_relay_urls; + } + + // Stage 2: hinted relays (from nprofile) + if !config.hinted_relay_urls.is_empty() { + tracing::info!( + target: LOG_TARGET, + relay_count = config.hinted_relay_urls.len(), + "Using relay hints from server identity" + ); + return config.hinted_relay_urls; + } + + // Stage 3: no discovery relays available + if config.discovery_relay_urls.is_empty() { + if !config.fallback_operational_relay_urls.is_empty() { + tracing::info!( + target: LOG_TARGET, + relay_count = config.fallback_operational_relay_urls.len(), + "Using configured fallback operational relays" + ); + return config.fallback_operational_relay_urls; + } + return vec![]; + } + + // Stage 4: race discovery vs fallback + let discovery_urls = config.discovery_relay_urls; + let last_resort_urls = discovery_urls.clone(); + let fallback_urls = config.fallback_operational_relay_urls; + let server_pubkey = config.server_pubkey; + let signer = config.signer; + let timeout = config.timeout; + + let discovery_fut = async { + let entries = + fetch_server_relay_list(&server_pubkey, &discovery_urls, signer.clone(), timeout) + .await + .unwrap_or_default(); + select_operational_relay_urls(&entries) + }; + + let fallback_fut = connect_fallback_operational_relays(&fallback_urls, signer.clone(), timeout); + + pin!(discovery_fut); + pin!(fallback_fut); + + // Race: first non-empty wins. If winner is empty, await the loser. + enum RaceWinner { + Discovery(Vec), + Fallback(Vec), + } + + // When the winner is non-empty the losing future is dropped. + // If it was connect_fallback_operational_relays, the temporary + // pool disconnects when the Client drops -- resource leak is minimal. + let winner = tokio::select! { + result = &mut discovery_fut => RaceWinner::Discovery(result), + result = &mut fallback_fut => RaceWinner::Fallback(result), + }; + + match winner { + RaceWinner::Discovery(urls) if !urls.is_empty() => { + tracing::info!(target: LOG_TARGET, relay_count = urls.len(), "Resolved operational relays"); + return urls; + } + RaceWinner::Fallback(urls) if !urls.is_empty() => { + tracing::info!(target: LOG_TARGET, relay_count = urls.len(), "Resolved operational relays"); + return urls; + } + RaceWinner::Discovery(_) => { + // Discovery returned empty; await fallback + let fallback_result = fallback_fut.await; + if !fallback_result.is_empty() { + tracing::info!(target: LOG_TARGET, relay_count = fallback_result.len(), "Using configured fallback operational relays"); + return fallback_result; + } + } + RaceWinner::Fallback(_) => { + // Fallback returned empty; await discovery + let discovery_result = discovery_fut.await; + if !discovery_result.is_empty() { + tracing::info!(target: LOG_TARGET, relay_count = discovery_result.len(), "Resolved operational relays from server relay list"); + return discovery_result; + } + } + } + + // Stage 6: last resort — use discovery relays as operational + tracing::warn!( + target: LOG_TARGET, + relay_count = last_resort_urls.len(), + "No operational relays discovered from kind 10002; falling back to discovery relays" + ); + last_resort_urls +} + +/// Probe fallback operational relays for connectivity. +/// +/// Creates a temporary pool, attempts to connect within `timeout`. +/// Returns the fallback URLs on success, empty vec on failure. +/// Mirrors the TS SDK `connectFallbackOperationalRelays()`. +pub async fn connect_fallback_operational_relays( + fallback_urls: &[String], + signer: Arc, + timeout: Duration, +) -> Vec { + if fallback_urls.is_empty() { + return vec![]; + } + + let pool = match RelayPool::new(signer).await { + Ok(p) => p, + Err(_) => return vec![], + }; + + let connect_result = tokio::time::timeout(timeout, pool.connect(fallback_urls)).await; + + let _ = pool.disconnect().await; + + match connect_result { + Ok(Ok(())) => fallback_urls.to_vec(), + Ok(Err(e)) => { + tracing::warn!( + target: LOG_TARGET, + error = %e, + "Fallback operational relay connection failed" + ); + vec![] + } + Err(_) => { + tracing::warn!( + target: LOG_TARGET, + "Fallback operational relay probing timed out" + ); + vec![] + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::MockRelayPool; + + fn make_signer() -> Arc { + let keys = Keys::generate(); + Arc::new(keys) as Arc + } + + fn make_config( + configured: Vec, + hinted: Vec, + discovery: Vec, + fallback: Vec, + ) -> RelayResolutionConfig { + RelayResolutionConfig { + configured_relay_urls: configured, + hinted_relay_urls: hinted, + discovery_relay_urls: discovery, + fallback_operational_relay_urls: fallback, + server_pubkey: Keys::generate().public_key(), + signer: make_signer(), + timeout: Duration::from_secs(5), + } + } + + #[tokio::test] + async fn configured_relays_returned_immediately() { + let config = make_config( + vec!["wss://configured.example.com".to_string()], + vec!["wss://hinted.example.com".to_string()], + vec!["wss://discovery.example.com".to_string()], + vec!["wss://fallback.example.com".to_string()], + ); + let result = resolve_operational_relays(config).await; + assert_eq!(result, vec!["wss://configured.example.com"]); + } + + #[tokio::test] + async fn hinted_relays_returned_when_no_configured() { + let config = make_config( + vec![], + vec!["wss://hint1.example.com".to_string()], + vec!["wss://discovery.example.com".to_string()], + vec![], + ); + let result = resolve_operational_relays(config).await; + assert_eq!(result, vec!["wss://hint1.example.com"]); + } + + #[tokio::test] + async fn no_discovery_with_fallback_returns_fallback() { + let config = make_config( + vec![], + vec![], + vec![], + vec!["wss://fallback.example.com".to_string()], + ); + let result = resolve_operational_relays(config).await; + assert_eq!(result, vec!["wss://fallback.example.com"]); + } + + #[tokio::test] + async fn no_discovery_no_fallback_returns_empty() { + let config = make_config(vec![], vec![], vec![], vec![]); + let result = resolve_operational_relays(config).await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn both_empty_falls_back_to_discovery_relays() { + // discovery_relay_urls are non-empty but the server has no kind 10002 event, + // and fallback is empty — last resort returns discovery URLs. + let config = make_config( + vec![], + vec![], + vec!["wss://bootstrap.example.com".to_string()], + vec![], + ); + let result = resolve_operational_relays(config).await; + assert_eq!(result, vec!["wss://bootstrap.example.com"]); + } + + #[tokio::test] + async fn connect_fallback_empty_urls_returns_empty() { + let result = + connect_fallback_operational_relays(&[], make_signer(), Duration::from_secs(1)).await; + assert!(result.is_empty()); + } + + #[tokio::test] + async fn discovery_with_mock_returns_relay_entries() { + // Test the fetch path via fetch_relay_list_from_pool directly + use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND}; + use crate::transport::client::server_relay_discovery::fetch_relay_list_from_pool; + + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + let tags = vec![Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://discovered.example.com"], + )]; + let event = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "") + .tags(tags) + .custom_created_at(Timestamp::from(1000u64)) + .sign_with_keys(&server_keys) + .unwrap(); + pool.inject_event(event).await; + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + let urls = select_operational_relay_urls(&entries); + assert_eq!(urls, vec!["wss://discovered.example.com"]); + } + + #[tokio::test] + async fn marker_precedence_unmarked_preferred() { + use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND}; + use crate::transport::client::server_relay_discovery::fetch_relay_list_from_pool; + + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + let tags = vec![ + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://unmarked.example.com"], + ), + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://read.example.com", "read"], + ), + ]; + let event = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "") + .tags(tags) + .custom_created_at(Timestamp::from(1000u64)) + .sign_with_keys(&server_keys) + .unwrap(); + pool.inject_event(event).await; + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + let urls = select_operational_relay_urls(&entries); + assert_eq!(urls, vec!["wss://unmarked.example.com"]); + } + + #[test] + fn with_discovery_relay_urls_overrides_bootstrap() { + use crate::transport::client::NostrClientTransportConfig; + + let config = NostrClientTransportConfig::default() + .with_discovery_relay_urls(vec!["wss://custom-discovery.example.com".to_string()]); + assert_eq!( + config.discovery_relay_urls, + Some(vec!["wss://custom-discovery.example.com".to_string()]) + ); + } + + #[test] + fn with_fallback_operational_relay_urls_stored_separately() { + use crate::transport::client::NostrClientTransportConfig; + + let config = NostrClientTransportConfig::default() + .with_fallback_operational_relay_urls(vec!["wss://fallback.example.com".to_string()]); + assert_eq!( + config.fallback_operational_relay_urls, + Some(vec!["wss://fallback.example.com".to_string()]) + ); + assert!(config.relay_urls.is_empty()); + } +} diff --git a/src/transport/client/server_identity.rs b/src/transport/client/server_identity.rs new file mode 100644 index 0000000..21e46b0 --- /dev/null +++ b/src/transport/client/server_identity.rs @@ -0,0 +1,102 @@ +//! Server identity parsing for CEP-17 client-side relay discovery. +//! +//! Accepts hex pubkeys, npub (NIP-19), or nprofile (NIP-19) strings and +//! extracts the server's public key and any embedded relay hints. +//! Mirrors the TS SDK's `parseServerIdentity()`. + +use nostr_sdk::prelude::*; + +use crate::core::error::{Error, Result}; + +/// Parse a server identity string into a public key and optional relay hints. +/// +/// Supported formats: +/// - **Hex**: 64-character hex-encoded public key +/// - **npub**: NIP-19 bech32-encoded public key +/// - **nprofile**: NIP-19 bech32-encoded profile (pubkey + relay hints) +/// +/// Returns `(PublicKey, Vec)` where the second element contains relay +/// hint URLs extracted from an nprofile, or an empty vec for hex/npub. +pub fn parse_server_identity(input: &str) -> Result<(PublicKey, Vec)> { + // Try hex first + if let Ok(pk) = PublicKey::from_hex(input) { + return Ok((pk, vec![])); + } + + // Try bech32 (npub / nprofile) + match Nip19::from_bech32(input) { + Ok(Nip19::Pubkey(pk)) => Ok((pk, vec![])), + Ok(Nip19::Profile(profile)) => { + let relays: Vec = profile.relays.into_iter().map(|r| r.to_string()).collect(); + Ok((profile.public_key, relays)) + } + _ => Err(Error::Other(format!( + "Invalid serverPubkey format: {input}. Expected hex pubkey, npub, or nprofile." + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_pubkey_roundtrip() { + let keys = Keys::generate(); + let hex = keys.public_key().to_hex(); + let (pk, hints) = parse_server_identity(&hex).unwrap(); + assert_eq!(pk, keys.public_key()); + assert!(hints.is_empty()); + } + + #[test] + fn npub_roundtrip() { + let keys = Keys::generate(); + let npub = keys.public_key().to_bech32().unwrap(); + let (pk, hints) = parse_server_identity(&npub).unwrap(); + assert_eq!(pk, keys.public_key()); + assert!(hints.is_empty()); + } + + #[test] + fn nprofile_with_relays() { + let keys = Keys::generate(); + let relay1 = RelayUrl::parse("wss://relay1.example.com").unwrap(); + let relay2 = RelayUrl::parse("wss://relay2.example.com").unwrap(); + let profile = Nip19Profile::new(keys.public_key(), vec![relay1.clone(), relay2.clone()]); + let nprofile = profile.to_bech32().unwrap(); + + let (pk, hints) = parse_server_identity(&nprofile).unwrap(); + assert_eq!(pk, keys.public_key()); + assert_eq!(hints.len(), 2); + assert!(hints.contains(&relay1.to_string())); + assert!(hints.contains(&relay2.to_string())); + } + + #[test] + fn nprofile_without_relays() { + let keys = Keys::generate(); + let profile = Nip19Profile::new(keys.public_key(), Vec::::new()); + let nprofile = profile.to_bech32().unwrap(); + + let (pk, hints) = parse_server_identity(&nprofile).unwrap(); + assert_eq!(pk, keys.public_key()); + assert!(hints.is_empty()); + } + + #[test] + fn invalid_string_returns_error() { + let result = parse_server_identity("not-a-valid-key"); + assert!(result.is_err()); + let err = result.unwrap_err().to_string(); + assert!(err.contains("Invalid serverPubkey format")); + assert!(err.contains("Expected hex pubkey, npub, or nprofile")); + } + + #[test] + fn invalid_npub_checksum_returns_error() { + // Valid npub prefix but corrupted data + let result = parse_server_identity("npub1invalidchecksum"); + assert!(result.is_err()); + } +} diff --git a/src/transport/client/server_relay_discovery.rs b/src/transport/client/server_relay_discovery.rs new file mode 100644 index 0000000..14ebd32 --- /dev/null +++ b/src/transport/client/server_relay_discovery.rs @@ -0,0 +1,368 @@ +//! CEP-17 server relay list fetching and operational relay selection. +//! +//! Fetches kind 10002 relay-list metadata events from discovery relays and +//! selects operational relay URLs based on marker precedence (unmarked > read+write). +//! Mirrors the TS SDK's `fetchServerRelayList()` and `selectOperationalRelayUrls()`. + +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use nostr_sdk::prelude::*; + +use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND}; +use crate::core::error::Result; +use crate::relay::{RelayPool, RelayPoolTrait}; + +/// A single entry from a kind 10002 relay list event. +/// +/// Mirrors the TS SDK `RelayListEntry` interface. +#[derive(Debug, Clone, PartialEq)] +pub struct RelayListEntry { + /// The relay URL. + pub url: String, + /// Optional marker: `"read"`, `"write"`, or `None` (unmarked). + pub marker: Option, +} + +/// Select operational relay URLs from relay list entries using marker precedence. +/// +/// 1. If any entries are unmarked (no marker), return those URLs (deduplicated). +/// 2. Otherwise, return the union of `read` + `write` entries (deduplicated). +/// 3. Empty strings are filtered out in all cases. +/// +/// Mirrors the TS SDK `selectOperationalRelayUrls()`. +pub fn select_operational_relay_urls(entries: &[RelayListEntry]) -> Vec { + // Collect unmarked entries + let unmarked: Vec<&str> = entries + .iter() + .filter(|e| e.marker.is_none() && !e.url.is_empty()) + .map(|e| e.url.as_str()) + .collect(); + + if !unmarked.is_empty() { + return dedup(unmarked); + } + + // Fall back to read + write union + let read_write: Vec<&str> = entries + .iter() + .filter(|e| { + !e.url.is_empty() && matches!(e.marker.as_deref(), Some("read") | Some("write")) + }) + .map(|e| e.url.as_str()) + .collect(); + + dedup(read_write) +} + +/// Deduplicate URLs preserving first-seen order. +fn dedup(urls: Vec<&str>) -> Vec { + let mut seen = HashSet::new(); + urls.into_iter() + .filter(|u| seen.insert(*u)) + .map(|u| u.to_string()) + .collect() +} + +/// Fetch the server's kind 10002 relay list from discovery relays. +/// +/// Creates a temporary relay pool, connects to `relay_urls`, fetches kind 10002 +/// events for `server_pubkey`, extracts relay entries from the latest event, +/// and disconnects. Mirrors the TS SDK `fetchServerRelayList()`. +pub async fn fetch_server_relay_list( + server_pubkey: &PublicKey, + relay_urls: &[String], + signer: Arc, + timeout: Duration, +) -> Result> { + let pool = RelayPool::new(signer).await?; + pool.connect(relay_urls).await?; + let result = fetch_relay_list_from_pool(server_pubkey, &pool, timeout).await; + let _ = pool.disconnect().await; + result +} + +/// Core relay-list fetch+parse logic operating on an existing pool. +/// +/// Separated from [`fetch_server_relay_list`] so tests can inject events via +/// `MockRelayPool` without needing network access. +pub(crate) async fn fetch_relay_list_from_pool( + server_pubkey: &PublicKey, + relay_pool: &dyn RelayPoolTrait, + timeout: Duration, +) -> Result> { + let filter = Filter::new() + .kind(Kind::Custom(RELAY_LIST_METADATA_KIND)) + .author(*server_pubkey); + + let mut events = relay_pool.fetch_events(vec![filter], timeout).await?; + + if events.is_empty() { + return Ok(vec![]); + } + + // Sort by created_at descending, take the latest + events.sort_by_key(|e| std::cmp::Reverse(e.created_at)); + let latest = &events[0]; + + // Extract relay entries from "r" tags. + // NOTE: Empty URLs are filtered here (diverges from TS SDK which keeps them); + // malformed empty-URL tags don't occur per NIP-65. + let entries: Vec = latest + .tags + .iter() + .filter_map(|tag| { + let parts = tag.clone().to_vec(); + if parts.first().map(|s| s.as_str()) != Some(tags::RELAY) { + return None; + } + let url = parts.get(1)?.clone(); + if url.is_empty() { + return None; + } + // NOTE: An empty-string marker becomes Some(""), not None. The TS SDK + // treats "" as unmarked (JS falsy). In practice NIP-65 tags never + // carry an empty marker, so this divergence is benign. + let marker = parts.get(2).cloned(); + Some(RelayListEntry { url, marker }) + }) + .collect(); + + Ok(entries) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::MockRelayPool; + + // ── select_operational_relay_urls tests ─────────────────────── + + #[test] + fn select_all_unmarked_returns_all_urls() { + let entries = vec![ + RelayListEntry { + url: "wss://relay1.example.com".to_string(), + marker: None, + }, + RelayListEntry { + url: "wss://relay2.example.com".to_string(), + marker: None, + }, + ]; + let result = select_operational_relay_urls(&entries); + assert_eq!(result.len(), 2); + assert!(result.contains(&"wss://relay1.example.com".to_string())); + assert!(result.contains(&"wss://relay2.example.com".to_string())); + } + + #[test] + fn select_mixed_markers_prefers_unmarked() { + let entries = vec![ + RelayListEntry { + url: "wss://unmarked.example.com".to_string(), + marker: None, + }, + RelayListEntry { + url: "wss://read.example.com".to_string(), + marker: Some("read".to_string()), + }, + RelayListEntry { + url: "wss://write.example.com".to_string(), + marker: Some("write".to_string()), + }, + ]; + let result = select_operational_relay_urls(&entries); + assert_eq!(result, vec!["wss://unmarked.example.com"]); + } + + #[test] + fn select_only_read_write_returns_union() { + let entries = vec![ + RelayListEntry { + url: "wss://read.example.com".to_string(), + marker: Some("read".to_string()), + }, + RelayListEntry { + url: "wss://write.example.com".to_string(), + marker: Some("write".to_string()), + }, + ]; + let result = select_operational_relay_urls(&entries); + assert_eq!(result.len(), 2); + assert!(result.contains(&"wss://read.example.com".to_string())); + assert!(result.contains(&"wss://write.example.com".to_string())); + } + + #[test] + fn select_empty_input_returns_empty() { + let result = select_operational_relay_urls(&[]); + assert!(result.is_empty()); + } + + #[test] + fn select_deduplicates_urls() { + let entries = vec![ + RelayListEntry { + url: "wss://relay.example.com".to_string(), + marker: None, + }, + RelayListEntry { + url: "wss://relay.example.com".to_string(), + marker: None, + }, + ]; + let result = select_operational_relay_urls(&entries); + assert_eq!(result, vec!["wss://relay.example.com"]); + } + + #[test] + fn select_filters_empty_strings() { + let entries = vec![ + RelayListEntry { + url: String::new(), + marker: None, + }, + RelayListEntry { + url: "wss://relay.example.com".to_string(), + marker: None, + }, + ]; + let result = select_operational_relay_urls(&entries); + assert_eq!(result, vec!["wss://relay.example.com"]); + } + + // ── fetch_server_relay_list tests ──────────────────────────── + + fn build_relay_list_event(keys: &Keys, tags: Vec, created_at: u64) -> Event { + let builder = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "") + .tags(tags) + .custom_created_at(Timestamp::from(created_at)); + builder.sign_with_keys(keys).unwrap() + } + + #[tokio::test] + async fn fetch_returns_parsed_entries_from_injected_event() { + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + let tags = vec![ + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://relay1.example.com"], + ), + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://relay2.example.com"], + ), + ]; + let event = build_relay_list_event(&server_keys, tags, 1000); + pool.inject_event(event).await; + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + + assert_eq!(entries.len(), 2); + assert_eq!(entries[0].url, "wss://relay1.example.com"); + assert!(entries[0].marker.is_none()); + assert_eq!(entries[1].url, "wss://relay2.example.com"); + assert!(entries[1].marker.is_none()); + } + + #[tokio::test] + async fn fetch_no_events_returns_empty() { + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + + assert!(entries.is_empty()); + } + + #[tokio::test] + async fn fetch_multiple_events_returns_latest() { + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + // Older event + let old_tags = vec![Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://old.example.com"], + )]; + let old_event = build_relay_list_event(&server_keys, old_tags, 1000); + pool.inject_event(old_event).await; + + // Newer event + let new_tags = vec![Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://new.example.com"], + )]; + let new_event = build_relay_list_event(&server_keys, new_tags, 2000); + pool.inject_event(new_event).await; + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + + assert_eq!(entries.len(), 1); + assert_eq!(entries[0].url, "wss://new.example.com"); + } + + #[tokio::test] + async fn fetch_extracts_marker_from_third_tag_element() { + let pool = MockRelayPool::new(); + let server_keys = Keys::generate(); + + let tags = vec![ + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://read.example.com", "read"], + ), + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://write.example.com", "write"], + ), + Tag::custom( + TagKind::Custom(tags::RELAY.into()), + vec!["wss://both.example.com"], + ), + ]; + let event = build_relay_list_event(&server_keys, tags, 1000); + pool.inject_event(event).await; + + let entries = + fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5)) + .await + .unwrap(); + + assert_eq!(entries.len(), 3); + assert_eq!( + entries[0], + RelayListEntry { + url: "wss://read.example.com".to_string(), + marker: Some("read".to_string()), + } + ); + assert_eq!( + entries[1], + RelayListEntry { + url: "wss://write.example.com".to_string(), + marker: Some("write".to_string()), + } + ); + assert_eq!( + entries[2], + RelayListEntry { + url: "wss://both.example.com".to_string(), + marker: None, + } + ); + } +} diff --git a/src/transport/discovery_tags.rs b/src/transport/discovery_tags.rs new file mode 100644 index 0000000..e1545f4 --- /dev/null +++ b/src/transport/discovery_tags.rs @@ -0,0 +1,294 @@ +//! Discovery tag utilities for CEP-35 capability exchange. +//! +//! Ports the TS SDK's `discovery-tags.ts` module. Provides functions to filter, +//! parse, and learn discovery tags on Nostr events exchanged between MCP clients +//! and servers. + +use nostr_sdk::prelude::*; + +use crate::core::constants::tags; + +/// Routing tag names that are excluded from discovery tags. +const NON_DISCOVERY_TAG_NAMES: &[&str] = &["p", "e"]; + +/// Capability flags learned from inbound peer discovery tags. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub struct PeerCapabilities { + /// Peer supports NIP-44/NIP-59 encrypted messaging. + pub supports_encryption: bool, + /// Peer supports ephemeral gift wraps (kind 21059, CEP-19). + pub supports_ephemeral_encryption: bool, + /// Peer supports CEP-22 oversized payload transfer. + pub supports_oversized_transfer: bool, + /// Peer supports CEP-41 open-ended streaming. + pub supports_open_stream: bool, +} + +/// Returns `true` when the tag list contains a single-valued tag whose name matches `name`. +/// +/// A single-valued tag is a tag array whose only element is the tag name itself, +/// e.g. `["support_encryption"]`. +pub fn has_single_tag(tags: &[Tag], name: &str) -> bool { + tags.iter().any(|tag| { + let v = tag.clone().to_vec(); + v.len() == 1 && v[0] == name + }) +} + +/// Filters out routing tags (`p`, `e`) and returns cloned discovery tags. +/// +/// Mirrors TS SDK `getDiscoveryTags()`. +pub fn get_discovery_tags(tags: &[Tag]) -> Vec { + tags.iter() + .filter(|tag| { + let v = (*tag).clone().to_vec(); + match v.first() { + Some(name) => !NON_DISCOVERY_TAG_NAMES.contains(&name.as_str()), + None => false, + } + }) + .cloned() + .collect() +} + +/// Inspects tags and returns discovered peer capabilities. +/// +/// Mirrors TS SDK `learnPeerCapabilities()`. +pub fn learn_peer_capabilities(tags: &[Tag]) -> PeerCapabilities { + PeerCapabilities { + supports_encryption: has_single_tag(tags, tags::SUPPORT_ENCRYPTION), + supports_ephemeral_encryption: has_single_tag(tags, tags::SUPPORT_ENCRYPTION_EPHEMERAL), + supports_oversized_transfer: has_single_tag(tags, tags::SUPPORT_OVERSIZED_TRANSFER), + supports_open_stream: has_single_tag(tags, tags::SUPPORT_OPEN_STREAM), + } +} + +/// Parsed capability flags together with the raw discovery tags. +#[derive(Debug, Clone)] +pub struct DiscoveredPeerCapabilities { + /// The filtered discovery tags (routing tags stripped). + pub discovery_tags: Vec, + /// Parsed capability flags. + pub capabilities: PeerCapabilities, +} + +/// Parses peer discovery tags into normalized capability flags plus the raw +/// discovery tags for storage/forwarding. +/// +/// Mirrors TS SDK `parseDiscoveredPeerCapabilities()`. +pub fn parse_discovered_peer_capabilities(tags: &[Tag]) -> DiscoveredPeerCapabilities { + let discovery_tags = get_discovery_tags(tags); + let capabilities = learn_peer_capabilities(&discovery_tags); + DiscoveredPeerCapabilities { + discovery_tags, + capabilities, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_tag(parts: &[&str]) -> Tag { + let kind = TagKind::Custom(parts[0].into()); + let values: Vec = parts[1..].iter().map(|s| s.to_string()).collect(); + Tag::custom(kind, values) + } + + fn tag_name(tag: &Tag) -> String { + tag.clone().to_vec()[0].clone() + } + + // ── has_single_tag ────────────────────────────────────────────── + + #[test] + fn has_single_tag_finds_present() { + let tags = vec![make_tag(&["support_encryption"])]; + assert!(has_single_tag(&tags, "support_encryption")); + } + + #[test] + fn has_single_tag_ignores_multi_value() { + let tags = vec![make_tag(&["support_encryption", "extra"])]; + assert!(!has_single_tag(&tags, "support_encryption")); + } + + #[test] + fn has_single_tag_returns_false_when_absent() { + let tags = vec![make_tag(&["other_tag"])]; + assert!(!has_single_tag(&tags, "support_encryption")); + } + + #[test] + fn has_single_tag_empty_tags() { + assert!(!has_single_tag(&[], "support_encryption")); + } + + // ── get_discovery_tags ────────────────────────────────────────── + + #[test] + fn get_discovery_tags_filters_routing_tags() { + let tags = vec![ + Tag::public_key(Keys::generate().public_key()), + Tag::event(EventId::all_zeros()), + make_tag(&["support_encryption"]), + make_tag(&["name", "My Server"]), + ]; + let discovery = get_discovery_tags(&tags); + assert_eq!(discovery.len(), 2); + assert_eq!(tag_name(&discovery[0]), "support_encryption"); + assert_eq!(tag_name(&discovery[1]), "name"); + } + + #[test] + fn get_discovery_tags_empty_input() { + let discovery = get_discovery_tags(&[]); + assert!(discovery.is_empty()); + } + + #[test] + fn get_discovery_tags_all_routing() { + let tags = vec![ + Tag::public_key(Keys::generate().public_key()), + Tag::event(EventId::all_zeros()), + ]; + let discovery = get_discovery_tags(&tags); + assert!(discovery.is_empty()); + } + + #[test] + fn get_discovery_tags_preserves_order() { + let tags = vec![ + make_tag(&["about", "hello"]), + Tag::public_key(Keys::generate().public_key()), + make_tag(&["website", "https://example.com"]), + make_tag(&["support_encryption"]), + ]; + let discovery = get_discovery_tags(&tags); + assert_eq!(discovery.len(), 3); + assert_eq!(tag_name(&discovery[0]), "about"); + assert_eq!(tag_name(&discovery[1]), "website"); + assert_eq!(tag_name(&discovery[2]), "support_encryption"); + } + + // ── learn_peer_capabilities ───────────────────────────────────── + + #[test] + fn learn_peer_capabilities_all_present() { + let tags = vec![ + make_tag(&["support_encryption"]), + make_tag(&["support_encryption_ephemeral"]), + make_tag(&["support_oversized_transfer"]), + make_tag(&["support_open_stream"]), + ]; + let caps = learn_peer_capabilities(&tags); + assert!(caps.supports_encryption); + assert!(caps.supports_ephemeral_encryption); + assert!(caps.supports_oversized_transfer); + assert!(caps.supports_open_stream); + } + + #[test] + fn learn_peer_capabilities_none_present() { + let tags = vec![make_tag(&["name", "Server"])]; + let caps = learn_peer_capabilities(&tags); + assert!(!caps.supports_encryption); + assert!(!caps.supports_ephemeral_encryption); + assert!(!caps.supports_oversized_transfer); + } + + #[test] + fn learn_peer_capabilities_partial() { + let tags = vec![make_tag(&["support_encryption"])]; + let caps = learn_peer_capabilities(&tags); + assert!(caps.supports_encryption); + assert!(!caps.supports_ephemeral_encryption); + assert!(!caps.supports_oversized_transfer); + assert!(!caps.supports_open_stream); + } + + #[test] + fn learn_peer_capabilities_open_stream_only() { + // A single-element `support_open_stream` tag flips only the open-stream + // flag; multi-element variants of the same tag are ignored. + let tags = vec![make_tag(&["support_open_stream"])]; + let caps = learn_peer_capabilities(&tags); + assert!(caps.supports_open_stream); + assert!(!caps.supports_encryption); + + let multi = vec![make_tag(&["support_open_stream", "extra"])]; + assert!(!learn_peer_capabilities(&multi).supports_open_stream); + } + + #[test] + fn learn_peer_capabilities_empty() { + let caps = learn_peer_capabilities(&[]); + assert_eq!(caps, PeerCapabilities::default()); + } + + #[test] + fn learn_peer_capabilities_ignores_multi_value_capability_tags() { + // Tags with values (e.g. ["support_encryption", "extra"]) are not + // single-valued and should not be treated as capability flags. + let tags = vec![ + make_tag(&["support_encryption", "yes"]), + make_tag(&["support_encryption_ephemeral"]), + ]; + let caps = learn_peer_capabilities(&tags); + assert!(!caps.supports_encryption); + assert!(caps.supports_ephemeral_encryption); + assert!(!caps.supports_oversized_transfer); + } + + // ── parse_discovered_peer_capabilities ────────────────────────── + + #[test] + fn parse_discovered_peer_capabilities_filters_and_parses() { + let tags = vec![ + Tag::public_key(Keys::generate().public_key()), + Tag::event(EventId::all_zeros()), + make_tag(&["support_encryption"]), + make_tag(&["support_encryption_ephemeral"]), + make_tag(&["name", "Test Server"]), + ]; + let result = parse_discovered_peer_capabilities(&tags); + + // Routing tags filtered out + assert_eq!(result.discovery_tags.len(), 3); + + // Capabilities parsed correctly + assert!(result.capabilities.supports_encryption); + assert!(result.capabilities.supports_ephemeral_encryption); + assert!(!result.capabilities.supports_oversized_transfer); + } + + #[test] + fn parse_discovered_peer_capabilities_empty() { + let result = parse_discovered_peer_capabilities(&[]); + assert!(result.discovery_tags.is_empty()); + assert_eq!(result.capabilities, PeerCapabilities::default()); + } + + // ── PeerCapabilities ──────────────────────────────────────────── + + #[test] + fn peer_capabilities_default_all_false() { + let caps = PeerCapabilities::default(); + assert!(!caps.supports_encryption); + assert!(!caps.supports_ephemeral_encryption); + assert!(!caps.supports_oversized_transfer); + assert!(!caps.supports_open_stream); + } + + #[test] + fn peer_capabilities_copy_semantics() { + let caps = PeerCapabilities { + supports_encryption: true, + supports_ephemeral_encryption: true, + supports_oversized_transfer: false, + supports_open_stream: true, + }; + let copy = caps; + assert_eq!(caps, copy); + } +} diff --git a/src/transport/mod.rs b/src/transport/mod.rs index 13a4f8a..99b8290 100644 --- a/src/transport/mod.rs +++ b/src/transport/mod.rs @@ -5,7 +5,15 @@ pub mod base; pub mod client; +pub mod discovery_tags; +pub mod open_stream; +pub mod oversized_transfer; pub mod server; -pub use client::{NostrClientTransport, NostrClientTransportConfig}; -pub use server::{NostrServerTransport, NostrServerTransportConfig}; +pub use client::{ClientCorrelationStore, NostrClientTransport, NostrClientTransportConfig}; +pub use discovery_tags::*; +pub use open_stream::{ + OpenStreamConfig, OpenStreamError, OpenStreamFrame, OpenStreamRegistry, + OpenStreamRegistryPolicy, OpenStreamSession, OpenStreamWriter, +}; +pub use server::{NostrServerTransport, NostrServerTransportConfig, ServerEventRouteStore}; diff --git a/src/transport/open_stream/constants.rs b/src/transport/open_stream/constants.rs new file mode 100644 index 0000000..076cca5 --- /dev/null +++ b/src/transport/open_stream/constants.rs @@ -0,0 +1,26 @@ +//! CEP-41 open-stream constants. +//! +//! The normative CEP leaves these numeric defaults to implementers, so we adopt +//! the TypeScript SDK's values (`sdk/src/transport/open-stream/constants.ts`) +//! for cross-implementation interop. + +/// The `cvm.type` discriminator carried in every open-stream frame. +pub const OPEN_STREAM_TYPE: &str = "open-stream"; + +/// Default upper bound on concurrently active open streams (per peer/registry). +pub const DEFAULT_MAX_CONCURRENT_OPEN_STREAMS: usize = 64; + +/// Default maximum number of buffered + queued chunks held for a single stream. +pub const DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM: usize = 64; + +/// Default maximum buffered + queued payload bytes held for a single stream. +pub const DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM: usize = 512 * 1024; + +/// Default idle interval after which a reader probes the peer with a `ping` (milliseconds). +pub const DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS: u64 = 30_000; + +/// Default time a reader waits for a `pong` after probing before aborting (milliseconds). +pub const DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS: u64 = 20_000; + +/// Default grace period after a `close` with unresolved gaps before aborting (milliseconds). +pub const DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS: u64 = 5_000; diff --git a/src/transport/open_stream/errors.rs b/src/transport/open_stream/errors.rs new file mode 100644 index 0000000..2ca475a --- /dev/null +++ b/src/transport/open_stream/errors.rs @@ -0,0 +1,49 @@ +//! Error taxonomy for CEP-41 open streams. +//! +//! Mirrors the TypeScript SDK's error classes +//! (`sdk/src/transport/open-stream/errors.ts`) so failure classification is +//! identical across implementations. Surfaced through the crate-level +//! [`crate::Error`] via a `#[from]` conversion. + +/// Errors raised while reading, writing, or sequencing an open stream. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum OpenStreamError { + /// The stream was aborted locally or by the remote peer (terminal). + #[error("Open stream aborted (token: {token}){}", .reason.as_deref().map(|r| format!(": {r}")).unwrap_or_default())] + Abort { + /// The `progressToken` of the aborted stream. + token: String, + /// Optional advisory reason supplied in the `abort` frame. + reason: Option, + }, + + /// A stream violated local admission or buffering policy + /// (concurrency, buffered-chunk count, buffered+queued byte budget). + #[error("Open stream policy violation: {0}")] + Policy(String), + + /// Frames violated CEP-41 lifecycle or ordering rules (non-increasing + /// progress, stale/duplicate `chunkIndex`, frame before `start`, frame + /// after a terminal close/abort, incomplete `close.lastChunkIndex`). + #[error("Open stream sequence error: {0}")] + Sequence(String), +} + +impl OpenStreamError { + /// Construct an [`OpenStreamError::Abort`]. + pub fn abort(token: impl Into, reason: Option) -> Self { + Self::Abort { + token: token.into(), + reason, + } + } + + /// Returns `true` if this is a terminal [`abort`](OpenStreamError::Abort). + pub fn is_abort(&self) -> bool { + matches!(self, Self::Abort { .. }) + } +} + +// The crate-level `From for crate::Error` conversion is +// generated by the `#[from]` attribute on `Error::OpenStream` (see +// `src/core/error.rs`), mirroring `OversizedTransferError`. diff --git a/src/transport/open_stream/frame.rs b/src/transport/open_stream/frame.rs new file mode 100644 index 0000000..2a4ba25 --- /dev/null +++ b/src/transport/open_stream/frame.rs @@ -0,0 +1,342 @@ +//! CEP-41 `cvm` frame types and their `notifications/progress` envelope. +//! +//! A frame is the ContextVM `cvm` extension object embedded in the `params` of +//! an MCP `notifications/progress` notification. The outer `params` also carry +//! the `progressToken` (the stream id) and a strictly-monotonic `progress` +//! value that orders *all* frames in one direction. See the frame shapes in +//! `sdk/src/transport/open-stream/types.ts`. +//! +//! Unlike CEP-22, an open-stream `chunk` also carries a `chunkIndex` (contiguous +//! from 0) that tracks payload completeness independently of the outer +//! `progress` counter. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::core::types::JsonRpcNotification; +use crate::transport::oversized_transfer::NOTIFICATIONS_PROGRESS_METHOD; + +use super::constants::OPEN_STREAM_TYPE; + +/// A CEP-41 open-stream frame (the `cvm` object). +/// +/// Internally tagged on `frameType`. The constant `cvm.type` (`"open-stream"`) +/// is handled separately by [`to_cvm_value`](Self::to_cvm_value) / +/// [`from_cvm_value`](Self::from_cvm_value) rather than encoded as an enum +/// field, exactly mirroring CEP-22's `oversized_transfer/frame.rs`. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "frameType", rename_all = "camelCase")] +pub enum OpenStreamFrame { + /// Opens a stream. Carries only optional application-defined advisory + /// metadata; receivers MUST NOT depend on it for stream correctness. + #[serde(rename_all = "camelCase")] + Start { + /// Optional advisory content type (writer-settable, receiver-ignored). + #[serde(default, skip_serializing_if = "Option::is_none")] + content_type: Option, + }, + /// Reader handshake acknowledgement, sent only in reply to an inbound `start`. + Accept, + /// One ordered payload fragment of the stream. + #[serde(rename_all = "camelCase")] + Chunk { + /// Contiguous-from-0 chunk index used for completeness/ordering. + chunk_index: u64, + /// The chunk payload (an arbitrary UTF-8 string). + data: String, + }, + /// Keepalive probe; the peer must echo the `nonce` in a `pong`. + Ping { + /// Opaque probe nonce echoed back by the peer. + nonce: String, + }, + /// Keepalive response echoing a prior `ping` nonce. + Pong { + /// The nonce of the `ping` being acknowledged. + nonce: String, + }, + /// Closes the stream gracefully (terminal). Declares the final chunk index + /// when any chunks were emitted, so the reader can verify completeness. + #[serde(rename_all = "camelCase")] + Close { + /// The highest `chunkIndex` emitted, or `None` for a zero-chunk stream. + #[serde(default, skip_serializing_if = "Option::is_none")] + last_chunk_index: Option, + }, + /// Terminates the stream early (terminal). + Abort { + /// Optional advisory reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, +} + +impl OpenStreamFrame { + /// Returns the `frameType` discriminator string for this frame. + pub fn frame_type(&self) -> &'static str { + match self { + Self::Start { .. } => "start", + Self::Accept => "accept", + Self::Chunk { .. } => "chunk", + Self::Ping { .. } => "ping", + Self::Pong { .. } => "pong", + Self::Close { .. } => "close", + Self::Abort { .. } => "abort", + } + } + + /// Serialize this frame into a `cvm` object [`Value`], injecting the constant + /// `type` discriminator (`"open-stream"`). + pub fn to_cvm_value(&self) -> Result { + let mut value = serde_json::to_value(self)?; + if let Value::Object(map) = &mut value { + map.insert( + "type".to_string(), + Value::String(OPEN_STREAM_TYPE.to_string()), + ); + } + Ok(value) + } + + /// Parse a `cvm` object [`Value`] into a typed frame, verifying the `type` + /// discriminator. Returns `None` when `value` is not an open-stream frame or + /// fails to parse. + pub fn from_cvm_value(value: &Value) -> Option { + if value.get("type").and_then(Value::as_str) != Some(OPEN_STREAM_TYPE) { + return None; + } + serde_json::from_value(value.clone()).ok() + } + + /// Returns `true` when `value` structurally looks like an open-stream frame + /// (`type == "open-stream"` and a string `frameType`). + /// + /// Mirrors the TS `isOpenStreamFrame` structural narrowing. + pub fn is_frame_value(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some(OPEN_STREAM_TYPE) + && value.get("frameType").and_then(Value::as_str).is_some() + } + + /// Wrap this frame in a `notifications/progress` [`JsonRpcNotification`]. + /// + /// Builds the outer `params` with `progressToken`, `progress`, an optional + /// human-readable `message` (non-normative UX), and the `cvm` frame. The + /// token is always emitted as a JSON **string**, even when the originating + /// request carried a numeric one (matching the TS SDK's + /// `String(progressToken)`); see + /// [`progress_token_string`](crate::transport::oversized_transfer::progress_token_string). + pub fn into_progress_notification( + &self, + progress_token: &str, + progress: u64, + message: Option<&str>, + ) -> Result { + let mut params = Map::new(); + params.insert( + "progressToken".to_string(), + Value::String(progress_token.to_string()), + ); + params.insert("progress".to_string(), Value::Number(progress.into())); + if let Some(message) = message { + params.insert("message".to_string(), Value::String(message.to_string())); + } + params.insert("cvm".to_string(), self.to_cvm_value()?); + + Ok(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: NOTIFICATIONS_PROGRESS_METHOD.to_string(), + params: Some(Value::Object(params)), + }) + } +} + +/// Extract a typed [`OpenStreamFrame`] from a `notifications/progress` payload's +/// `params.cvm`, returning `None` for any non-open-stream notification. +pub fn open_stream_frame_from_notification( + notification: &JsonRpcNotification, +) -> Option { + notification + .params + .as_ref() + .and_then(|params| params.get("cvm")) + .and_then(OpenStreamFrame::from_cvm_value) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn assert_cvm_roundtrip(frame: OpenStreamFrame) { + let value = frame.to_cvm_value().unwrap(); + assert_eq!(value["type"], json!("open-stream")); + assert_eq!(value["frameType"], json!(frame.frame_type())); + assert_eq!(OpenStreamFrame::from_cvm_value(&value), Some(frame)); + } + + #[test] + fn all_frame_variants_roundtrip() { + assert_cvm_roundtrip(OpenStreamFrame::Start { + content_type: Some("text/plain".to_string()), + }); + assert_cvm_roundtrip(OpenStreamFrame::Start { content_type: None }); + assert_cvm_roundtrip(OpenStreamFrame::Accept); + assert_cvm_roundtrip(OpenStreamFrame::Chunk { + chunk_index: 7, + data: "payload".to_string(), + }); + assert_cvm_roundtrip(OpenStreamFrame::Ping { + nonce: "tok:1".to_string(), + }); + assert_cvm_roundtrip(OpenStreamFrame::Pong { + nonce: "tok:1".to_string(), + }); + assert_cvm_roundtrip(OpenStreamFrame::Close { + last_chunk_index: Some(4), + }); + assert_cvm_roundtrip(OpenStreamFrame::Close { + last_chunk_index: None, + }); + assert_cvm_roundtrip(OpenStreamFrame::Abort { + reason: Some("boom".to_string()), + }); + assert_cvm_roundtrip(OpenStreamFrame::Abort { reason: None }); + } + + #[test] + fn chunk_frame_serializes_with_camel_case_fields() { + let value = OpenStreamFrame::Chunk { + chunk_index: 3, + data: "abc".to_string(), + } + .to_cvm_value() + .unwrap(); + assert_eq!(value["type"], json!("open-stream")); + assert_eq!(value["frameType"], json!("chunk")); + assert_eq!(value["chunkIndex"], json!(3)); + assert_eq!(value["data"], json!("abc")); + // snake_case field name must not leak onto the wire. + assert!(!value.as_object().unwrap().contains_key("chunk_index")); + } + + #[test] + fn start_content_type_serializes_camel_case_and_omits_when_none() { + let with = OpenStreamFrame::Start { + content_type: Some("application/json".to_string()), + } + .to_cvm_value() + .unwrap(); + assert_eq!(with["contentType"], json!("application/json")); + + let without = OpenStreamFrame::Start { content_type: None } + .to_cvm_value() + .unwrap(); + assert!(!without.as_object().unwrap().contains_key("contentType")); + } + + #[test] + fn close_last_chunk_index_serializes_camel_case_and_omits_when_none() { + let with = OpenStreamFrame::Close { + last_chunk_index: Some(9), + } + .to_cvm_value() + .unwrap(); + assert_eq!(with["lastChunkIndex"], json!(9)); + + let without = OpenStreamFrame::Close { + last_chunk_index: None, + } + .to_cvm_value() + .unwrap(); + assert!(!without.as_object().unwrap().contains_key("lastChunkIndex")); + } + + #[test] + fn abort_reason_omitted_when_none() { + let value = OpenStreamFrame::Abort { reason: None } + .to_cvm_value() + .unwrap(); + assert!(!value.as_object().unwrap().contains_key("reason")); + } + + #[test] + fn from_cvm_value_rejects_wrong_type() { + let value = json!({ "type": "oversized-transfer", "frameType": "start" }); + assert_eq!(OpenStreamFrame::from_cvm_value(&value), None); + assert!(!OpenStreamFrame::is_frame_value(&value)); + } + + #[test] + fn from_cvm_value_rejects_unknown_frame_type() { + let value = json!({ "type": "open-stream", "frameType": "bogus" }); + assert_eq!(OpenStreamFrame::from_cvm_value(&value), None); + } + + #[test] + fn is_frame_value_requires_type_and_frame_type() { + assert!(OpenStreamFrame::is_frame_value( + &json!({ "type": "open-stream", "frameType": "chunk", "chunkIndex": 0, "data": "x" }) + )); + assert!(!OpenStreamFrame::is_frame_value( + &json!({ "type": "open-stream" }) + )); + assert!(!OpenStreamFrame::is_frame_value( + &json!({ "frameType": "chunk" }) + )); + assert!(!OpenStreamFrame::is_frame_value(&json!("not an object"))); + } + + #[test] + fn into_progress_notification_builds_progress_envelope() { + let notification = OpenStreamFrame::Chunk { + chunk_index: 2, + data: "frag".to_string(), + } + .into_progress_notification("tok-1", 7, Some("hi")) + .unwrap(); + + assert_eq!(notification.method, "notifications/progress"); + let params = notification.params.as_ref().unwrap(); + // progressToken is always a string on the wire. + assert_eq!(params["progressToken"], json!("tok-1")); + // progress is numeric. + assert_eq!(params["progress"], json!(7)); + assert_eq!(params["message"], json!("hi")); + assert_eq!(params["cvm"]["frameType"], json!("chunk")); + assert_eq!(params["cvm"]["chunkIndex"], json!(2)); + assert_eq!(params["cvm"]["data"], json!("frag")); + assert_eq!(params["cvm"]["type"], json!("open-stream")); + } + + #[test] + fn into_progress_notification_omits_absent_message() { + let notification = OpenStreamFrame::Accept + .into_progress_notification("tok-1", 9, None) + .unwrap(); + let params = notification.params.as_ref().unwrap(); + assert!(!params.as_object().unwrap().contains_key("message")); + } + + #[test] + fn open_stream_frame_from_notification_extracts_typed_frame() { + let notification = OpenStreamFrame::Ping { + nonce: "n".to_string(), + } + .into_progress_notification("tok", 1, None) + .unwrap(); + assert_eq!( + open_stream_frame_from_notification(¬ification), + Some(OpenStreamFrame::Ping { + nonce: "n".to_string() + }) + ); + + // A plain (non-cvm) progress notification yields None. + let plain = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ "progressToken": "t", "progress": 3 })), + }; + assert_eq!(open_stream_frame_from_notification(&plain), None); + } +} diff --git a/src/transport/open_stream/mod.rs b/src/transport/open_stream/mod.rs new file mode 100644 index 0000000..64d7197 --- /dev/null +++ b/src/transport/open_stream/mod.rs @@ -0,0 +1,213 @@ +//! CEP-41 open-ended streaming — transport-agnostic engine. +//! +//! A server tool can emit an ordered, unbounded sequence of `chunk` fragments +//! back to a client *while a request is in flight*; the client consumes them +//! incrementally as an async [`Stream`](futures::Stream). Unlike CEP-22, the +//! stream does **not** replace the final JSON-RPC response — one `tools/call` +//! produces two outputs: a live `notifications/progress` stream and the normal +//! final response that still concludes the request. +//! +//! Frames ride inside `notifications/progress` notifications on the existing +//! `CTXVM_MESSAGES_KIND`, discriminated by `params.cvm.type == "open-stream"` +//! ([`frame`]). The stream id is the request `progressToken`. See the CEP-41 +//! spec and the TypeScript reference at `sdk/src/transport/open-stream/`. +//! +//! This module is the **pure engine**: framing ([`frame`]), the reader +//! [`OpenStreamSession`] (incl. the pure keepalive [`tick`](OpenStreamSession::tick)), +//! the producer [`OpenStreamWriter`], and the per-peer [`OpenStreamRegistry`]. +//! It carries no transport or live timers — the client and server transports +//! drive it (the keepalive sweep calls `tick`; inbound frames feed +//! `process_frame`). + +pub mod constants; +pub mod errors; +pub mod frame; +pub mod receiver; +pub mod registry; +pub mod session; +pub mod writer; + +pub use constants::*; +pub use errors::OpenStreamError; +pub use frame::{open_stream_frame_from_notification, OpenStreamFrame}; +pub use receiver::OpenStreamReceiver; +pub use registry::{OpenStreamRegistry, OpenStreamRegistryPolicy}; +pub use session::{FrameOutcome, KeepaliveAction, OpenStreamSession, PublishFrame}; +pub use writer::OpenStreamWriter; + +/// CEP-41 open-stream configuration shared by both transports. +/// +/// Bundles the capability gate plus the reader buffering/keepalive knobs. +/// Attached to +/// [`NostrServerTransportConfig`](crate::transport::NostrServerTransportConfig) +/// and [`NostrClientTransportConfig`](crate::transport::NostrClientTransportConfig) +/// via their `with_open_stream` builders. +/// +/// **Disabled by default** (opt-in): the capability is neither advertised nor +/// activated until a future default flip, so the field is inert data the event +/// loops do not consult yet. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct OpenStreamConfig { + /// Master gate, `false` by default. When `false` the capability is neither + /// advertised nor activated, and the server does not learn a client's flag. + pub enabled: bool, + /// Upper bound on concurrently active streams (per peer/registry). + pub max_concurrent_streams: usize, + /// Upper bound on buffered + queued chunks held for a single stream. + pub max_buffered_chunks_per_stream: usize, + /// Upper bound on buffered + queued payload bytes held for a single stream. + pub max_buffered_bytes_per_stream: usize, + /// Idle interval after which a reader probes the peer with a `ping` (ms). + pub idle_timeout_ms: u64, + /// Time a reader waits for a `pong` after probing before aborting (ms). + pub probe_timeout_ms: u64, + /// Grace period after a `close` with unresolved gaps before aborting (ms). + pub close_grace_period_ms: u64, + /// Optional hard cap on total stream lifetime (ms). + /// + /// `None` (the default) means no lifetime cap. When set, only + /// `call_tool_stream` reads it (the registry and keepalive sweep never do); + /// it is **not** the CEP-22 `DEFAULT_OVERSIZED_MAX_TOTAL_TIMEOUT`. + pub max_total_timeout_ms: Option, +} + +impl Default for OpenStreamConfig { + fn default() -> Self { + Self { + enabled: false, + max_concurrent_streams: constants::DEFAULT_MAX_CONCURRENT_OPEN_STREAMS, + max_buffered_chunks_per_stream: constants::DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, + max_buffered_bytes_per_stream: constants::DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, + idle_timeout_ms: constants::DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, + probe_timeout_ms: constants::DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, + close_grace_period_ms: constants::DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, + max_total_timeout_ms: None, + } + } +} + +impl OpenStreamConfig { + /// An explicitly enabled config with all other knobs at their defaults. + pub fn enabled() -> Self { + Self { + enabled: true, + ..Self::default() + } + } + + /// Enable or disable open-stream support. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Set the upper bound on concurrently active streams. + pub fn with_max_concurrent_streams(mut self, max: usize) -> Self { + self.max_concurrent_streams = max; + self + } + + /// Set the upper bound on buffered + queued chunks per stream. + pub fn with_max_buffered_chunks_per_stream(mut self, max: usize) -> Self { + self.max_buffered_chunks_per_stream = max; + self + } + + /// Set the upper bound on buffered + queued payload bytes per stream. + pub fn with_max_buffered_bytes_per_stream(mut self, max: usize) -> Self { + self.max_buffered_bytes_per_stream = max; + self + } + + /// Set the idle interval before a reader probes with a `ping` (ms). + pub fn with_idle_timeout_ms(mut self, ms: u64) -> Self { + self.idle_timeout_ms = ms; + self + } + + /// Set the time a reader waits for a `pong` before aborting (ms). + pub fn with_probe_timeout_ms(mut self, ms: u64) -> Self { + self.probe_timeout_ms = ms; + self + } + + /// Set the close grace period before aborting on unresolved gaps (ms). + pub fn with_close_grace_period_ms(mut self, ms: u64) -> Self { + self.close_grace_period_ms = ms; + self + } + + /// Set the optional hard cap on total stream lifetime (ms). + pub fn with_max_total_timeout_ms(mut self, ms: Option) -> Self { + self.max_total_timeout_ms = ms; + self + } +} + +impl From<&OpenStreamConfig> for OpenStreamRegistryPolicy { + /// Project the registry-relevant knobs of an [`OpenStreamConfig`] into an + /// [`OpenStreamRegistryPolicy`] (the reader admission/buffering policy). + fn from(config: &OpenStreamConfig) -> Self { + OpenStreamRegistryPolicy { + max_concurrent_streams: config.max_concurrent_streams, + max_buffered_chunks_per_stream: config.max_buffered_chunks_per_stream, + max_buffered_bytes_per_stream: config.max_buffered_bytes_per_stream, + idle_timeout_ms: config.idle_timeout_ms, + probe_timeout_ms: config.probe_timeout_ms, + close_grace_period_ms: config.close_grace_period_ms, + } + } +} + +#[cfg(test)] +mod config_tests { + use super::*; + + #[test] + fn default_is_disabled_with_ts_parity_knobs() { + let config = OpenStreamConfig::default(); + assert!(!config.enabled); + assert_eq!(config.max_concurrent_streams, 64); + assert_eq!(config.max_buffered_chunks_per_stream, 64); + assert_eq!(config.max_buffered_bytes_per_stream, 512 * 1024); + assert_eq!(config.idle_timeout_ms, 30_000); + assert_eq!(config.probe_timeout_ms, 20_000); + assert_eq!(config.close_grace_period_ms, 5_000); + // No hard lifetime cap by default. + assert_eq!(config.max_total_timeout_ms, None); + } + + #[test] + fn builders_opt_in_and_override() { + let config = OpenStreamConfig::default() + .with_enabled(true) + .with_max_concurrent_streams(8) + .with_max_buffered_bytes_per_stream(1024) + .with_max_total_timeout_ms(Some(60_000)); + assert!(config.enabled); + assert_eq!(config.max_concurrent_streams, 8); + assert_eq!(config.max_buffered_bytes_per_stream, 1024); + assert_eq!(config.max_total_timeout_ms, Some(60_000)); + + assert!(OpenStreamConfig::enabled().enabled); + } + + #[test] + fn projects_into_registry_policy() { + let config = OpenStreamConfig::default() + .with_max_concurrent_streams(3) + .with_max_buffered_chunks_per_stream(5) + .with_max_buffered_bytes_per_stream(7) + .with_idle_timeout_ms(11) + .with_probe_timeout_ms(13) + .with_close_grace_period_ms(17); + let policy: OpenStreamRegistryPolicy = (&config).into(); + assert_eq!(policy.max_concurrent_streams, 3); + assert_eq!(policy.max_buffered_chunks_per_stream, 5); + assert_eq!(policy.max_buffered_bytes_per_stream, 7); + assert_eq!(policy.idle_timeout_ms, 11); + assert_eq!(policy.probe_timeout_ms, 13); + assert_eq!(policy.close_grace_period_ms, 17); + } +} diff --git a/src/transport/open_stream/receiver.rs b/src/transport/open_stream/receiver.rs new file mode 100644 index 0000000..709e4f1 --- /dev/null +++ b/src/transport/open_stream/receiver.rs @@ -0,0 +1,146 @@ +//! Thin transport-facing adapter over an [`OpenStreamRegistry`]. +//! +//! Ports `sdk/src/transport/open-stream/receiver.ts`. The client transport owns +//! one receiver (single peer); the server owns one per peer. It recognizes +//! inbound CEP-41 frames +//! ([`is_open_stream_frame`](OpenStreamReceiver::is_open_stream_frame)) and feeds +//! them to the registry with the real clock +//! ([`process_frame`](OpenStreamReceiver::process_frame)). The registry's own +//! `process_frame` takes an explicit `now` so the engine stays unit-testable +//! with an injected clock; this adapter supplies `Instant::now()` at the +//! transport boundary. + +use std::time::Instant; + +use crate::core::types::JsonRpcNotification; + +use super::errors::OpenStreamError; +use super::registry::{OpenStreamRegistry, OpenStreamRegistryPolicy}; +use super::session::{FrameOutcome, OpenStreamSession}; + +/// Transport-facing CEP-41 receiver wrapping a per-peer registry. +pub struct OpenStreamReceiver { + registry: OpenStreamRegistry, +} + +impl Default for OpenStreamReceiver { + fn default() -> Self { + Self::new() + } +} + +impl OpenStreamReceiver { + /// Create a receiver with the default policy. + pub fn new() -> Self { + Self { + registry: OpenStreamRegistry::new(), + } + } + + /// Create a receiver with an explicit registry policy. + pub fn with_policy(policy: OpenStreamRegistryPolicy) -> Self { + Self { + registry: OpenStreamRegistry::with_policy(policy), + } + } + + /// Returns `true` when `notification` carries a CEP-41 frame in `params.cvm`. + pub fn is_open_stream_frame(notification: &JsonRpcNotification) -> bool { + OpenStreamRegistry::is_open_stream_progress(notification) + } + + /// Feed one inbound `notifications/progress` frame to the registry, stamping + /// liveness with the real clock. + pub async fn process_frame( + &mut self, + notification: &JsonRpcNotification, + ) -> Result { + self.registry + .process_frame(Instant::now(), notification) + .await + } + + /// Look up an active reader session by token. + pub fn get_session(&self, progress_token: &str) -> Option { + self.registry.get_session(progress_token) + } + + /// Number of active streams. + pub fn active_stream_count(&self) -> usize { + self.registry.size() + } + + /// Borrow the underlying registry (e.g. to create an outbound reader session + /// or drive the keepalive sweep). + pub fn registry(&self) -> &OpenStreamRegistry { + &self.registry + } + + /// Mutably borrow the underlying registry. + pub fn registry_mut(&mut self) -> &mut OpenStreamRegistry { + &mut self.registry + } + + /// Dispose all sessions and clear the registry. + pub fn clear(&mut self) { + self.registry.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::transport::open_stream::frame::OpenStreamFrame; + use serde_json::json; + + fn start_notification(token: &str, progress: u64) -> JsonRpcNotification { + OpenStreamFrame::Start { content_type: None } + .into_progress_notification(token, progress, None) + .unwrap() + } + + #[test] + fn is_open_stream_frame_detects_cvm_payload() { + let frame = start_notification("tok", 1); + assert!(OpenStreamReceiver::is_open_stream_frame(&frame)); + + let plain = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ "progressToken": "tok", "progress": 1 })), + }; + assert!(!OpenStreamReceiver::is_open_stream_frame(&plain)); + + // An oversized-transfer frame is not an open-stream frame. + let oversized = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ + "progressToken": "tok", + "progress": 1, + "cvm": { "type": "oversized-transfer", "frameType": "end" } + })), + }; + assert!(!OpenStreamReceiver::is_open_stream_frame(&oversized)); + } + + #[tokio::test] + async fn process_frame_delegates_to_registry_and_tracks_session() { + let mut receiver = OpenStreamReceiver::new(); + receiver + .process_frame(&start_notification("tok", 1)) + .await + .unwrap(); + assert_eq!(receiver.active_stream_count(), 1); + assert!(receiver.get_session("tok").is_some()); + + // Driving a graceful close through the adapter removes the session. + let close = OpenStreamFrame::Close { + last_chunk_index: None, + } + .into_progress_notification("tok", 2, None) + .unwrap(); + receiver.process_frame(&close).await.unwrap(); + assert_eq!(receiver.active_stream_count(), 0); + } +} diff --git a/src/transport/open_stream/registry.rs b/src/transport/open_stream/registry.rs new file mode 100644 index 0000000..609591b --- /dev/null +++ b/src/transport/open_stream/registry.rs @@ -0,0 +1,704 @@ +//! Per-peer registry of active CEP-41 reader sessions, keyed by `progressToken`. +//! +//! Ports `sdk/src/transport/open-stream/registry.ts`. Enforces admission +//! (max-concurrent → `Policy`; duplicate token → `Sequence`; a session is +//! created **only** on a `start` frame — any other first frame is a `Sequence` +//! error), routes inbound frames to the matching [`OpenStreamSession`], and +//! removes a session on its terminal close/abort (running optional per-session +//! hooks, which are always cleaned up even if they error). + +use std::collections::HashMap; +use std::time::Instant; + +use futures::future::BoxFuture; +use serde_json::Value; + +use crate::core::types::JsonRpcNotification; +use crate::transport::oversized_transfer::progress_token_string; + +use super::constants::{ + DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, + DEFAULT_MAX_CONCURRENT_OPEN_STREAMS, DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, + DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, +}; +use super::errors::OpenStreamError; +use super::frame::OpenStreamFrame; +use super::session::{FrameOutcome, OpenStreamSession, OpenStreamSessionOptions, PublishFrame}; + +const LOG_TARGET: &str = "contextvm_sdk::transport::open_stream"; + +/// Hook fired after a session closes gracefully. Errors are logged and swallowed. +pub type RegistryCloseHook = Box BoxFuture<'static, crate::Result<()>> + Send>; + +/// Hook fired after a session aborts (with the advisory reason). Errors are +/// logged and swallowed. +pub type RegistryAbortHook = + Box) -> BoxFuture<'static, crate::Result<()>> + Send>; + +/// Reader admission / buffering / keepalive policy for a registry's sessions. +/// +/// Projected from +/// [`OpenStreamConfig`](crate::transport::open_stream::OpenStreamConfig) via +/// `From<&OpenStreamConfig>`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct OpenStreamRegistryPolicy { + /// Maximum concurrently active streams. + pub max_concurrent_streams: usize, + /// Maximum buffered + queued chunks per stream. + pub max_buffered_chunks_per_stream: usize, + /// Maximum buffered + queued payload bytes per stream. + pub max_buffered_bytes_per_stream: usize, + /// Idle interval before a reader probes with a `ping` (ms). + pub idle_timeout_ms: u64, + /// Time a reader waits for a `pong` before aborting (ms). + pub probe_timeout_ms: u64, + /// Grace period after a `close` with unresolved gaps before aborting (ms). + pub close_grace_period_ms: u64, +} + +impl Default for OpenStreamRegistryPolicy { + fn default() -> Self { + Self { + max_concurrent_streams: DEFAULT_MAX_CONCURRENT_OPEN_STREAMS, + max_buffered_chunks_per_stream: DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM, + max_buffered_bytes_per_stream: DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM, + idle_timeout_ms: DEFAULT_OPEN_STREAM_IDLE_TIMEOUT_MS, + probe_timeout_ms: DEFAULT_OPEN_STREAM_PROBE_TIMEOUT_MS, + close_grace_period_ms: DEFAULT_OPEN_STREAM_CLOSE_GRACE_PERIOD_MS, + } + } +} + +/// Optional per-session wiring supplied at creation time. +#[derive(Default)] +pub struct OpenStreamSessionInit { + /// Outbound publisher for the reader's consumer `abort` frame. + pub publish_frame: Option, + /// Hook fired after a graceful close. + pub on_close: Option, + /// Hook fired after an abort. + pub on_abort: Option, +} + +/// A registered session plus its terminal lifecycle hooks. +struct RegistryEntry { + session: OpenStreamSession, + on_close: Option, + on_abort: Option, +} + +/// Registry of active CEP-41 reader sessions keyed by `progressToken`. +pub struct OpenStreamRegistry { + policy: OpenStreamRegistryPolicy, + sessions: HashMap, +} + +impl Default for OpenStreamRegistry { + fn default() -> Self { + Self::new() + } +} + +impl OpenStreamRegistry { + /// Create a registry with the default policy. + pub fn new() -> Self { + Self::with_policy(OpenStreamRegistryPolicy::default()) + } + + /// Create a registry with an explicit policy. + pub fn with_policy(policy: OpenStreamRegistryPolicy) -> Self { + Self { + policy, + sessions: HashMap::new(), + } + } + + /// Number of active sessions. + pub fn size(&self) -> usize { + self.sessions.len() + } + + /// Look up an active session by token. + pub fn get_session(&self, progress_token: &str) -> Option { + self.sessions + .get(progress_token) + .map(|entry| entry.session.clone()) + } + + /// Returns `true` when `notification` carries a CEP-41 frame in `params.cvm`. + /// + /// Mirrors the TS `OpenStreamRegistry.isOpenStreamProgress` narrowing. + pub fn is_open_stream_progress(notification: &JsonRpcNotification) -> bool { + notification + .params + .as_ref() + .and_then(|params| params.get("cvm")) + .map(OpenStreamFrame::is_frame_value) + .unwrap_or(false) + } + + /// Create a session for `progress_token` with default wiring. + pub fn create_session( + &mut self, + progress_token: impl Into, + ) -> Result { + self.create_session_with(progress_token, OpenStreamSessionInit::default()) + } + + /// Create a session for `progress_token` with explicit wiring, enforcing the + /// duplicate-token (`Sequence`) and max-concurrent (`Policy`) admission rules. + pub fn create_session_with( + &mut self, + progress_token: impl Into, + init: OpenStreamSessionInit, + ) -> Result { + let progress_token = progress_token.into(); + if self.sessions.contains_key(&progress_token) { + return Err(OpenStreamError::Sequence(format!( + "Stream session already exists for {progress_token}" + ))); + } + if self.sessions.len() >= self.policy.max_concurrent_streams { + return Err(OpenStreamError::Policy( + "Maximum concurrent open streams exceeded".to_string(), + )); + } + + let session = OpenStreamSession::new(OpenStreamSessionOptions { + progress_token: progress_token.clone(), + max_buffered_chunks: self.policy.max_buffered_chunks_per_stream, + max_buffered_bytes: self.policy.max_buffered_bytes_per_stream as u64, + idle_timeout_ms: self.policy.idle_timeout_ms, + probe_timeout_ms: self.policy.probe_timeout_ms, + close_grace_period_ms: self.policy.close_grace_period_ms, + publish_frame: init.publish_frame, + }); + self.sessions.insert( + progress_token, + RegistryEntry { + session: session.clone(), + on_close: init.on_close, + on_abort: init.on_abort, + }, + ); + Ok(session) + } + + /// Return the existing session for `progress_token`, creating one (with + /// default wiring) if absent. + pub fn get_or_create_session( + &mut self, + progress_token: impl Into, + ) -> Result { + let progress_token = progress_token.into(); + if let Some(session) = self.get_session(&progress_token) { + return Ok(session); + } + self.create_session(progress_token) + } + + /// Route one inbound frame to its session, creating the session on a `start`. + /// + /// On a sequencing/policy violation the offending session is failed and + /// removed before the error is returned; on a terminal close/abort the + /// session is removed and its hook run (errors logged, never propagated). + pub async fn process_frame( + &mut self, + now: Instant, + notification: &JsonRpcNotification, + ) -> Result { + let (progress_token, progress, frame) = parse_frame(notification)?; + + if !self.sessions.contains_key(&progress_token) { + if frame.frame_type() != "start" { + return Err(OpenStreamError::Sequence(format!( + "Received {} frame before start for {progress_token}", + frame.frame_type() + ))); + } + self.create_session_with(progress_token.clone(), OpenStreamSessionInit::default())?; + } + + // Clone the Arc-backed handle out so the map is not borrowed across the + // hook `.await`s below. + let session = self + .sessions + .get(&progress_token) + .expect("session present after create") + .session + .clone(); + + match session.process_frame(now, progress, frame) { + Ok(FrameOutcome::Closed) => { + self.run_close(&progress_token).await; + Ok(FrameOutcome::Closed) + } + Ok(FrameOutcome::Aborted(reason)) => { + self.run_abort(&progress_token, reason.clone()).await; + Ok(FrameOutcome::Aborted(reason)) + } + Ok(other) => Ok(other), + Err(error) => { + session.fail(error.clone()); + self.run_abort(&progress_token, None).await; + Err(error) + } + } + } + + /// Dispose every session gracefully and drop them (runs no hooks). + pub fn clear(&mut self) { + for (_, entry) in self.sessions.drain() { + entry.session.dispose(); + } + } + + /// Remove a closed session and run its `on_close` hook (errors swallowed). + async fn run_close(&mut self, progress_token: &str) { + if let Some(entry) = self.sessions.remove(progress_token) { + if let Some(hook) = entry.on_close { + if let Err(error) = hook().await { + tracing::debug!( + target: LOG_TARGET, + token = %progress_token, + %error, + "open-stream on_close hook errored" + ); + } + } + } + } + + /// Remove an aborted session and run its `on_abort` hook (errors swallowed). + async fn run_abort(&mut self, progress_token: &str, reason: Option) { + if let Some(entry) = self.sessions.remove(progress_token) { + if let Some(hook) = entry.on_abort { + if let Err(error) = hook(reason).await { + tracing::debug!( + target: LOG_TARGET, + token = %progress_token, + %error, + "open-stream on_abort hook errored" + ); + } + } + } + } +} + +/// Extract `(progressToken, progress, frame)` from a `notifications/progress` +/// payload, rejecting any non-CEP-41 or malformed notification with `Sequence`. +fn parse_frame( + notification: &JsonRpcNotification, +) -> Result<(String, i64, OpenStreamFrame), OpenStreamError> { + let params = notification.params.as_ref().ok_or_else(|| { + OpenStreamError::Sequence("Open stream frame is missing params".to_string()) + })?; + let frame = params + .get("cvm") + .and_then(OpenStreamFrame::from_cvm_value) + .ok_or_else(|| { + OpenStreamError::Sequence("Notification is not an open-stream frame".to_string()) + })?; + + let progress_token = params + .get("progressToken") + .and_then(progress_token_string) + .filter(|token| !token.is_empty()) + .ok_or_else(|| { + OpenStreamError::Sequence("Open stream frame is missing progressToken".to_string()) + })?; + + let progress = parse_progress(params.get("progress"), &progress_token)?; + Ok((progress_token, progress, frame)) +} + +/// Parse the outer `progress` as an integer (CEP-41 requires it on every frame). +fn parse_progress(value: Option<&Value>, token: &str) -> Result { + let progress = match value { + Some(Value::Number(n)) => n.as_i64().or_else(|| { + n.as_f64() + .and_then(|f| if f.is_finite() { Some(f as i64) } else { None }) + }), + _ => None, + }; + progress.ok_or_else(|| { + OpenStreamError::Sequence(format!("Invalid progress value (token: {token})")) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::Error; + use futures::StreamExt; + use serde_json::json; + + fn now() -> Instant { + Instant::now() + } + + fn notif(token: &str, progress: u64, frame: OpenStreamFrame) -> JsonRpcNotification { + frame + .into_progress_notification(token, progress, None) + .unwrap() + } + + fn start() -> OpenStreamFrame { + OpenStreamFrame::Start { content_type: None } + } + + fn chunk(index: u64, data: &str) -> OpenStreamFrame { + OpenStreamFrame::Chunk { + chunk_index: index, + data: data.to_string(), + } + } + + fn small_policy(max_concurrent: usize) -> OpenStreamRegistryPolicy { + OpenStreamRegistryPolicy { + max_concurrent_streams: max_concurrent, + max_buffered_chunks_per_stream: 4, + max_buffered_bytes_per_stream: 128, + ..OpenStreamRegistryPolicy::default() + } + } + + #[tokio::test] + async fn enforces_max_concurrent_and_reuses_slot_after_close() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(1)); + let _first = registry.create_session("token-1").unwrap(); + assert!(matches!( + registry.create_session("token-2").unwrap_err(), + OpenStreamError::Policy(_) + )); + + // Drive token-1 to a graceful close through the registry to free the slot. + registry + .process_frame(now(), ¬if("token-1", 1, start())) + .await + .unwrap(); + registry + .process_frame( + now(), + ¬if( + "token-1", + 2, + OpenStreamFrame::Close { + last_chunk_index: None, + }, + ), + ) + .await + .unwrap(); + + registry.create_session("token-2").unwrap(); + assert_eq!(registry.size(), 1); + } + + #[test] + fn get_or_create_reuses_the_same_session() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + let first = registry.get_or_create_session("token-shared").unwrap(); + let second = registry.get_or_create_session("token-shared").unwrap(); + assert!(first.shares_state_with(&second)); + assert_eq!(registry.size(), 1); + } + + #[test] + fn rejects_creating_a_duplicate_token() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(4)); + registry.create_session("token-dup").unwrap(); + // A second `create` for a live token is a sequence violation (not a new + // slot and not silent reuse) — distinct from the concurrency `Policy` cap. + assert!(matches!( + registry.create_session("token-dup").unwrap_err(), + OpenStreamError::Sequence(_) + )); + assert_eq!(registry.size(), 1); + } + + #[tokio::test] + async fn routes_a_duplicate_start_frame_to_a_sequence_error() { + // Admission creates the session on the first `start`; a second `start` + // routes to the live session, which rejects it (the `Duplicate start` + // guard), and the registry then fails + removes the session. + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + registry + .process_frame(now(), ¬if("token-dup-start", 1, start())) + .await + .unwrap(); + let err = registry + .process_frame(now(), ¬if("token-dup-start", 2, start())) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + assert!(registry.get_session("token-dup-start").is_none()); + } + + #[tokio::test] + async fn rejects_non_start_frames_for_unknown_tokens() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + let err = registry + .process_frame(now(), ¬if("token-missing-start", 1, chunk(0, "orphan"))) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + assert!(registry.get_session("token-missing-start").is_none()); + assert_eq!(registry.size(), 0); + } + + #[tokio::test] + async fn terminates_session_when_frame_processing_fails_after_creation() { + let mut registry = OpenStreamRegistry::with_policy(OpenStreamRegistryPolicy { + max_buffered_bytes_per_stream: 4, + ..small_policy(2) + }); + registry + .process_frame(now(), ¬if("token-fail", 1, start())) + .await + .unwrap(); + let mut session = registry.get_session("token-fail").unwrap(); + + // 'hello' (5 bytes) at an out-of-order index exceeds the 4-byte budget. + let err = registry + .process_frame(now(), ¬if("token-fail", 2, chunk(1, "hello"))) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + assert!(registry.get_session("token-fail").is_none()); + + // The session was failed: its stream surfaces the same error class. + match session.next().await { + Some(Err(OpenStreamError::Sequence(_))) => {} + other => panic!("expected sequence error on the stream, got {other:?}"), + } + } + + #[tokio::test] + async fn applies_default_buffering_limits() { + let mut registry = OpenStreamRegistry::new(); + registry + .process_frame(now(), ¬if("token-chunks", 1, start())) + .await + .unwrap(); + // Buffer exactly DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM out-of-order chunks. + for i in 0..DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 { + registry + .process_frame(now(), ¬if("token-chunks", i + 2, chunk(i + 1, "x"))) + .await + .unwrap(); + } + let err = registry + .process_frame( + now(), + ¬if( + "token-chunks", + DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 + 2, + chunk(DEFAULT_MAX_BUFFERED_CHUNKS_PER_STREAM as u64 + 1, "x"), + ), + ) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + + // The default byte budget is enforced too. + let mut byte_registry = OpenStreamRegistry::new(); + byte_registry + .process_frame(now(), ¬if("token-bytes", 1, start())) + .await + .unwrap(); + let oversized = "x".repeat(DEFAULT_MAX_BUFFERED_BYTES_PER_STREAM + 1); + let err = byte_registry + .process_frame(now(), ¬if("token-bytes", 2, chunk(1, &oversized))) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn ping_frame_routes_to_a_pong_outcome() { + let mut registry = OpenStreamRegistry::new(); + registry + .process_frame(now(), ¬if("token-timers", 1, start())) + .await + .unwrap(); + let outcome = registry + .process_frame( + now(), + ¬if( + "token-timers", + 2, + OpenStreamFrame::Ping { + nonce: "peer-nonce".to_string(), + }, + ), + ) + .await + .unwrap(); + assert_eq!(outcome, FrameOutcome::SendPong("peer-nonce".to_string())); + } + + #[tokio::test] + async fn clear_disposes_active_sessions() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + let session = registry.create_session("token-clear").unwrap(); + registry + .process_frame(now(), ¬if("token-clear", 1, start())) + .await + .unwrap(); + + registry.clear(); + assert_eq!(registry.size(), 0); + // Dispose finalized the session gracefully. + session.closed().await; + } + + #[tokio::test] + async fn accepts_start_frame_with_advisory_metadata_omitted() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + let outcome = registry + .process_frame(now(), ¬if("token-advisory", 1, start())) + .await + .unwrap(); + assert_eq!(outcome, FrameOutcome::None); + assert!(registry.get_session("token-advisory").is_some()); + + registry.clear(); + assert_eq!(registry.size(), 0); + } + + #[tokio::test] + async fn rejects_malformed_non_cep41_payloads() { + // The structural predicate rejects every non-CEP-41 shape. + let malformed = [ + json!({ "progressToken": "missing-cvm", "progress": 1 }), + json!({ "progressToken": "wrong-type", "progress": 1, "cvm": { "type": "other", "frameType": "start" } }), + json!({ "progressToken": "missing-frame-type", "progress": 1, "cvm": { "type": "open-stream" } }), + ]; + for params in malformed { + let notification = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(params), + }; + assert!(!OpenStreamRegistry::is_open_stream_progress(¬ification)); + } + assert!(OpenStreamRegistry::is_open_stream_progress(¬if( + "ok", + 1, + start() + ))); + + // Feeding a malformed payload to the router is a Sequence error. + let mut registry = OpenStreamRegistry::new(); + let bad = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ "progressToken": "t", "progress": 1 })), + }; + assert!(matches!( + registry.process_frame(now(), &bad).await.unwrap_err(), + OpenStreamError::Sequence(_) + )); + } + + #[tokio::test] + async fn rejects_accept_as_the_first_frame() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(2)); + let err = registry + .process_frame( + now(), + ¬if("token-orphan-accept", 1, OpenStreamFrame::Accept), + ) + .await + .unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + assert!(registry.get_session("token-orphan-accept").is_none()); + } + + #[tokio::test] + async fn removes_session_even_when_on_close_hook_errors() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(1)); + let on_close: RegistryCloseHook = Box::new(|| { + Box::pin(async { Err::<(), Error>(Error::Other("close failed".to_string())) }) + }); + registry + .create_session_with( + "token-close-throws", + OpenStreamSessionInit { + on_close: Some(on_close), + ..Default::default() + }, + ) + .unwrap(); + + registry + .process_frame(now(), ¬if("token-close-throws", 1, start())) + .await + .unwrap(); + // Graceful close fires on_close (which errors) but the session is removed. + registry + .process_frame( + now(), + ¬if( + "token-close-throws", + 2, + OpenStreamFrame::Close { + last_chunk_index: None, + }, + ), + ) + .await + .unwrap(); + + assert!(registry.get_session("token-close-throws").is_none()); + assert_eq!(registry.size(), 0); + } + + #[tokio::test] + async fn removes_session_even_when_on_abort_hook_errors() { + let mut registry = OpenStreamRegistry::with_policy(small_policy(1)); + let on_abort: RegistryAbortHook = Box::new(|_reason| { + Box::pin(async { Err::<(), Error>(Error::Other("abort failed".to_string())) }) + }); + registry + .create_session_with( + "token-abort-throws", + OpenStreamSessionInit { + on_abort: Some(on_abort), + ..Default::default() + }, + ) + .unwrap(); + let mut session = registry.get_session("token-abort-throws").unwrap(); + + registry + .process_frame(now(), ¬if("token-abort-throws", 1, start())) + .await + .unwrap(); + registry + .process_frame( + now(), + ¬if( + "token-abort-throws", + 2, + OpenStreamFrame::Abort { + reason: Some("boom".to_string()), + }, + ), + ) + .await + .unwrap(); + + assert!(registry.get_session("token-abort-throws").is_none()); + assert_eq!(registry.size(), 0); + // The session was finalized with the peer's abort reason. + match session.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("boom")); + } + other => panic!("expected abort error on the stream, got {other:?}"), + } + } +} diff --git a/src/transport/open_stream/session.rs b/src/transport/open_stream/session.rs new file mode 100644 index 0000000..581240c --- /dev/null +++ b/src/transport/open_stream/session.rs @@ -0,0 +1,1182 @@ +//! Reader-side view of a CEP-41 open stream. +//! +//! Ports `sdk/src/transport/open-stream/session.ts`. An [`OpenStreamSession`] is +//! fed inbound frames via the **synchronous** [`process_frame`](OpenStreamSession::process_frame) +//! and consumed as an async [`Stream`] of payload chunks. It owns no live timers: +//! the keepalive state machine is the pure [`tick`](OpenStreamSession::tick) +//! method (idle → `ping`, probe → abort, close-grace → abort), which the owning +//! transport drives from its periodic sweep. +//! +//! Design: the feed path is a plain sync call +//! under a `std::sync::Mutex`; the drain path is a manual [`Stream`] impl over a +//! `VecDeque` plus a stored [`Waker`]. The terminal error is delivered **once** +//! on the next poll (`terminal.take()`), an intentional divergence from the TS +//! reference where it is rejected to every parked reader; [`closed`](OpenStreamSession::closed) +//! resolves to `()` on any finalize and never carries the error. + +use std::collections::{BTreeMap, VecDeque}; +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; +use std::task::{Context, Poll, Waker}; +use std::time::{Duration, Instant}; + +use futures::future::BoxFuture; +use futures::Stream; +use nostr_sdk::prelude::EventId; + +use crate::core::types::JsonRpcNotification; + +use super::errors::OpenStreamError; +use super::frame::OpenStreamFrame; + +/// Injected outbound publish closure (same seam as the writer). +/// +/// Used by the reader's consumer [`abort`](OpenStreamSession::abort) to emit an +/// `abort` frame to the peer. Cheaply clonable (`Arc`) so a session handle stays +/// clonable across the registry and the consumer. +pub type PublishFrame = + Arc BoxFuture<'static, crate::Result> + Send + Sync>; + +/// The effect a single processed frame requires of the async caller. +/// +/// Keeps the state machine I/O-free (like the oversized receiver): the inbound +/// dispatcher performs any send the outcome demands. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum FrameOutcome { + /// No side effect; await more frames. + None, + /// A `ping` was received; the caller must publish a `pong` echoing the nonce. + SendPong(String), + /// The stream closed gracefully (terminal). + Closed, + /// The stream was aborted by the peer (terminal); advisory reason attached. + Aborted(Option), +} + +/// The keepalive action a [`tick`](OpenStreamSession::tick) requires of the sweep. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeepaliveAction { + /// Idle threshold not yet crossed (or a probe is already in flight). + None, + /// Idle threshold crossed: publish a `ping` carrying this nonce. + SendPing(String), + /// A deadline expired: the stream was aborted locally with this reason. + Abort(String), +} + +/// Construction options for an [`OpenStreamSession`]. +pub struct OpenStreamSessionOptions { + /// The stream id (stringified `progressToken`). + pub progress_token: String, + /// Maximum buffered + queued chunks held before a `Sequence` error. + pub max_buffered_chunks: usize, + /// Maximum buffered + queued payload bytes before a `Sequence` error. + pub max_buffered_bytes: u64, + /// Idle interval before the reader probes the peer with a `ping` (ms). + pub idle_timeout_ms: u64, + /// Time the reader waits for a `pong` after probing before aborting (ms). + pub probe_timeout_ms: u64, + /// Grace period after a `close` with unresolved gaps before aborting (ms). + pub close_grace_period_ms: u64, + /// Outbound publisher for the consumer `abort` frame (optional). + pub publish_frame: Option, +} + +/// Mutable per-stream state, guarded by a `std::sync::Mutex`. +/// +/// All mutation happens under the lock in synchronous methods that never +/// `.await`, so the lock is never held across a suspension point. +struct SessionState { + progress_token: String, + + // ── policy ────────────────────────────────────────────────────── + max_buffered_chunks: usize, + max_buffered_bytes: u64, + idle_timeout: Duration, + probe_timeout: Duration, + close_grace_period: Duration, + + // ── data plane ────────────────────────────────────────────────── + /// Emittable chunks awaiting the consumer (FIFO). + queue: VecDeque, + /// Out-of-order chunks buffered by `chunkIndex` until contiguous. + buffered: BTreeMap, + /// UTF-8 byte total of [`buffered`](Self::buffered). + buffered_bytes: u64, + /// UTF-8 byte total of [`queue`](Self::queue). + queued_bytes: u64, + /// The next contiguous `chunkIndex` to emit. + next_expected_chunk: u64, + /// Highest outer `progress` accepted so far (sentinel `-1`). + last_progress: i64, + started: bool, + active: bool, + closed_remotely: bool, + /// `close.lastChunkIndex` (only meaningful once `closed_remotely`). + expected_last_chunk_index: Option, + /// Terminal error, delivered once on the next stream poll. + terminal: Option, + /// Parked stream consumer. + waker: Option, + /// Parked `closed()` futures. + closed_wakers: Vec, + + // ── keepalive (pure `tick`, driven by the transport sweep) ────── + last_activity: Instant, + pending_probe_nonce: Option, + control_nonce: u64, + probe_deadline: Option, + close_grace_deadline: Option, + /// Outbound `progress` counter for the consumer `abort` frame. + outbound_progress: u64, +} + +impl SessionState { + fn assert_active(&self) -> Result<(), OpenStreamError> { + if !self.active { + return Err(OpenStreamError::Sequence(format!( + "Received frame for inactive stream {}", + self.progress_token + ))); + } + Ok(()) + } + + fn assert_started(&self) -> Result<(), OpenStreamError> { + if !self.started { + return Err(OpenStreamError::Sequence(format!( + "Received non-start frame before start for {}", + self.progress_token + ))); + } + Ok(()) + } + + /// The outer `progress` must be strictly increasing across all frames. + fn assert_progress(&mut self, progress: i64) -> Result<(), OpenStreamError> { + if progress <= self.last_progress { + return Err(OpenStreamError::Sequence(format!( + "Non-increasing progress for stream {}", + self.progress_token + ))); + } + self.last_progress = progress; + Ok(()) + } + + fn buffer_chunk(&mut self, chunk_index: u64, data: String) -> Result<(), OpenStreamError> { + if chunk_index < self.next_expected_chunk { + return Err(OpenStreamError::Sequence(format!( + "Stale chunkIndex {chunk_index} for {}", + self.progress_token + ))); + } + if self.buffered.contains_key(&chunk_index) { + return Err(OpenStreamError::Sequence(format!( + "Duplicate chunkIndex {chunk_index} for {}", + self.progress_token + ))); + } + + let chunk_bytes = data.len() as u64; + if self.buffered.len() + self.queue.len() >= self.max_buffered_chunks { + return Err(OpenStreamError::Sequence(format!( + "Buffered chunk limit exceeded for stream {}", + self.progress_token + ))); + } + if self.buffered_bytes + self.queued_bytes + chunk_bytes > self.max_buffered_bytes { + return Err(OpenStreamError::Sequence(format!( + "Buffered byte limit exceeded for stream {}", + self.progress_token + ))); + } + + self.buffered.insert(chunk_index, data); + self.buffered_bytes += chunk_bytes; + Ok(()) + } + + /// Flush every contiguous buffered chunk into the emit queue, then — if the + /// peer has closed — attempt a graceful finish. Returns `Ok(true)` when the + /// stream finished gracefully on this call. + fn flush_contiguous_chunks(&mut self) -> Result { + while let Some(data) = self.buffered.remove(&self.next_expected_chunk) { + self.buffered_bytes = self.buffered_bytes.saturating_sub(data.len() as u64); + self.emit(data); + self.next_expected_chunk += 1; + } + + if self.closed_remotely { + return self.maybe_finish_gracefully(); + } + Ok(false) + } + + /// Resolve a graceful close if all declared chunks have been emitted. + /// Returns `Ok(true)` on finish, `Ok(false)` while still waiting on a gap, + /// `Err(Sequence)` when the declared `lastChunkIndex` can never be met. + fn maybe_finish_gracefully(&mut self) -> Result { + if !self.closed_remotely || !self.buffered.is_empty() { + return Ok(false); + } + + if let Some(expected) = self.expected_last_chunk_index { + if self.next_expected_chunk != expected + 1 { + return Err(OpenStreamError::Sequence(format!( + "Incomplete stream for {}: expected chunks through {expected}", + self.progress_token + ))); + } + } + + self.finalize(None); + Ok(true) + } + + /// Push an emittable chunk and wake the parked consumer. + fn emit(&mut self, data: String) { + self.queued_bytes += data.len() as u64; + self.queue.push_back(data); + self.wake_stream(); + } + + fn handle_pong(&mut self, nonce: &str) { + if self.pending_probe_nonce.as_deref() == Some(nonce) { + self.pending_probe_nonce = None; + self.probe_deadline = None; + } + } + + fn next_control_nonce(&mut self) -> String { + self.control_nonce += 1; + format!("{}:{}", self.progress_token, self.control_nonce) + } + + /// Pure keepalive transition. The owning transport calls this from its sweep + /// with `Instant::now()` and performs any returned send. + fn tick(&mut self, now: Instant) -> KeepaliveAction { + if !self.active { + return KeepaliveAction::None; + } + + // 1. A pending probe whose deadline passed → abort. + if let (Some(deadline), true) = (self.probe_deadline, self.pending_probe_nonce.is_some()) { + if now >= deadline { + let token = self.progress_token.clone(); + self.finalize(Some(OpenStreamError::abort( + token, + Some("Probe timeout".to_string()), + ))); + return KeepaliveAction::Abort("Probe timeout".to_string()); + } + } + + // 2. Close-grace deadline passed with chunks still missing → abort. + if let Some(deadline) = self.close_grace_deadline { + if self.closed_remotely && !self.buffered.is_empty() && now >= deadline { + let token = self.progress_token.clone(); + self.finalize(Some(OpenStreamError::abort( + token, + Some("Close grace period expired".to_string()), + ))); + return KeepaliveAction::Abort("Close grace period expired".to_string()); + } + } + + // 3. Idle past the threshold (and not closed / not already probing) → ping. + if !self.closed_remotely + && self.pending_probe_nonce.is_none() + && now.saturating_duration_since(self.last_activity) >= self.idle_timeout + { + let nonce = self.next_control_nonce(); + self.pending_probe_nonce = Some(nonce.clone()); + self.probe_deadline = Some(now + self.probe_timeout); + return KeepaliveAction::SendPing(nonce); + } + + KeepaliveAction::None + } + + /// Terminate the stream. Idempotent. `error` is delivered once on the next + /// stream poll; `None` ends the stream gracefully. Wakes the consumer and + /// any `closed()` futures. + fn finalize(&mut self, error: Option) { + if !self.active { + return; + } + self.active = false; + self.terminal = error; + self.pending_probe_nonce = None; + self.probe_deadline = None; + self.close_grace_deadline = None; + self.wake_stream(); + for waker in self.closed_wakers.drain(..) { + waker.wake(); + } + } + + fn wake_stream(&mut self) { + if let Some(waker) = self.waker.take() { + waker.wake(); + } + } +} + +/// Readable reader-side handle for a CEP-41 stream. +/// +/// Cheaply clonable (`Arc`-backed): the registry holds one clone to feed frames +/// while the consumer holds another to drain the [`Stream`]. +#[derive(Clone)] +pub struct OpenStreamSession { + /// Immutable stream id, duplicated here so accessors/`Debug` need no lock. + progress_token: String, + state: Arc>, + publish_frame: Option, +} + +impl std::fmt::Debug for OpenStreamSession { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("OpenStreamSession") + .field("progress_token", &self.progress_token) + .finish_non_exhaustive() + } +} + +impl OpenStreamSession { + /// Create a new reader session from explicit options. + pub fn new(options: OpenStreamSessionOptions) -> Self { + let publish_frame = options.publish_frame.clone(); + let progress_token = options.progress_token.clone(); + let state = SessionState { + progress_token: options.progress_token, + max_buffered_chunks: options.max_buffered_chunks, + max_buffered_bytes: options.max_buffered_bytes, + idle_timeout: Duration::from_millis(options.idle_timeout_ms), + probe_timeout: Duration::from_millis(options.probe_timeout_ms), + close_grace_period: Duration::from_millis(options.close_grace_period_ms), + queue: VecDeque::new(), + buffered: BTreeMap::new(), + buffered_bytes: 0, + queued_bytes: 0, + next_expected_chunk: 0, + last_progress: -1, + started: false, + active: true, + closed_remotely: false, + expected_last_chunk_index: None, + terminal: None, + waker: None, + closed_wakers: Vec::new(), + last_activity: Instant::now(), + pending_probe_nonce: None, + control_nonce: 0, + probe_deadline: None, + close_grace_deadline: None, + outbound_progress: 0, + }; + Self { + progress_token, + state: Arc::new(Mutex::new(state)), + publish_frame, + } + } + + /// The stream id (stringified `progressToken`). + pub fn progress_token(&self) -> &str { + &self.progress_token + } + + /// Whether the stream is still live (not yet closed/aborted/failed). + pub fn is_active(&self) -> bool { + self.state.lock().unwrap().active + } + + /// Whether the `start` frame has been observed. + pub fn has_started(&self) -> bool { + self.state.lock().unwrap().started + } + + /// Process one inbound frame, stamping `last_activity = now`. + /// + /// Returns the [`FrameOutcome`] the async caller must act on (publish a + /// `pong`, observe a terminal close/abort). On a sequencing/policy violation + /// it returns `Err` **without** finalizing — the caller (registry) decides + /// whether to [`fail`](Self::fail) the session. + pub fn process_frame( + &self, + now: Instant, + progress: i64, + frame: OpenStreamFrame, + ) -> Result { + let mut s = self.state.lock().unwrap(); + s.assert_active()?; + s.assert_progress(progress)?; + // Every accepted frame counts as liveness. + s.last_activity = now; + + match frame { + OpenStreamFrame::Start { .. } => { + if s.started { + return Err(OpenStreamError::Sequence(format!( + "Duplicate start frame for stream {}", + s.progress_token + ))); + } + s.started = true; + Ok(FrameOutcome::None) + } + OpenStreamFrame::Accept => Ok(FrameOutcome::None), + OpenStreamFrame::Ping { nonce } => { + s.assert_started()?; + Ok(FrameOutcome::SendPong(nonce)) + } + OpenStreamFrame::Pong { nonce } => { + s.assert_started()?; + s.handle_pong(&nonce); + Ok(FrameOutcome::None) + } + OpenStreamFrame::Chunk { chunk_index, data } => { + s.assert_started()?; + s.buffer_chunk(chunk_index, data)?; + let finished = s.flush_contiguous_chunks()?; + Ok(if finished { + FrameOutcome::Closed + } else { + FrameOutcome::None + }) + } + OpenStreamFrame::Close { last_chunk_index } => { + s.assert_started()?; + s.closed_remotely = true; + s.expected_last_chunk_index = last_chunk_index; + let finished = s.flush_contiguous_chunks()?; + if finished { + Ok(FrameOutcome::Closed) + } else { + // A real out-of-order gap remains: arm the close grace timer + // (only reached when chunks are still buffered). + s.close_grace_deadline = Some(now + s.close_grace_period); + Ok(FrameOutcome::None) + } + } + OpenStreamFrame::Abort { reason } => { + let token = s.progress_token.clone(); + s.finalize(Some(OpenStreamError::abort(token, reason.clone()))); + Ok(FrameOutcome::Aborted(reason)) + } + } + } + + /// Pure keepalive transition (idle → `ping`, probe/grace → abort). Driven by + /// the owning transport's periodic sweep with `Instant::now()`. + pub fn tick(&self, now: Instant) -> KeepaliveAction { + self.state.lock().unwrap().tick(now) + } + + /// Terminate the stream with an explicit error (no abort frame published). + /// Mirrors the registry's `fail` path; the error surfaces on the next poll. + pub fn fail(&self, error: OpenStreamError) { + self.state.lock().unwrap().finalize(Some(error)); + } + + /// Dispose the stream gracefully (ends the [`Stream`] with `None`). Used by + /// the registry's `clear`; runs no hooks. + pub fn dispose(&self) { + self.state.lock().unwrap().finalize(None); + } + + /// Consumer-initiated cancel: finalize locally (terminal abort error on the + /// next poll) and, if a publisher was injected, emit an `abort` frame to the + /// peer. Idempotent; the transport exposes this as the consumer's stream cancel. + pub async fn abort(&self, reason: Option) { + let publish = { + let mut s = self.state.lock().unwrap(); + if !s.active { + return; + } + let token = s.progress_token.clone(); + s.finalize(Some(OpenStreamError::abort(token.clone(), reason.clone()))); + self.publish_frame.as_ref().map(|publisher| { + s.outbound_progress += 1; + (publisher.clone(), token, s.outbound_progress) + }) + }; + + if let Some((publisher, token, progress)) = publish { + if let Ok(notification) = (OpenStreamFrame::Abort { + reason: reason.clone(), + }) + .into_progress_notification(&token, progress, None) + { + // Best effort — the local stream is already finalized. + let _ = publisher(notification).await; + } + } + } + + /// A future that resolves to `()` when the stream finalizes (graceful close, + /// abort, fail, or dispose). It never carries the terminal error — that is + /// delivered once on the [`Stream`] (intentional Rust divergence from TS). + pub fn closed(&self) -> Closed { + Closed { + state: self.state.clone(), + } + } + + /// Whether two handles refer to the same underlying session state (used by + /// the registry's get-or-create reuse tests). + #[cfg(test)] + pub(crate) fn shares_state_with(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.state, &other.state) + } +} + +impl Stream for OpenStreamSession { + type Item = Result; + + fn poll_next(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll> { + let mut s = self.state.lock().unwrap(); + if let Some(item) = s.queue.pop_front() { + s.queued_bytes = s.queued_bytes.saturating_sub(item.len() as u64); + return Poll::Ready(Some(Ok(item))); + } + if !s.active { + return match s.terminal.take() { + Some(error) => Poll::Ready(Some(Err(error))), + None => Poll::Ready(None), + }; + } + s.waker = Some(cx.waker().clone()); + Poll::Pending + } +} + +/// Future returned by [`OpenStreamSession::closed`]; resolves on finalize. +pub struct Closed { + state: Arc>, +} + +impl Future for Closed { + type Output = (); + + fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll { + let mut s = self.state.lock().unwrap(); + if !s.active { + Poll::Ready(()) + } else { + s.closed_wakers.push(cx.waker().clone()); + Poll::Pending + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use futures::{poll, StreamExt}; + + fn make_session(token: &str, max_chunks: usize, max_bytes: u64) -> OpenStreamSession { + OpenStreamSession::new(OpenStreamSessionOptions { + progress_token: token.to_string(), + max_buffered_chunks: max_chunks, + max_buffered_bytes: max_bytes, + idle_timeout_ms: 30_000, + probe_timeout_ms: 20_000, + close_grace_period_ms: 5_000, + publish_frame: None, + }) + } + + fn make_session_timers(token: &str, idle: u64, probe: u64, grace: u64) -> OpenStreamSession { + OpenStreamSession::new(OpenStreamSessionOptions { + progress_token: token.to_string(), + max_buffered_chunks: 8, + max_buffered_bytes: 1024, + idle_timeout_ms: idle, + probe_timeout_ms: probe, + close_grace_period_ms: grace, + publish_frame: None, + }) + } + + fn start() -> OpenStreamFrame { + OpenStreamFrame::Start { content_type: None } + } + + fn chunk(index: u64, data: &str) -> OpenStreamFrame { + OpenStreamFrame::Chunk { + chunk_index: index, + data: data.to_string(), + } + } + + fn close(last: Option) -> OpenStreamFrame { + OpenStreamFrame::Close { + last_chunk_index: last, + } + } + + /// Drain a *finalized* stream's queued chunks (panics on a terminal error). + async fn drain_ok(session: &mut OpenStreamSession) -> Vec { + let mut out = Vec::new(); + while let Some(item) = session.next().await { + out.push(item.expect("graceful stream must not yield an error")); + } + out + } + + // ── data-plane ────────────────────────────────────────────────── + + #[tokio::test] + async fn yields_ordered_chunks_and_finishes_after_close() { + let now = Instant::now(); + let mut s = make_session("token-1", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "hello")).unwrap(); + s.process_frame(now, 3, chunk(1, " world")).unwrap(); + assert_eq!( + s.process_frame(now, 4, close(None)).unwrap(), + FrameOutcome::Closed + ); + + assert_eq!(drain_ok(&mut s).await, vec!["hello", " world"]); + s.closed().await; + } + + #[tokio::test] + async fn buffers_out_of_order_chunks_until_contiguous() { + let now = Instant::now(); + let mut s = make_session("token-2", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(1, "world")).unwrap(); + s.process_frame(now, 3, chunk(0, "hello ")).unwrap(); + s.process_frame(now, 4, close(None)).unwrap(); + + assert_eq!(drain_ok(&mut s).await, vec!["hello ", "world"]); + } + + #[tokio::test] + async fn fails_when_progress_does_not_increase() { + let now = Instant::now(); + let s = make_session("token-3", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + let err = s.process_frame(now, 1, chunk(0, "repeat")).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn rejects_a_duplicate_start_frame() { + let now = Instant::now(); + let s = make_session("token-dup-start", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + // A second `start` on an already-started stream is a sequencing violation. + let err = s.process_frame(now, 2, start()).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn abort_frame_terminates_stream_with_error_on_next_poll() { + let now = Instant::now(); + let mut s = make_session("token-4", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + assert_eq!( + s.process_frame( + now, + 2, + OpenStreamFrame::Abort { + reason: Some("boom".to_string()) + } + ) + .unwrap(), + FrameOutcome::Aborted(Some("boom".to_string())) + ); + + match s.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("boom")); + } + other => panic!("expected abort error, got {other:?}"), + } + // The error is delivered once; the stream then ends. + assert!(s.next().await.is_none()); + s.closed().await; + } + + #[tokio::test] + async fn fails_when_buffered_chunk_count_exceeds_limit() { + let now = Instant::now(); + let s = make_session("token-buffer-count", 1, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(1, "late")).unwrap(); + let err = s.process_frame(now, 3, chunk(2, "later")).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn fails_when_buffered_byte_count_exceeds_limit_utf8() { + let now = Instant::now(); + let s = make_session("token-buffer-bytes", 4, 4); + s.process_frame(now, 1, start()).unwrap(); + // "héllo" is 6 UTF-8 bytes (é = 2), exceeding the 4-byte cap; this also + // pins UTF-8 byte accounting (a `str::len` over chars would miscount). + let err = s.process_frame(now, 2, chunk(1, "héllo")).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn fail_terminates_stream_and_closed_resolves() { + let now = Instant::now(); + let mut s = make_session("token-explicit-fail", 4, 16); + s.process_frame(now, 1, start()).unwrap(); + s.fail(OpenStreamError::Sequence("synthetic failure".to_string())); + + match s.next().await { + Some(Err(OpenStreamError::Sequence(msg))) => assert!(msg.contains("synthetic")), + other => panic!("expected sequence error, got {other:?}"), + } + // `closed()` resolves to `()` (the error rode the stream, not `closed`). + s.closed().await; + } + + #[tokio::test] + async fn counts_unread_queued_chunks_against_the_byte_limit() { + let now = Instant::now(); + let s = make_session("token-queued-bytes", 4, 5); + s.process_frame(now, 1, start()).unwrap(); + // chunk 0 flushes immediately → 3 queued bytes counted against the cap. + s.process_frame(now, 2, chunk(0, "abc")).unwrap(); + let err = s.process_frame(now, 3, chunk(1, "def")).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn releases_queued_byte_budget_after_consume() { + let now = Instant::now(); + let mut s = make_session("token-queued-release", 4, 6); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "abc")).unwrap(); + s.process_frame(now, 3, chunk(1, "def")).unwrap(); + // 3 + 3 + 1 = 7 > 6 → rejected (progress 4 is consumed by assert_progress). + assert!(matches!( + s.process_frame(now, 4, chunk(2, "g")).unwrap_err(), + OpenStreamError::Sequence(_) + )); + + // Consuming "abc" frees 3 bytes, so the retry (progress 5) now fits. + assert_eq!(s.next().await.unwrap().unwrap(), "abc"); + s.process_frame(now, 5, chunk(2, "g")).unwrap(); + } + + #[tokio::test] + async fn rejects_frames_after_close() { + let now = Instant::now(); + let s = make_session("token-post-close", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + assert_eq!( + s.process_frame(now, 2, close(None)).unwrap(), + FrameOutcome::Closed + ); + let err = s.process_frame(now, 3, chunk(0, "late")).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn rejects_frames_after_abort() { + let now = Instant::now(); + let s = make_session("token-post-abort", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame( + now, + 2, + OpenStreamFrame::Abort { + reason: Some("boom".to_string()), + }, + ) + .unwrap(); + assert!(matches!( + s.process_frame(now, 3, chunk(0, "late")).unwrap_err(), + OpenStreamError::Sequence(_) + )); + assert!(matches!( + s.process_frame(now, 4, close(None)).unwrap_err(), + OpenStreamError::Sequence(_) + )); + } + + #[tokio::test] + async fn rejects_stale_and_duplicate_chunk_indexes() { + let now = Instant::now(); + // Stale: an index below the contiguous frontier (already flushed). + let s = make_session("token-stale", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "hello")).unwrap(); + assert!(matches!( + s.process_frame(now, 3, chunk(0, "late-duplicate")) + .unwrap_err(), + OpenStreamError::Sequence(_) + )); + + // Duplicate: a still-buffered out-of-order index re-delivered. + let s2 = make_session("token-dup", 8, 1024); + s2.process_frame(now, 1, start()).unwrap(); + s2.process_frame(now, 2, chunk(2, "a")).unwrap(); + assert!(matches!( + s2.process_frame(now, 3, chunk(2, "b")).unwrap_err(), + OpenStreamError::Sequence(_) + )); + } + + #[tokio::test] + async fn requires_all_chunks_through_close_last_chunk_index() { + let now = Instant::now(); + let s = make_session("token-last-chunk-index", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "hello")).unwrap(); + let err = s.process_frame(now, 3, close(Some(1))).unwrap_err(); + assert!(matches!(err, OpenStreamError::Sequence(_))); + } + + #[tokio::test] + async fn allows_graceful_close_when_last_chunk_index_matches() { + let now = Instant::now(); + let mut s = make_session("token-last-chunk-complete", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "hello")).unwrap(); + assert_eq!( + s.process_frame(now, 3, close(Some(0))).unwrap(), + FrameOutcome::Closed + ); + assert_eq!(drain_ok(&mut s).await, vec!["hello"]); + s.closed().await; + } + + #[tokio::test] + async fn rejects_malformed_close_last_chunk_index_and_stays_active() { + let now = Instant::now(); + let mut s = make_session("token-malformed-last", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + s.process_frame(now, 2, chunk(0, "hello")).unwrap(); + // lastChunkIndex far larger than anything received → Sequence, but the + // stream is not finalized (a consumer abort can still clean it up). + assert!(matches!( + s.process_frame(now, 3, close(Some(5))).unwrap_err(), + OpenStreamError::Sequence(_) + )); + assert!(s.is_active()); + + s.abort(Some("cleanup".to_string())).await; + // The already-flushed "hello" drains before the terminal abort error. + assert_eq!(s.next().await.unwrap().unwrap(), "hello"); + match s.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("cleanup")); + } + other => panic!("expected abort error, got {other:?}"), + } + s.closed().await; + } + + #[tokio::test] + async fn rejects_negative_chunk_index_and_stays_active() { + use crate::transport::open_stream::OpenStreamRegistry; + use serde_json::json; + + // A `chunk` whose `chunkIndex` is a negative JSON number is not a valid + // CEP-41 frame: `chunk_index` is a `u64`, so it fails to deserialize at + // the wire boundary and never becomes a typed `Chunk`. A non-integer + // index is rejected the same way. + let negative = json!({ + "type": "open-stream", + "frameType": "chunk", + "chunkIndex": -1, + "data": "oops", + }); + assert_eq!(OpenStreamFrame::from_cvm_value(&negative), None); + let fractional = json!({ + "type": "open-stream", + "frameType": "chunk", + "chunkIndex": 1.5, + "data": "oops", + }); + assert_eq!(OpenStreamFrame::from_cvm_value(&fractional), None); + + // End to end: unlike a malformed-but-typed `lastChunkIndex` (see + // `rejects_malformed_close_last_chunk_index_and_stays_active`, which the + // session itself rejects), a negative index is rejected one layer out at + // the registry's `parse_frame` boundary with a `Sequence` error, and the + // already-admitted stream is left active (cleanable via a later frame). + let now = Instant::now(); + let mut registry = OpenStreamRegistry::new(); + registry + .process_frame( + now, + &start() + .into_progress_notification("tok-neg", 1, None) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(registry.size(), 1); + + let bad = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ + "progressToken": "tok-neg", + "progress": 2, + "cvm": negative, + })), + }; + assert!(matches!( + registry.process_frame(now, &bad).await.unwrap_err(), + OpenStreamError::Sequence(_) + )); + + // The malformed frame neither terminated nor removed the stream. + assert_eq!(registry.size(), 1); + assert!(registry.get_session("tok-neg").unwrap().is_active()); + } + + #[tokio::test] + async fn zero_chunk_close_succeeds() { + let now = Instant::now(); + let mut s = make_session("token-zero-chunk", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + assert_eq!( + s.process_frame(now, 2, close(None)).unwrap(), + FrameOutcome::Closed + ); + assert!(s.next().await.is_none()); + s.closed().await; + } + + #[tokio::test] + async fn chunk_filling_last_gap_after_close_yields_closed() { + let now = Instant::now(); + let mut s = make_session("token-fill-gap", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + // Out-of-order chunk 1 buffered; close declares 1 is the last chunk. + s.process_frame(now, 2, chunk(1, "world")).unwrap(); + assert_eq!( + s.process_frame(now, 3, close(Some(1))).unwrap(), + FrameOutcome::None + ); + // Chunk 0 fills the gap → both flush and the stream closes. + assert_eq!( + s.process_frame(now, 4, chunk(0, "hello ")).unwrap(), + FrameOutcome::Closed + ); + assert_eq!(drain_ok(&mut s).await, vec!["hello ", "world"]); + } + + #[tokio::test] + async fn parked_reader_is_woken_by_an_arriving_chunk() { + let now = Instant::now(); + let mut s = make_session("token-park", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + // No data yet → the manual Stream impl parks (Pending), storing the waker. + assert!(poll!(s.next()).is_pending()); + s.process_frame(now, 2, chunk(0, "x")).unwrap(); + match poll!(s.next()) { + Poll::Ready(Some(Ok(value))) => assert_eq!(value, "x"), + other => panic!("expected the queued chunk, got {other:?}"), + } + } + + #[tokio::test] + async fn consumer_abort_publishes_an_abort_frame_then_finalizes() { + use nostr_sdk::prelude::EventId; + + let now = Instant::now(); + let published: Arc>> = Arc::new(Mutex::new(Vec::new())); + let recorder = published.clone(); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + let recorder = recorder.clone(); + Box::pin(async move { + recorder.lock().unwrap().push(frame); + Ok(EventId::all_zeros()) + }) + }); + let mut s = OpenStreamSession::new(OpenStreamSessionOptions { + progress_token: "token-consumer-abort".to_string(), + max_buffered_chunks: 8, + max_buffered_bytes: 1024, + idle_timeout_ms: 30_000, + probe_timeout_ms: 20_000, + close_grace_period_ms: 5_000, + publish_frame: Some(publish), + }); + s.process_frame(now, 1, start()).unwrap(); + + s.abort(Some("user cancelled".to_string())).await; + + // The injected publisher emitted exactly one `abort` frame to the peer. + { + let frames = published.lock().unwrap(); + assert_eq!(frames.len(), 1); + let cvm = &frames[0].params.as_ref().unwrap()["cvm"]; + assert_eq!(cvm["frameType"], "abort"); + assert_eq!(cvm["reason"], "user cancelled"); + } + + // And the local stream is finalized with the abort error. + match s.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("user cancelled")); + } + other => panic!("expected abort error, got {other:?}"), + } + } + + // ── keepalive (pure `tick`, injected clock) ───────────────────── + + #[tokio::test] + async fn ping_frame_requests_a_pong() { + let now = Instant::now(); + let s = make_session("token-ping", 8, 1024); + s.process_frame(now, 1, start()).unwrap(); + assert_eq!( + s.process_frame( + now, + 2, + OpenStreamFrame::Ping { + nonce: "nonce-1".to_string() + } + ) + .unwrap(), + FrameOutcome::SendPong("nonce-1".to_string()) + ); + } + + #[tokio::test] + async fn idle_sends_ping_then_probe_timeout_aborts() { + let t0 = Instant::now(); + let mut s = make_session_timers("token-probe", 10, 10, 100); + s.process_frame(t0, 1, start()).unwrap(); + + // Before the idle threshold: nothing. + assert_eq!(s.tick(t0 + Duration::from_millis(5)), KeepaliveAction::None); + // At the idle threshold: probe with a `{token}:{n}` nonce. + assert_eq!( + s.tick(t0 + Duration::from_millis(10)), + KeepaliveAction::SendPing("token-probe:1".to_string()) + ); + // Probe in flight, deadline (t0+20) not yet reached: nothing. + assert_eq!( + s.tick(t0 + Duration::from_millis(15)), + KeepaliveAction::None + ); + // Probe deadline reached with no pong: abort. + assert_eq!( + s.tick(t0 + Duration::from_millis(20)), + KeepaliveAction::Abort("Probe timeout".to_string()) + ); + + match s.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("Probe timeout")); + } + other => panic!("expected probe-timeout abort, got {other:?}"), + } + } + + #[tokio::test] + async fn matching_pong_clears_probe_and_stream_survives() { + let t0 = Instant::now(); + let s = make_session_timers("token-probe-ok", 10, 20, 100); + s.process_frame(t0, 1, start()).unwrap(); + + let nonce = match s.tick(t0 + Duration::from_millis(10)) { + KeepaliveAction::SendPing(nonce) => nonce, + other => panic!("expected a ping, got {other:?}"), + }; + // A matching pong clears the probe and refreshes liveness. + s.process_frame( + t0 + Duration::from_millis(15), + 2, + OpenStreamFrame::Pong { nonce }, + ) + .unwrap(); + + // At the original probe deadline the stream is NOT aborted. + assert!(!matches!( + s.tick(t0 + Duration::from_millis(30)), + KeepaliveAction::Abort(_) + )); + assert!(s.is_active()); + } + + #[tokio::test] + async fn unexpected_pong_is_ignored_for_liveness() { + let t0 = Instant::now(); + let mut s = make_session_timers("token-bad-pong", 10, 10, 100); + s.process_frame(t0, 1, start()).unwrap(); + assert!(matches!( + s.tick(t0 + Duration::from_millis(10)), + KeepaliveAction::SendPing(_) + )); + + // A pong with a non-matching nonce must not clear the pending probe. + s.process_frame( + t0 + Duration::from_millis(12), + 2, + OpenStreamFrame::Pong { + nonce: "unexpected".to_string(), + }, + ) + .unwrap(); + + assert_eq!( + s.tick(t0 + Duration::from_millis(20)), + KeepaliveAction::Abort("Probe timeout".to_string()) + ); + match s.next().await { + Some(Err(OpenStreamError::Abort { .. })) => {} + other => panic!("expected abort, got {other:?}"), + } + } + + #[tokio::test] + async fn close_grace_deadline_with_missing_chunks_aborts() { + let t0 = Instant::now(); + let mut s = make_session_timers("token-grace", 100, 100, 10); + s.process_frame(t0, 1, start()).unwrap(); + // Out-of-order chunk buffered, then a close with the gap unresolved. + s.process_frame(t0, 2, chunk(1, "late")).unwrap(); + assert_eq!( + s.process_frame(t0, 3, close(None)).unwrap(), + FrameOutcome::None + ); + + // Before the grace deadline: nothing. At it: abort. + assert_eq!(s.tick(t0 + Duration::from_millis(5)), KeepaliveAction::None); + assert_eq!( + s.tick(t0 + Duration::from_millis(10)), + KeepaliveAction::Abort("Close grace period expired".to_string()) + ); + match s.next().await { + Some(Err(OpenStreamError::Abort { reason, .. })) => { + assert_eq!(reason.as_deref(), Some("Close grace period expired")); + } + other => panic!("expected grace abort, got {other:?}"), + } + } + + #[tokio::test] + async fn close_with_missing_tail_and_nothing_buffered_fails_immediately_without_grace() { + let t0 = Instant::now(); + let s = make_session_timers("token-no-grace", 100, 100, 10); + s.process_frame(t0, 1, start()).unwrap(); + s.process_frame(t0, 2, chunk(0, "hello")).unwrap(); + // Tail declared (lastChunkIndex=1) but chunk 1 never arrived and nothing + // is buffered → immediate Sequence error, no grace timer armed. + assert!(matches!( + s.process_frame(t0, 3, close(Some(1))).unwrap_err(), + OpenStreamError::Sequence(_) + )); + // No close-grace deadline was armed: even far in the future, tick is inert. + assert_eq!(s.tick(t0 + Duration::from_secs(60)), KeepaliveAction::None); + } +} diff --git a/src/transport/open_stream/writer.rs b/src/transport/open_stream/writer.rs new file mode 100644 index 0000000..0c1b160 --- /dev/null +++ b/src/transport/open_stream/writer.rs @@ -0,0 +1,685 @@ +//! Producer-side writer for a CEP-41 open stream. +//! +//! Ports `sdk/src/transport/open-stream/writer.ts`. A tool emits an ordered +//! sequence of `chunk` frames (plus keepalive `ping`/`pong` and a terminal +//! `close`/`abort`) through an injected publish closure. +//! +//! Serialization design: `write`/`close`/`ping`/`pong` +//! hold a `tokio::sync::Mutex` across their publish `.await`, so **call order == +//! wire order** natively (each op increments `progress`/`chunkIndex` under the +//! lock). The liveness flag lives in a **separate `AtomicBool` outside that +//! lock**, so [`abort`](OpenStreamWriter::abort) can claim the terminal +//! transition and publish without queueing behind a stuck `write`. +//! +//! The handle is `Arc`-backed and `Clone` so it can be inserted into the rmcp +//! request `extensions` typemap (`T: Clone + Send + Sync + 'static`) when the +//! server transport wiring lands. + +use std::sync::atomic::{AtomicBool, AtomicU64, Ordering}; +use std::sync::Arc; + +use futures::future::BoxFuture; +use tokio::sync::Mutex; + +use super::frame::OpenStreamFrame; +use super::session::PublishFrame; + +/// Lifecycle hook fired after a terminal `close` frame is published. +/// +/// Currently inert; the server transport will wire it to flush a deferred final response. +pub type OnCloseHook = Arc BoxFuture<'static, ()> + Send + Sync>; + +/// Lifecycle hook fired after a terminal `abort` frame is published (with the +/// advisory reason). +/// +/// Currently inert; the server transport will wire it to flush a deferred final response. +pub type OnAbortHook = Arc) -> BoxFuture<'static, ()> + Send + Sync>; + +/// Construction options for an [`OpenStreamWriter`]. +pub struct OpenStreamWriterOptions { + /// The stream id (stringified `progressToken`). + pub progress_token: String, + /// Outbound publisher (same seam as the session). + pub publish_frame: PublishFrame, + /// Optional advisory `start` content type (writer-settable, receiver-ignored). + pub content_type: Option, + /// Fired after a terminal `close` frame is published. + pub on_close: Option, + /// Fired after a terminal `abort` frame is published. + pub on_abort: Option, +} + +/// State mutated only under the op `Mutex` (serialized publishes). +struct WriterOpState { + /// Next `chunkIndex` to assign (touched only by `write`). + chunk_index: u64, + /// Control-frame nonce counter (touched only by `ping`). + control_nonce: u64, +} + +struct WriterInner { + progress_token: String, + content_type: Option, + publish_frame: PublishFrame, + on_close: Option, + on_abort: Option, + /// Serializes `write`/`close`/`ping`/`pong` so call order == wire order. + op: Mutex, + /// Monotonic outer `progress`, shared so `abort` can mint one without the op + /// lock. Frames use `fetch_add(1) + 1` → 1, 2, 3, … + progress: AtomicU64, + /// Liveness flag, **outside** the op lock so `abort` never blocks on a stuck + /// write. `false` once closed or aborted. + active: AtomicBool, + /// Whether the lazy `start` frame has been published. + started: AtomicBool, +} + +/// Minimal CEP-41 producer/writer. +#[derive(Clone)] +pub struct OpenStreamWriter { + inner: Arc, +} + +impl OpenStreamWriter { + /// Create a new writer from explicit options. + pub fn new(options: OpenStreamWriterOptions) -> Self { + Self { + inner: Arc::new(WriterInner { + progress_token: options.progress_token, + content_type: options.content_type, + publish_frame: options.publish_frame, + on_close: options.on_close, + on_abort: options.on_abort, + op: Mutex::new(WriterOpState { + chunk_index: 0, + control_nonce: 0, + }), + progress: AtomicU64::new(0), + active: AtomicBool::new(true), + started: AtomicBool::new(false), + }), + } + } + + /// The stream id (stringified `progressToken`). + pub fn progress_token(&self) -> &str { + &self.inner.progress_token + } + + /// Whether the writer is still live (not yet closed/aborted). + /// + /// `true` for any freshly-created writer; see [`has_started`](Self::has_started). + pub fn is_active(&self) -> bool { + self.inner.active.load(Ordering::SeqCst) + } + + /// Whether the writer has begun streaming by publishing its `start` frame. + /// + /// Distinct from [`is_active`](Self::is_active): used to tell apart writers a + /// tool actually streams through from ones created only because the request + /// carried a progress token (the response-deferral guard). + pub fn has_started(&self) -> bool { + self.inner.started.load(Ordering::SeqCst) + } + + /// Mint the next monotonic outer `progress` value (1, 2, 3, …). + fn next_progress(&self) -> u64 { + self.inner.progress.fetch_add(1, Ordering::SeqCst) + 1 + } + + /// Publish the lazy `start` frame on first use. Caller MUST hold the op lock. + /// On a publish failure `started` stays `false`, so a later op retries. + async fn start_internal(&self) -> crate::Result<()> { + if self.inner.started.load(Ordering::SeqCst) || !self.inner.active.load(Ordering::SeqCst) { + return Ok(()); + } + let notification = OpenStreamFrame::Start { + content_type: self.inner.content_type.clone(), + } + .into_progress_notification( + &self.inner.progress_token, + self.next_progress(), + None, + )?; + (self.inner.publish_frame)(notification).await?; + self.inner.started.store(true, Ordering::SeqCst); + Ok(()) + } + + /// Explicitly publish the `start` frame (idempotent; lazy on first `write`). + pub async fn start(&self) -> crate::Result<()> { + let _op = self.inner.op.lock().await; + self.start_internal().await + } + + /// Publish one ordered `chunk` frame, starting the stream lazily. + pub async fn write(&self, data: String) -> crate::Result<()> { + let mut op = self.inner.op.lock().await; + self.start_internal().await?; + if !self.inner.active.load(Ordering::SeqCst) { + return Ok(()); + } + let progress = self.next_progress(); + let chunk_index = op.chunk_index; + let notification = OpenStreamFrame::Chunk { chunk_index, data } + .into_progress_notification(&self.inner.progress_token, progress, None)?; + (self.inner.publish_frame)(notification).await?; + op.chunk_index += 1; + Ok(()) + } + + /// Publish a keepalive `ping` carrying a fresh `{token}:{n}` nonce. + pub async fn ping(&self) -> crate::Result<()> { + let mut op = self.inner.op.lock().await; + if !self.inner.active.load(Ordering::SeqCst) { + return Ok(()); + } + op.control_nonce += 1; + let nonce = format!("{}:{}", self.inner.progress_token, op.control_nonce); + let progress = self.next_progress(); + let notification = OpenStreamFrame::Ping { nonce }.into_progress_notification( + &self.inner.progress_token, + progress, + None, + )?; + (self.inner.publish_frame)(notification).await?; + Ok(()) + } + + /// Publish a `pong` echoing the peer's `ping` nonce. + pub async fn pong(&self, nonce: String) -> crate::Result<()> { + let _op = self.inner.op.lock().await; + if !self.inner.active.load(Ordering::SeqCst) { + return Ok(()); + } + let progress = self.next_progress(); + let notification = OpenStreamFrame::Pong { nonce }.into_progress_notification( + &self.inner.progress_token, + progress, + None, + )?; + (self.inner.publish_frame)(notification).await?; + Ok(()) + } + + /// Close the stream gracefully. Declares `lastChunkIndex` iff any chunks were + /// written. Runs [`on_close`](OpenStreamWriterOptions::on_close) **after** the + /// frame is published (even on publish failure); propagates the publish error. + pub async fn close(&self) -> crate::Result<()> { + let op = self.inner.op.lock().await; + self.start_internal().await?; + // Claim the terminal transition atomically; lose to a racing abort. + if !self.inner.active.swap(false, Ordering::SeqCst) { + return Ok(()); + } + let last_chunk_index = if op.chunk_index > 0 { + Some(op.chunk_index - 1) + } else { + None + }; + let notification = OpenStreamFrame::Close { last_chunk_index }.into_progress_notification( + &self.inner.progress_token, + self.next_progress(), + None, + )?; + let publish_result = (self.inner.publish_frame)(notification).await; + if let Some(hook) = &self.inner.on_close { + hook().await; + } + publish_result.map(|_| ()) + } + + /// Abort the stream (terminal). Claims the terminal transition without the op + /// lock, so it never waits on a stuck `write`. Runs + /// [`on_abort`](OpenStreamWriterOptions::on_abort) **after** the frame is + /// published (even on publish failure); propagates the publish error. + /// Idempotent: a second `abort` (or one after `close`) is a no-op. + pub async fn abort(&self, reason: Option) -> crate::Result<()> { + // Claim the terminal transition; no op lock → never blocks on a write. + if !self.inner.active.swap(false, Ordering::SeqCst) { + return Ok(()); + } + let notification = OpenStreamFrame::Abort { + reason: reason.clone(), + } + .into_progress_notification( + &self.inner.progress_token, + self.next_progress(), + None, + )?; + let publish_result = (self.inner.publish_frame)(notification).await; + if let Some(hook) = &self.inner.on_abort { + hook(reason).await; + } + publish_result.map(|_| ()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::JsonRpcNotification; + use crate::Error; + use nostr_sdk::prelude::EventId; + use std::sync::Mutex as StdMutex; + + type FrameLog = Arc>>; + + fn frame_log() -> FrameLog { + Arc::new(StdMutex::new(Vec::new())) + } + + /// A publish closure that records every frame and returns a dummy id. + fn recording_publisher(log: FrameLog) -> PublishFrame { + Arc::new(move |frame: JsonRpcNotification| { + let log = log.clone(); + Box::pin(async move { + log.lock().unwrap().push(frame); + Ok(EventId::all_zeros()) + }) + }) + } + + fn frame_type(notification: &JsonRpcNotification) -> String { + notification.params.as_ref().unwrap()["cvm"]["frameType"] + .as_str() + .unwrap() + .to_string() + } + + fn frame_types(log: &FrameLog) -> Vec { + log.lock().unwrap().iter().map(frame_type).collect() + } + + fn progress_of(notification: &JsonRpcNotification) -> u64 { + notification.params.as_ref().unwrap()["progress"] + .as_u64() + .unwrap() + } + + fn cvm(notification: &JsonRpcNotification) -> &serde_json::Value { + ¬ification.params.as_ref().unwrap()["cvm"] + } + + fn writer_with(log: FrameLog) -> OpenStreamWriter { + OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "tok".to_string(), + publish_frame: recording_publisher(log), + content_type: None, + on_close: None, + on_abort: None, + }) + } + + #[tokio::test] + async fn has_started_reflects_start_or_chunk_frame() { + let log = frame_log(); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-started".to_string(), + publish_frame: recording_publisher(log), + content_type: None, + on_close: None, + on_abort: None, + }); + + assert!(writer.is_active()); + assert!(!writer.has_started()); + + // Control frames do not start the stream. + writer.ping().await.unwrap(); + writer.pong("nonce".to_string()).await.unwrap(); + assert!(!writer.has_started()); + + writer.write("hello".to_string()).await.unwrap(); + assert!(writer.has_started()); + } + + #[tokio::test] + async fn has_started_after_explicit_start() { + let log = frame_log(); + let writer = writer_with(log.clone()); + assert!(!writer.has_started()); + + writer.start().await.unwrap(); + + assert!(writer.has_started()); + assert_eq!(frame_types(&log), vec!["start"]); + } + + #[tokio::test] + async fn emits_ping_and_pong_with_matching_nonces() { + let log = frame_log(); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-keepalive".to_string(), + publish_frame: recording_publisher(log.clone()), + content_type: None, + on_close: None, + on_abort: None, + }); + + writer.start().await.unwrap(); + writer.ping().await.unwrap(); + writer.pong("keepalive-nonce".to_string()).await.unwrap(); + + let frames = log.lock().unwrap(); + assert_eq!(frames.len(), 3); + assert_eq!(progress_of(&frames[1]), 2); + assert_eq!(cvm(&frames[1])["frameType"], "ping"); + assert_eq!(cvm(&frames[1])["nonce"], "token-keepalive:1"); + assert_eq!(progress_of(&frames[2]), 3); + assert_eq!(cvm(&frames[2])["frameType"], "pong"); + assert_eq!(cvm(&frames[2])["nonce"], "keepalive-nonce"); + } + + #[tokio::test] + async fn close_omits_last_chunk_index_when_no_chunks() { + let log = frame_log(); + let writer = writer_with(log.clone()); + writer.close().await.unwrap(); + + let frames = log.lock().unwrap(); + assert_eq!(frames.len(), 2); + assert_eq!(frame_type(&frames[1]), "close"); + assert!(!cvm(&frames[1]) + .as_object() + .unwrap() + .contains_key("lastChunkIndex")); + } + + #[tokio::test] + async fn close_includes_last_chunk_index_after_chunks() { + let log = frame_log(); + let writer = writer_with(log.clone()); + writer.write("hello".to_string()).await.unwrap(); + writer.write("world".to_string()).await.unwrap(); + writer.close().await.unwrap(); + + let frames = log.lock().unwrap(); + assert_eq!(frames.len(), 4); + assert_eq!(frame_type(&frames[3]), "close"); + assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1); + } + + #[tokio::test] + async fn lifecycle_hooks_fire_after_terminal_frames() { + let lifecycle = Arc::new(StdMutex::new(Vec::::new())); + let log = frame_log(); + + let lc = lifecycle.clone(); + let on_close: OnCloseHook = Arc::new(move || { + let lc = lc.clone(); + Box::pin(async move { + lc.lock().unwrap().push("close".to_string()); + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-hooks".to_string(), + publish_frame: recording_publisher(log.clone()), + content_type: None, + on_close: Some(on_close), + on_abort: None, + }); + writer.close().await.unwrap(); + assert_eq!(frame_type(log.lock().unwrap().last().unwrap()), "close"); + assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]); + + let lc = lifecycle.clone(); + let on_abort: OnAbortHook = Arc::new(move |reason: Option| { + let lc = lc.clone(); + Box::pin(async move { + lc.lock() + .unwrap() + .push(format!("abort:{}", reason.unwrap_or_default())); + }) + }); + let abort_writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-hooks-abort".to_string(), + publish_frame: recording_publisher(log.clone()), + content_type: None, + on_close: None, + on_abort: Some(on_abort), + }); + abort_writer.abort(Some("done".to_string())).await.unwrap(); + let frames = log.lock().unwrap(); + assert_eq!(frame_type(frames.last().unwrap()), "abort"); + assert_eq!(cvm(frames.last().unwrap())["reason"], "done"); + assert_eq!(*lifecycle.lock().unwrap(), vec!["close", "abort:done"]); + } + + #[tokio::test] + async fn publishes_abort_before_running_abort_hook() { + let events = Arc::new(StdMutex::new(Vec::::new())); + + let ev = events.clone(); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + let ev = ev.clone(); + Box::pin(async move { + ev.lock() + .unwrap() + .push(format!("publish:{}", frame_type(&frame))); + Ok(EventId::all_zeros()) + }) + }); + let ev = events.clone(); + let on_abort: OnAbortHook = Arc::new(move |reason: Option| { + let ev = ev.clone(); + Box::pin(async move { + ev.lock() + .unwrap() + .push(format!("abort:{}", reason.unwrap_or_default())); + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-abort-order".to_string(), + publish_frame: publish, + content_type: None, + on_close: None, + on_abort: Some(on_abort), + }); + + writer.abort(Some("ordered".to_string())).await.unwrap(); + assert_eq!( + *events.lock().unwrap(), + vec!["publish:abort", "abort:ordered"] + ); + } + + #[tokio::test] + async fn retries_start_when_first_start_publish_fails() { + let log = frame_log(); + let fail_start = Arc::new(AtomicBool::new(true)); + + let f = log.clone(); + let fs = fail_start.clone(); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + let f = f.clone(); + let fs = fs.clone(); + Box::pin(async move { + if frame_type(&frame) == "start" && fs.swap(false, Ordering::SeqCst) { + return Err(Error::Transport("relay unavailable".to_string())); + } + f.lock().unwrap().push(frame); + Ok(EventId::all_zeros()) + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-start-retry".to_string(), + publish_frame: publish, + content_type: None, + on_close: None, + on_abort: None, + }); + + assert!(writer.start().await.is_err()); + writer.write("hello".to_string()).await.unwrap(); + assert_eq!(frame_types(&log), vec!["start", "chunk"]); + } + + #[tokio::test] + async fn runs_close_cleanup_when_close_publish_fails() { + let lifecycle = Arc::new(StdMutex::new(Vec::::new())); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + Box::pin(async move { + if frame_type(&frame) == "close" { + return Err(Error::Transport("close publish failed".to_string())); + } + Ok(EventId::all_zeros()) + }) + }); + let lc = lifecycle.clone(); + let on_close: OnCloseHook = Arc::new(move || { + let lc = lc.clone(); + Box::pin(async move { + lc.lock().unwrap().push("close".to_string()); + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-close-fail".to_string(), + publish_frame: publish, + content_type: None, + on_close: Some(on_close), + on_abort: None, + }); + + assert!(writer.close().await.is_err()); + assert!(!writer.is_active()); + assert_eq!(*lifecycle.lock().unwrap(), vec!["close"]); + } + + #[tokio::test] + async fn runs_abort_cleanup_when_abort_publish_fails() { + let lifecycle = Arc::new(StdMutex::new(Vec::::new())); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + Box::pin(async move { + if frame_type(&frame) == "abort" { + return Err(Error::Transport("abort publish failed".to_string())); + } + Ok(EventId::all_zeros()) + }) + }); + let lc = lifecycle.clone(); + let on_abort: OnAbortHook = Arc::new(move |reason: Option| { + let lc = lc.clone(); + Box::pin(async move { + lc.lock() + .unwrap() + .push(format!("abort:{}", reason.unwrap_or_default())); + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-abort-fail".to_string(), + publish_frame: publish, + content_type: None, + on_close: None, + on_abort: Some(on_abort), + }); + + assert!(writer.abort(Some("cleanup".to_string())).await.is_err()); + assert!(!writer.is_active()); + assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:cleanup"]); + } + + #[tokio::test] + async fn abort_deactivates_without_waiting_for_a_stuck_write() { + let lifecycle = Arc::new(StdMutex::new(Vec::::new())); + let reached = Arc::new(tokio::sync::Notify::new()); + let gate = Arc::new(tokio::sync::Notify::new()); + + let r = reached.clone(); + let g = gate.clone(); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + let r = r.clone(); + let g = g.clone(); + Box::pin(async move { + if frame_type(&frame) == "chunk" { + // Signal that the write has reached the (stuck) chunk publish, + // then block until released. + r.notify_one(); + g.notified().await; + } + Ok(EventId::all_zeros()) + }) + }); + let lc = lifecycle.clone(); + let on_abort: OnAbortHook = Arc::new(move |reason: Option| { + let lc = lc.clone(); + Box::pin(async move { + lc.lock() + .unwrap() + .push(format!("abort:{}", reason.unwrap_or_default())); + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-stuck".to_string(), + publish_frame: publish, + content_type: None, + on_close: None, + on_abort: Some(on_abort), + }); + + let writer2 = writer.clone(); + let write_handle = tokio::spawn(async move { writer2.write("hello".to_string()).await }); + + // Wait until the write is parked inside the stuck chunk publish. + reached.notified().await; + + // Abort must complete without acquiring the op lock the stuck write holds. + writer + .abort(Some("stuck publish".to_string())) + .await + .unwrap(); + assert!(!writer.is_active()); + assert_eq!(*lifecycle.lock().unwrap(), vec!["abort:stuck publish"]); + + // Release the stuck write so the spawned task can finish cleanly. + gate.notify_one(); + write_handle.await.unwrap().unwrap(); + } + + #[tokio::test] + async fn serializes_concurrent_writes_before_close() { + let log = frame_log(); + let f = log.clone(); + let publish: PublishFrame = Arc::new(move |frame: JsonRpcNotification| { + let f = f.clone(); + Box::pin(async move { + // A small delay on chunks would expose any interleaving if the op + // lock did not serialize build+publish. + if frame_type(&frame) == "chunk" { + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + f.lock().unwrap().push(frame); + Ok(EventId::all_zeros()) + }) + }); + let writer = OpenStreamWriter::new(OpenStreamWriterOptions { + progress_token: "token-concurrent".to_string(), + publish_frame: publish, + content_type: None, + on_close: None, + on_abort: None, + }); + + let (a, b, c) = tokio::join!( + writer.write("hello".to_string()), + writer.write("world".to_string()), + writer.close() + ); + a.unwrap(); + b.unwrap(); + c.unwrap(); + + let frames = log.lock().unwrap(); + let types: Vec = frames.iter().map(frame_type).collect(); + assert_eq!(types, vec!["start", "chunk", "chunk", "close"]); + assert_eq!(progress_of(&frames[1]), 2); + assert_eq!(cvm(&frames[1])["chunkIndex"], 0); + assert_eq!(cvm(&frames[1])["data"], "hello"); + assert_eq!(progress_of(&frames[2]), 3); + assert_eq!(cvm(&frames[2])["chunkIndex"], 1); + assert_eq!(cvm(&frames[2])["data"], "world"); + assert_eq!(progress_of(&frames[3]), 4); + assert_eq!(cvm(&frames[3])["lastChunkIndex"], 1); + } +} diff --git a/src/transport/oversized_transfer/codec.rs b/src/transport/oversized_transfer/codec.rs new file mode 100644 index 0000000..f5b1bf4 --- /dev/null +++ b/src/transport/oversized_transfer/codec.rs @@ -0,0 +1,360 @@ +//! Pure framing codec: digest, UTF-8 char-aware splitting, and frame building. +//! +//! Ports `sdk/src/transport/oversized-transfer/sender.ts`. No transport or I/O — +//! given a serialized JSON-RPC string this produces the ordered sequence of +//! `notifications/progress` frames a sender publishes. + +use sha2::{Digest, Sha256}; + +use crate::core::types::JsonRpcNotification; + +use super::constants::{ACCEPT_PROGRESS, DEFAULT_CHUNK_SIZE, DIGEST_PREFIX, START_PROGRESS}; +use super::errors::OversizedTransferError; +use super::frame::{CompletionMode, OversizedFrame}; + +/// Options controlling how a payload is split into frames. +#[derive(Debug, Clone)] +pub struct OversizedSenderOptions { + /// The MCP `progressToken` stamped on every frame in the transfer. + pub progress_token: String, + /// Per-chunk byte size. Defaults to [`DEFAULT_CHUNK_SIZE`]. + pub chunk_size_bytes: usize, + /// Whether to reserve the `accept` slot (progress 2) for a handshake, so the + /// first `chunk` starts at progress 3. When `false`, chunks start at 2. + pub needs_accept_handshake: bool, +} + +impl OversizedSenderOptions { + /// Create options for `progress_token` with default chunk size and no handshake. + pub fn new(progress_token: impl Into) -> Self { + Self { + progress_token: progress_token.into(), + chunk_size_bytes: DEFAULT_CHUNK_SIZE, + needs_accept_handshake: false, + } + } + + /// Override the per-chunk byte size. + pub fn with_chunk_size(mut self, chunk_size_bytes: usize) -> Self { + self.chunk_size_bytes = chunk_size_bytes; + self + } + + /// Set whether the `accept` handshake slot is reserved. + pub fn with_accept_handshake(mut self, needs_accept_handshake: bool) -> Self { + self.needs_accept_handshake = needs_accept_handshake; + self + } +} + +/// The ordered frames produced from a serialized payload. +#[derive(Debug, Clone)] +pub struct BuiltOversizedFrames { + /// The opening `start` frame (progress [`START_PROGRESS`]). + pub start: JsonRpcNotification, + /// The `chunk` frames in ascending `progress` order. + pub chunks: Vec, + /// The closing `end` frame. + pub end: JsonRpcNotification, +} + +impl BuiltOversizedFrames { + /// Total number of frames (`start` + chunks + `end`). + pub fn frame_count(&self) -> usize { + self.chunks.len() + 2 + } + + /// Consume into a flat vector in canonical send order: `start`, chunks…, `end`. + pub fn into_ordered(self) -> Vec { + let mut frames = Vec::with_capacity(self.frame_count()); + frames.push(self.start); + frames.extend(self.chunks); + frames.push(self.end); + frames + } +} + +/// UTF-8 byte length of a string (Rust strings are already UTF-8). +pub fn utf8_byte_len(value: &str) -> usize { + value.len() +} + +/// Compute the CEP-22 digest of `value`: `"sha256:"` + lowercase hex of the +/// SHA-256 of its UTF-8 bytes. +pub fn sha256_digest(value: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(value.as_bytes()); + format!("{DIGEST_PREFIX}{}", hex::encode(hasher.finalize())) +} + +/// Split `value` into fragments each at most `max_bytes` UTF-8 bytes, never +/// breaking a multibyte codepoint across a boundary. +/// +/// Concatenating the fragments in order reproduces `value` exactly. Returns an +/// error if `max_bytes` is zero or a single character is larger than `max_bytes`. +pub fn split_string_by_byte_size( + value: &str, + max_bytes: usize, +) -> Result, OversizedTransferError> { + if max_bytes == 0 { + return Err(OversizedTransferError::Policy( + "chunk_size_bytes must be greater than zero".to_string(), + )); + } + + let mut chunks: Vec = Vec::new(); + let mut current = String::new(); + let mut current_bytes = 0usize; + + for ch in value.chars() { + let char_bytes = ch.len_utf8(); + if char_bytes > max_bytes { + return Err(OversizedTransferError::Policy(format!( + "Unable to split message: single character exceeds chunk size ({char_bytes} > {max_bytes})" + ))); + } + + if current_bytes > 0 && current_bytes + char_bytes > max_bytes { + chunks.push(std::mem::take(&mut current)); + current_bytes = 0; + } + + current.push(ch); + current_bytes += char_bytes; + } + + if !current.is_empty() { + chunks.push(current); + } + + Ok(chunks) +} + +/// Build the ordered `start` / `chunk…` / `end` frames for `serialized`. +/// +/// Serializes once at the call site (the caller passes the exact JSON-RPC +/// string), then: computes the digest over that string, char-aware splits it, +/// and lays the chunks out on `progress` slots. When +/// [`needs_accept_handshake`](OversizedSenderOptions::needs_accept_handshake) is +/// set, progress 2 is reserved for the receiver's `accept` and chunks begin at 3. +pub fn build_oversized_frames( + serialized: &str, + options: &OversizedSenderOptions, +) -> crate::core::error::Result { + let total_bytes = utf8_byte_len(serialized) as u64; + let digest = sha256_digest(serialized); + + let text_chunks = split_string_by_byte_size(serialized, options.chunk_size_bytes)?; + let total_chunks = text_chunks.len() as u64; + + // No-handshake: chunks reuse the reserved accept slot (progress 2). + // Handshake: accept occupies slot 2, so chunks begin at slot 3. + let chunk_base_progress = if options.needs_accept_handshake { + ACCEPT_PROGRESS + 1 + } else { + ACCEPT_PROGRESS + }; + + let token = options.progress_token.as_str(); + + let start = OversizedFrame::Start { + completion_mode: CompletionMode::Render, + digest, + total_bytes, + total_chunks, + } + .into_progress_notification(token, START_PROGRESS, Some("starting oversized transfer"))?; + + let mut chunks = Vec::with_capacity(text_chunks.len()); + for (i, data) in text_chunks.into_iter().enumerate() { + let progress = chunk_base_progress + i as u64; + let frame = OversizedFrame::Chunk { data }; + chunks.push(frame.into_progress_notification(token, progress, None)?); + } + + let end = OversizedFrame::End.into_progress_notification( + token, + chunk_base_progress + total_chunks, + Some("oversized transfer complete"), + )?; + + Ok(BuiltOversizedFrames { start, chunks, end }) +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::Value; + + fn frame_type(notification: &JsonRpcNotification) -> String { + notification.params.as_ref().unwrap()["cvm"]["frameType"] + .as_str() + .unwrap() + .to_string() + } + + fn progress(notification: &JsonRpcNotification) -> u64 { + notification.params.as_ref().unwrap()["progress"] + .as_u64() + .unwrap() + } + + // ── digest ────────────────────────────────────────────────────── + + #[test] + fn sha256_digest_known_vectors() { + // Reference SHA-256 vectors with the CEP-22 "sha256:" prefix. + assert_eq!( + sha256_digest(""), + "sha256:e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855" + ); + assert_eq!( + sha256_digest("abc"), + "sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" + ); + } + + #[test] + fn sha256_digest_has_prefix_and_lowercase_hex() { + let digest = sha256_digest("hello world"); + assert!(digest.starts_with("sha256:")); + let hex_part = &digest["sha256:".len()..]; + assert_eq!(hex_part.len(), 64); + assert!(hex_part + .chars() + .all(|c| c.is_ascii_hexdigit() && !c.is_ascii_uppercase())); + } + + #[test] + fn utf8_byte_len_counts_bytes_not_chars() { + assert_eq!(utf8_byte_len("abc"), 3); + assert_eq!(utf8_byte_len("é"), 2); + assert_eq!(utf8_byte_len("🦀"), 4); + assert_eq!(utf8_byte_len("a🦀"), 5); + } + + // ── split_string_by_byte_size ─────────────────────────────────── + + #[test] + fn split_ascii_exact_and_remainder() { + let chunks = split_string_by_byte_size("abcdefghij", 4).unwrap(); + assert_eq!(chunks, vec!["abcd", "efgh", "ij"]); + assert_eq!(chunks.concat(), "abcdefghij"); + } + + #[test] + fn split_empty_yields_no_chunks() { + assert!(split_string_by_byte_size("", 4).unwrap().is_empty()); + } + + #[test] + fn split_rejects_zero_max() { + let err = split_string_by_byte_size("abc", 0).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + } + + #[test] + fn split_multibyte_never_breaks_codepoints() { + // Mixed 1/2/3/4-byte codepoints; chunk size 5 forces boundaries mid-string. + let original = "aé🦀b日x☃yz🦀"; + let chunks = split_string_by_byte_size(original, 5).unwrap(); + + // Concatenation in order reproduces the exact bytes. + assert_eq!(chunks.concat(), original); + // No chunk exceeds the byte budget, and every chunk is valid UTF-8 + // (guaranteed by `String`, i.e. no codepoint was split). + for chunk in &chunks { + assert!(utf8_byte_len(chunk) <= 5, "chunk {chunk:?} exceeds 5 bytes"); + assert!(!chunk.is_empty()); + } + } + + #[test] + fn split_packs_multibyte_up_to_budget() { + // Each 🦀 is 4 bytes; with a budget of 8 two fit per chunk. + assert_eq!( + split_string_by_byte_size("🦀🦀🦀", 8).unwrap(), + vec!["🦀🦀", "🦀"] + ); + } + + #[test] + fn split_rejects_single_char_larger_than_budget() { + // 🦀 is 4 bytes; a 3-byte budget cannot hold it. + let err = split_string_by_byte_size("🦀", 3).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + } + + // ── build_oversized_frames ────────────────────────────────────── + + #[test] + fn build_lays_out_progress_slots_without_handshake() { + let opts = OversizedSenderOptions::new("tok").with_chunk_size(4); + let frames = build_oversized_frames("hello world", &opts).unwrap(); + + // "hello world" (11 bytes) / 4 → 3 chunks. + assert_eq!(frames.chunks.len(), 3); + assert_eq!(frames.frame_count(), 5); + + assert_eq!(progress(&frames.start), 1); + assert_eq!(frame_type(&frames.start), "start"); + // No handshake: chunks reuse the reserved accept slot (start at 2). + assert_eq!(progress(&frames.chunks[0]), 2); + assert_eq!(progress(&frames.chunks[1]), 3); + assert_eq!(progress(&frames.chunks[2]), 4); + assert_eq!(frame_type(&frames.chunks[0]), "chunk"); + assert_eq!(progress(&frames.end), 5); + assert_eq!(frame_type(&frames.end), "end"); + } + + #[test] + fn build_reserves_accept_slot_with_handshake() { + let opts = OversizedSenderOptions::new("tok") + .with_chunk_size(4) + .with_accept_handshake(true); + let frames = build_oversized_frames("hello world", &opts).unwrap(); + + assert_eq!(progress(&frames.start), 1); + // Handshake: slot 2 reserved for accept, chunks begin at 3. + assert_eq!(progress(&frames.chunks[0]), 3); + assert_eq!(progress(&frames.chunks[2]), 5); + assert_eq!(progress(&frames.end), 6); + } + + #[test] + fn build_start_frame_declares_digest_bytes_chunks() { + let opts = OversizedSenderOptions::new("tok").with_chunk_size(4); + let frames = build_oversized_frames("hello world", &opts).unwrap(); + let cvm = &frames.start.params.as_ref().unwrap()["cvm"]; + + assert_eq!(cvm["type"].as_str(), Some("oversized-transfer")); + assert_eq!(cvm["completionMode"], Value::String("render".to_string())); + assert_eq!(cvm["digest"], Value::String(sha256_digest("hello world"))); + assert_eq!(cvm["totalBytes"].as_u64(), Some(11)); + assert_eq!(cvm["totalChunks"].as_u64(), Some(3)); + } + + #[test] + fn build_propagates_single_char_over_budget_error() { + let opts = OversizedSenderOptions::new("tok").with_chunk_size(3); + let err = build_oversized_frames("🦀", &opts).unwrap_err(); + // Surfaced through the crate error as an oversized-transfer policy error. + assert!(matches!( + err, + crate::core::error::Error::OversizedTransfer(OversizedTransferError::Policy(_)) + )); + } + + #[test] + fn build_into_ordered_is_start_chunks_end() { + let opts = OversizedSenderOptions::new("tok").with_chunk_size(4); + let frames = build_oversized_frames("hello world", &opts).unwrap(); + let ordered = frames.into_ordered(); + assert_eq!(ordered.len(), 5); + assert_eq!(frame_type(&ordered[0]), "start"); + assert_eq!(frame_type(&ordered[4]), "end"); + // Progress is strictly increasing across the whole sequence. + let progresses: Vec = ordered.iter().map(progress).collect(); + assert_eq!(progresses, vec![1, 2, 3, 4, 5]); + } +} diff --git a/src/transport/oversized_transfer/constants.rs b/src/transport/oversized_transfer/constants.rs new file mode 100644 index 0000000..66f6764 --- /dev/null +++ b/src/transport/oversized_transfer/constants.rs @@ -0,0 +1,61 @@ +//! CEP-22 oversized-transfer constants. +//! +//! The normative CEP leaves these numeric defaults to implementers, so we adopt +//! the TypeScript SDK's values (`sdk/src/transport/oversized-transfer/constants.ts`) +//! for cross-implementation interop. + +/// The `cvm.type` discriminator carried in every oversized-transfer frame. +pub const OVERSIZED_TRANSFER_TYPE: &str = "oversized-transfer"; + +/// Prefix applied to SHA-256 digest values (lowercase hex follows). +pub const DIGEST_PREFIX: &str = "sha256:"; + +/// JSON-RPC method that carries oversized-transfer frames in `params.cvm`. +pub const NOTIFICATIONS_PROGRESS_METHOD: &str = "notifications/progress"; + +/// Default per-chunk data size (bytes). +/// +/// Conservative: leaves ~16 KiB of headroom under the ~64 KiB relay event +/// threshold so a single gift-wrapped frame stays well below it. +pub const DEFAULT_CHUNK_SIZE: usize = 48_000; + +/// Serialized byte length at or above which the sender switches to oversized transfer. +pub const DEFAULT_OVERSIZED_THRESHOLD: usize = 48_000; + +/// Default upper bound on the total reassembled payload a receiver will accept (100 MiB). +pub const DEFAULT_MAX_TRANSFER_BYTES: u64 = 100 * 1024 * 1024; + +/// Default upper bound on the number of chunks a receiver will accept. +pub const DEFAULT_MAX_TRANSFER_CHUNKS: u64 = 10_000; + +/// Default upper bound on concurrently active receiver-side transfers. +pub const DEFAULT_MAX_CONCURRENT_TRANSFERS: usize = 64; + +/// Default hard timeout for an in-flight transfer (milliseconds). +/// +/// Measured from `start` admission, never refreshed by chunk activity, and +/// enforced by sweeping +/// [`OversizedTransferReceiver::remove_expired`](super::OversizedTransferReceiver::remove_expired). +pub const DEFAULT_TRANSFER_TIMEOUT_MS: u64 = 5 * 60 * 1000; + +/// Default maximum forward gap between the next expected chunk and an +/// out-of-order chunk that will still be buffered. +pub const DEFAULT_MAX_OUT_OF_ORDER_WINDOW: u64 = 21; + +/// Default maximum number of buffered out-of-order chunks. +pub const DEFAULT_MAX_OUT_OF_ORDER_CHUNKS: usize = 42; + +/// Default timeout a sender waits for an `accept` frame before giving up (milliseconds). +/// +/// Used by the sender handshake once transport integration lands; defined here for parity. +pub const DEFAULT_ACCEPT_TIMEOUT_MS: u64 = 30_000; + +/// Canonical progress slot for the `start` frame. +pub const START_PROGRESS: u64 = 1; + +/// Progress slot reserved for the `accept` frame. +/// +/// In the handshake case the `accept` frame occupies this slot and the first +/// `chunk` starts at [`START_PROGRESS`] + 2. In the no-handshake case no +/// `accept` is sent, so this slot is reused as the first `chunk`. +pub const ACCEPT_PROGRESS: u64 = 2; diff --git a/src/transport/oversized_transfer/errors.rs b/src/transport/oversized_transfer/errors.rs new file mode 100644 index 0000000..baf9a23 --- /dev/null +++ b/src/transport/oversized_transfer/errors.rs @@ -0,0 +1,54 @@ +//! Error taxonomy for CEP-22 oversized transfers. +//! +//! Mirrors the TypeScript SDK's error classes +//! (`sdk/src/transport/oversized-transfer/errors.ts`) so failure classification +//! is identical across implementations. Surfaced through the crate-level +//! [`crate::Error`] via a `#[from]` conversion. + +/// Errors raised while building, framing, or reassembling an oversized transfer. +#[derive(Debug, Clone, PartialEq, Eq, thiserror::Error)] +pub enum OversizedTransferError { + /// The remote peer aborted the transfer (terminal). + #[error("Transfer aborted (token: {token}){}", .reason.as_deref().map(|r| format!(": {r}")).unwrap_or_default())] + Abort { + /// The `progressToken` of the aborted transfer. + token: String, + /// Optional advisory reason supplied in the `abort` frame. + reason: Option, + }, + + /// A frame violated a declared or configured policy limit + /// (`totalBytes`, `totalChunks`, concurrency, out-of-order bounds, chunk size). + #[error("Oversized transfer policy violation: {0}")] + Policy(String), + + /// The byte length or SHA-256 digest of the reassembled payload did not match + /// the values declared in the `start` frame. + #[error("Oversized transfer integrity error: {0}")] + Digest(String), + + /// Chunks could not be reassembled (missing frames, unresolved gaps, or the + /// reassembled payload is not a valid JSON-RPC message). + #[error("Oversized transfer reassembly error: {0}")] + Reassembly(String), + + /// Frames violated CEP-22 sequencing rules (bad progress ordering, + /// duplicate `start`, conflicting duplicate chunk, missing token). + #[error("Oversized transfer sequence error: {0}")] + Sequence(String), +} + +impl OversizedTransferError { + /// Construct an [`OversizedTransferError::Abort`]. + pub fn abort(token: impl Into, reason: Option) -> Self { + Self::Abort { + token: token.into(), + reason, + } + } + + /// Returns `true` if this is a terminal [`abort`](OversizedTransferError::Abort). + pub fn is_abort(&self) -> bool { + matches!(self, Self::Abort { .. }) + } +} diff --git a/src/transport/oversized_transfer/frame.rs b/src/transport/oversized_transfer/frame.rs new file mode 100644 index 0000000..452143e --- /dev/null +++ b/src/transport/oversized_transfer/frame.rs @@ -0,0 +1,287 @@ +//! CEP-22 `cvm` frame types and their `notifications/progress` envelope. +//! +//! A frame is the ContextVM `cvm` extension object embedded in the `params` of +//! an MCP `notifications/progress` notification. The outer `params` also carry +//! the `progressToken` and a strictly-monotonic `progress` value (the canonical +//! reassembly index). See `frame` shapes in +//! `sdk/src/transport/oversized-transfer/types.ts`. + +use serde::{Deserialize, Serialize}; +use serde_json::{Map, Value}; + +use crate::core::types::JsonRpcNotification; + +use super::constants::{NOTIFICATIONS_PROGRESS_METHOD, OVERSIZED_TRANSFER_TYPE}; + +/// Completion mode of a `start` frame. CEP-22 v1 mandates `render`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)] +#[serde(rename_all = "lowercase")] +pub enum CompletionMode { + /// The only completion mode defined in v1: the receiver renders the + /// reassembled payload as a single MCP message. + #[default] + Render, +} + +/// A CEP-22 oversized-transfer frame (the `cvm` object). +/// +/// Internally tagged on `frameType`. The constant `cvm.type` +/// (`"oversized-transfer"`) is handled separately by +/// [`to_cvm_value`](Self::to_cvm_value) / [`from_cvm_value`](Self::from_cvm_value) +/// rather than encoded as an enum field. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(tag = "frameType", rename_all = "camelCase")] +pub enum OversizedFrame { + /// Opens a transfer: declares the digest, total byte length, and chunk count. + #[serde(rename_all = "camelCase")] + Start { + /// Completion mode (MUST be [`CompletionMode::Render`] in v1). + completion_mode: CompletionMode, + /// `"sha256:"` + lowercase hex of the SHA-256 of the serialized payload. + digest: String, + /// Total UTF-8 byte length of the serialized JSON-RPC payload. + total_bytes: u64, + /// Number of `chunk` frames that follow. + total_chunks: u64, + }, + /// Receiver handshake acknowledgement; lets the sender begin sending chunks. + Accept, + /// One ordered fragment of the serialized JSON-RPC string. + Chunk { + /// A UTF-8 substring fragment; concatenating chunks in `progress` order + /// reproduces the exact serialized payload bytes. + data: String, + }, + /// Closes a transfer; the receiver validates and materializes the payload. + End, + /// Terminates a transfer early (terminal). + Abort { + /// Optional advisory reason. + #[serde(default, skip_serializing_if = "Option::is_none")] + reason: Option, + }, +} + +impl OversizedFrame { + /// Returns the `frameType` discriminator string for this frame. + pub fn frame_type(&self) -> &'static str { + match self { + Self::Start { .. } => "start", + Self::Accept => "accept", + Self::Chunk { .. } => "chunk", + Self::End => "end", + Self::Abort { .. } => "abort", + } + } + + /// Serialize this frame into a `cvm` object [`Value`], injecting the constant + /// `type` discriminator (`"oversized-transfer"`). + pub fn to_cvm_value(&self) -> Result { + let mut value = serde_json::to_value(self)?; + if let Value::Object(map) = &mut value { + map.insert( + "type".to_string(), + Value::String(OVERSIZED_TRANSFER_TYPE.to_string()), + ); + } + Ok(value) + } + + /// Parse a `cvm` object [`Value`] into a typed frame, verifying the `type` + /// discriminator. Returns `None` when `value` is not an oversized-transfer + /// frame or fails to parse. + pub fn from_cvm_value(value: &Value) -> Option { + if value.get("type").and_then(Value::as_str) != Some(OVERSIZED_TRANSFER_TYPE) { + return None; + } + serde_json::from_value(value.clone()).ok() + } + + /// Returns `true` when `value` structurally looks like an oversized-transfer + /// frame (`type == "oversized-transfer"` and a string `frameType`). + /// + /// Mirrors the TS `isOversizedTransferFrame` structural narrowing. + pub fn is_frame_value(value: &Value) -> bool { + value.get("type").and_then(Value::as_str) == Some(OVERSIZED_TRANSFER_TYPE) + && value.get("frameType").and_then(Value::as_str).is_some() + } + + /// Wrap this frame in a `notifications/progress` [`JsonRpcNotification`]. + /// + /// Builds the outer `params` with `progressToken`, `progress`, an optional + /// human-readable `message` (non-normative UX), and the `cvm` frame. The + /// token is always emitted as a JSON **string**, even when the originating + /// request carried a numeric one (matching the TS SDK's + /// `String(progressToken)`); see [`progress_token_string`]. + pub fn into_progress_notification( + &self, + progress_token: &str, + progress: u64, + message: Option<&str>, + ) -> Result { + let mut params = Map::new(); + params.insert( + "progressToken".to_string(), + Value::String(progress_token.to_string()), + ); + params.insert("progress".to_string(), Value::Number(progress.into())); + if let Some(message) = message { + params.insert("message".to_string(), Value::String(message.to_string())); + } + params.insert("cvm".to_string(), self.to_cvm_value()?); + + Ok(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: NOTIFICATIONS_PROGRESS_METHOD.to_string(), + params: Some(Value::Object(params)), + }) + } +} + +/// Coerce a `progressToken` JSON value to its canonical string form. +/// +/// MCP progress tokens may be JSON strings **or numbers** (rmcp stamps a +/// numeric token into every outgoing request), so token extraction must accept +/// both; all transport-internal keying (correlation routes, accept waiters, +/// reassembly state, frame addressing) uses the stringified form. The wire +/// format is unchanged — frames always carry a string token +/// ([`OversizedFrame::into_progress_notification`]), exactly like the TS SDK's +/// `String(progressToken)`. Returns `None` for any other JSON type. +pub fn progress_token_string(value: &Value) -> Option { + match value { + Value::String(s) => Some(s.clone()), + Value::Number(n) => Some(n.to_string()), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn assert_cvm_roundtrip(frame: OversizedFrame) { + let value = frame.to_cvm_value().unwrap(); + assert_eq!(value["type"], json!("oversized-transfer")); + assert_eq!(value["frameType"], json!(frame.frame_type())); + assert_eq!(OversizedFrame::from_cvm_value(&value), Some(frame)); + } + + #[test] + fn start_frame_serializes_with_camel_case_fields() { + let frame = OversizedFrame::Start { + completion_mode: CompletionMode::Render, + digest: "sha256:abcd".to_string(), + total_bytes: 100, + total_chunks: 3, + }; + let value = frame.to_cvm_value().unwrap(); + assert_eq!(value["type"], json!("oversized-transfer")); + assert_eq!(value["frameType"], json!("start")); + assert_eq!(value["completionMode"], json!("render")); + assert_eq!(value["digest"], json!("sha256:abcd")); + assert_eq!(value["totalBytes"], json!(100)); + assert_eq!(value["totalChunks"], json!(3)); + assert_cvm_roundtrip(frame); + } + + #[test] + fn all_frame_variants_roundtrip() { + assert_cvm_roundtrip(OversizedFrame::Start { + completion_mode: CompletionMode::Render, + digest: "sha256:abcd".to_string(), + total_bytes: 8, + total_chunks: 2, + }); + assert_cvm_roundtrip(OversizedFrame::Accept); + assert_cvm_roundtrip(OversizedFrame::Chunk { + data: "payload".to_string(), + }); + assert_cvm_roundtrip(OversizedFrame::End); + assert_cvm_roundtrip(OversizedFrame::Abort { + reason: Some("boom".to_string()), + }); + assert_cvm_roundtrip(OversizedFrame::Abort { reason: None }); + } + + #[test] + fn abort_reason_omitted_when_none() { + let value = OversizedFrame::Abort { reason: None } + .to_cvm_value() + .unwrap(); + assert!(!value.as_object().unwrap().contains_key("reason")); + } + + #[test] + fn from_cvm_value_rejects_wrong_type() { + let value = json!({ "type": "something-else", "frameType": "end" }); + assert_eq!(OversizedFrame::from_cvm_value(&value), None); + assert!(!OversizedFrame::is_frame_value(&value)); + } + + #[test] + fn from_cvm_value_rejects_unknown_frame_type() { + let value = json!({ "type": "oversized-transfer", "frameType": "bogus" }); + assert_eq!(OversizedFrame::from_cvm_value(&value), None); + } + + #[test] + fn is_frame_value_requires_type_and_frame_type() { + assert!(OversizedFrame::is_frame_value( + &json!({ "type": "oversized-transfer", "frameType": "chunk", "data": "x" }) + )); + assert!(!OversizedFrame::is_frame_value( + &json!({ "type": "oversized-transfer" }) + )); + assert!(!OversizedFrame::is_frame_value( + &json!({ "frameType": "chunk" }) + )); + assert!(!OversizedFrame::is_frame_value(&json!("not an object"))); + } + + #[test] + fn into_progress_notification_builds_progress_envelope() { + let notification = OversizedFrame::Chunk { + data: "frag".to_string(), + } + .into_progress_notification("tok-1", 7, Some("hi")) + .unwrap(); + + assert_eq!(notification.method, "notifications/progress"); + let params = notification.params.as_ref().unwrap(); + assert_eq!(params["progressToken"], json!("tok-1")); + assert_eq!(params["progress"], json!(7)); + assert_eq!(params["message"], json!("hi")); + assert_eq!(params["cvm"]["frameType"], json!("chunk")); + assert_eq!(params["cvm"]["data"], json!("frag")); + } + + #[test] + fn into_progress_notification_omits_absent_message() { + let notification = OversizedFrame::End + .into_progress_notification("tok-1", 9, None) + .unwrap(); + let params = notification.params.as_ref().unwrap(); + assert!(!params.as_object().unwrap().contains_key("message")); + } + + #[test] + fn progress_token_string_accepts_string_and_number() { + assert_eq!( + progress_token_string(&json!("tok-1")), + Some("tok-1".to_string()) + ); + assert_eq!(progress_token_string(&json!(7)), Some("7".to_string())); + assert_eq!(progress_token_string(&json!(0)), Some("0".to_string())); + assert_eq!(progress_token_string(&json!(-3)), Some("-3".to_string())); + assert_eq!(progress_token_string(&json!(7.5)), Some("7.5".to_string())); + } + + #[test] + fn progress_token_string_rejects_other_types() { + assert_eq!(progress_token_string(&json!(null)), None); + assert_eq!(progress_token_string(&json!(true)), None); + assert_eq!(progress_token_string(&json!({ "t": 1 })), None); + assert_eq!(progress_token_string(&json!([1])), None); + } +} diff --git a/src/transport/oversized_transfer/mod.rs b/src/transport/oversized_transfer/mod.rs new file mode 100644 index 0000000..828ab12 --- /dev/null +++ b/src/transport/oversized_transfer/mod.rs @@ -0,0 +1,217 @@ +//! CEP-22 oversized payload transfer — transport-agnostic framing engine. +//! +//! A serialized JSON-RPC message too large to publish as a single relay event is +//! split into an ordered sequence of frames carried inside MCP +//! `notifications/progress` messages, transmitted as ordinary kind-`25910` +//! events, and reassembled by the receiver after SHA-256 + size validation. +//! See the CEP-22 spec and the TypeScript reference at +//! `sdk/src/transport/oversized-transfer/`. +//! +//! This module is the **pure engine**: building frames ([`codec`]) and +//! reassembling them ([`receiver`]). It carries no transport, I/O, or live +//! timers — the client and server transports drive it. The hard per-transfer +//! watchdog (`transfer_timeout_ms`) is tracked from `start` admission and +//! reaped via [`OversizedTransferReceiver::remove_expired`] when the owning +//! transport sweeps. +//! +//! ``` +//! use contextvm_sdk::transport::oversized_transfer::{ +//! build_oversized_frames, OversizedSenderOptions, OversizedTransferReceiver, +//! }; +//! use contextvm_sdk::core::types::{JsonRpcMessage, JsonRpcResponse}; +//! use serde_json::json; +//! +//! let message = JsonRpcMessage::Response(JsonRpcResponse { +//! jsonrpc: "2.0".to_string(), +//! id: json!(1), +//! result: json!({ "value": "a large payload" }), +//! }); +//! let serialized = serde_json::to_string(&message).unwrap(); +//! +//! // Sender: split into ordered frames. +//! let opts = OversizedSenderOptions::new("token-1").with_chunk_size(8); +//! let frames = build_oversized_frames(&serialized, &opts).unwrap(); +//! +//! // Receiver: feed frames back; the last frame yields the reassembled message. +//! let mut receiver = OversizedTransferReceiver::new(); +//! let mut reassembled = None; +//! for frame in frames.into_ordered() { +//! if let Some(message) = receiver.process_frame(&frame).unwrap() { +//! reassembled = Some(message); +//! } +//! } +//! assert_eq!(reassembled.unwrap().id(), message.id()); +//! ``` + +pub mod codec; +pub mod constants; +pub mod errors; +pub mod frame; +pub mod receiver; +pub mod sender; +pub mod sizing; + +pub use codec::{ + build_oversized_frames, sha256_digest, split_string_by_byte_size, utf8_byte_len, + BuiltOversizedFrames, OversizedSenderOptions, +}; +pub use constants::*; +pub use errors::OversizedTransferError; +pub use frame::{progress_token_string, CompletionMode, OversizedFrame}; +pub use receiver::{OversizedTransferReceiver, TransferPolicy}; +pub use sender::send_oversized_transfer; +pub use sizing::{measure_published_event_size, resolve_safe_chunk_size}; + +/// CEP-22 oversized-transfer configuration shared by both transports. +/// +/// Bundles the capability gate plus the sender/receiver tuning knobs so the +/// nine numeric defaults don't clutter the flat transport configs. Attached to +/// [`NostrServerTransportConfig`](crate::transport::NostrServerTransportConfig) +/// and [`NostrClientTransportConfig`](crate::transport::NostrClientTransportConfig) +/// via their `with_oversized_transfer` / `with_oversized_enabled` builders. +/// +/// **Enabled by default** (TS parity) — opt out with +/// [`with_enabled(false)`](Self::with_enabled) or the transports' +/// `with_oversized_enabled(false)` builders. The negotiation gates make the +/// default safe for non-oversized peers: the server activates only for +/// clients that advertise support, and the client fragments only requests +/// carrying a `progressToken` — a disabled peer just sees one extra +/// `support_oversized_transfer` tag. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct OversizedTransferConfig { + /// Master gate, `true` by default. When `false` the capability is neither + /// advertised nor activated, and the server does not learn a client's flag. + pub enabled: bool, + /// Serialized byte length at or above which the sender switches to oversized transfer. + pub threshold: usize, + /// Per-chunk data size (bytes). + pub chunk_size: usize, + /// Upper bound on the total reassembled payload a receiver will accept (bytes). + pub max_transfer_bytes: u64, + /// Upper bound on the number of chunks a receiver will accept. + pub max_transfer_chunks: u64, + /// Upper bound on concurrently active receiver-side transfers. + pub max_concurrent_transfers: usize, + /// Hard timeout for an in-flight transfer (milliseconds), measured from + /// admission. `0` disables the receiver-side watchdog. + pub transfer_timeout_ms: u64, + /// Maximum forward gap between the next expected chunk and an out-of-order + /// chunk that will still be buffered. + pub max_out_of_order_window: u64, + /// Maximum number of buffered out-of-order chunks. + pub max_out_of_order_chunks: usize, + /// Timeout a sender waits for an `accept` frame before giving up (milliseconds). + /// + /// Used by the **client** transport only. The client is the sole party that + /// sends a `start` frame with an accept handshake and then waits for the + /// `accept`. On the **server** transport this field is inert: a server is + /// always the *receiver* of the handshake — it emits the `accept`, it never + /// waits for one — so its value is never read server-side. + pub accept_timeout_ms: u64, +} + +impl Default for OversizedTransferConfig { + fn default() -> Self { + Self { + enabled: true, + threshold: DEFAULT_OVERSIZED_THRESHOLD, + chunk_size: DEFAULT_CHUNK_SIZE, + max_transfer_bytes: DEFAULT_MAX_TRANSFER_BYTES, + max_transfer_chunks: DEFAULT_MAX_TRANSFER_CHUNKS, + max_concurrent_transfers: DEFAULT_MAX_CONCURRENT_TRANSFERS, + transfer_timeout_ms: DEFAULT_TRANSFER_TIMEOUT_MS, + max_out_of_order_window: DEFAULT_MAX_OUT_OF_ORDER_WINDOW, + max_out_of_order_chunks: DEFAULT_MAX_OUT_OF_ORDER_CHUNKS, + accept_timeout_ms: DEFAULT_ACCEPT_TIMEOUT_MS, + } + } +} + +impl OversizedTransferConfig { + /// An explicitly enabled config with all other knobs at their defaults. + /// + /// Redundant since `enabled` defaulted to `true` (kept for API + /// stability); equivalent to [`OversizedTransferConfig::default`]. + pub fn enabled() -> Self { + Self { + enabled: true, + ..Self::default() + } + } + + /// Enable or disable oversized transfer. + pub fn with_enabled(mut self, enabled: bool) -> Self { + self.enabled = enabled; + self + } + + /// Set the serialized-byte threshold at which the sender fragments. + pub fn with_threshold(mut self, threshold: usize) -> Self { + self.threshold = threshold; + self + } + + /// Set the per-chunk data size (bytes). + pub fn with_chunk_size(mut self, chunk_size: usize) -> Self { + self.chunk_size = chunk_size; + self + } + + /// Set the upper bound on the total reassembled payload (bytes). + pub fn with_max_transfer_bytes(mut self, max: u64) -> Self { + self.max_transfer_bytes = max; + self + } + + /// Set the upper bound on the number of chunks a receiver will accept. + pub fn with_max_transfer_chunks(mut self, max: u64) -> Self { + self.max_transfer_chunks = max; + self + } + + /// Set the upper bound on concurrently active receiver-side transfers. + pub fn with_max_concurrent_transfers(mut self, max: usize) -> Self { + self.max_concurrent_transfers = max; + self + } + + /// Set the hard per-transfer timeout (milliseconds). + pub fn with_transfer_timeout_ms(mut self, ms: u64) -> Self { + self.transfer_timeout_ms = ms; + self + } + + /// Set the maximum forward gap for buffering out-of-order chunks. + pub fn with_max_out_of_order_window(mut self, window: u64) -> Self { + self.max_out_of_order_window = window; + self + } + + /// Set the maximum number of buffered out-of-order chunks. + pub fn with_max_out_of_order_chunks(mut self, max: usize) -> Self { + self.max_out_of_order_chunks = max; + self + } + + /// Set the sender's `accept`-frame wait timeout (milliseconds). + pub fn with_accept_timeout_ms(mut self, ms: u64) -> Self { + self.accept_timeout_ms = ms; + self + } +} + +impl From<&OversizedTransferConfig> for TransferPolicy { + /// Project the receiver-relevant knobs of an [`OversizedTransferConfig`] into + /// a [`TransferPolicy`] (the receiver admission policy). + fn from(config: &OversizedTransferConfig) -> Self { + TransferPolicy { + max_transfer_bytes: config.max_transfer_bytes, + max_transfer_chunks: config.max_transfer_chunks, + max_concurrent_transfers: config.max_concurrent_transfers, + max_out_of_order_window: config.max_out_of_order_window, + max_out_of_order_chunks: config.max_out_of_order_chunks, + transfer_timeout_ms: config.transfer_timeout_ms, + } + } +} diff --git a/src/transport/oversized_transfer/receiver.rs b/src/transport/oversized_transfer/receiver.rs new file mode 100644 index 0000000..8f8811f --- /dev/null +++ b/src/transport/oversized_transfer/receiver.rs @@ -0,0 +1,1296 @@ +//! Stateful reassembly engine for CEP-22 oversized transfers. +//! +//! Ports `sdk/src/transport/oversized-transfer/receiver.ts`. Feeds inbound +//! `notifications/progress` frames through [`OversizedTransferReceiver::process_frame`] +//! and returns the reassembled [`JsonRpcMessage`] once a transfer completes and +//! passes byte-length, SHA-256, and JSON-RPC validation. +//! +//! This engine is **pure and synchronous**: it owns no live timers. The hard +//! per-transfer watchdog deadline (`transfer_timeout_ms`) is measured from +//! `start` admission and enforced by the owning transport calling +//! [`OversizedTransferReceiver::remove_expired`] on its sweep tick; the +//! sender-side accept-waiter lives in the transport. + +use std::collections::{BTreeMap, HashMap}; +use std::time::{Duration, Instant}; + +use serde_json::Value; + +use crate::core::types::{JsonRpcMessage, JsonRpcNotification}; +use crate::core::validation::validate_and_parse_oversized; + +use super::codec::sha256_digest; +use super::constants::{ + DEFAULT_MAX_CONCURRENT_TRANSFERS, DEFAULT_MAX_OUT_OF_ORDER_CHUNKS, + DEFAULT_MAX_OUT_OF_ORDER_WINDOW, DEFAULT_MAX_TRANSFER_BYTES, DEFAULT_MAX_TRANSFER_CHUNKS, + DEFAULT_TRANSFER_TIMEOUT_MS, DIGEST_PREFIX, +}; +use super::errors::OversizedTransferError; +use super::frame::{progress_token_string, OversizedFrame}; + +/// Receiver-side admission and out-of-order policy. +/// +/// Mirrors the TS `TransferPolicy`. Construct via [`TransferPolicy::default`] +/// and override individual fields with struct-update syntax. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct TransferPolicy { + /// Maximum total reassembled payload size accepted (bytes). + pub max_transfer_bytes: u64, + /// Maximum number of chunks accepted. + pub max_transfer_chunks: u64, + /// Maximum number of concurrently active transfers. + pub max_concurrent_transfers: usize, + /// Maximum forward gap from the contiguous frontier that is still buffered. + pub max_out_of_order_window: u64, + /// Maximum number of buffered out-of-order chunks. + pub max_out_of_order_chunks: usize, + /// Hard per-transfer timeout (milliseconds), measured from `start` + /// admission and never refreshed by chunk activity (TS parity). + /// Enforced by sweeping [`OversizedTransferReceiver::remove_expired`]; + /// `0` disables the watchdog. + pub transfer_timeout_ms: u64, +} + +impl Default for TransferPolicy { + fn default() -> Self { + Self { + max_transfer_bytes: DEFAULT_MAX_TRANSFER_BYTES, + max_transfer_chunks: DEFAULT_MAX_TRANSFER_CHUNKS, + max_concurrent_transfers: DEFAULT_MAX_CONCURRENT_TRANSFERS, + max_out_of_order_window: DEFAULT_MAX_OUT_OF_ORDER_WINDOW, + max_out_of_order_chunks: DEFAULT_MAX_OUT_OF_ORDER_CHUNKS, + transfer_timeout_ms: DEFAULT_TRANSFER_TIMEOUT_MS, + } + } +} + +/// In-flight state for a single transfer, keyed by `progressToken`. +#[derive(Debug)] +struct ActiveTransfer { + digest: String, + total_bytes: u64, + total_chunks: u64, + start_progress: u64, + accept_progress: Option, + first_chunk_progress: Option, + next_expected_chunk_progress: Option, + highest_observed_progress: u64, + /// Chunk fragments keyed by the outer `progress` value (canonical index). + chunks: BTreeMap, + /// When the `start` frame was admitted. The watchdog deadline is measured + /// from here and never refreshed (a hard cap, not an idle timer). + admitted_at: Instant, +} + +impl ActiveTransfer { + /// The progress slot the first chunk occupies, given handshake state. + fn first_chunk_slot(&self) -> u64 { + if self.accept_progress.is_some() { + self.start_progress + 2 + } else { + self.start_progress + 1 + } + } + + /// The next contiguous chunk slot the assembler is waiting for. + fn next_expected_chunk_progress(&self) -> u64 { + self.next_expected_chunk_progress + .unwrap_or_else(|| self.first_chunk_slot()) + } + + /// Recompute the contiguous-frontier bookkeeping after a chunk insert. + fn refresh_chunk_progress_state(&mut self) { + if self.chunks.is_empty() { + self.first_chunk_progress = None; + self.next_expected_chunk_progress = None; + return; + } + + let first = self.first_chunk_slot(); + let mut next = first; + while self.chunks.contains_key(&next) { + next += 1; + } + + self.first_chunk_progress = Some(first); + self.next_expected_chunk_progress = Some(next); + } + + /// Count of buffered chunks sitting beyond the contiguous frontier. + fn buffered_out_of_order_count(&self) -> usize { + match (self.first_chunk_progress, self.next_expected_chunk_progress) { + (Some(first), Some(next)) => self.chunks.len() - (next - first) as usize, + _ => 0, + } + } + + /// Whether every slot in `[first, first + total_chunks)` is present. + fn has_complete_chunk_range(&self, first: u64) -> bool { + (0..self.total_chunks).all(|i| self.chunks.contains_key(&(first + i))) + } + + /// Resolve the first chunk slot for assembly, tolerating the reserved accept + /// slot being used or skipped. Returns `None` if neither layout is complete. + fn assembly_first_chunk_progress(&self) -> Option { + let direct = self.start_progress + 1; + let accept_gated = self.start_progress + 2; + if self.has_complete_chunk_range(direct) { + Some(direct) + } else if self.has_complete_chunk_range(accept_gated) { + Some(accept_gated) + } else { + None + } + } + + /// Concatenate chunks in `progress` order. `None` on any unresolved gap. + fn assemble(&self) -> Option { + let first = self.assembly_first_chunk_progress()?; + let mut out = String::with_capacity(self.total_bytes as usize); + for i in 0..self.total_chunks { + out.push_str(self.chunks.get(&(first + i))?); + } + Some(out) + } +} + +/// Stateful CEP-22 reassembly engine. +/// +/// Tracks per-`progressToken` transfer state, enforces admission and +/// out-of-order policy, and validates integrity before surfacing a reassembled +/// message. Never surfaces partial payloads. +#[derive(Debug)] +pub struct OversizedTransferReceiver { + max_transfer_bytes: u64, + max_transfer_chunks: u64, + max_concurrent_transfers: usize, + max_out_of_order_window: u64, + max_out_of_order_chunks: usize, + /// Hard per-transfer deadline in milliseconds, from `start` admission. + /// `0` disables the watchdog: [`Self::remove_expired`] skips the sweep + /// entirely rather than expiring transfers instantly. + transfer_timeout_ms: u64, + transfers: HashMap, +} + +impl Default for OversizedTransferReceiver { + fn default() -> Self { + Self::new() + } +} + +impl OversizedTransferReceiver { + /// Create a receiver with the default [`TransferPolicy`]. + pub fn new() -> Self { + Self::with_policy(TransferPolicy::default()) + } + + /// Create a receiver with an explicit policy. + pub fn with_policy(policy: TransferPolicy) -> Self { + Self { + max_transfer_bytes: policy.max_transfer_bytes, + max_transfer_chunks: policy.max_transfer_chunks, + max_concurrent_transfers: policy.max_concurrent_transfers, + max_out_of_order_window: policy.max_out_of_order_window, + max_out_of_order_chunks: policy.max_out_of_order_chunks, + transfer_timeout_ms: policy.transfer_timeout_ms, + transfers: HashMap::new(), + } + } + + /// Number of currently active in-flight transfers. + pub fn active_transfer_count(&self) -> usize { + self.transfers.len() + } + + /// Whether a transfer keyed by `token` is currently in flight. + /// + /// Lets the transport gate progress forwarding on tracked transfers, so a + /// late or orphan frame (e.g. after watchdog reaping) cannot keep a dead + /// request's timer alive. + pub fn is_tracking(&self, token: &str) -> bool { + self.transfers.contains_key(token) + } + + /// Reap transfers whose age since `start` admission exceeds the policy's + /// `transfer_timeout_ms`, returning the reaped tokens (for logging). + /// + /// The deadline is hard — never refreshed by chunk activity (TS parity): + /// liveness is the requester's idle timer; this sweep is the receiver's + /// memory bound. A `transfer_timeout_ms` of `0` disables the watchdog (no + /// sweep). Reaping is local-only — no abort frame is emitted; the peer's + /// own timeout covers the other side. The token slot is freed: a later + /// `start` re-using a reaped token is admitted as a fresh transfer, while + /// its late chunk/end frames are orphan-ignored. + pub fn remove_expired(&mut self) -> Vec { + if self.transfer_timeout_ms == 0 { + return Vec::new(); + } + let deadline = Duration::from_millis(self.transfer_timeout_ms); + let now = Instant::now(); + let mut reaped = Vec::new(); + self.transfers.retain(|token, transfer| { + if now.duration_since(transfer.admitted_at) > deadline { + reaped.push(token.clone()); + false + } else { + true + } + }); + reaped + } + + /// Release all in-flight transfer state. + pub fn clear(&mut self) { + self.transfers.clear(); + } + + /// Returns `true` when `notification` carries an oversized-transfer frame in + /// its `params.cvm` field. + pub fn is_oversized_frame(notification: &JsonRpcNotification) -> bool { + notification + .params + .as_ref() + .and_then(|params| params.get("cvm")) + .map(OversizedFrame::is_frame_value) + .unwrap_or(false) + } + + /// Process one inbound `notifications/progress` frame. + /// + /// Returns `Ok(Some(message))` once the transfer is complete and validated, + /// `Ok(None)` when more frames are needed (or the notification is not an + /// oversized frame), and `Err(..)` on abort, policy, integrity, sequence, or + /// reassembly failure. A failing or aborting transfer is cleaned up before + /// the error is returned. + pub fn process_frame( + &mut self, + notification: &JsonRpcNotification, + ) -> Result, OversizedTransferError> { + let params = match notification.params.as_ref() { + Some(params) => params, + None => return Ok(None), + }; + let cvm = match params.get("cvm") { + Some(cvm) => cvm, + None => return Ok(None), + }; + let frame = match OversizedFrame::from_cvm_value(cvm) { + Some(frame) => frame, + None => return Ok(None), + }; + + // The outer progressToken keys the transfer; the outer progress is the + // canonical ordering index. Both are validated before dispatch. + let token = token_to_string(params.get("progressToken")); + assert_valid_token(&token)?; + let progress = parse_progress(params.get("progress"), &token)?; + + match frame { + OversizedFrame::Start { + digest, + total_bytes, + total_chunks, + .. + } => self.handle_start(&token, progress, digest, total_bytes, total_chunks), + OversizedFrame::Accept => self.handle_accept(&token, progress), + OversizedFrame::Chunk { data } => self.handle_chunk(&token, progress, data), + OversizedFrame::End => self.handle_end(&token, progress), + OversizedFrame::Abort { reason } => self.handle_abort(&token, reason), + } + } + + fn handle_start( + &mut self, + token: &str, + progress: u64, + digest: String, + total_bytes: u64, + total_chunks: u64, + ) -> Result, OversizedTransferError> { + if self.transfers.contains_key(token) { + return Err(OversizedTransferError::Sequence(format!( + "Duplicate start frame for active transfer (token: {token})" + ))); + } + if total_bytes > self.max_transfer_bytes { + return Err(OversizedTransferError::Policy(format!( + "totalBytes {total_bytes} exceeds policy limit {} (token: {token})", + self.max_transfer_bytes + ))); + } + if total_chunks > self.max_transfer_chunks { + return Err(OversizedTransferError::Policy(format!( + "totalChunks {total_chunks} exceeds policy limit {} (token: {token})", + self.max_transfer_chunks + ))); + } + if self.transfers.len() >= self.max_concurrent_transfers { + return Err(OversizedTransferError::Policy(format!( + "Active transfers exceed policy limit {} (token: {token})", + self.max_concurrent_transfers + ))); + } + if !digest.starts_with(DIGEST_PREFIX) { + return Err(OversizedTransferError::Reassembly(format!( + "Invalid digest format in start frame (token: {token})" + ))); + } + + self.transfers.insert( + token.to_string(), + ActiveTransfer { + digest, + total_bytes, + total_chunks, + start_progress: progress, + accept_progress: None, + first_chunk_progress: None, + next_expected_chunk_progress: None, + highest_observed_progress: progress, + chunks: BTreeMap::new(), + admitted_at: Instant::now(), + }, + ); + Ok(None) + } + + fn handle_accept( + &mut self, + token: &str, + progress: u64, + ) -> Result, OversizedTransferError> { + // Dropping `transfer` on the error path is the cleanup (failTransfer). + let mut transfer = match self.transfers.remove(token) { + Some(transfer) => transfer, + // Late or duplicated accept frames are ignored after cleanup. + None => return Ok(None), + }; + + if progress <= transfer.start_progress { + return Err(OversizedTransferError::Sequence(format!( + "Accept frame progress must be greater than start progress (token: {token})" + ))); + } + + transfer.highest_observed_progress = transfer.highest_observed_progress.max(progress); + transfer.accept_progress = Some(progress); + self.transfers.insert(token.to_string(), transfer); + Ok(None) + } + + fn handle_chunk( + &mut self, + token: &str, + progress: u64, + data: String, + ) -> Result, OversizedTransferError> { + let max_window = self.max_out_of_order_window; + let max_ooo_chunks = self.max_out_of_order_chunks; + + let mut transfer = match self.transfers.remove(token) { + Some(transfer) => transfer, + // Late or duplicated chunk frames are ignored after cleanup. + None => return Ok(None), + }; + + let minimum_chunk_progress = transfer.start_progress + 1; + let maximum_chunk_progress = transfer.start_progress + transfer.total_chunks + 1; + + if progress < minimum_chunk_progress { + return Err(OversizedTransferError::Sequence(format!( + "Chunk progress must be greater than start progress (token: {token})" + ))); + } + + let next_expected = transfer.next_expected_chunk_progress(); + // i128 so a hostile `progress` near u64::MAX cannot overflow the subtraction. + let forward_gap = i128::from(progress) - i128::from(next_expected); + if forward_gap > i128::from(max_window) { + return Err(OversizedTransferError::Policy(format!( + "Out-of-order gap {forward_gap} exceeds policy limit {max_window} (token: {token})" + ))); + } + + if progress > maximum_chunk_progress { + return Err(OversizedTransferError::Sequence(format!( + "Chunk progress exceeds declared transfer bounds (token: {token})" + ))); + } + + if progress > transfer.start_progress + 2 + && !transfer.chunks.contains_key(&(transfer.start_progress + 1)) + && !transfer.chunks.contains_key(&(transfer.start_progress + 2)) + { + return Err(OversizedTransferError::Sequence(format!( + "First chunk skips beyond the reserved accept slot (token: {token})" + ))); + } + + if let Some(existing) = transfer.chunks.get(&progress) { + if existing != &data { + return Err(OversizedTransferError::Sequence(format!( + "Conflicting duplicate chunk detected (token: {token}, progress: {progress})" + ))); + } + // Idempotent identical duplicate: keep state, await more frames. + self.transfers.insert(token.to_string(), transfer); + return Ok(None); + } + + transfer.chunks.insert(progress, data); + transfer.highest_observed_progress = transfer.highest_observed_progress.max(progress); + transfer.refresh_chunk_progress_state(); + + if forward_gap > 0 && transfer.buffered_out_of_order_count() > max_ooo_chunks { + return Err(OversizedTransferError::Policy(format!( + "Buffered out-of-order chunks exceed policy limit {max_ooo_chunks} (token: {token})" + ))); + } + + self.transfers.insert(token.to_string(), transfer); + Ok(None) + } + + fn handle_end( + &mut self, + token: &str, + progress: u64, + ) -> Result, OversizedTransferError> { + let max_bytes = self.max_transfer_bytes; + // Removed up front: every end-path outcome (success or failure) is terminal. + let transfer = match self.transfers.remove(token) { + Some(transfer) => transfer, + // Late or duplicated end frames are ignored after cleanup. + None => return Ok(None), + }; + + if progress <= transfer.highest_observed_progress { + return Err(OversizedTransferError::Sequence(format!( + "End frame progress must be greater than all prior transfer frames (token: {token})" + ))); + } + if transfer.total_chunks > 0 && transfer.chunks.is_empty() { + return Err(OversizedTransferError::Reassembly(format!( + "Transfer ended before any chunks were received (token: {token})" + ))); + } + if transfer.chunks.len() as u64 != transfer.total_chunks { + return Err(OversizedTransferError::Reassembly(format!( + "Expected {} chunks but received {} (token: {token})", + transfer.total_chunks, + transfer.chunks.len() + ))); + } + + let assembled = match transfer.assemble() { + Some(assembled) => assembled, + None => { + return Err(OversizedTransferError::Reassembly(format!( + "Transfer ended with unresolved chunk gaps (token: {token})" + ))) + } + }; + + // 1. Byte-length validation. + if assembled.len() as u64 != transfer.total_bytes { + return Err(OversizedTransferError::Digest(format!( + "Byte length mismatch: expected {}, got {} (token: {token})", + transfer.total_bytes, + assembled.len() + ))); + } + + // 2. SHA-256 digest validation. + if sha256_digest(&assembled) != transfer.digest { + return Err(OversizedTransferError::Digest(format!( + "SHA-256 digest mismatch (token: {token})" + ))); + } + + // 3. Parse + validate as a JSON-RPC message, bypassing the 1 MB + // per-event cap in favor of the receiver policy's maxTransferBytes. + match validate_and_parse_oversized(&assembled, max_bytes as usize) { + Some(message) => Ok(Some(message)), + None => Err(OversizedTransferError::Reassembly(format!( + "Reassembled payload is not a valid JSON-RPC message (token: {token})" + ))), + } + } + + fn handle_abort( + &mut self, + token: &str, + reason: Option, + ) -> Result, OversizedTransferError> { + // Abort is terminal whether or not a transfer is currently tracked. + self.transfers.remove(token); + Err(OversizedTransferError::abort(token, reason)) + } +} + +/// Coerce a `progressToken` value to a string (mirrors TS `String(token ?? '')`). +fn token_to_string(value: Option<&Value>) -> String { + value.and_then(progress_token_string).unwrap_or_default() +} + +/// A non-empty token is required on every frame. +fn assert_valid_token(token: &str) -> Result<(), OversizedTransferError> { + if token.is_empty() { + return Err(OversizedTransferError::Sequence( + "Oversized transfer frame is missing progressToken".to_string(), + )); + } + Ok(()) +} + +/// Parse and validate the outer `progress`: a positive integer. +fn parse_progress(value: Option<&Value>, token: &str) -> Result { + let progress = match value { + Some(Value::Number(n)) => n.as_u64().or_else(|| { + n.as_f64().and_then(|f| { + if f.fract() == 0.0 && f > 0.0 { + Some(f as u64) + } else { + None + } + }) + }), + _ => None, + }; + + match progress { + Some(progress) if progress > 0 => Ok(progress), + _ => Err(OversizedTransferError::Sequence(format!( + "Invalid progress value (token: {token})" + ))), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::core::types::{JsonRpcRequest, JsonRpcResponse}; + use crate::transport::oversized_transfer::codec::{ + build_oversized_frames, BuiltOversizedFrames, OversizedSenderOptions, + }; + use crate::transport::oversized_transfer::frame::{CompletionMode, OversizedFrame}; + use crate::transport::oversized_transfer::START_PROGRESS; + use serde_json::json; + + const TOKEN: &str = "tok"; + + fn sample_response(id: i64, value: &str) -> JsonRpcMessage { + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: json!(id), + result: json!({ "value": value }), + }) + } + + fn build(message: &JsonRpcMessage, chunk_size: usize) -> BuiltOversizedFrames { + let serialized = serde_json::to_string(message).unwrap(); + let opts = OversizedSenderOptions::new(TOKEN).with_chunk_size(chunk_size); + build_oversized_frames(&serialized, &opts).unwrap() + } + + fn start_frame( + token: &str, + progress: u64, + digest: &str, + total_bytes: u64, + total_chunks: u64, + ) -> JsonRpcNotification { + OversizedFrame::Start { + completion_mode: CompletionMode::Render, + digest: digest.to_string(), + total_bytes, + total_chunks, + } + .into_progress_notification(token, progress, None) + .unwrap() + } + + fn chunk_frame(token: &str, progress: u64, data: &str) -> JsonRpcNotification { + OversizedFrame::Chunk { + data: data.to_string(), + } + .into_progress_notification(token, progress, None) + .unwrap() + } + + fn end_frame(token: &str, progress: u64) -> JsonRpcNotification { + OversizedFrame::End + .into_progress_notification(token, progress, None) + .unwrap() + } + + /// Drive a full ordered frame set through the receiver, returning the message. + fn run_to_completion( + receiver: &mut OversizedTransferReceiver, + frames: BuiltOversizedFrames, + ) -> JsonRpcMessage { + let mut out = None; + for frame in frames.into_ordered() { + if let Some(message) = receiver.process_frame(&frame).unwrap() { + out = Some(message); + } + } + out.expect("transfer should complete on the end frame") + } + + // ── roundtrip ─────────────────────────────────────────────────── + + #[test] + fn roundtrip_in_order() { + let message = sample_response(1, &"x".repeat(60)); + let frames = build(&message, 8); + assert!(frames.chunks.len() > 1); + + let mut receiver = OversizedTransferReceiver::new(); + let reconstructed = run_to_completion(&mut receiver, frames); + + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn roundtrip_request_message() { + // Reassembly validates any JSON-RPC variant, not just responses. + let message = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: json!("abc"), + method: "tools/call".to_string(), + params: Some(json!({ "blob": "y".repeat(40) })), + }); + let frames = build(&message, 10); + + let mut receiver = OversizedTransferReceiver::new(); + let reconstructed = run_to_completion(&mut receiver, frames); + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + } + + #[test] + fn roundtrip_multibyte_payload() { + // Small chunks force boundaries inside multibyte UTF-8 runs. + let message = sample_response(7, "héllo 🦀 wörld 日本語 ☃ même 🚀🚀"); + let frames = build(&message, 5); + + let mut receiver = OversizedTransferReceiver::new(); + let reconstructed = run_to_completion(&mut receiver, frames); + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + } + + #[test] + fn roundtrip_out_of_order_within_window() { + let message = sample_response(2, &"z".repeat(60)); + let frames = build(&message, 8); + assert!(frames.chunks.len() >= 3); + + let mut receiver = OversizedTransferReceiver::with_policy(TransferPolicy { + max_out_of_order_window: 4, + max_out_of_order_chunks: 4, + ..Default::default() + }); + + receiver.process_frame(&frames.start).unwrap(); + // Deliver the first two chunks swapped. + receiver.process_frame(&frames.chunks[1]).unwrap(); + receiver.process_frame(&frames.chunks[0]).unwrap(); + for chunk in &frames.chunks[2..] { + receiver.process_frame(chunk).unwrap(); + } + let reconstructed = receiver.process_frame(&frames.end).unwrap().unwrap(); + + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn roundtrip_accept_gated_handshake() { + let message = sample_response(6, &"q".repeat(40)); + let serialized = serde_json::to_string(&message).unwrap(); + let opts = OversizedSenderOptions::new(TOKEN) + .with_chunk_size(8) + .with_accept_handshake(true); + let frames = build_oversized_frames(&serialized, &opts).unwrap(); + + let mut receiver = OversizedTransferReceiver::new(); + receiver.process_frame(&frames.start).unwrap(); + // Sender waits for the receiver's accept on the reserved slot 2. + let accept = OversizedFrame::Accept + .into_progress_notification(TOKEN, 2, None) + .unwrap(); + receiver.process_frame(&accept).unwrap(); + for chunk in &frames.chunks { + receiver.process_frame(chunk).unwrap(); + } + let reconstructed = receiver.process_frame(&frames.end).unwrap().unwrap(); + + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + } + + // ── out-of-order policy ───────────────────────────────────────── + + #[test] + fn out_of_order_gap_over_window_is_policy_error() { + let message = sample_response(2, "abcdefghijklmnop"); + let frames = build(&message, 4); + assert!(frames.chunks.len() >= 3); + + let mut receiver = OversizedTransferReceiver::with_policy(TransferPolicy { + max_out_of_order_window: 1, + ..Default::default() + }); + receiver.process_frame(&frames.start).unwrap(); + + // Jump straight to the third chunk: forward gap 2 > window 1. + let err = receiver.process_frame(&frames.chunks[2]).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn chunk_with_u64_max_progress_does_not_panic() { + // Regression: the forward-gap subtraction must not overflow when a + // hostile peer sends progress = u64::MAX (previously panicked under i64). + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + + let err = receiver + .process_frame(&chunk_frame(TOKEN, u64::MAX, "x")) + .unwrap_err(); + // Rejected cleanly (huge forward gap → Policy) rather than panicking. + assert!(matches!( + err, + OversizedTransferError::Policy(_) | OversizedTransferError::Sequence(_) + )); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn first_chunk_skipping_reserved_accept_slot_is_sequence_error() { + // 5 declared chunks keep slot 4 within the declared bounds (slots 2..=6), + // so this exercises the reserved-slot guard, not the bounds check. + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 20, 5)) + .unwrap(); + + // First chunk jumps to slot 4, skipping the reserved accept slot (2) and + // the first chunk slot (3) while both are still empty. + let err = receiver + .process_frame(&chunk_frame(TOKEN, 4, "data")) + .unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + assert!(err.to_string().contains("reserved accept slot")); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn accept_not_advancing_past_start_is_sequence_error() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, START_PROGRESS, "sha256:abcd", 4, 1)) + .unwrap(); + + // The accept frame MUST advance past the start slot; progress equal to + // START_PROGRESS does not. + let accept = OversizedFrame::Accept + .into_progress_notification(TOKEN, START_PROGRESS, None) + .unwrap(); + let err = receiver.process_frame(&accept).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + assert_eq!(receiver.active_transfer_count(), 0); + } + + /// An `accept` on the wrong progress slot is rejected by the receiver, so a + /// transport that wakes the sender's accept-waiter only on a successful + /// `process_frame` leaves the oneshot unfired — the sender stays blocked and + /// falls back to its accept-timeout → abort (see [`super`]'s sibling sender + /// test `missed_accept_returns_abort`). + /// + /// Scope: this pins the engine-level accept validation (`handle_accept` + /// requires `progress > start_progress`). The live *client* upload path wakes + /// its waiter on `cvm.frameType == "accept"` + a matching token alone (the + /// accept's slot is only meaningful to a reassembling receiver), so this models + /// the receiver/reassembly contract, not that path. + #[test] + fn invalid_accept_leaves_oneshot_unfired() { + use tokio::sync::oneshot; + + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, START_PROGRESS, "sha256:abcd", 4, 1)) + .unwrap(); + + // The sender registers a oneshot before publishing `start`; a transport + // wakes it only once the receiver has accepted the accept frame. + let (accept_tx, mut accept_rx) = oneshot::channel::<()>(); + + // An accept on the start slot is invalid (it must advance past start). + let invalid_accept = OversizedFrame::Accept + .into_progress_notification(TOKEN, START_PROGRESS, None) + .unwrap(); + + // Fire-on-Ok: wake the waiter only if the receiver accepts the frame. + match receiver.process_frame(&invalid_accept) { + Ok(_) => { + let _ = accept_tx.send(()); + } + Err(error) => assert!(matches!(error, OversizedTransferError::Sequence(_))), + } + + // The accept was rejected, so the oneshot was never fired: still `Empty` + // (the sender — held alive here — has not been signalled). + assert!( + matches!( + accept_rx.try_recv(), + Err(oneshot::error::TryRecvError::Empty) + ), + "an invalid accept must not fire the sender's accept-waiter" + ); + assert_eq!(receiver.active_transfer_count(), 0); + } + + // ── integrity ─────────────────────────────────────────────────── + + #[test] + fn digest_mismatch_is_digest_error() { + let message = sample_response(1, "hello"); + let serialized = serde_json::to_string(&message).unwrap(); + let total_bytes = serialized.len() as u64; + + let mut receiver = OversizedTransferReceiver::new(); + // Correct byte length, but a wrong (well-formed) digest. + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:deadbeef", total_bytes, 1)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, &serialized)) + .unwrap(); + let err = receiver.process_frame(&end_frame(TOKEN, 3)).unwrap_err(); + + assert!(matches!(err, OversizedTransferError::Digest(_))); + assert!(err.to_string().to_lowercase().contains("digest")); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn byte_length_mismatch_is_digest_error() { + let message = sample_response(1, "hello"); + let serialized = serde_json::to_string(&message).unwrap(); + let digest = sha256_digest(&serialized); + let wrong_total = serialized.len() as u64 + 1; + + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, &digest, wrong_total, 1)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, &serialized)) + .unwrap(); + let err = receiver.process_frame(&end_frame(TOKEN, 3)).unwrap_err(); + + assert!(matches!(err, OversizedTransferError::Digest(_))); + assert!(err.to_string().to_lowercase().contains("byte length")); + } + + // ── sequencing ────────────────────────────────────────────────── + + #[test] + fn duplicate_start_is_sequence_error() { + let start = start_frame(TOKEN, 1, "sha256:abcd", 4, 1); + let mut receiver = OversizedTransferReceiver::new(); + receiver.process_frame(&start).unwrap(); + + let err = receiver.process_frame(&start).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + assert!(err.to_string().contains("Duplicate start")); + // The pre-existing transfer is left intact (duplicate is rejected, not reset). + assert_eq!(receiver.active_transfer_count(), 1); + } + + #[test] + fn chunk_count_mismatch_is_reassembly_error() { + let mut receiver = OversizedTransferReceiver::new(); + // Declares two chunks but only one arrives before end. + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 8, 2)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, "abcd")) + .unwrap(); + let err = receiver.process_frame(&end_frame(TOKEN, 3)).unwrap_err(); + + assert!(matches!(err, OversizedTransferError::Reassembly(_))); + assert!(err.to_string().contains("Expected 2 chunks but received 1")); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn conflicting_duplicate_chunk_is_sequence_error() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 8, 2)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, "abcd")) + .unwrap(); + let err = receiver + .process_frame(&chunk_frame(TOKEN, 2, "wxyz")) + .unwrap_err(); + + assert!(matches!(err, OversizedTransferError::Sequence(_))); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn identical_duplicate_chunk_is_idempotent() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 8, 2)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, "abcd")) + .unwrap(); + // Re-delivering the identical chunk is a no-op, not an error. + assert!(receiver + .process_frame(&chunk_frame(TOKEN, 2, "abcd")) + .unwrap() + .is_none()); + assert_eq!(receiver.active_transfer_count(), 1); + } + + #[test] + fn end_not_advancing_progress_is_sequence_error() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + receiver + .process_frame(&chunk_frame(TOKEN, 2, "test")) + .unwrap(); + // End at the same progress as the last chunk must not advance. + let err = receiver.process_frame(&end_frame(TOKEN, 2)).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + } + + #[test] + fn missing_token_is_sequence_error() { + let frame = chunk_frame("", 2, "abcd"); + let mut receiver = OversizedTransferReceiver::new(); + let err = receiver.process_frame(&frame).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + assert!(err.to_string().contains("missing progressToken")); + } + + #[test] + fn non_positive_progress_is_sequence_error() { + let frame = start_frame(TOKEN, 0, "sha256:abcd", 4, 1); + let mut receiver = OversizedTransferReceiver::new(); + let err = receiver.process_frame(&frame).unwrap_err(); + assert!(matches!(err, OversizedTransferError::Sequence(_))); + } + + // ── abort ─────────────────────────────────────────────────────── + + #[test] + fn abort_terminates_active_transfer() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + + let abort = OversizedFrame::Abort { + reason: Some("peer cancelled".to_string()), + } + .into_progress_notification(TOKEN, 5, None) + .unwrap(); + let err = receiver.process_frame(&abort).unwrap_err(); + + assert!(err.is_abort()); + match err { + OversizedTransferError::Abort { token, reason } => { + assert_eq!(token, TOKEN); + assert_eq!(reason.as_deref(), Some("peer cancelled")); + } + other => panic!("expected abort, got {other:?}"), + } + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn orphan_abort_still_errors() { + let mut receiver = OversizedTransferReceiver::new(); + let abort = OversizedFrame::Abort { reason: None } + .into_progress_notification(TOKEN, 2, None) + .unwrap(); + assert!(receiver.process_frame(&abort).unwrap_err().is_abort()); + } + + // ── admission limits ──────────────────────────────────────────── + + #[test] + fn start_over_byte_limit_is_policy_error() { + let mut receiver = OversizedTransferReceiver::with_policy(TransferPolicy { + max_transfer_bytes: 10, + ..Default::default() + }); + let err = receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 11, 1)) + .unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn start_over_chunk_limit_is_policy_error() { + let mut receiver = OversizedTransferReceiver::with_policy(TransferPolicy { + max_transfer_chunks: 1, + ..Default::default() + }); + let err = receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 2)) + .unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + } + + #[test] + fn start_over_concurrency_limit_is_policy_error() { + let mut receiver = OversizedTransferReceiver::with_policy(TransferPolicy { + max_concurrent_transfers: 1, + ..Default::default() + }); + receiver + .process_frame(&start_frame("tok-a", 1, "sha256:abcd", 4, 1)) + .unwrap(); + let err = receiver + .process_frame(&start_frame("tok-b", 1, "sha256:efgh", 4, 1)) + .unwrap_err(); + assert!(matches!(err, OversizedTransferError::Policy(_))); + } + + #[test] + fn start_with_malformed_digest_is_reassembly_error() { + let mut receiver = OversizedTransferReceiver::new(); + // Missing the "sha256:" prefix. + let err = receiver + .process_frame(&start_frame(TOKEN, 1, "abcd", 4, 1)) + .unwrap_err(); + assert!(matches!(err, OversizedTransferError::Reassembly(_))); + } + + // ── orphan / non-frame handling ───────────────────────────────── + + #[test] + fn orphan_late_frames_are_ignored() { + let mut receiver = OversizedTransferReceiver::new(); + let accept = OversizedFrame::Accept + .into_progress_notification("orphan-accept", 2, None) + .unwrap(); + assert!(receiver.process_frame(&accept).unwrap().is_none()); + assert!(receiver + .process_frame(&chunk_frame("orphan-chunk", 2, "x")) + .unwrap() + .is_none()); + assert!(receiver + .process_frame(&end_frame("orphan-end", 2)) + .unwrap() + .is_none()); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn non_frame_progress_notification_is_passthrough_none() { + let mut receiver = OversizedTransferReceiver::new(); + let plain = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ "progressToken": "t", "progress": 3 })), + }; + assert!(receiver.process_frame(&plain).unwrap().is_none()); + + let no_params = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }; + assert!(receiver.process_frame(&no_params).unwrap().is_none()); + } + + #[test] + fn is_oversized_frame_detects_cvm_payload() { + let frame = end_frame(TOKEN, 3); + assert!(OversizedTransferReceiver::is_oversized_frame(&frame)); + + let plain = JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(json!({ "progressToken": "t", "progress": 3 })), + }; + assert!(!OversizedTransferReceiver::is_oversized_frame(&plain)); + } + + #[test] + fn clear_releases_in_flight_state() { + let mut receiver = OversizedTransferReceiver::new(); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + assert_eq!(receiver.active_transfer_count(), 1); + receiver.clear(); + assert_eq!(receiver.active_transfer_count(), 0); + } + + #[test] + fn reassembled_payload_exceeds_one_megabyte() { + // The whole point of CEP-22: reassemble a payload larger than the 1 MB + // per-event cap. Chunks stay small; the result is ~1.2 MB. + let message = sample_response(1, &"m".repeat(1_200_000)); + let serialized = serde_json::to_string(&message).unwrap(); + assert!(serialized.len() > crate::core::constants::MAX_MESSAGE_SIZE); + + let frames = build(&message, 48_000); + let mut receiver = OversizedTransferReceiver::new(); + let reconstructed = run_to_completion(&mut receiver, frames); + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + } + + // ── watchdog: admitted_at / remove_expired / is_tracking ──────────── + + fn watchdog_policy(transfer_timeout_ms: u64) -> TransferPolicy { + TransferPolicy { + transfer_timeout_ms, + ..TransferPolicy::default() + } + } + + /// Backdate a tracked transfer's admission so deadline checks are + /// deterministic (no sleeping in unit tests). + fn backdate_admission(receiver: &mut OversizedTransferReceiver, token: &str, ms: u64) { + receiver + .transfers + .get_mut(token) + .expect("transfer must be tracked") + .admitted_at -= Duration::from_millis(ms); + } + + #[test] + fn remove_expired_reaps_past_deadline_and_orphans_late_frames() { + let mut receiver = OversizedTransferReceiver::with_policy(watchdog_policy(50)); + let mut frames = build(&sample_response(1, "payload"), 4).into_ordered(); + let rest = frames.split_off(1); + receiver.process_frame(&frames[0]).unwrap(); + assert!(receiver.is_tracking(TOKEN)); + + backdate_admission(&mut receiver, TOKEN, 100); + let reaped = receiver.remove_expired(); + assert_eq!(reaped, vec![TOKEN.to_string()]); + assert!(!receiver.is_tracking(TOKEN)); + assert_eq!(receiver.active_transfer_count(), 0); + + // Late chunk/end frames of the reaped transfer are orphan-ignored: + // no error, and nothing is ever surfaced. + for frame in rest { + assert!(receiver.process_frame(&frame).unwrap().is_none()); + } + } + + #[test] + fn remove_expired_skips_unexpired_transfers() { + let mut receiver = OversizedTransferReceiver::with_policy(watchdog_policy(50)); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + receiver + .process_frame(&start_frame("tok-stale", 1, "sha256:abcd", 4, 1)) + .unwrap(); + + backdate_admission(&mut receiver, "tok-stale", 100); + let reaped = receiver.remove_expired(); + assert_eq!(reaped, vec!["tok-stale".to_string()]); + assert!(receiver.is_tracking(TOKEN), "fresh transfer must survive"); + assert!(!receiver.is_tracking("tok-stale")); + } + + #[test] + fn remove_expired_zero_timeout_disables_watchdog() { + // 0 means "no watchdog" (sweep skipped), NOT instant expiry. + let mut receiver = OversizedTransferReceiver::with_policy(watchdog_policy(0)); + receiver + .process_frame(&start_frame(TOKEN, 1, "sha256:abcd", 4, 1)) + .unwrap(); + backdate_admission(&mut receiver, TOKEN, 200); + + assert!(receiver.remove_expired().is_empty()); + assert!(receiver.is_tracking(TOKEN)); + } + + #[test] + fn reaped_token_is_readmittable_as_fresh_transfer() { + // remove_expired frees the slot; a later `start` re-using the token is + // a fresh transfer (duplicate-start would error if state lingered), and + // it runs to completion. + let mut receiver = OversizedTransferReceiver::with_policy(watchdog_policy(50)); + let message = sample_response(7, "again"); + let mut first = build(&message, 4).into_ordered(); + first.truncate(2); // start + one chunk, then the sender stalls + for frame in &first { + receiver.process_frame(frame).unwrap(); + } + + backdate_admission(&mut receiver, TOKEN, 100); + assert_eq!(receiver.remove_expired(), vec![TOKEN.to_string()]); + + let reconstructed = run_to_completion(&mut receiver, build(&message, 4)); + assert_eq!( + serde_json::to_value(&reconstructed).unwrap(), + serde_json::to_value(&message).unwrap() + ); + assert!( + !receiver.is_tracking(TOKEN), + "completed transfer is released" + ); + } + + #[test] + fn is_tracking_reflects_transfer_lifecycle() { + let mut receiver = OversizedTransferReceiver::new(); + assert!(!receiver.is_tracking(TOKEN)); + + let frames = build(&sample_response(1, "lifecycle"), 4); + let ordered = frames.into_ordered(); + let (end, mid) = ordered.split_last().expect("frames are never empty"); + + for frame in mid { + receiver.process_frame(frame).unwrap(); + assert!(receiver.is_tracking(TOKEN), "tracked while in flight"); + } + receiver + .process_frame(end) + .unwrap() + .expect("end frame completes the transfer"); + assert!(!receiver.is_tracking(TOKEN), "released after completion"); + } +} diff --git a/src/transport/oversized_transfer/sender.rs b/src/transport/oversized_transfer/sender.rs new file mode 100644 index 0000000..a649065 --- /dev/null +++ b/src/transport/oversized_transfer/sender.rs @@ -0,0 +1,241 @@ +//! Transport-agnostic async driver for sending an oversized transfer. +//! +//! Given the ordered frames produced by [`build_oversized_frames`](super::codec::build_oversized_frames), +//! this sequences `start → [await accept] → chunks → end`, publishing each frame +//! through a caller-supplied closure, and returns the **end-frame `EventId`** so +//! the transport can correlate the eventual response. +//! +//! The driver carries no transport itself: the client publishes via +//! `prepare_mcp_message` + `publish_event` (registering a pending entry between +//! the two), the server via `base.send_mcp_message`. Those signatures differ, so +//! the publish step is injected as a closure and this module stays transport-free +//! (there is no active `abort` frame in v1 — on a missed `accept` the sender +//! fails and cleans up locally, letting the peer's own timeout fire). + +use std::future::Future; +use std::time::Duration; + +use nostr_sdk::prelude::EventId; +use tokio::sync::oneshot; + +use crate::core::types::JsonRpcNotification; + +use super::codec::BuiltOversizedFrames; +use super::errors::OversizedTransferError; + +/// Extract the outer `progressToken` from a frame's `params`, used to label a +/// locally-raised [`OversizedTransferError::Abort`]. +fn progress_token_of(frame: &JsonRpcNotification) -> String { + frame + .params + .as_ref() + .and_then(|params| params.get("progressToken")) + .and_then(|value| value.as_str()) + .unwrap_or_default() + .to_string() +} + +/// Publish an oversized transfer in canonical frame order, returning the +/// `EventId` of the **end** frame (the id the peer correlates its response to). +/// +/// `publish` is invoked once per frame, in order, and must return the published +/// event's inner `EventId`. When `needs_accept` is set the driver waits up to +/// `accept_timeout` for the receiver's `accept` to fire `await_accept` before +/// sending any chunk; on timeout (or a dropped waiter) it returns +/// [`OversizedTransferError::Abort`] without emitting an `abort` frame. +/// +/// `await_accept` is `Some` iff `needs_accept` is `true`. +pub async fn send_oversized_transfer( + frames: BuiltOversizedFrames, + needs_accept: bool, + await_accept: Option>, + accept_timeout: Duration, + mut publish: P, +) -> crate::Result +where + P: FnMut(JsonRpcNotification) -> Fut, + Fut: Future>, +{ + // The token is only needed to label a potential abort; capture it before the + // start frame is moved into `publish`. + let token = progress_token_of(&frames.start); + + // 1. Publish `start`. Its id is not used for correlation — the end id is. + publish(frames.start).await?; + + // 2. If a handshake is required, block until the receiver's `accept` arrives. + if needs_accept { + let outcome = match await_accept { + Some(rx) => tokio::time::timeout(accept_timeout, rx).await, + None => { + // Defensive: `needs_accept` implies a waiter was provided. + return Err(OversizedTransferError::abort( + token, + Some("no accept waiter registered".to_string()), + ) + .into()); + } + }; + + // `Err(_)` → timed out; `Ok(Err(_))` → waiter dropped. Either way we abort + // locally and let the peer's own timeout fire (no active abort frame). + if matches!(outcome, Err(_) | Ok(Err(_))) { + return Err(OversizedTransferError::abort( + token, + Some("timed out waiting for accept frame".to_string()), + ) + .into()); + } + } + + // 3. Publish each chunk sequentially in canonical (`progress`) order. + for chunk in frames.chunks { + publish(chunk).await?; + } + + // 4. Publish `end`; its id correlates the eventual response. + let end_id = publish(frames.end).await?; + Ok(end_id) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::cell::RefCell; + use std::rc::Rc; + + use crate::transport::oversized_transfer::codec::{ + build_oversized_frames, OversizedSenderOptions, + }; + + /// `frameType` discriminator of a published frame. + fn frame_type(notification: &JsonRpcNotification) -> String { + notification.params.as_ref().unwrap()["cvm"]["frameType"] + .as_str() + .unwrap() + .to_string() + } + + /// Deterministic [`EventId`] for the n-th published frame (1-based). + fn nth_event_id(n: usize) -> EventId { + EventId::from_hex(&format!("{n:064x}")).unwrap() + } + + /// Build frames for a payload large enough to span several chunks. + fn sample_frames(token: &str, needs_accept: bool) -> BuiltOversizedFrames { + let payload = "x".repeat(50); + let options = OversizedSenderOptions::new(token) + .with_chunk_size(8) + .with_accept_handshake(needs_accept); + build_oversized_frames(&payload, &options).unwrap() + } + + /// An in-memory publish closure: records each frame and hands back a + /// deterministic id keyed on publish order (matching [`nth_event_id`]). + fn recording_publisher( + log: Rc>>, + ) -> impl FnMut(JsonRpcNotification) -> std::pin::Pin>>> + { + move |frame: JsonRpcNotification| { + let log = log.clone(); + Box::pin(async move { + log.borrow_mut().push(frame); + let n = log.borrow().len(); + Ok(nth_event_id(n)) + }) + } + } + + #[tokio::test] + async fn publishes_frames_in_canonical_order_and_returns_end_id() { + let frames = sample_frames("tok", false); + let expected_count = frames.frame_count(); + + let log = Rc::new(RefCell::new(Vec::new())); + let publish = recording_publisher(log.clone()); + + let end_id = send_oversized_transfer(frames, false, None, Duration::from_secs(1), publish) + .await + .unwrap(); + + let frames_log = log.borrow(); + assert_eq!(frames_log.len(), expected_count); + assert!(expected_count > 2, "payload should span multiple chunks"); + + // start → chunks… → end + assert_eq!(frame_type(&frames_log[0]), "start"); + assert_eq!(frame_type(frames_log.last().unwrap()), "end"); + for mid in &frames_log[1..frames_log.len() - 1] { + assert_eq!(frame_type(mid), "chunk"); + } + + // Returned id is the end frame's id (the last one published). + assert_eq!(end_id, nth_event_id(expected_count)); + } + + #[tokio::test] + async fn accept_is_awaited_and_satisfied_when_needed() { + let frames = sample_frames("tok", true); + let expected_count = frames.frame_count(); + + let log = Rc::new(RefCell::new(Vec::new())); + let publish = recording_publisher(log.clone()); + + // Pre-arm the oneshot so the awaited accept resolves immediately. + let (tx, rx) = oneshot::channel(); + tx.send(()).unwrap(); + + let end_id = + send_oversized_transfer(frames, true, Some(rx), Duration::from_secs(1), publish) + .await + .unwrap(); + + let frames_log = log.borrow(); + assert_eq!(frames_log.len(), expected_count); + assert_eq!(frame_type(&frames_log[0]), "start"); + assert_eq!(frame_type(frames_log.last().unwrap()), "end"); + assert_eq!(end_id, nth_event_id(expected_count)); + } + + #[tokio::test] + async fn accept_is_not_awaited_when_not_needed() { + let frames = sample_frames("tok", false); + + let log = Rc::new(RefCell::new(Vec::new())); + let publish = recording_publisher(log.clone()); + + // A waiter is supplied but never fired; because `needs_accept` is false + // the driver must ignore it and complete without abort. + let (_tx, rx) = oneshot::channel(); + let result = + send_oversized_transfer(frames, false, Some(rx), Duration::from_millis(5), publish) + .await; + + assert!(result.is_ok()); + } + + #[tokio::test] + async fn missed_accept_returns_abort() { + let frames = sample_frames("tok", true); + + let log = Rc::new(RefCell::new(Vec::new())); + let publish = recording_publisher(log.clone()); + + // Keep `_tx` alive so the waiter is not "closed" — this exercises the + // timeout (elapsed) path specifically. + let (_tx, rx) = oneshot::channel(); + let result = + send_oversized_transfer(frames, true, Some(rx), Duration::from_millis(20), publish) + .await; + + match result { + Err(crate::Error::OversizedTransfer(OversizedTransferError::Abort { + token, .. + })) => assert_eq!(token, "tok"), + other => panic!("expected abort error, got {other:?}"), + } + + // Only the start frame was published before the failed handshake. + assert_eq!(log.borrow().len(), 1); + } +} diff --git a/src/transport/oversized_transfer/sizing.rs b/src/transport/oversized_transfer/sizing.rs new file mode 100644 index 0000000..975b5c3 --- /dev/null +++ b/src/transport/oversized_transfer/sizing.rs @@ -0,0 +1,278 @@ +//! CEP-22 published-event sizing helpers. +//! +//! Ports the TypeScript `measurePublishedMcpMessageSize` and +//! `resolveSafeOversizedChunkSize` from `base-nostr-transport.ts`. +//! +//! Each oversized frame is published as an independently signed (and, when +//! encrypted, gift-wrapped) Nostr event. The *published* event is materially +//! larger than its raw chunk payload: the JSON-RPC `notifications/progress` +//! envelope, the 64-byte id / 64-byte pubkey / 128-byte signature, the tags, and +//! — when encrypted — the NIP-44 + base64 gift-wrap expansion all add overhead, +//! and the chunk `data` string is escaped at every JSON layer. Picking a fixed +//! chunk size therefore risks frames much larger than intended. These helpers +//! measure the *real* published size so the sender can choose a per-chunk budget +//! that keeps every frame within a byte ceiling. + +use nostr_sdk::prelude::{Kind, PublicKey, Tag}; + +use crate::core::constants::CTXVM_MESSAGES_KIND; +use crate::core::error::Result; +use crate::core::types::{JsonRpcMessage, JsonRpcNotification}; +use crate::transport::base::BaseTransport; + +use super::frame::OversizedFrame; + +/// Probe `progressToken` used when sizing chunk frames. A UUID-length placeholder +/// so the measured envelope is representative of (and slightly conservative for) +/// real progress tokens, which the sizing signature deliberately does not thread +/// through (cf. TS, which passes the real token — a negligible byte difference). +const SIZING_PROBE_TOKEN: &str = "00000000-0000-0000-0000-000000000000"; + +/// Probe `progress` slot used when sizing chunk frames (the no-handshake +/// first-chunk slot). The slot only affects size by a digit or two. +const SIZING_PROBE_PROGRESS: u64 = 2; + +/// Build the final outbound Nostr event for `frame` — signing, and gift-wrapping +/// when `is_encrypted` — and return its serialized UTF-8 byte length. +/// +/// Mirrors TS `measurePublishedMcpMessageSize`: it reuses +/// [`BaseTransport::prepare_mcp_message`] (the rs-sdk equivalent of TS +/// `buildPublishedMcpEvent`) to produce the exact event that would hit the relay, +/// then measures `serde_json::to_string(event).len()` (Rust strings are UTF-8, so +/// `.len()` is the byte length). The caller passes the continuation-frame `tags` +/// so the measurement reflects exactly what the real chunk frames carry — the +/// client's recipient `p`-tag, or the server's response `p`+`e` tags. +pub async fn measure_published_event_size( + frame: &JsonRpcNotification, + base: &BaseTransport, + recipient: &PublicKey, + tags: &[Tag], + is_encrypted: bool, + gift_wrap_kind: Kind, +) -> Result { + let message = JsonRpcMessage::Notification(frame.clone()); + let (_event_id, published) = base + .prepare_mcp_message( + &message, + recipient, + CTXVM_MESSAGES_KIND, + tags.to_vec(), + Some(is_encrypted), + Some(gift_wrap_kind.as_u16()), + ) + .await?; + + Ok(serde_json::to_string(&published)?.len()) +} + +/// Binary-search the largest per-chunk payload size in `[1, desired]` whose +/// published frame event stays within `max_event_bytes`. +/// +/// Mirrors TS `resolveSafeOversizedChunkSize`: the probe chunk's payload is a run +/// of backslash bytes — the JSON worst case, since a backslash escapes to two +/// bytes at *every* serialization layer — so the chosen budget stays safe for +/// arbitrary real payloads. `tags` are the continuation-frame tags (passed +/// straight to [`measure_published_event_size`]). Always returns at least 1 +/// (matching TS, which never returns 0, even when a single-byte payload already +/// overflows the ceiling). +pub async fn resolve_safe_chunk_size( + desired: usize, + base: &BaseTransport, + recipient: &PublicKey, + tags: &[Tag], + is_encrypted: bool, + gift_wrap_kind: Kind, + max_event_bytes: usize, +) -> Result { + let mut low: usize = 1; + let mut high: usize = desired.max(1); + let mut best: usize = 1; + + while low <= high { + let mid = low + (high - low) / 2; + let probe = OversizedFrame::Chunk { + data: "\\".repeat(mid), + } + .into_progress_notification(SIZING_PROBE_TOKEN, SIZING_PROBE_PROGRESS, None)?; + + // A frame we cannot even build at this size is, by definition, too big and + // must be searched lower. This is not hypothetical: in the encrypted path a + // large probe's JSON double-escapes past NIP-44's ~64 KiB plaintext ceiling + // and `gift_wrap` errors — without this, the very first probe (mid = + // desired/2 ≈ 24 000 for the default config) would abort the whole resolve. + let fits = match measure_published_event_size( + &probe, + base, + recipient, + tags, + is_encrypted, + gift_wrap_kind, + ) + .await + { + Ok(size) => size <= max_event_bytes, + Err(_) => false, + }; + + if fits { + best = mid; + low = mid + 1; + } else { + // `mid >= 1` always (low starts at 1), so `mid - 1` cannot underflow. + high = mid - 1; + } + } + + Ok(best) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::Arc; + + use nostr_sdk::prelude::Keys; + + use crate::core::types::EncryptionMode; + use crate::relay::mock::MockRelayPool; + use crate::relay::RelayPoolTrait; + + fn test_base(mode: EncryptionMode) -> BaseTransport { + BaseTransport { + relay_pool: Arc::new(MockRelayPool::new()) as Arc, + encryption_mode: mode, + is_connected: true, + } + } + + fn chunk_frame(data_len: usize) -> JsonRpcNotification { + OversizedFrame::Chunk { + data: "x".repeat(data_len), + } + .into_progress_notification("tok", 2, None) + .unwrap() + } + + #[tokio::test] + async fn measure_grows_with_data_length() { + let base = test_base(EncryptionMode::Disabled); + let recipient = Keys::generate().public_key(); + let tags = BaseTransport::create_recipient_tags(&recipient); + + let small = measure_published_event_size( + &chunk_frame(10), + &base, + &recipient, + &tags, + false, + Kind::Custom(crate::core::constants::GIFT_WRAP_KIND), + ) + .await + .unwrap(); + let large = measure_published_event_size( + &chunk_frame(1000), + &base, + &recipient, + &tags, + false, + Kind::Custom(crate::core::constants::GIFT_WRAP_KIND), + ) + .await + .unwrap(); + + assert!(large > small, "larger data must yield a larger event"); + assert!(small > 0); + } + + #[tokio::test] + async fn resolve_never_exceeds_desired_and_is_at_least_one() { + let base = test_base(EncryptionMode::Disabled); + let recipient = Keys::generate().public_key(); + let tags = BaseTransport::create_recipient_tags(&recipient); + + // A tiny ceiling that even a 1-byte frame overflows still yields 1. + let clamped = resolve_safe_chunk_size( + 5000, + &base, + &recipient, + &tags, + false, + Kind::Custom(crate::core::constants::GIFT_WRAP_KIND), + 10, + ) + .await + .unwrap(); + assert_eq!(clamped, 1, "an unsatisfiable ceiling must still return 1"); + + // The result is capped at `desired`. + let capped = resolve_safe_chunk_size( + 8, + &base, + &recipient, + &tags, + false, + Kind::Custom(crate::core::constants::GIFT_WRAP_KIND), + 10_000_000, + ) + .await + .unwrap(); + assert_eq!(capped, 8, "a generous ceiling must cap at `desired`"); + } + + #[tokio::test] + async fn resolve_is_monotonic_in_budget() { + let base = test_base(EncryptionMode::Disabled); + let recipient = Keys::generate().public_key(); + let tags = BaseTransport::create_recipient_tags(&recipient); + let gw = Kind::Custom(crate::core::constants::GIFT_WRAP_KIND); + + let small_budget = + resolve_safe_chunk_size(48_000, &base, &recipient, &tags, false, gw, 4_000) + .await + .unwrap(); + let large_budget = + resolve_safe_chunk_size(48_000, &base, &recipient, &tags, false, gw, 16_000) + .await + .unwrap(); + + assert!( + large_budget >= small_budget, + "a larger byte ceiling must allow an equal-or-larger chunk ({large_budget} >= {small_budget})" + ); + } + + #[tokio::test] + async fn encrypted_frames_force_smaller_chunks_than_plaintext() { + let recipient = Keys::generate().public_key(); + let tags = BaseTransport::create_recipient_tags(&recipient); + let gw = Kind::Custom(crate::core::constants::GIFT_WRAP_KIND); + + let plain = resolve_safe_chunk_size( + 48_000, + &test_base(EncryptionMode::Disabled), + &recipient, + &tags, + false, + gw, + 8_000, + ) + .await + .unwrap(); + let encrypted = resolve_safe_chunk_size( + 48_000, + &test_base(EncryptionMode::Required), + &recipient, + &tags, + true, + gw, + 8_000, + ) + .await + .unwrap(); + + assert!( + encrypted < plain, + "gift-wrap expansion must shrink the safe chunk ({encrypted} < {plain})" + ); + } +} diff --git a/src/transport/server.rs b/src/transport/server.rs deleted file mode 100644 index 43d97a8..0000000 --- a/src/transport/server.rs +++ /dev/null @@ -1,755 +0,0 @@ -//! Server-side Nostr transport for ContextVM. -//! -//! Listens for incoming MCP requests from clients over Nostr, manages multi-client -//! sessions, handles request/response correlation, and optionally publishes -//! server announcements. - -use std::collections::HashMap; -use std::sync::Arc; -use std::time::{Duration, Instant}; - -use nostr_sdk::prelude::*; -use tokio::sync::RwLock; - -use crate::core::constants::*; -use crate::core::error::{Error, Result}; -use crate::core::serializers; -use crate::core::types::*; -use crate::encryption; -use crate::relay::RelayPool; -use crate::transport::base::BaseTransport; - -/// Configuration for the server transport. -pub struct NostrServerTransportConfig { - /// Relay URLs to connect to. - pub relay_urls: Vec, - /// Encryption mode. - pub encryption_mode: EncryptionMode, - /// Server information for announcements. - pub server_info: Option, - /// Whether this is a public server (publishes announcements). - pub is_public_server: bool, - /// Allowed client public keys (hex). Empty = allow all. - pub allowed_public_keys: Vec, - /// Capabilities excluded from pubkey whitelisting. - pub excluded_capabilities: Vec, - /// Session cleanup interval (default: 60s). - pub cleanup_interval: Duration, - /// Session timeout (default: 300s). - pub session_timeout: Duration, -} - -impl Default for NostrServerTransportConfig { - fn default() -> Self { - Self { - relay_urls: vec!["wss://relay.damus.io".to_string()], - encryption_mode: EncryptionMode::Optional, - server_info: None, - is_public_server: false, - allowed_public_keys: Vec::new(), - excluded_capabilities: Vec::new(), - cleanup_interval: Duration::from_secs(60), - session_timeout: Duration::from_secs(300), - } - } -} - -/// Server-side Nostr transport — receives MCP requests and sends responses. -pub struct NostrServerTransport { - base: BaseTransport, - config: NostrServerTransportConfig, - /// Client sessions: client_pubkey_hex → ClientSession - sessions: Arc>>, - /// Reverse lookup: event_id → client_pubkey_hex - event_to_client: Arc>>, - /// Channel for incoming MCP messages (consumed by the MCP server). - message_tx: tokio::sync::mpsc::UnboundedSender, - message_rx: Option>, -} - -/// An incoming MCP request with metadata for routing the response. -#[derive(Debug)] -pub struct IncomingRequest { - /// The parsed MCP message. - pub message: JsonRpcMessage, - /// The client's public key (hex). - pub client_pubkey: String, - /// The Nostr event ID (for response correlation). - pub event_id: String, - /// Whether the original message was encrypted. - pub is_encrypted: bool, -} - -impl NostrServerTransport { - /// Create a new server transport. - pub async fn new(signer: T, config: NostrServerTransportConfig) -> Result - where - T: IntoNostrSigner, - { - let relay_pool = Arc::new(RelayPool::new(signer).await?); - let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); - - Ok(Self { - base: BaseTransport { - relay_pool, - encryption_mode: config.encryption_mode, - is_connected: false, - }, - config, - sessions: Arc::new(RwLock::new(HashMap::new())), - event_to_client: Arc::new(RwLock::new(HashMap::new())), - message_tx: tx, - message_rx: Some(rx), - }) - } - - /// Start listening for incoming requests. - pub async fn start(&mut self) -> Result<()> { - self.base.connect(&self.config.relay_urls).await?; - - let pubkey = self.base.get_public_key().await?; - tracing::info!(pubkey = %pubkey.to_hex(), "Server transport started"); - - self.base.subscribe_for_pubkey(&pubkey).await?; - - // Spawn event loop - let client = self.base.relay_pool.client().clone(); - let sessions = self.sessions.clone(); - let event_to_client = self.event_to_client.clone(); - let tx = self.message_tx.clone(); - let allowed = self.config.allowed_public_keys.clone(); - let excluded = self.config.excluded_capabilities.clone(); - let encryption_mode = self.config.encryption_mode; - - tokio::spawn(async move { - Self::event_loop(client, sessions, event_to_client, tx, allowed, excluded, encryption_mode).await; - }); - - // Spawn session cleanup - let sessions_cleanup = self.sessions.clone(); - let event_to_client_cleanup = self.event_to_client.clone(); - let cleanup_interval = self.config.cleanup_interval; - let session_timeout = self.config.session_timeout; - - tokio::spawn(async move { - let mut interval = tokio::time::interval(cleanup_interval); - loop { - interval.tick().await; - let cleaned = Self::cleanup_sessions( - &sessions_cleanup, - &event_to_client_cleanup, - session_timeout, - ) - .await; - if cleaned > 0 { - tracing::info!(cleaned, "Cleaned up inactive sessions"); - } - } - }); - - Ok(()) - } - - /// Close the transport. - pub async fn close(&mut self) -> Result<()> { - self.base.disconnect().await?; - self.sessions.write().await.clear(); - self.event_to_client.write().await.clear(); - Ok(()) - } - - /// Send a response back to the client that sent the original request. - pub async fn send_response( - &self, - event_id: &str, - mut response: JsonRpcMessage, - ) -> Result<()> { - let event_to_client = self.event_to_client.read().await; - let client_pubkey_hex = event_to_client - .get(event_id) - .ok_or_else(|| Error::Other(format!("No client found for event {event_id}")))? - .clone(); - drop(event_to_client); - - let sessions = self.sessions.read().await; - let session = sessions - .get(&client_pubkey_hex) - .ok_or_else(|| Error::Other(format!("No session for client {client_pubkey_hex}")))?; - - // Restore original request ID - if let Some(original_id) = session.pending_requests.get(event_id) { - match &mut response { - JsonRpcMessage::Response(r) => r.id = original_id.clone(), - JsonRpcMessage::ErrorResponse(r) => r.id = original_id.clone(), - _ => {} - } - } - - let is_encrypted = session.is_encrypted; - drop(sessions); - - let client_pubkey = PublicKey::from_hex(&client_pubkey_hex) - .map_err(|e| Error::Other(e.to_string()))?; - - let event_id_parsed = - EventId::from_hex(event_id).map_err(|e| Error::Other(e.to_string()))?; - - let tags = BaseTransport::create_response_tags(&client_pubkey, &event_id_parsed); - - self.base - .send_mcp_message( - &response, - &client_pubkey, - CTXVM_MESSAGES_KIND, - tags, - Some(is_encrypted), - ) - .await?; - - // Clean up - let mut sessions = self.sessions.write().await; - if let Some(session) = sessions.get_mut(&client_pubkey_hex) { - // Clean up progress token - if let Some(token) = session.event_to_progress_token.remove(event_id) { - session.pending_requests.remove(&token); - } - session.pending_requests.remove(event_id); - } - drop(sessions); - - self.event_to_client.write().await.remove(event_id); - - Ok(()) - } - - /// Send a notification to a specific client. - pub async fn send_notification( - &self, - client_pubkey_hex: &str, - notification: &JsonRpcMessage, - correlated_event_id: Option<&str>, - ) -> Result<()> { - let sessions = self.sessions.read().await; - let session = sessions - .get(client_pubkey_hex) - .ok_or_else(|| Error::Other(format!("No session for {client_pubkey_hex}")))?; - let is_encrypted = session.is_encrypted; - drop(sessions); - - let client_pubkey = PublicKey::from_hex(client_pubkey_hex) - .map_err(|e| Error::Other(e.to_string()))?; - - let mut tags = BaseTransport::create_recipient_tags(&client_pubkey); - if let Some(eid) = correlated_event_id { - let event_id = EventId::from_hex(eid).map_err(|e| Error::Other(e.to_string()))?; - tags.push(Tag::event(event_id)); - } - - self.base - .send_mcp_message( - notification, - &client_pubkey, - CTXVM_MESSAGES_KIND, - tags, - Some(is_encrypted), - ) - .await?; - - Ok(()) - } - - /// Broadcast a notification to all initialized clients. - pub async fn broadcast_notification(&self, notification: &JsonRpcMessage) -> Result<()> { - let sessions = self.sessions.read().await; - let initialized: Vec = sessions - .iter() - .filter(|(_, s)| s.is_initialized) - .map(|(k, _)| k.clone()) - .collect(); - drop(sessions); - - for pubkey in initialized { - if let Err(e) = self.send_notification(&pubkey, notification, None).await { - tracing::error!(client = %pubkey, "Failed to send notification: {e}"); - } - } - Ok(()) - } - - /// Take the message receiver for consuming incoming requests. - pub fn take_message_receiver( - &mut self, - ) -> Option> { - self.message_rx.take() - } - - /// Publish server announcement (kind 11316). - pub async fn announce(&self) -> Result { - let info = self - .config - .server_info - .as_ref() - .ok_or_else(|| Error::Other("No server info configured".to_string()))?; - - let content = serde_json::to_string(info)?; - - let mut tags = Vec::new(); - if let Some(ref name) = info.name { - tags.push(Tag::custom( - TagKind::Custom(tags::NAME.into()), - vec![name.clone()], - )); - } - if let Some(ref about) = info.about { - tags.push(Tag::custom( - TagKind::Custom(tags::ABOUT.into()), - vec![about.clone()], - )); - } - if let Some(ref website) = info.website { - tags.push(Tag::custom( - TagKind::Custom(tags::WEBSITE.into()), - vec![website.clone()], - )); - } - if let Some(ref picture) = info.picture { - tags.push(Tag::custom( - TagKind::Custom(tags::PICTURE.into()), - vec![picture.clone()], - )); - } - if self.config.encryption_mode != EncryptionMode::Disabled { - tags.push(Tag::custom( - TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), - Vec::::new(), - )); - } - - let builder = - EventBuilder::new(Kind::Custom(SERVER_ANNOUNCEMENT_KIND), content).tags(tags); - - self.base.relay_pool.publish(builder).await - } - - /// Publish tools list (kind 11317). - pub async fn publish_tools(&self, tools: Vec) -> Result { - let content = serde_json::json!({ "tools": tools }); - let builder = EventBuilder::new( - Kind::Custom(TOOLS_LIST_KIND), - serde_json::to_string(&content)?, - ); - self.base.relay_pool.publish(builder).await - } - - /// Publish resources list (kind 11318). - pub async fn publish_resources(&self, resources: Vec) -> Result { - let content = serde_json::json!({ "resources": resources }); - let builder = EventBuilder::new( - Kind::Custom(RESOURCES_LIST_KIND), - serde_json::to_string(&content)?, - ); - self.base.relay_pool.publish(builder).await - } - - /// Publish prompts list (kind 11320). - pub async fn publish_prompts(&self, prompts: Vec) -> Result { - let content = serde_json::json!({ "prompts": prompts }); - let builder = EventBuilder::new( - Kind::Custom(PROMPTS_LIST_KIND), - serde_json::to_string(&content)?, - ); - self.base.relay_pool.publish(builder).await - } - - /// Publish resource templates list (kind 11319). - pub async fn publish_resource_templates( - &self, - templates: Vec, - ) -> Result { - let content = serde_json::json!({ "resourceTemplates": templates }); - let builder = EventBuilder::new( - Kind::Custom(RESOURCETEMPLATES_LIST_KIND), - serde_json::to_string(&content)?, - ); - self.base.relay_pool.publish(builder).await - } - - /// Delete server announcements (NIP-09 kind 5). - pub async fn delete_announcements(&self, reason: &str) -> Result<()> { - // We publish kind 5 events for each announcement kind - let pubkey = self.base.get_public_key().await?; - let _pubkey_hex = pubkey.to_hex(); - - for kind in UNENCRYPTED_KINDS { - let builder = EventBuilder::new(Kind::Custom(5), reason) - .tag(Tag::custom( - TagKind::Custom("k".into()), - vec![kind.to_string()], - )); - self.base.relay_pool.publish(builder).await?; - } - Ok(()) - } - - // ── Internal ──────────────────────────────────────────────── - - fn is_capability_excluded( - excluded: &[CapabilityExclusion], - method: &str, - name: Option<&str>, - ) -> bool { - // Always allow fundamental MCP methods - if method == "initialize" || method == "notifications/initialized" { - return true; - } - - excluded.iter().any(|excl| { - if excl.method != method { - return false; - } - match (&excl.name, name) { - (Some(excl_name), Some(req_name)) => excl_name == req_name, - (None, _) => true, // method-only match - _ => false, - } - }) - } - - async fn event_loop( - client: Arc, - sessions: Arc>>, - event_to_client: Arc>>, - tx: tokio::sync::mpsc::UnboundedSender, - allowed_pubkeys: Vec, - excluded_capabilities: Vec, - encryption_mode: EncryptionMode, - ) { - let mut notifications = client.notifications(); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Event { event, .. } = notification { - let (content, sender_pubkey, event_id, is_encrypted) = - if event.kind == Kind::Custom(GIFT_WRAP_KIND) { - if encryption_mode == EncryptionMode::Disabled { - tracing::warn!("Received encrypted message but encryption is disabled"); - continue; - } - // Single-layer NIP-44 decrypt (matches JS/TS SDK) - let signer = match client.signer().await { - Ok(s) => s, - Err(e) => { - tracing::error!("Failed to get signer: {e}"); - continue; - } - }; - match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await { - Ok(decrypted_json) => { - // The decrypted content is JSON of the inner signed event. - // Use the INNER event's ID for correlation — the client - // registers the inner event ID in its correlation store. - match serde_json::from_str::(&decrypted_json) { - Ok(inner) => ( - inner.content, - inner.pubkey.to_hex(), - inner.id.to_hex(), - true, - ), - Err(e) => { - tracing::error!("Failed to parse inner event: {e}"); - continue; - } - } - } - Err(e) => { - tracing::error!("Failed to decrypt: {e}"); - continue; - } - } - } else { - if encryption_mode == EncryptionMode::Required { - tracing::warn!( - pubkey = %event.pubkey, - "Received unencrypted message but encryption is required" - ); - continue; - } - ( - event.content.clone(), - event.pubkey.to_hex(), - event.id.to_hex(), - false, - ) - }; - - // Parse MCP message - let mcp_msg = match serializers::nostr_event_to_mcp_message(&content) { - Some(msg) => msg, - None => { - tracing::warn!("Invalid MCP message from {sender_pubkey}"); - continue; - } - }; - - // Authorization check - if !allowed_pubkeys.is_empty() { - let method = mcp_msg.method().unwrap_or(""); - let name = match &mcp_msg { - JsonRpcMessage::Request(r) => r - .params - .as_ref() - .and_then(|p| p.get("name")) - .and_then(|n| n.as_str()), - _ => None, - }; - - let is_excluded = - Self::is_capability_excluded(&excluded_capabilities, method, name); - - if !allowed_pubkeys.contains(&sender_pubkey) && !is_excluded { - tracing::warn!( - pubkey = %sender_pubkey, - method = %method, - "Unauthorized request" - ); - continue; - } - } - - // Session management - let mut sessions_w = sessions.write().await; - let session = sessions_w - .entry(sender_pubkey.clone()) - .or_insert_with(|| ClientSession::new(is_encrypted)); - session.update_activity(); - session.is_encrypted = is_encrypted; - - // Track request for correlation - if let JsonRpcMessage::Request(ref req) = mcp_msg { - let original_id = req.id.clone(); - session - .pending_requests - .insert(event_id.clone(), original_id); - event_to_client - .write() - .await - .insert(event_id.clone(), sender_pubkey.clone()); - - // Track progress token - if let Some(token) = req - .params - .as_ref() - .and_then(|p| p.get("_meta")) - .and_then(|m| m.get("progressToken")) - .and_then(|t| t.as_str()) - { - session - .pending_requests - .insert(token.to_string(), serde_json::json!(event_id)); - session - .event_to_progress_token - .insert(event_id.clone(), token.to_string()); - } - } - - // Handle initialized notification - if let JsonRpcMessage::Notification(ref n) = mcp_msg { - if n.method == "notifications/initialized" { - session.is_initialized = true; - } - } - - drop(sessions_w); - - // Forward to consumer - let _ = tx.send(IncomingRequest { - message: mcp_msg, - client_pubkey: sender_pubkey, - event_id, - is_encrypted, - }); - } - } - } - - async fn cleanup_sessions( - sessions: &RwLock>, - event_to_client: &RwLock>, - timeout: Duration, - ) -> usize { - let mut sessions_w = sessions.write().await; - let mut event_map = event_to_client.write().await; - let mut cleaned = 0; - - sessions_w.retain(|pubkey, session| { - if session.last_activity.elapsed() > timeout { - // Clean up reverse mappings - for event_id in session.pending_requests.keys() { - event_map.remove(event_id); - } - for event_id in session.event_to_progress_token.keys() { - event_map.remove(event_id); - } - tracing::debug!(client = %pubkey, "Session expired"); - cleaned += 1; - false - } else { - true - } - }); - - cleaned - } -} - -#[cfg(test)] -mod tests { - use super::*; - use std::thread; - - // ── Session management ────────────────────────────────────── - - #[test] - fn test_client_session_creation() { - let session = ClientSession::new(true); - assert!(!session.is_initialized); - assert!(session.is_encrypted); - assert!(session.pending_requests.is_empty()); - assert!(session.event_to_progress_token.is_empty()); - } - - #[test] - fn test_client_session_update_activity() { - let mut session = ClientSession::new(false); - let first = session.last_activity; - thread::sleep(Duration::from_millis(10)); - session.update_activity(); - assert!(session.last_activity > first); - } - - #[tokio::test] - async fn test_cleanup_sessions_removes_expired() { - let sessions = Arc::new(RwLock::new(HashMap::new())); - let event_to_client = Arc::new(RwLock::new(HashMap::new())); - - // Insert a session with an old activity time - let mut session = ClientSession::new(false); - session.pending_requests.insert("evt1".to_string(), serde_json::json!(1)); - sessions.write().await.insert("pubkey1".to_string(), session); - event_to_client.write().await.insert("evt1".to_string(), "pubkey1".to_string()); - - // With a long timeout, nothing should be cleaned - let cleaned = NostrServerTransport::cleanup_sessions( - &sessions, &event_to_client, Duration::from_secs(300), - ).await; - assert_eq!(cleaned, 0); - assert_eq!(sessions.read().await.len(), 1); - - // With zero timeout, it should be cleaned - thread::sleep(Duration::from_millis(5)); - let cleaned = NostrServerTransport::cleanup_sessions( - &sessions, &event_to_client, Duration::from_millis(1), - ).await; - assert_eq!(cleaned, 1); - assert!(sessions.read().await.is_empty()); - assert!(event_to_client.read().await.is_empty()); - } - - #[tokio::test] - async fn test_cleanup_preserves_active_sessions() { - let sessions = Arc::new(RwLock::new(HashMap::new())); - let event_to_client = Arc::new(RwLock::new(HashMap::new())); - - let session = ClientSession::new(false); - sessions.write().await.insert("active".to_string(), session); - - let cleaned = NostrServerTransport::cleanup_sessions( - &sessions, &event_to_client, Duration::from_secs(300), - ).await; - assert_eq!(cleaned, 0); - assert_eq!(sessions.read().await.len(), 1); - } - - // ── Request ID correlation ────────────────────────────────── - - #[test] - fn test_pending_request_tracking() { - let mut session = ClientSession::new(false); - session.pending_requests.insert("event_abc".to_string(), serde_json::json!(42)); - assert_eq!(session.pending_requests.get("event_abc"), Some(&serde_json::json!(42))); - } - - #[test] - fn test_progress_token_tracking() { - let mut session = ClientSession::new(false); - session.event_to_progress_token.insert("evt1".to_string(), "token1".to_string()); - session.pending_requests.insert("token1".to_string(), serde_json::json!("evt1")); - assert_eq!(session.event_to_progress_token.get("evt1"), Some(&"token1".to_string())); - } - - // ── Authorization (is_capability_excluded) ────────────────── - - #[test] - fn test_initialize_always_excluded() { - assert!(NostrServerTransport::is_capability_excluded(&[], "initialize", None)); - assert!(NostrServerTransport::is_capability_excluded(&[], "notifications/initialized", None)); - } - - #[test] - fn test_method_excluded_without_name() { - let exclusions = vec![CapabilityExclusion { - method: "tools/list".to_string(), - name: None, - }]; - assert!(NostrServerTransport::is_capability_excluded(&exclusions, "tools/list", None)); - assert!(NostrServerTransport::is_capability_excluded(&exclusions, "tools/list", Some("anything"))); - } - - #[test] - fn test_method_excluded_with_name() { - let exclusions = vec![CapabilityExclusion { - method: "tools/call".to_string(), - name: Some("get_weather".to_string()), - }]; - assert!(NostrServerTransport::is_capability_excluded(&exclusions, "tools/call", Some("get_weather"))); - assert!(!NostrServerTransport::is_capability_excluded(&exclusions, "tools/call", Some("other_tool"))); - assert!(!NostrServerTransport::is_capability_excluded(&exclusions, "tools/call", None)); - } - - #[test] - fn test_non_excluded_method() { - let exclusions = vec![CapabilityExclusion { - method: "tools/list".to_string(), - name: None, - }]; - assert!(!NostrServerTransport::is_capability_excluded(&exclusions, "tools/call", None)); - assert!(!NostrServerTransport::is_capability_excluded(&exclusions, "resources/list", None)); - } - - #[test] - fn test_empty_exclusions_non_init_method() { - assert!(!NostrServerTransport::is_capability_excluded(&[], "tools/list", None)); - assert!(!NostrServerTransport::is_capability_excluded(&[], "tools/call", Some("x"))); - } - - // ── Encryption mode enforcement ───────────────────────────── - - #[test] - fn test_encryption_mode_default() { - let config = NostrServerTransportConfig::default(); - assert_eq!(config.encryption_mode, EncryptionMode::Optional); - } - - // ── Config defaults ───────────────────────────────────────── - - #[test] - fn test_config_defaults() { - let config = NostrServerTransportConfig::default(); - assert_eq!(config.relay_urls, vec!["wss://relay.damus.io".to_string()]); - assert!(!config.is_public_server); - assert!(config.allowed_public_keys.is_empty()); - assert!(config.excluded_capabilities.is_empty()); - assert_eq!(config.cleanup_interval, Duration::from_secs(60)); - assert_eq!(config.session_timeout, Duration::from_secs(300)); - assert!(config.server_info.is_none()); - } -} diff --git a/src/transport/server/announcement_manager.rs b/src/transport/server/announcement_manager.rs new file mode 100644 index 0000000..c680899 --- /dev/null +++ b/src/transport/server/announcement_manager.rs @@ -0,0 +1,1999 @@ +//! Announcement tag management for the server transport. +//! +//! Encapsulates tag composition, caching, and publishing for CEP-6 server +//! announcements (kinds 11316–11320) and CEP-35 first-response discovery. + +use std::collections::HashSet; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use nostr_sdk::prelude::*; +use tokio::sync::Notify; + +use super::IncomingRequest; +use crate::core::constants::*; +use crate::core::error::{Error, Result}; +use crate::core::types::*; +use crate::relay::RelayPoolTrait; + +const LOG_TARGET: &str = "contextvm_sdk::transport::server::announcement"; + +/// Default timeout waiting for the rmcp handler to respond to the synthetic +/// initialize request during announcement auto-publish. +#[cfg_attr(not(feature = "rmcp"), allow(dead_code))] +const ANNOUNCEMENT_INIT_TIMEOUT: Duration = Duration::from_secs(10); + +/// Manages tag composition and publishing for server announcements. +/// +/// Handles CEP-6 announcement event publishing (kinds 11316–11320) and +/// CEP-35 common tag composition for first-response discovery replay. +/// Tag accessors use interior-mutability caching so callers may hold `&self`. +pub(crate) struct AnnouncementManager { + /// Shared relay pool for publishing announcement events. + relay_pool: Arc, + /// Server metadata for announcement tags. + server_info: Option, + /// Encryption mode — determines whether encryption tags are emitted. + encryption_mode: EncryptionMode, + /// Gift-wrap mode — determines whether ephemeral tag is emitted. + gift_wrap_mode: GiftWrapMode, + /// User-provided extra tags (e.g. PMI discovery for CEP-8). + extra_common_tags: Vec, + /// Transport-owned internal tags (e.g. CEP-22 oversized support signal). + internal_common_tags: Vec, + /// CEP-8 pricing tags for capability list responses. + pricing_tags: Vec, + /// Cached result of `get_common_tags()`. Invalidated by tag setters. + cached_common_tags: Mutex>>, + /// Channel for injecting synthetic MCP messages into the transport's inbound queue. + /// Wrapped in `Option` so it can be dropped during `close()` — otherwise this + /// clone keeps the message channel alive after `message_tx` is taken. + dispatch_fn: Option>, + /// Notifier signaled when the announcement init response has been processed. + init_notify: Arc, + /// Whether the announcement initialization has completed. + /// Only read by `handle_announcement_response`, which is called from the + /// rmcp worker — unused when the `rmcp` feature is disabled. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + initialized: Mutex, + /// Transport's connected relay URLs (fallback for `relay_list_urls`). + relay_urls: Vec, + /// Explicit relay URLs to advertise in the kind 10002 relay list event. + relay_list_urls: Option>, + /// Additional bootstrap relay URLs for discoverability publication. + bootstrap_relay_urls: Option>, + /// Whether to publish a relay list event (kind 10002). + should_publish_relay_list: bool, + /// NIP-01 profile metadata (kind 0) to publish at startup. + profile_metadata: Option, +} + +/// Maps an MCP result schema to the Nostr event kind for announcement publishing. +/// +/// The `matches` function validates the result's structure (not just field presence), +/// matching the TS SDK's Zod `safeParse` semantics. +struct AnnouncementMapping { + matches: fn(&serde_json::Value) -> bool, + kind: u16, +} + +/// Schema-to-kind mapping table for MCP announcement responses. +/// +/// Checked in order — first match wins. Validation checks field types: +/// `protocolVersion` must be a string, `capabilities` must be an object, +/// and capability lists (`tools`, `resources`, etc.) must be arrays. +const ANNOUNCEMENT_MAPPINGS: &[AnnouncementMapping] = &[ + AnnouncementMapping { + matches: |r| r.get("protocolVersion").is_some_and(|v| v.is_string()), + kind: SERVER_ANNOUNCEMENT_KIND, + }, + AnnouncementMapping { + matches: |r| r.get("capabilities").is_some_and(|v| v.is_object()), + kind: SERVER_ANNOUNCEMENT_KIND, + }, + AnnouncementMapping { + matches: |r| r.get("tools").is_some_and(|v| v.is_array()), + kind: TOOLS_LIST_KIND, + }, + AnnouncementMapping { + matches: |r| r.get("resources").is_some_and(|v| v.is_array()), + kind: RESOURCES_LIST_KIND, + }, + AnnouncementMapping { + matches: |r| r.get("resourceTemplates").is_some_and(|v| v.is_array()), + kind: RESOURCETEMPLATES_LIST_KIND, + }, + AnnouncementMapping { + matches: |r| r.get("prompts").is_some_and(|v| v.is_array()), + kind: PROMPTS_LIST_KIND, + }, +]; + +/// Check whether a relay URL points to a local address. +/// +/// Used for smart bootstrap relay detection: default bootstrap relays are +/// skipped when all advertised relays are local and no explicit bootstrap +/// URLs were provided. +fn is_local_relay_url(url: &str) -> bool { + let without_proto = url + .strip_prefix("wss://") + .or_else(|| url.strip_prefix("ws://")) + .unwrap_or(url); + let lower = without_proto.to_lowercase(); + for prefix in &["localhost", "127.0.0.1", "0.0.0.0", "[::1]"] { + if let Some(rest) = lower.strip_prefix(prefix) { + if rest.is_empty() || rest.starts_with(':') || rest.starts_with('/') { + return true; + } + } + } + false +} + +impl AnnouncementManager { + /// Create a new announcement manager. + /// + /// `dispatch_fn` is a clone of the transport's `message_tx` channel, used to + /// inject synthetic MCP messages (initialize, notifications/initialized, + /// capability list requests) during the auto-publish flow. + #[allow(clippy::too_many_arguments)] + pub fn new( + relay_pool: Arc, + server_info: Option, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + dispatch_fn: tokio::sync::mpsc::UnboundedSender, + relay_urls: Vec, + relay_list_urls: Option>, + bootstrap_relay_urls: Option>, + should_publish_relay_list: bool, + profile_metadata: Option, + ) -> Self { + Self { + relay_pool, + server_info, + encryption_mode, + gift_wrap_mode, + extra_common_tags: Vec::new(), + internal_common_tags: Vec::new(), + pricing_tags: Vec::new(), + cached_common_tags: Mutex::new(None), + dispatch_fn: Some(dispatch_fn), + init_notify: Arc::new(Notify::new()), + initialized: Mutex::new(false), + relay_urls, + relay_list_urls, + bootstrap_relay_urls, + should_publish_relay_list, + profile_metadata, + } + } + + // ── Tag accessors ────────────────────────────────────────────── + + /// Build server identity tags (name, about, website, picture). + pub fn get_server_info_tags(&self) -> Vec { + let mut tags = Vec::new(); + if let Some(ref info) = self.server_info { + if let Some(ref name) = info.name { + tags.push(Tag::custom( + TagKind::Custom(tags::NAME.into()), + vec![name.clone()], + )); + } + if let Some(ref about) = info.about { + tags.push(Tag::custom( + TagKind::Custom(tags::ABOUT.into()), + vec![about.clone()], + )); + } + if let Some(ref website) = info.website { + tags.push(Tag::custom( + TagKind::Custom(tags::WEBSITE.into()), + vec![website.clone()], + )); + } + if let Some(ref picture) = info.picture { + tags.push(Tag::custom( + TagKind::Custom(tags::PICTURE.into()), + vec![picture.clone()], + )); + } + } + tags + } + + /// Build the **encryption** capability tags based on encryption and gift-wrap + /// mode. Returns encryption capability tags only; CEP-22 oversized transfer and + /// other capabilities are contributed separately via `internal_common_tags`. + pub fn get_encryption_capability_tags(&self) -> Vec { + let mut tags = Vec::new(); + if self.encryption_mode != EncryptionMode::Disabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + )); + if self.gift_wrap_mode.supports_ephemeral() { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )); + } + } + tags + } + + /// Returns combined common tags: server info + capability + extra + internal. + /// + /// Results are cached; the cache is invalidated when extra or internal + /// common tags are updated via their setters. + pub fn get_common_tags(&self) -> Vec { + let mut cache = self + .cached_common_tags + .lock() + .unwrap_or_else(|e| e.into_inner()); + if let Some(ref cached) = *cache { + return cached.clone(); + } + let mut tags = self.get_server_info_tags(); + tags.extend(self.get_encryption_capability_tags()); + tags.extend(self.extra_common_tags.iter().cloned()); + tags.extend(self.internal_common_tags.iter().cloned()); + *cache = Some(tags.clone()); + tags + } + + /// Returns a reference to the current pricing tags. + #[allow(dead_code)] // API reserved for CEP-8 pricing integration + pub fn get_pricing_tags(&self) -> &[Tag] { + &self.pricing_tags + } + + /// Build tags for a specific announcement kind. + /// + /// Kind 11316 (server announcement) receives common + pricing tags. + /// Kinds 11317–11320 (capability lists) receive pricing tags only. + pub fn get_announcement_tags(&self, kind: u16) -> Vec { + if kind == SERVER_ANNOUNCEMENT_KIND { + let mut tags = self.get_common_tags(); + tags.extend(self.pricing_tags.iter().cloned()); + tags + } else { + self.pricing_tags.clone() + } + } + + // ── Setters ──────────────────────────────────────────────────── + + /// Set user-provided extra common tags (e.g. PMI discovery for CEP-8). + /// + /// Invalidates the common tags cache. + pub fn set_extra_common_tags(&mut self, tags: Vec) { + self.extra_common_tags = tags; + *self + .cached_common_tags + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + } + + /// Set transport-owned internal common tags (e.g. CEP-22 oversized support). + /// + /// Invalidates the common tags cache. + pub fn set_internal_common_tags(&mut self, tags: Vec) { + self.internal_common_tags = tags; + *self + .cached_common_tags + .lock() + .unwrap_or_else(|e| e.into_inner()) = None; + } + + /// Set CEP-8 pricing tags for capability list responses. + /// + /// Does not invalidate the common tags cache (pricing is separate). + pub fn set_pricing_tags(&mut self, tags: Vec) { + self.pricing_tags = tags; + } + + // ── Publish methods ──────────────────────────────────────────── + + /// Publish server announcement (kind 11316). + pub async fn announce(&self) -> Result { + let info = self + .server_info + .as_ref() + .ok_or_else(|| Error::Other("No server info configured".to_string()))?; + + let content = serde_json::to_string(info)?; + let tags = self.get_announcement_tags(SERVER_ANNOUNCEMENT_KIND); + let builder = EventBuilder::new(Kind::Custom(SERVER_ANNOUNCEMENT_KIND), content).tags(tags); + self.relay_pool.publish(builder).await + } + + /// Publish tools list (kind 11317). + pub async fn publish_tools(&self, tools: Vec) -> Result { + let content = serde_json::json!({ "tools": tools }); + let builder = EventBuilder::new( + Kind::Custom(TOOLS_LIST_KIND), + serde_json::to_string(&content)?, + ) + .tags(self.pricing_tags.iter().cloned()); + self.relay_pool.publish(builder).await + } + + /// Publish resources list (kind 11318). + pub async fn publish_resources(&self, resources: Vec) -> Result { + let content = serde_json::json!({ "resources": resources }); + let builder = EventBuilder::new( + Kind::Custom(RESOURCES_LIST_KIND), + serde_json::to_string(&content)?, + ) + .tags(self.pricing_tags.iter().cloned()); + self.relay_pool.publish(builder).await + } + + /// Publish prompts list (kind 11320). + pub async fn publish_prompts(&self, prompts: Vec) -> Result { + let content = serde_json::json!({ "prompts": prompts }); + let builder = EventBuilder::new( + Kind::Custom(PROMPTS_LIST_KIND), + serde_json::to_string(&content)?, + ) + .tags(self.pricing_tags.iter().cloned()); + self.relay_pool.publish(builder).await + } + + /// Publish resource templates list (kind 11319). + pub async fn publish_resource_templates( + &self, + templates: Vec, + ) -> Result { + let content = serde_json::json!({ "resourceTemplates": templates }); + let builder = EventBuilder::new( + Kind::Custom(RESOURCETEMPLATES_LIST_KIND), + serde_json::to_string(&content)?, + ) + .tags(self.pricing_tags.iter().cloned()); + self.relay_pool.publish(builder).await + } + + /// Delete server announcements (NIP-09 kind 5). + /// + /// Queries existing announcement events per kind and publishes deletion + /// events with `["e", event_id]` tags via `EventBuilder::delete()`. + pub async fn delete_announcements(&self, reason: &str) -> Result<()> { + let pubkey = self.relay_pool.public_key().await?; + for &kind in UNENCRYPTED_KINDS { + let filter = Filter::new().kind(Kind::Custom(kind)).author(pubkey); + let events = self + .relay_pool + .fetch_events(vec![filter], Duration::from_secs(10)) + .await?; + if events.is_empty() { + continue; + } + let request = EventDeletionRequest::new() + .ids(events.iter().map(|e| e.id)) + .reason(reason); + self.relay_pool + .publish(EventBuilder::delete(request)) + .await?; + } + Ok(()) + } + + /// Publish tools list from rmcp typed tool descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_tools_typed(&self, tools: Vec) -> Result { + let tools = tools + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?; + self.publish_tools(tools).await + } + + /// Publish resources list from rmcp typed resource descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_resources_typed( + &self, + resources: Vec, + ) -> Result { + let resources = resources + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?; + self.publish_resources(resources).await + } + + /// Publish prompts list from rmcp typed prompt descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_prompts_typed( + &self, + prompts: Vec, + ) -> Result { + let prompts = prompts + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?; + self.publish_prompts(prompts).await + } + + /// Publish resource templates list from rmcp typed template descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_resource_templates_typed( + &self, + templates: Vec, + ) -> Result { + let templates = templates + .into_iter() + .map(serde_json::to_value) + .collect::, _>>()?; + self.publish_resource_templates(templates).await + } + + // ── Relay list + profile metadata (CEP-6) ───────────────────────── + + /// Returns the relay URLs to advertise in the kind 10002 relay list event. + /// + /// Uses `relay_list_urls` if explicitly configured, otherwise falls back + /// to the transport's connected `relay_urls`. + pub(crate) fn get_advertised_relay_urls(&self) -> &[String] { + self.relay_list_urls.as_deref().unwrap_or(&self.relay_urls) + } + + /// Compute relay URLs for discoverability event publication. + /// + /// Merges advertised relay URLs with bootstrap relay URLs, deduplicated. + /// Skips default bootstrap relays when all advertised URLs are local and + /// no explicit `bootstrap_relay_urls` were provided. + pub(crate) fn get_discoverability_publish_relay_urls(&self) -> Vec { + let advertised = self.get_advertised_relay_urls(); + let has_explicit_bootstrap = self.bootstrap_relay_urls.is_some(); + + let should_skip_bootstrap = !has_explicit_bootstrap + && !advertised.is_empty() + && advertised.iter().all(|url| is_local_relay_url(url)); + + let mut seen = HashSet::new(); + let mut result = Vec::new(); + + for url in advertised { + if seen.insert(url.clone()) { + result.push(url.clone()); + } + } + + if !should_skip_bootstrap { + let default_bootstrap: Vec = DEFAULT_BOOTSTRAP_RELAY_URLS + .iter() + .map(|s| (*s).to_string()) + .collect(); + let bootstrap = self + .bootstrap_relay_urls + .as_deref() + .unwrap_or(&default_bootstrap); + for url in bootstrap { + if seen.insert(url.clone()) { + result.push(url.clone()); + } + } + } + + result + } + + /// Publish relay list (kind 10002). + /// + /// No-op if `should_publish_relay_list` is false or no relay URLs are available. + /// Publishes to the merged advertised + bootstrap relay set. + #[cfg(test)] + pub(crate) async fn publish_relay_list(&self) -> Result<()> { + if !self.should_publish_relay_list { + return Ok(()); + } + let urls = self.get_advertised_relay_urls(); + if urls.is_empty() { + tracing::warn!(target: LOG_TARGET, "No relay URLs to publish relay list"); + return Ok(()); + } + let tags: Vec = urls + .iter() + .map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()])) + .collect(); + let builder = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags); + match self.publish_to_discoverability_relays(builder).await { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published relay list (kind 10002)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish relay list" + ), + } + Ok(()) + } + + /// Publish profile metadata (kind 0). + /// + /// No-op if `profile_metadata` is not configured. + /// Publishes to the merged advertised + bootstrap relay set. + #[cfg(test)] + pub(crate) async fn publish_profile_metadata(&self) -> Result<()> { + let metadata = match &self.profile_metadata { + Some(m) => m, + None => return Ok(()), + }; + let content = serde_json::to_string(metadata)?; + let builder = EventBuilder::new(Kind::Custom(0), content); + match self.publish_to_discoverability_relays(builder).await { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published profile metadata (kind 0)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish profile metadata" + ), + } + Ok(()) + } + + /// Publish an event to the discoverability relay set. + /// + /// Uses `get_discoverability_publish_relay_urls()` for targeted publication + /// when bootstrap relays are configured. Falls back to pool-wide publish + /// when the merged set is empty. + #[cfg(test)] + async fn publish_to_discoverability_relays(&self, builder: EventBuilder) -> Result { + let urls = self.get_discoverability_publish_relay_urls(); + if urls.is_empty() { + self.relay_pool.publish(builder).await + } else { + self.relay_pool.publish_to(&urls, builder).await + } + } + + /// Spawn a task to publish profile metadata and relay list. + /// + /// Unconditional — guards live inside the individual publish methods. + /// Event-building logic mirrors `publish_relay_list()` and `publish_profile_metadata()`. + /// Duplication is intentional — `&self` can't be moved into a spawned task in Rust. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) fn spawn_publish_discoverability(&self) -> tokio::task::JoinHandle<()> { + let relay_pool = Arc::clone(&self.relay_pool); + let target_urls = self.get_discoverability_publish_relay_urls(); + + // Build events before spawning (borrows self) to avoid sending &self + let profile_event = self.profile_metadata.as_ref().and_then(|metadata| { + serde_json::to_string(metadata) + .ok() + .map(|content| EventBuilder::new(Kind::Custom(0), content)) + }); + + let relay_list_event = if self.should_publish_relay_list { + let urls = self.get_advertised_relay_urls(); + if urls.is_empty() { + None + } else { + let tags: Vec = urls + .iter() + .map(|url| Tag::custom(TagKind::Custom(tags::RELAY.into()), vec![url.clone()])) + .collect(); + Some(EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "").tags(tags)) + } + } else { + None + }; + + tokio::spawn(async move { + if let Some(builder) = profile_event { + let result = if target_urls.is_empty() { + relay_pool.publish(builder).await + } else { + relay_pool.publish_to(&target_urls, builder).await + }; + match result { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published profile metadata (kind 0)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish profile metadata" + ), + } + } + if let Some(builder) = relay_list_event { + let result = if target_urls.is_empty() { + relay_pool.publish(builder).await + } else { + relay_pool.publish_to(&target_urls, builder).await + }; + match result { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + "Published relay list (kind 10002)" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + "Failed to publish relay list" + ), + } + } + tracing::info!( + target: LOG_TARGET, + "Discoverability event publishing complete" + ); + }) + } + + // ── Event loop support ───────────────────────────────────────── + + /// Snapshot the tag state needed by the event loop. + /// + /// Returns a lightweight, cloneable copy of the fields the event loop + /// needs for `append_common_response_tags` without holding a reference + /// to the full manager. + pub fn common_tags_snapshot(&self) -> CommonTagsSnapshot { + CommonTagsSnapshot { + server_info: self.server_info.clone(), + extra_common_tags: self.extra_common_tags.clone(), + internal_common_tags: self.internal_common_tags.clone(), + encryption_mode: self.encryption_mode, + gift_wrap_mode: self.gift_wrap_mode, + } + } + + /// Drop the dispatch channel clone so `close()` can fully shut down the + /// message channel. + pub(crate) fn shutdown(&mut self) { + self.dispatch_fn.take(); + } + + // ── Auto-publish orchestration ──────────────────────────────── + + /// Handle a response to a synthetic announcement request. + /// + /// Schema-matches the result to determine which event kind to publish: + /// `InitializeResult` → 11316, `ListToolsResult` → 11317, + /// `ListResourcesResult` → 11318, `ListResourceTemplatesResult` → 11319, + /// `ListPromptsResult` → 11320. + /// + /// On `InitializeResult`, dispatches `notifications/initialized` via + /// `dispatch_fn` **before** signaling `init_notify` — this ordering is + /// critical so the notification enters the worker queue before any + /// capability-list requests. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) async fn handle_announcement_response( + &self, + response: JsonRpcMessage, + ) -> Result<()> { + let result = match &response { + JsonRpcMessage::Response(resp) => &resp.result, + JsonRpcMessage::ErrorResponse(resp) => { + tracing::warn!( + target: LOG_TARGET, + error_code = resp.error.code, + error_message = %resp.error.message, + "Announcement request returned error, skipping publish" + ); + // If init hasn't completed yet, signal so publish_public_announcements + // doesn't hang waiting for the Notify. + let mut flag = self.initialized.lock().unwrap_or_else(|e| e.into_inner()); + if !*flag { + *flag = true; + drop(flag); + self.init_notify.notify_one(); + } + return Ok(()); + } + _ => return Ok(()), + }; + + // Determine event kind via the schema-to-kind mapping table. + let kind = ANNOUNCEMENT_MAPPINGS + .iter() + .find(|m| (m.matches)(result)) + .map(|m| m.kind); + + if kind.is_none() { + tracing::warn!( + target: LOG_TARGET, + "Announcement response has unrecognized schema, skipping publish" + ); + } + + if let Some(kind) = kind { + let content = serde_json::to_string(result)?; + let tags = self.get_announcement_tags(kind); + let builder = EventBuilder::new(Kind::Custom(kind), content).tags(tags); + match self.relay_pool.publish(builder).await { + Ok(id) => tracing::info!( + target: LOG_TARGET, + event_id = %id, + kind, + "Published announcement event" + ), + Err(e) => tracing::warn!( + target: LOG_TARGET, + error = %e, + kind, + "Failed to publish announcement event" + ), + } + + // For InitializeResult: dispatch notifications/initialized and signal Notify. + if kind == SERVER_ANNOUNCEMENT_KIND { + // Critical ordering: dispatch notifications/initialized FIRST so it + // enters the worker queue before capability-list requests. + if let Some(ref tx) = self.dispatch_fn { + let _ = tx.send(IncomingRequest { + message: JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: NOTIFICATIONS_INITIALIZED_METHOD.to_string(), + params: None, + }), + client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(), + event_id: ANNOUNCEMENT_REQUEST_ID.to_string(), + is_encrypted: false, + }); + } + + // THEN signal the Notify — publish_public_announcements will dispatch + // capability-list requests after this, ensuring they arrive after the + // initialized notification in the worker queue. + let mut flag = self.initialized.lock().unwrap_or_else(|e| e.into_inner()); + *flag = true; + drop(flag); + self.init_notify.notify_one(); + } + } + + Ok(()) + } + + /// Spawn the auto-publish orchestration task. + /// + /// Returns a `JoinHandle` that the caller should track for cleanup. + /// The task dispatches a synthetic `initialize` request, waits for the + /// response, then dispatches capability-list requests. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) fn spawn_publish_public_announcements( + &self, + cancel: tokio_util::sync::CancellationToken, + ) -> tokio::task::JoinHandle<()> { + let dispatch_fn = self + .dispatch_fn + .clone() + .expect("dispatch_fn must be set before spawning announcements"); + let init_notify = Arc::clone(&self.init_notify); + tokio::spawn(publish_public_announcements( + dispatch_fn, + init_notify, + cancel, + )) + } +} + +/// Auto-publish orchestration: dispatches synthetic requests and waits for init. +/// +/// Standalone async function (not a method) so it can be moved into a spawned task +/// without borrowing the `AnnouncementManager`. +#[cfg_attr(not(feature = "rmcp"), allow(dead_code))] +async fn publish_public_announcements( + dispatch_fn: tokio::sync::mpsc::UnboundedSender, + init_notify: Arc, + cancel: tokio_util::sync::CancellationToken, +) { + tracing::info!(target: LOG_TARGET, "Starting auto-publish of server announcements"); + + // Dispatch synthetic initialize request. + let init_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + method: INITIALIZE_METHOD.to_string(), + params: Some(serde_json::json!({ + "protocolVersion": crate::core::constants::mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { + "name": "contextvm-announcement-client", + "version": "0.1.0" + } + })), + }); + if dispatch_fn + .send(IncomingRequest { + message: init_request, + client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(), + event_id: ANNOUNCEMENT_REQUEST_ID.to_string(), + is_encrypted: false, + }) + .is_err() + { + tracing::warn!( + target: LOG_TARGET, + "Transport channel closed before init request could be sent" + ); + return; + } + + // Wait for handle_announcement_response to signal completion of the init + // response, with cancellation support so close() isn't blocked. + tokio::select! { + _ = cancel.cancelled() => { + tracing::info!(target: LOG_TARGET, "Announcement publish cancelled during init wait"); + return; + } + result = tokio::time::timeout(ANNOUNCEMENT_INIT_TIMEOUT, init_notify.notified()) => { + match result { + Ok(()) => tracing::info!( + target: LOG_TARGET, + "Announcement init complete, dispatching capability list requests" + ), + Err(_) => tracing::warn!( + target: LOG_TARGET, + timeout_secs = ANNOUNCEMENT_INIT_TIMEOUT.as_secs(), + "Announcement init timed out, proceeding with capability list requests" + ), + } + } + } + + // Dispatch all four capability-list requests at once (no per-request await). + for method in &[ + "tools/list", + "resources/list", + "resources/templates/list", + "prompts/list", + ] { + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + method: method.to_string(), + params: None, + }); + let _ = dispatch_fn.send(IncomingRequest { + message: request, + client_pubkey: ANNOUNCEMENT_REQUEST_ID.to_string(), + event_id: ANNOUNCEMENT_REQUEST_ID.to_string(), + is_encrypted: false, + }); + } + + tracing::info!( + target: LOG_TARGET, + "Dispatched all announcement capability list requests" + ); +} + +/// Cloneable snapshot of tag-building state for the event loop. +/// +/// Passed into the static `event_loop` function so it can append discovery +/// tags on first-response without holding a reference to the full manager. +#[derive(Clone)] +pub(crate) struct CommonTagsSnapshot { + /// Server metadata for name tag. + pub server_info: Option, + /// User-provided extra common tags. + pub extra_common_tags: Vec, + /// Transport-owned internal common tags (e.g. CEP-22 oversized support). + pub internal_common_tags: Vec, + /// Encryption mode for capability tag decisions. + pub encryption_mode: EncryptionMode, + /// Gift-wrap mode for ephemeral tag decisions. + pub gift_wrap_mode: GiftWrapMode, +} + +impl CommonTagsSnapshot { + /// Append server capability discovery tags to the given tag vec. + /// + /// Used in the event loop for first-response tags on announced-server + /// unauthorized error responses. + pub fn append_common_response_tags(&self, tags: &mut Vec) { + if self.encryption_mode != EncryptionMode::Disabled { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + )); + if self.gift_wrap_mode.supports_ephemeral() { + tags.push(Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + )); + } + } + if let Some(ref info) = self.server_info { + if let Some(ref name) = info.name { + tags.push(Tag::custom( + TagKind::Custom(tags::NAME.into()), + vec![name.clone()], + )); + } + } + tags.extend(self.extra_common_tags.iter().cloned()); + tags.extend(self.internal_common_tags.iter().cloned()); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn make_manager( + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + server_info: Option, + ) -> AnnouncementManager { + // Tests only exercise tag building; relay pool and dispatch channel are unused. + use crate::relay::mock::MockRelayPool; + let pool: Arc = Arc::new(MockRelayPool::new()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + AnnouncementManager::new( + pool, + server_info, + encryption_mode, + gift_wrap_mode, + tx, + Vec::new(), + None, + None, + true, + None, + ) + } + + // ── 1. Server info tags ──────────────────────────────────────── + + /// Helper: extract the first element (tag name) from a Tag. + fn tag_name(tag: &Tag) -> String { + tag.clone().to_vec().first().cloned().unwrap_or_default() + } + + #[test] + fn server_info_tags_all_fields() { + let info = ServerInfo { + name: Some("Test".into()), + about: Some("A test server".into()), + website: Some("https://example.com".into()), + picture: Some("https://example.com/pic.png".into()), + ..Default::default() + }; + let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, Some(info)); + let tags = mgr.get_server_info_tags(); + assert_eq!(tags.len(), 4); + let names: Vec = tags.iter().map(tag_name).collect(); + assert!(names.contains(&"name".to_string())); + assert!(names.contains(&"about".to_string())); + assert!(names.contains(&"website".to_string())); + assert!(names.contains(&"picture".to_string())); + } + + #[test] + fn server_info_tags_partial() { + let info = ServerInfo { + name: Some("OnlyName".into()), + ..Default::default() + }; + let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, Some(info)); + let tags = mgr.get_server_info_tags(); + assert_eq!(tags.len(), 1); + } + + // ── 3–6. Capability tags ─────────────────────────────────────── + + #[test] + fn capability_tags_encryption_enabled() { + let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None); + let tags = mgr.get_encryption_capability_tags(); + let names: Vec = tags.iter().map(tag_name).collect(); + assert!(names.contains(&tags::SUPPORT_ENCRYPTION.to_string())); + } + + #[test] + fn capability_tags_ephemeral_enabled() { + let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, None); + let tags = mgr.get_encryption_capability_tags(); + let names: Vec = tags.iter().map(tag_name).collect(); + assert!(names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string())); + } + + #[test] + fn capability_tags_ephemeral_excluded() { + let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Persistent, None); + let tags = mgr.get_encryption_capability_tags(); + let names: Vec = tags.iter().map(tag_name).collect(); + assert!( + !names.contains(&tags::SUPPORT_ENCRYPTION_EPHEMERAL.to_string()), + "Persistent mode should not include ephemeral tag" + ); + } + + #[test] + fn capability_tags_encryption_disabled() { + let mgr = make_manager(EncryptionMode::Disabled, GiftWrapMode::Optional, None); + let tags = mgr.get_encryption_capability_tags(); + assert!( + tags.is_empty(), + "Disabled encryption should produce no capability tags" + ); + } + + // ── 7. Caching ───────────────────────────────────────────────── + + #[test] + fn common_tags_cached() { + let info = ServerInfo { + name: Some("Cache".into()), + ..Default::default() + }; + let mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, Some(info)); + let first = mgr.get_common_tags(); + let second = mgr.get_common_tags(); + assert_eq!(first.len(), second.len()); + // Verify cache is populated + let cache = mgr.cached_common_tags.lock().unwrap(); + assert!( + cache.is_some(), + "Cache should be populated after get_common_tags" + ); + } + + // ── 8. Cache invalidation ────────────────────────────────────── + + #[test] + fn set_extra_common_tags_invalidates_cache() { + let mgr_info = ServerInfo { + name: Some("Extra".into()), + ..Default::default() + }; + let mut mgr = make_manager( + EncryptionMode::Disabled, + GiftWrapMode::Optional, + Some(mgr_info), + ); + + // Populate cache + let before = mgr.get_common_tags(); + assert!(!before.is_empty()); + + // Set extra tags — should invalidate and include new tags + let extra = vec![Tag::custom( + TagKind::Custom("pmi".into()), + vec!["lightning".to_string()], + )]; + mgr.set_extra_common_tags(extra); + let after = mgr.get_common_tags(); + assert_eq!(after.len(), before.len() + 1); + } + + // ── 9. Pricing separate from common ──────────────────────────── + + #[test] + fn pricing_tags_separate_from_common() { + let mut mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, None); + mgr.set_pricing_tags(vec![Tag::custom( + TagKind::Custom(tags::CAPABILITY.into()), + vec![ + "tool:echo".to_string(), + "100".to_string(), + "sats".to_string(), + ], + )]); + let common = mgr.get_common_tags(); + let names: Vec = common.iter().map(tag_name).collect(); + assert!( + !names.contains(&tags::CAPABILITY.to_string()), + "get_common_tags() should not include pricing tags" + ); + } + + // ── 10. Announcement tags by kind ────────────────────────────── + + #[test] + fn announcement_tags_kind_11316_includes_pricing() { + let info = ServerInfo { + name: Some("Ann".into()), + ..Default::default() + }; + let mut mgr = make_manager(EncryptionMode::Optional, GiftWrapMode::Optional, Some(info)); + mgr.set_pricing_tags(vec![Tag::custom( + TagKind::Custom(tags::CAPABILITY.into()), + vec![ + "tool:echo".to_string(), + "100".to_string(), + "sats".to_string(), + ], + )]); + + let ann_tags = mgr.get_announcement_tags(SERVER_ANNOUNCEMENT_KIND); + let ann_names: Vec = ann_tags.iter().map(tag_name).collect(); + assert!( + ann_names.contains(&tags::CAPABILITY.to_string()), + "Kind 11316 should include pricing tags" + ); + + let tools_tags = mgr.get_announcement_tags(TOOLS_LIST_KIND); + let tools_names: Vec = tools_tags.iter().map(tag_name).collect(); + assert!( + !tools_names.contains(&"name".to_string()), + "Kind 11317 should NOT include common tags, only pricing" + ); + assert!( + tools_names.contains(&tags::CAPABILITY.to_string()), + "Kind 11317 should include pricing tags" + ); + } + + // ── 11. Auto-publish: handle_announcement_response ─────────── + + fn make_manager_with_pool( + server_info: Option, + ) -> ( + AnnouncementManager, + Arc, + tokio::sync::mpsc::UnboundedReceiver, + ) { + use crate::relay::mock::MockRelayPool; + let pool = Arc::new(MockRelayPool::new()); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let mgr = AnnouncementManager::new( + Arc::clone(&pool) as Arc, + server_info, + EncryptionMode::Disabled, + GiftWrapMode::Optional, + tx, + Vec::new(), + None, + None, + true, + None, + ); + (mgr, pool, rx) + } + + #[tokio::test] + async fn handle_announcement_response_publishes_init_result() { + let (mgr, pool, mut rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "test-server", "version": "0.1.0" } + }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + // Verify kind 11316 event published + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(SERVER_ANNOUNCEMENT_KIND)); + + // Verify notifications/initialized dispatched + let notif = rx + .try_recv() + .expect("should dispatch notifications/initialized"); + assert_eq!( + notif.message.method(), + Some(NOTIFICATIONS_INITIALIZED_METHOD) + ); + assert_eq!(notif.client_pubkey, ANNOUNCEMENT_REQUEST_ID); + + // Verify initialized flag set + assert!(*mgr.initialized.lock().unwrap()); + } + + #[tokio::test] + async fn handle_announcement_response_publishes_tools_list() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ + "tools": [{ "name": "echo", "description": "Echo tool" }] + }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(TOOLS_LIST_KIND)); + } + + #[tokio::test] + async fn handle_announcement_response_publishes_resources_list() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ "resources": [] }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(RESOURCES_LIST_KIND)); + } + + #[tokio::test] + async fn handle_announcement_response_publishes_resource_templates_list() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ "resourceTemplates": [] }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(RESOURCETEMPLATES_LIST_KIND)); + } + + #[tokio::test] + async fn handle_announcement_response_publishes_prompts_list() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ "prompts": [] }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(PROMPTS_LIST_KIND)); + } + + #[tokio::test] + async fn handle_announcement_response_error_signals_notify_without_publishing() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + error: JsonRpcError { + code: -32600, + message: "test error".to_string(), + data: None, + }, + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + // No events published + assert!(pool.stored_events().await.is_empty()); + // But initialized flag is set (to unblock publish_public_announcements) + assert!(*mgr.initialized.lock().unwrap()); + } + + #[tokio::test] + async fn handle_announcement_response_unknown_schema_no_publish() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ "unknown": "data" }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + assert!(pool.stored_events().await.is_empty()); + } + + // ── 12. Auto-publish: publish_public_announcements ─────────── + + #[tokio::test] + async fn publish_public_announcements_dispatches_init_then_capability_lists() { + let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel::(); + let init_notify = Arc::new(Notify::new()); + let cancel = tokio_util::sync::CancellationToken::new(); + + let init_notify_clone = Arc::clone(&init_notify); + let handle = tokio::spawn(publish_public_announcements(tx, init_notify_clone, cancel)); + + // First message should be the synthetic initialize request. + let init_msg = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive init request within 1s") + .expect("channel should not be closed"); + assert_eq!(init_msg.message.method(), Some(INITIALIZE_METHOD)); + assert_eq!(init_msg.client_pubkey, ANNOUNCEMENT_REQUEST_ID); + assert_eq!(init_msg.event_id, ANNOUNCEMENT_REQUEST_ID); + assert!(!init_msg.is_encrypted); + + // Signal init complete — the task should then dispatch capability lists. + init_notify.notify_one(); + + // Should receive 4 capability list requests in order. + let expected_methods = [ + "tools/list", + "resources/list", + "resources/templates/list", + "prompts/list", + ]; + for expected_method in &expected_methods { + let msg = tokio::time::timeout(Duration::from_secs(1), rx.recv()) + .await + .expect("should receive capability request within 1s") + .expect("channel should not be closed"); + assert_eq!(msg.message.method(), Some(*expected_method)); + assert_eq!(msg.client_pubkey, ANNOUNCEMENT_REQUEST_ID); + } + + handle.await.unwrap(); + } + + #[tokio::test] + async fn handle_init_result_dispatches_notification_before_notify_signal() { + let (mgr, _pool, mut rx) = make_manager_with_pool(None); + + // Clone the Notify so we can also wait on it from the test. + let init_notify = Arc::clone(&mgr.init_notify); + let notified = init_notify.notified(); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ + "protocolVersion": "2025-11-25", + "capabilities": {}, + "serverInfo": { "name": "test" } + }), + }); + + mgr.handle_announcement_response(response).await.unwrap(); + + // The Notify should have been signaled. + tokio::time::timeout(Duration::from_millis(100), notified) + .await + .expect("init_notify should have been signaled"); + + // And the notifications/initialized message should already be in the + // channel (dispatched BEFORE the Notify signal). + let notif = rx + .try_recv() + .expect("notification should be queued before Notify"); + assert_eq!( + notif.message.method(), + Some(NOTIFICATIONS_INITIALIZED_METHOD) + ); + } + + // ── 13. Relay list + profile metadata (CEP-6) ────────────────── + + fn make_manager_with_discoverability( + relay_urls: Vec, + relay_list_urls: Option>, + bootstrap_relay_urls: Option>, + publish_relay_list: bool, + profile_metadata: Option, + ) -> (AnnouncementManager, Arc) { + use crate::relay::mock::MockRelayPool; + let pool = Arc::new(MockRelayPool::new()); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let mgr = AnnouncementManager::new( + Arc::clone(&pool) as Arc, + None, + EncryptionMode::Disabled, + GiftWrapMode::Optional, + tx, + relay_urls, + relay_list_urls, + bootstrap_relay_urls, + publish_relay_list, + profile_metadata, + ); + (mgr, pool) + } + + #[test] + fn is_local_relay_url_detects_localhost() { + assert!(is_local_relay_url("ws://localhost:7777")); + assert!(is_local_relay_url("wss://localhost")); + assert!(is_local_relay_url("ws://127.0.0.1:8080")); + assert!(is_local_relay_url("ws://0.0.0.0:9999")); + assert!(is_local_relay_url("ws://[::1]:7777")); + } + + #[test] + fn is_local_relay_url_rejects_remote() { + assert!(!is_local_relay_url("wss://relay.damus.io")); + assert!(!is_local_relay_url("wss://relay.example.com")); + assert!(!is_local_relay_url("ws://10.0.0.1:7777")); + } + + #[test] + fn get_advertised_relay_urls_uses_relay_list_when_set() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + Some(vec!["wss://advertised.relay".into()]), + None, + true, + None, + ); + assert_eq!(mgr.get_advertised_relay_urls(), &["wss://advertised.relay"]); + } + + #[test] + fn get_advertised_relay_urls_falls_back_to_relay_urls() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + None, + None, + true, + None, + ); + assert_eq!(mgr.get_advertised_relay_urls(), &["wss://connected.relay"]); + } + + #[test] + fn get_discoverability_urls_merges_bootstrap() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://my.relay".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert!(urls.contains(&"wss://my.relay".to_string())); + // Should include default bootstrap relays + assert!(urls.len() > 1); + assert!(urls.contains(&DEFAULT_BOOTSTRAP_RELAY_URLS[0].to_string())); + } + + #[test] + fn get_discoverability_urls_skips_bootstrap_for_local_only() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["ws://127.0.0.1:7777".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert_eq!(urls, vec!["ws://127.0.0.1:7777"]); + } + + #[test] + fn get_discoverability_urls_keeps_explicit_bootstrap_even_for_local() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["ws://127.0.0.1:7777".into()], + None, + Some(vec!["wss://explicit-bootstrap.relay".into()]), + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + assert!(urls.contains(&"ws://127.0.0.1:7777".to_string())); + assert!(urls.contains(&"wss://explicit-bootstrap.relay".to_string())); + } + + #[test] + fn get_discoverability_urls_deduplicates() { + let (mgr, _pool) = make_manager_with_discoverability( + vec!["wss://relay.damus.io".into()], + None, + None, + true, + None, + ); + let urls = mgr.get_discoverability_publish_relay_urls(); + let damus_count = urls.iter().filter(|u| *u == "wss://relay.damus.io").count(); + assert_eq!(damus_count, 1, "should be deduplicated"); + } + + #[tokio::test] + async fn publish_relay_list_event_shape() { + let (mgr, pool) = make_manager_with_discoverability( + vec![ + "wss://relay1.example.com".into(), + "wss://relay2.example.com".into(), + ], + None, + None, + true, + None, + ); + mgr.publish_relay_list().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(RELAY_LIST_METADATA_KIND)); + assert_eq!(events[0].content, ""); + let tag_values: Vec = events[0] + .tags + .iter() + .filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r")) + .filter_map(|t| (*t).clone().to_vec().get(1).cloned()) + .collect(); + assert_eq!( + tag_values, + vec!["wss://relay1.example.com", "wss://relay2.example.com"] + ); + } + + #[tokio::test] + async fn publish_relay_list_uses_relay_list_urls_override() { + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + Some(vec!["wss://override.relay".into()]), + None, + true, + None, + ); + mgr.publish_relay_list().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + let tag_values: Vec = events[0] + .tags + .iter() + .filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r")) + .filter_map(|t| (*t).clone().to_vec().get(1).cloned()) + .collect(); + assert_eq!(tag_values, vec!["wss://override.relay"]); + } + + #[tokio::test] + async fn publish_relay_list_opt_out() { + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://relay.example.com".into()], + None, + None, + false, + None, + ); + mgr.publish_relay_list().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish when disabled" + ); + } + + #[tokio::test] + async fn publish_relay_list_empty_urls_no_publish() { + let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None); + mgr.publish_relay_list().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish with no URLs" + ); + } + + #[tokio::test] + async fn publish_profile_metadata_event_shape() { + let metadata = ProfileMetadata::default() + .with_name("Test Server") + .with_about("A test MCP server"); + let (mgr, pool) = + make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata)); + mgr.publish_profile_metadata().await.unwrap(); + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + assert_eq!(events[0].kind, Kind::Custom(0)); + assert!(events[0].tags.is_empty()); + let parsed: ProfileMetadata = serde_json::from_str(&events[0].content).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Test Server")); + assert_eq!(parsed.about.as_deref(), Some("A test MCP server")); + } + + #[tokio::test] + async fn publish_profile_metadata_noop_when_unconfigured() { + let (mgr, pool) = make_manager_with_discoverability(Vec::new(), None, None, true, None); + mgr.publish_profile_metadata().await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "should not publish without profile metadata" + ); + } + + // ── 14. Integration tests + cleanup (CEP-6) ──────────────────── + + #[test] + fn announcement_mapping_table_covers_all_kinds() { + let kinds: HashSet = ANNOUNCEMENT_MAPPINGS.iter().map(|m| m.kind).collect(); + assert!(kinds.contains(&SERVER_ANNOUNCEMENT_KIND)); + assert!(kinds.contains(&TOOLS_LIST_KIND)); + assert!(kinds.contains(&RESOURCES_LIST_KIND)); + assert!(kinds.contains(&RESOURCETEMPLATES_LIST_KIND)); + assert!(kinds.contains(&PROMPTS_LIST_KIND)); + } + + #[test] + fn announcement_mapping_resource_templates_regression() { + let result = serde_json::json!({ "resourceTemplates": [] }); + let kind = ANNOUNCEMENT_MAPPINGS + .iter() + .find(|m| (m.matches)(&result)) + .map(|m| m.kind); + assert_eq!(kind, Some(RESOURCETEMPLATES_LIST_KIND)); + } + + #[test] + fn announcement_mapping_rejects_null_field() { + let result = serde_json::json!({ "tools": null }); + let kind = ANNOUNCEMENT_MAPPINGS + .iter() + .find(|m| (m.matches)(&result)) + .map(|m| m.kind); + assert_eq!(kind, None, "null field should not match any schema"); + } + + #[test] + fn announcement_mapping_rejects_wrong_type() { + let result = serde_json::json!({ "tools": "not an array" }); + let kind = ANNOUNCEMENT_MAPPINGS + .iter() + .find(|m| (m.matches)(&result)) + .map(|m| m.kind); + assert_eq!(kind, None, "string field should not match array schema"); + } + + #[tokio::test] + async fn handle_announcement_response_rejects_malformed_payload() { + let (mgr, pool, _rx) = make_manager_with_pool(None); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result: serde_json::json!({ "tools": null }), + }); + mgr.handle_announcement_response(response).await.unwrap(); + assert!( + pool.stored_events().await.is_empty(), + "malformed payload should not produce an event" + ); + } + + #[tokio::test] + async fn roundtrip_publish_discover_all_5_kinds() { + let info = ServerInfo { + name: Some("Roundtrip".into()), + ..Default::default() + }; + let (mgr, pool, _rx) = make_manager_with_pool(Some(info)); + + let responses = vec![ + serde_json::json!({ + "protocolVersion": "2025-11-25", + "capabilities": {"tools": {}}, + "serverInfo": {"name": "Roundtrip", "version": "1.0"} + }), + serde_json::json!({"tools": [{"name": "echo"}]}), + serde_json::json!({"resources": [{"name": "config"}]}), + serde_json::json!({"resourceTemplates": [{"name": "tmpl"}]}), + serde_json::json!({"prompts": [{"name": "greet"}]}), + ]; + for result in responses { + mgr.handle_announcement_response(JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(ANNOUNCEMENT_REQUEST_ID), + result, + })) + .await + .unwrap(); + } + + // Discover via fetch_events for each kind + let pubkey = pool.mock_public_key(); + for (kind, key) in [ + (SERVER_ANNOUNCEMENT_KIND, "protocolVersion"), + (TOOLS_LIST_KIND, "tools"), + (RESOURCES_LIST_KIND, "resources"), + (RESOURCETEMPLATES_LIST_KIND, "resourceTemplates"), + (PROMPTS_LIST_KIND, "prompts"), + ] { + let filter = Filter::new().kind(Kind::Custom(kind)).author(pubkey); + let events = pool + .fetch_events(vec![filter], Duration::from_secs(1)) + .await + .unwrap(); + assert_eq!(events.len(), 1, "kind {kind} should have exactly 1 event"); + let content: serde_json::Value = serde_json::from_str(&events[0].content).unwrap(); + assert!( + content.get(key).is_some(), + "kind {kind} content should contain '{key}'" + ); + } + } + + // ── 15. CEP-6 parity tests ────────────────────────────────────── + + #[tokio::test] + async fn publish_profile_metadata_preserves_custom_fields() { + let mut metadata = ProfileMetadata::default() + .with_name("Full Server") + .with_about("All fields present") + .with_picture("https://example.com/pic.png") + .with_banner("https://example.com/banner.png") + .with_website("https://example.com") + .with_nip05("server@example.com") + .with_lud16("server@walletofsatoshi.com"); + metadata + .extra + .insert("custom_flag".into(), serde_json::json!(true)); + + let (mgr, pool) = + make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata)); + mgr.publish_profile_metadata().await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + let parsed: ProfileMetadata = serde_json::from_str(&events[0].content).unwrap(); + assert_eq!(parsed.name.as_deref(), Some("Full Server")); + assert_eq!(parsed.about.as_deref(), Some("All fields present")); + assert_eq!( + parsed.picture.as_deref(), + Some("https://example.com/pic.png") + ); + assert_eq!( + parsed.banner.as_deref(), + Some("https://example.com/banner.png") + ); + assert_eq!(parsed.website.as_deref(), Some("https://example.com")); + assert_eq!(parsed.nip05.as_deref(), Some("server@example.com")); + assert_eq!(parsed.lud16.as_deref(), Some("server@walletofsatoshi.com")); + assert_eq!( + parsed.extra.get("custom_flag"), + Some(&serde_json::json!(true)), + "custom field should survive publish round-trip" + ); + } + + #[tokio::test] + async fn publish_profile_metadata_publish_error_does_not_panic() { + use crate::relay::mock::MockRelayPool; + use std::sync::atomic::{AtomicBool, Ordering}; + + /// A relay pool that fails on publish when the flag is set. + struct FailingPool { + inner: MockRelayPool, + should_fail: AtomicBool, + } + + #[async_trait::async_trait] + impl RelayPoolTrait for FailingPool { + async fn connect(&self, urls: &[String]) -> Result<()> { + self.inner.connect(urls).await + } + async fn disconnect(&self) -> Result<()> { + self.inner.disconnect().await + } + async fn publish_event(&self, event: &Event) -> Result { + self.inner.publish_event(event).await + } + async fn publish(&self, builder: EventBuilder) -> Result { + if self.should_fail.load(Ordering::SeqCst) { + return Err(Error::Transport("injected publish failure".into())); + } + self.inner.publish(builder).await + } + async fn sign(&self, builder: EventBuilder) -> Result { + self.inner.sign(builder).await + } + async fn signer(&self) -> Result> { + self.inner.signer().await + } + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + self.inner.notifications() + } + async fn public_key(&self) -> Result { + self.inner.public_key().await + } + async fn subscribe(&self, filters: Vec) -> Result<()> { + self.inner.subscribe(filters).await + } + async fn publish_to(&self, urls: &[String], builder: EventBuilder) -> Result { + if self.should_fail.load(Ordering::SeqCst) { + return Err(Error::Transport("injected publish failure".into())); + } + self.inner.publish_to(urls, builder).await + } + async fn fetch_events( + &self, + filters: Vec, + timeout: Duration, + ) -> Result> { + self.inner.fetch_events(filters, timeout).await + } + } + + let pool: Arc = Arc::new(FailingPool { + inner: MockRelayPool::new(), + should_fail: AtomicBool::new(true), + }); + let (tx, _rx) = tokio::sync::mpsc::unbounded_channel(); + let metadata = ProfileMetadata::default().with_name("Err Server"); + let mgr = AnnouncementManager::new( + pool, + None, + EncryptionMode::Disabled, + GiftWrapMode::Optional, + tx, + Vec::new(), + None, + None, + true, + Some(metadata), + ); + + // publish_profile_metadata swallows the error and returns Ok(()) + let result = mgr.publish_profile_metadata().await; + assert!( + result.is_ok(), + "publish_profile_metadata should swallow publish errors" + ); + } + + #[tokio::test] + async fn private_server_publishes_relay_list_but_not_announcements() { + // A private server (is_announced_server: false at transport level) still + // publishes relay list for discoverability. The AnnouncementManager doesn't + // gate on is_announced_server — that's the transport's job — so calling + // publish_relay_list() should work, while never calling announce() means + // no kind 11316 events exist. + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://relay.example.com".into()], + None, + None, + true, + None, + ); + + // Publish relay list (kind 10002) — should succeed. + mgr.publish_relay_list().await.unwrap(); + + let events = pool.stored_events().await; + let relay_list_events: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(RELAY_LIST_METADATA_KIND)) + .collect(); + assert_eq!( + relay_list_events.len(), + 1, + "relay list (kind 10002) should be published" + ); + + // No kind 11316 events — announce() was never called (private server). + let announcement_events: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)) + .collect(); + assert!( + announcement_events.is_empty(), + "private server should have no kind 11316 announcements" + ); + } + + #[tokio::test] + async fn relay_list_advertises_different_urls_than_bootstrap() { + let (mgr, pool) = make_manager_with_discoverability( + vec!["wss://connected.relay".into()], + Some(vec![ + "wss://public1.relay".into(), + "wss://public2.relay".into(), + ]), + Some(vec!["wss://bootstrap.relay".into()]), + true, + None, + ); + mgr.publish_relay_list().await.unwrap(); + + let events = pool.stored_events().await; + assert_eq!(events.len(), 1); + let tag_values: Vec = events[0] + .tags + .iter() + .filter(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("r")) + .filter_map(|t| (*t).clone().to_vec().get(1).cloned()) + .collect(); + assert_eq!( + tag_values, + vec!["wss://public1.relay", "wss://public2.relay"], + "kind 10002 tags should contain only relay_list_urls" + ); + assert!( + !tag_values.contains(&"wss://bootstrap.relay".to_string()), + "bootstrap URLs must not appear in kind 10002 tags" + ); + } + + #[tokio::test] + async fn profile_metadata_published_regardless_of_announced_server() { + // Profile metadata (kind 0) is decoupled from is_announced_server. + // The AnnouncementManager publishes it whenever configured, even if the + // transport never calls announce(). + let metadata = ProfileMetadata::default() + .with_name("Private Server") + .with_about("Not publicly announced"); + let (mgr, pool) = + make_manager_with_discoverability(Vec::new(), None, None, true, Some(metadata)); + + // Publish only profile metadata — do NOT call announce(). + mgr.publish_profile_metadata().await.unwrap(); + + let events = pool.stored_events().await; + let profile_events: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(0)) + .collect(); + assert_eq!( + profile_events.len(), + 1, + "kind 0 should be published even without announcements" + ); + + let announcement_events: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)) + .collect(); + assert!( + announcement_events.is_empty(), + "no kind 11316 should exist when announce() not called" + ); + } + + #[tokio::test] + async fn delete_announcements_uses_e_tags() { + let info = ServerInfo { + name: Some("Del".into()), + ..Default::default() + }; + let (mgr, pool, _rx) = make_manager_with_pool(Some(info)); + + // Publish an announcement (kind 11316) + mgr.announce().await.unwrap(); + let published = pool.stored_events().await; + let announcement_id = published[0].id; + + // Delete + mgr.delete_announcements("going offline").await.unwrap(); + + // Find deletion events (kind 5) + let all_events = pool.stored_events().await; + let deletion_events: Vec<_> = all_events + .iter() + .filter(|e| e.kind == Kind::Custom(5)) + .collect(); + assert!(!deletion_events.is_empty(), "should have deletion events"); + + // Verify ["e", event_id] tags, not ["k", kind] + let del = &deletion_events[0]; + let tags: Vec> = del.tags.iter().map(|t| (*t).clone().to_vec()).collect(); + assert!(!tags.is_empty(), "deletion event should have tags"); + for tag in &tags { + assert_eq!(tag[0], "e", "deletion tag should be 'e', not 'k'"); + } + let ann_id_hex = announcement_id.to_hex(); + assert!( + tags.iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some(ann_id_hex.as_str())), + "deletion should reference the published announcement event ID" + ); + assert_eq!(del.content, "going offline"); + assert_eq!( + deletion_events.len(), + 1, + "only one kind was published so only one deletion event expected" + ); + } +} diff --git a/src/transport/server/correlation_store.rs b/src/transport/server/correlation_store.rs new file mode 100644 index 0000000..8fca3d5 --- /dev/null +++ b/src/transport/server/correlation_store.rs @@ -0,0 +1,389 @@ +//! Server-side event route store for mapping event IDs to client routes. + +use std::collections::{HashMap, HashSet}; +use std::num::NonZeroUsize; +use std::sync::Arc; +use std::time::{Duration, Instant}; + +use lru::LruCache; +use tokio::sync::RwLock; + +use crate::core::constants::DEFAULT_LRU_SIZE; + +/// A route entry for an in-flight request. +#[derive(Debug, Clone)] +pub struct RouteEntry { + /// The client's public key that originated this request. + pub client_pubkey: String, + /// The original JSON-RPC request ID (before replacement with event ID). + pub original_request_id: serde_json::Value, + /// Optional progress token for this request. + pub progress_token: Option, + /// The outer gift-wrap event kind that carried this request (e.g. 1059 or 21059). + /// Populated from the inbound event once wrap-kind mirroring is wired; `None` until then. + pub wrap_kind: Option, + /// When the route was registered. + pub registered_at: Instant, +} + +/// Internal state behind the lock. +struct Inner { + /// Primary index: event_id → route entry (LRU-ordered). + routes: LruCache, + /// Secondary index: progress_token → event_id. + progress_token_to_event: HashMap, + /// Secondary index: client_pubkey → set of event_ids. + client_event_ids: HashMap>, +} + +impl Inner { + fn new(max_routes: usize) -> Self { + let routes = + LruCache::new(NonZeroUsize::new(max_routes).unwrap_or(NonZeroUsize::new(1).unwrap())); + Self { + routes, + progress_token_to_event: HashMap::new(), + client_event_ids: HashMap::new(), + } + } + + /// Clean up secondary indexes for a removed route. + fn cleanup_indexes(&mut self, event_id: &str, route: &RouteEntry) { + if let Some(ref token) = route.progress_token { + self.progress_token_to_event.remove(token); + } + if let Some(set) = self.client_event_ids.get_mut(&route.client_pubkey) { + set.remove(event_id); + if set.is_empty() { + self.client_event_ids.remove(&route.client_pubkey); + } + } + } + + /// Remove a single route and clean up all secondary indexes. + fn remove_route(&mut self, event_id: &str) -> Option { + let route = self.routes.pop(event_id)?; + self.cleanup_indexes(event_id, &route); + Some(route) + } +} + +/// Maps event IDs to full route entries for response routing on the server side. +/// +/// An optional capacity limit enables LRU eviction; when the limit is reached +/// the oldest entry is evicted and its secondary indexes are cleaned up. +#[derive(Clone)] +pub struct ServerEventRouteStore { + inner: Arc>, +} + +impl Default for ServerEventRouteStore { + fn default() -> Self { + Self::new() + } +} + +impl ServerEventRouteStore { + /// Create a new store with the default capacity + pub fn new() -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::new(DEFAULT_LRU_SIZE))), + } + } + + /// Create a store with an upper bound on event routes. + /// When the limit is reached the oldest entry is evicted. + pub fn with_max_routes(max_routes: usize) -> Self { + Self { + inner: Arc::new(RwLock::new(Inner::new(max_routes))), + } + } + + /// Register a route for an incoming request. + pub async fn register( + &self, + event_id: String, + client_pubkey: String, + original_request_id: serde_json::Value, + progress_token: Option, + ) { + let mut inner = self.inner.write().await; + + // Update client index. + inner + .client_event_ids + .entry(client_pubkey.clone()) + .or_default() + .insert(event_id.clone()); + + // Update progress token index. + if let Some(ref token) = progress_token { + inner + .progress_token_to_event + .insert(token.clone(), event_id.clone()); + } + + // Insert into LRU; handle possible eviction. + let evicted = inner.routes.push( + event_id.clone(), + RouteEntry { + client_pubkey, + original_request_id, + progress_token, + wrap_kind: None, + registered_at: Instant::now(), + }, + ); + + if let Some((evicted_key, evicted_route)) = evicted { + if evicted_key != event_id { + // A different entry was evicted due to capacity — clean up its indexes. + inner.cleanup_indexes(&evicted_key, &evicted_route); + } + } + } + + /// Returns the client public key for the given event ID without removing it. + pub async fn get(&self, event_id: &str) -> Option { + self.inner + .read() + .await + .routes + .peek(event_id) + .map(|r| r.client_pubkey.clone()) + } + + /// Returns the full route entry for the given event ID without removing it. + pub async fn get_route(&self, event_id: &str) -> Option { + self.inner.read().await.routes.peek(event_id).cloned() + } + + /// Removes and returns the full route entry for the given event ID. + pub async fn pop(&self, event_id: &str) -> Option { + self.inner.write().await.remove_route(event_id) + } + + /// Removes all routes for a given client public key. Returns the count removed. + pub async fn remove_for_client(&self, client_pubkey: &str) -> usize { + let mut inner = self.inner.write().await; + + let event_ids = match inner.client_event_ids.remove(client_pubkey) { + Some(ids) => ids, + None => return 0, + }; + + let count = event_ids.len(); + for event_id in &event_ids { + if let Some(route) = inner.routes.pop(event_id.as_str()) { + if let Some(ref token) = route.progress_token { + inner.progress_token_to_event.remove(token); + } + } + } + count + } + + /// Check whether a route exists for the given event ID. + pub async fn has_event_route(&self, event_id: &str) -> bool { + self.inner.read().await.routes.contains(event_id) + } + + /// Check whether the given client has any active routes. + pub async fn has_active_routes_for_client(&self, client_pubkey: &str) -> bool { + self.inner + .read() + .await + .client_event_ids + .get(client_pubkey) + .is_some_and(|set| !set.is_empty()) + } + + /// Look up the event ID associated with a progress token. + pub async fn get_event_id_by_progress_token(&self, token: &str) -> Option { + self.inner + .read() + .await + .progress_token_to_event + .get(token) + .cloned() + } + + /// Check whether a progress token mapping exists. + pub async fn has_progress_token(&self, token: &str) -> bool { + self.inner + .read() + .await + .progress_token_to_event + .contains_key(token) + } + + /// Number of event routes currently tracked. + pub async fn event_route_count(&self) -> usize { + self.inner.read().await.routes.len() + } + + /// Number of progress token mappings currently tracked. + pub async fn progress_token_count(&self) -> usize { + self.inner.read().await.progress_token_to_event.len() + } + + /// Remove all route entries older than `timeout`. + /// (Routes for expired sessions are already cleaned by `cleanup_sessions`.) + /// Returns the event IDs of the removed entries. + pub async fn sweep_stale_routes(&self, timeout: Duration) -> Vec { + let now = Instant::now(); + let mut inner = self.inner.write().await; + let mut expired_keys = Vec::new(); + + for (key, entry) in inner.routes.iter() { + if now.duration_since(entry.registered_at) >= timeout { + expired_keys.push(key.clone()); + } + } + + for key in &expired_keys { + inner.remove_route(key); + } + expired_keys + } + + /// Remove all route entries and secondary indexes + pub async fn clear(&self) { + let mut inner = self.inner.write().await; + inner.routes.clear(); + inner.progress_token_to_event.clear(); + inner.client_event_ids.clear(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + #[tokio::test] + async fn pop_on_empty_returns_none() { + let store = ServerEventRouteStore::new(); + assert!(store.pop("nonexistent").await.is_none()); + } + + #[tokio::test] + async fn get_returns_without_removing() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!("r1"), None) + .await; + assert_eq!(store.get("e1").await.as_deref(), Some("pk1")); + assert_eq!(store.get("e1").await.as_deref(), Some("pk1")); + } + + #[tokio::test] + async fn pop_removes_entry() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!("r1"), None) + .await; + let route = store.pop("e1").await.unwrap(); + assert_eq!(route.client_pubkey, "pk1"); + assert!(store.pop("e1").await.is_none()); + } + + #[tokio::test] + async fn remove_for_client_only_removes_matching() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!("r1"), None) + .await; + store + .register("e2".into(), "pk2".into(), json!("r2"), None) + .await; + store + .register("e3".into(), "pk1".into(), json!("r3"), None) + .await; + + let removed = store.remove_for_client("pk1").await; + assert_eq!(removed, 2); + + assert!(store.get("e1").await.is_none()); + assert!(store.get("e3").await.is_none()); + assert_eq!(store.get("e2").await.as_deref(), Some("pk2")); + } + + #[tokio::test] + async fn remove_for_client_noop_when_no_match() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!("r1"), None) + .await; + let removed = store.remove_for_client("pk_other").await; + assert_eq!(removed, 0); + assert_eq!(store.get("e1").await.as_deref(), Some("pk1")); + } + + #[tokio::test] + async fn clear_empties_store() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!("r1"), None) + .await; + store + .register("e2".into(), "pk2".into(), json!("r2"), None) + .await; + store.clear().await; + assert!(store.get("e1").await.is_none()); + assert!(store.get("e2").await.is_none()); + } + + #[tokio::test] + async fn default_store_is_bounded() { + let store = ServerEventRouteStore::new(); + for i in 0..=DEFAULT_LRU_SIZE { + store + .register(format!("e{i}"), "pk1".into(), json!(i), None) + .await; + } + + assert_eq!(store.event_route_count().await, DEFAULT_LRU_SIZE); + assert!(!store.has_event_route("e0").await); + assert!(store.has_event_route(&format!("e{DEFAULT_LRU_SIZE}")).await); + } + + #[tokio::test] + async fn sweep_stale_routes_removes_only_expired() { + let store = ServerEventRouteStore::new(); + + // Insert a route that will age past the threshold. + store + .register("old".into(), "pk1".into(), json!(1), Some("tok1".into())) + .await; + + tokio::time::sleep(Duration::from_millis(20)).await; + + // Insert a fresh route. + store + .register("fresh".into(), "pk2".into(), json!(2), None) + .await; + + // Sweep with 10ms timeout — "old" should be removed, "fresh" should remain. + let swept = store.sweep_stale_routes(Duration::from_millis(10)).await; + assert_eq!(swept.len(), 1); + assert_eq!(swept[0], "old"); + assert!(!store.has_event_route("old").await); + assert!(store.has_event_route("fresh").await); + // Secondary indexes should also be cleaned. + assert!(!store.has_progress_token("tok1").await); + assert!(!store.has_active_routes_for_client("pk1").await); + } + + #[tokio::test] + async fn sweep_stale_routes_returns_zero_when_nothing_expired() { + let store = ServerEventRouteStore::new(); + store + .register("e1".into(), "pk1".into(), json!(1), None) + .await; + + let swept = store.sweep_stale_routes(Duration::from_secs(60)).await; + assert!(swept.is_empty()); + assert!(store.has_event_route("e1").await); + } +} diff --git a/src/transport/server/mod.rs b/src/transport/server/mod.rs new file mode 100644 index 0000000..9c50b7f --- /dev/null +++ b/src/transport/server/mod.rs @@ -0,0 +1,2487 @@ +//! Server-side Nostr transport for ContextVM. +//! +//! Listens for incoming MCP requests from clients over Nostr, manages multi-client +//! sessions, handles request/response correlation, and optionally publishes +//! server announcements. + +pub(crate) mod announcement_manager; +pub mod correlation_store; +pub mod session_store; + +pub use correlation_store::{RouteEntry, ServerEventRouteStore}; +pub use session_store::{SessionSnapshot, SessionStore}; +use tokio::sync::RwLock; + +use std::collections::HashMap; +use std::num::NonZeroUsize; +use std::sync::{Arc, Mutex}; +use std::time::Duration; + +use lru::LruCache; +use nostr_sdk::prelude::*; +use tokio_util::sync::CancellationToken; + +use crate::core::constants::*; +use crate::core::error::{Error, Result}; +use crate::core::types::*; +use crate::core::validation; +use crate::encryption; +use crate::relay::{RelayPool, RelayPoolTrait}; +use crate::transport::base::BaseTransport; +use crate::transport::discovery_tags::learn_peer_capabilities; +use crate::transport::open_stream::OpenStreamConfig; +use crate::transport::oversized_transfer::{ + build_oversized_frames, progress_token_string, resolve_safe_chunk_size, OversizedFrame, + OversizedSenderOptions, OversizedTransferConfig, OversizedTransferReceiver, TransferPolicy, + ACCEPT_PROGRESS, +}; + +const LOG_TARGET: &str = "contextvm_sdk::transport::server"; + +/// CEP-22: the `support_oversized_transfer` capability tags to advertise, or +/// empty when oversized transfer is disabled. +fn oversized_support_tags(config: &NostrServerTransportConfig) -> Vec { + if config.oversized_transfer.enabled { + vec![Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + )] + } else { + Vec::new() + } +} + +/// CEP-22: build the empty per-peer reassembly store, bounded to `max_sessions` +/// peers (one [`OversizedTransferReceiver`] per client pubkey, inserted lazily by +/// the inbound event loop). +fn new_oversized_receiver_store( + max_sessions: usize, +) -> Arc>> { + Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(max_sessions).unwrap_or(NonZeroUsize::new(1).unwrap()), + ))) +} + +/// Configuration for the server transport. +#[derive(Debug, Clone)] +#[non_exhaustive] +pub struct NostrServerTransportConfig { + /// Relay URLs to connect to. + pub relay_urls: Vec, + /// Encryption mode. + pub encryption_mode: EncryptionMode, + /// Gift-wrap kind selection policy (CEP-19). + pub gift_wrap_mode: GiftWrapMode, + /// Server information for announcements. + pub server_info: Option, + /// Whether this server publishes public announcements (CEP-6). + pub is_announced_server: bool, + /// Allowed client public keys (hex). Empty = allow all. + pub allowed_public_keys: Vec, + /// Capabilities excluded from pubkey whitelisting. + pub excluded_capabilities: Vec, + /// Maximum number of concurrent client sessions (LRU-bounded, default: 1000). + pub max_sessions: usize, + /// Session cleanup interval (default: 60s). + pub cleanup_interval: Duration, + /// Session timeout (default: 300s). + pub session_timeout: Duration, + /// Correlation-retention TTL for server-side event routes (default: 60s). + /// + /// Stale route entries older than this are swept from the correlation store. + /// This prevents leaks -- rmcp owns actual request timeout and cancellation. + /// Keep this value above your rmcp request timeout to avoid premature cleanup. + pub request_timeout: Duration, + /// Explicit relay URLs to advertise in kind 10002 (NIP-65 relay list). + /// + /// Falls back to the transport's `relay_urls` when omitted. + pub relay_list_urls: Option>, + /// Additional publication targets for discoverability events. + /// + /// Merged with `relay_list_urls` when computing where to send events. + /// Defaults to [`DEFAULT_BOOTSTRAP_RELAY_URLS`] when omitted. + pub bootstrap_relay_urls: Option>, + /// Whether to publish a relay list event (kind 10002). Default: `true`. + pub publish_relay_list: bool, + /// Optional NIP-01 profile metadata (kind 0) to publish at startup. + pub profile_metadata: Option, + /// CEP-22 oversized payload transfer configuration. Enabled by default. + pub oversized_transfer: OversizedTransferConfig, + /// CEP-41 open-stream configuration. Disabled by default (opt-in). + /// + /// **Data only in PR1** — the event loop does not consult it yet; activation + /// (capability advertisement, learning, response deferral, the keepalive + /// sweep) lands in PR2. + pub open_stream: OpenStreamConfig, +} + +impl Default for NostrServerTransportConfig { + fn default() -> Self { + Self { + relay_urls: vec!["wss://relay.damus.io".to_string()], + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + server_info: None, + is_announced_server: false, + allowed_public_keys: Vec::new(), + excluded_capabilities: Vec::new(), + max_sessions: session_store::DEFAULT_MAX_SESSIONS, + cleanup_interval: Duration::from_secs(60), + session_timeout: Duration::from_secs(300), + request_timeout: Duration::from_secs(60), + relay_list_urls: None, + bootstrap_relay_urls: None, + publish_relay_list: true, + profile_metadata: None, + oversized_transfer: OversizedTransferConfig::default(), + open_stream: OpenStreamConfig::default(), + } + } +} + +/// Server-side Nostr transport — receives MCP requests and sends responses. +pub struct NostrServerTransport { + /// Relay pool for publishing and subscribing. + base: BaseTransport, + /// Configuration for this server transport. + config: NostrServerTransportConfig, + /// Manages tag composition and publishing for CEP-6 announcements and CEP-35 discovery. + announcement_manager: announcement_manager::AnnouncementManager, + /// Client sessions. + sessions: SessionStore, + /// Reverse lookup: event_id → client route. + event_routes: ServerEventRouteStore, + /// CEP-19: Track the incoming gift-wrap kind per request for mirroring. + request_wrap_kinds: Arc>>>, + /// Outer gift-wrap event IDs successfully decrypted and verified (inner `verify()`). + /// Duplicate outer ids are skipped before decrypt; ids are inserted only after success + /// so failed decrypt/verify can be retried on redelivery. + seen_gift_wrap_ids: Arc>>, + /// CEP-22: per-peer reassembly engines for inbound oversized transfers, keyed + /// by client pubkey (hex) and bounded to `max_sessions` peers. Each receiver + /// enforces the configured per-peer admission policy. Populated by the inbound + /// event loop; cleared on [`close`](Self::close). + oversized_receiver: Arc>>, + /// Channel for incoming MCP messages (consumed by the MCP server). + message_tx: Option>, + message_rx: Option>, + /// Token used to cancel spawned tasks (event loop + cleanup) on close(). + cancellation_token: CancellationToken, + /// Handles for spawned tasks (event loop + cleanup). + task_handles: Vec>, +} + +impl NostrServerTransportConfig { + /// Set the encryption mode. + pub fn with_encryption_mode(mut self, mode: EncryptionMode) -> Self { + self.encryption_mode = mode; + self + } + /// Set the gift-wrap mode (CEP-19). + pub fn with_gift_wrap_mode(mut self, mode: GiftWrapMode) -> Self { + self.gift_wrap_mode = mode; + self + } + /// Set server information for announcements. + pub fn with_server_info(mut self, info: ServerInfo) -> Self { + self.server_info = Some(info); + self + } + /// Enable or disable public announcement publishing (CEP-6). + pub fn with_announced_server(mut self, announced: bool) -> Self { + self.is_announced_server = announced; + self + } + /// Set the allowed client public keys (hex). Empty = allow all. + pub fn with_allowed_public_keys(mut self, keys: Vec) -> Self { + self.allowed_public_keys = keys; + self + } + /// Set capabilities excluded from pubkey whitelisting. + pub fn with_excluded_capabilities(mut self, caps: Vec) -> Self { + self.excluded_capabilities = caps; + self + } + /// Set the maximum number of concurrent client sessions. + pub fn with_max_sessions(mut self, max: usize) -> Self { + self.max_sessions = max; + self + } + /// Set the relay URLs to connect to. + pub fn with_relay_urls(mut self, urls: Vec) -> Self { + self.relay_urls = urls; + self + } + /// Set the session cleanup interval. + pub fn with_cleanup_interval(mut self, interval: Duration) -> Self { + self.cleanup_interval = interval; + self + } + /// Set the session timeout. + pub fn with_session_timeout(mut self, timeout: Duration) -> Self { + self.session_timeout = timeout; + self + } + /// Set the correlation-retention TTL for event routes. + pub fn with_request_timeout(mut self, timeout: Duration) -> Self { + self.request_timeout = timeout; + self + } + /// Set explicit relay URLs to advertise in the relay list event (kind 10002). + pub fn with_relay_list_urls(mut self, urls: Vec) -> Self { + self.relay_list_urls = Some(urls); + self + } + /// Set additional bootstrap relay URLs for discoverability event publication. + pub fn with_bootstrap_relay_urls(mut self, urls: Vec) -> Self { + self.bootstrap_relay_urls = Some(urls); + self + } + /// Enable or disable relay list publication (kind 10002). + pub fn with_publish_relay_list(mut self, publish: bool) -> Self { + self.publish_relay_list = publish; + self + } + /// Set NIP-01 profile metadata (kind 0) for publication at startup. + pub fn with_profile_metadata(mut self, metadata: ProfileMetadata) -> Self { + self.profile_metadata = Some(metadata); + self + } + /// Set the full CEP-22 oversized payload transfer configuration. + pub fn with_oversized_transfer(mut self, config: OversizedTransferConfig) -> Self { + self.oversized_transfer = config; + self + } + /// Enable or disable CEP-22 oversized payload transfer, leaving other knobs at default. + pub fn with_oversized_enabled(mut self, enabled: bool) -> Self { + self.oversized_transfer.enabled = enabled; + self + } + /// Set the full CEP-41 open-stream configuration. + /// + /// Data only in PR1: the event loop does not read this until PR2. + pub fn with_open_stream(mut self, config: OpenStreamConfig) -> Self { + self.open_stream = config; + self + } +} + +/// An incoming MCP request with metadata for routing the response. +#[derive(Debug)] +#[non_exhaustive] +pub struct IncomingRequest { + /// The parsed MCP message. + pub message: JsonRpcMessage, + /// The client's public key (hex). + pub client_pubkey: String, + /// The Nostr event ID (for response correlation). + pub event_id: String, + /// Whether the original message was encrypted. + pub is_encrypted: bool, +} + +impl NostrServerTransport { + /// Create a new server transport. + pub async fn new(signer: T, config: NostrServerTransportConfig) -> Result + where + T: IntoNostrSigner, + { + let relay_pool: Arc = + Arc::new(RelayPool::new(signer).await.map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to initialize relay pool for server transport" + ); + error + })?); + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + tracing::info!( + target: LOG_TARGET, + relay_count = config.relay_urls.len(), + announced = config.is_announced_server, + encryption_mode = ?config.encryption_mode, + gift_wrap_mode = ?config.gift_wrap_mode, + "Created server transport" + ); + let mut announcement_manager = announcement_manager::AnnouncementManager::new( + Arc::clone(&relay_pool), + config.server_info.clone(), + config.encryption_mode, + config.gift_wrap_mode, + tx.clone(), + config.relay_urls.clone(), + config.relay_list_urls.clone(), + config.bootstrap_relay_urls.clone(), + config.publish_relay_list, + config.profile_metadata.clone(), + ); + // CEP-22: advertise oversized-transfer support in announcements + first responses. + announcement_manager.set_internal_common_tags(oversized_support_tags(&config)); + Ok(Self { + announcement_manager, + base: BaseTransport { + relay_pool, + encryption_mode: config.encryption_mode, + is_connected: false, + }, + sessions: SessionStore::with_capacity(config.max_sessions), + oversized_receiver: new_oversized_receiver_store(config.max_sessions), + config, + event_routes: ServerEventRouteStore::new(), + request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())), + seen_gift_wrap_ids, + message_tx: Some(tx), + message_rx: Some(rx), + cancellation_token: CancellationToken::new(), + task_handles: Vec::new(), + }) + } + + /// Like [`new`](Self::new) but accepts an existing relay pool. + pub async fn with_relay_pool( + config: NostrServerTransportConfig, + relay_pool: Arc, + ) -> Result { + let (tx, rx) = tokio::sync::mpsc::unbounded_channel(); + let seen_gift_wrap_ids = Arc::new(Mutex::new(LruCache::new( + NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero"), + ))); + + tracing::info!( + target: LOG_TARGET, + relay_count = config.relay_urls.len(), + announced = config.is_announced_server, + encryption_mode = ?config.encryption_mode, + "Created server transport (with_relay_pool)" + ); + let mut announcement_manager = announcement_manager::AnnouncementManager::new( + Arc::clone(&relay_pool), + config.server_info.clone(), + config.encryption_mode, + config.gift_wrap_mode, + tx.clone(), + config.relay_urls.clone(), + config.relay_list_urls.clone(), + config.bootstrap_relay_urls.clone(), + config.publish_relay_list, + config.profile_metadata.clone(), + ); + // CEP-22: advertise oversized-transfer support in announcements + first responses. + announcement_manager.set_internal_common_tags(oversized_support_tags(&config)); + Ok(Self { + announcement_manager, + base: BaseTransport { + relay_pool, + encryption_mode: config.encryption_mode, + is_connected: false, + }, + sessions: SessionStore::with_capacity(config.max_sessions), + oversized_receiver: new_oversized_receiver_store(config.max_sessions), + config, + request_wrap_kinds: Arc::new(RwLock::new(HashMap::new())), + event_routes: ServerEventRouteStore::new(), + seen_gift_wrap_ids, + message_tx: Some(tx), + message_rx: Some(rx), + cancellation_token: CancellationToken::new(), + task_handles: Vec::new(), + }) + } + + /// Start listening for incoming requests. + pub async fn start(&mut self) -> Result<()> { + self.base + .connect(&self.config.relay_urls) + .await + .map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to connect server transport to relays" + ); + error + })?; + + let pubkey = self.base.get_public_key().await.map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to fetch server transport public key" + ); + error + })?; + tracing::info!( + target: LOG_TARGET, + pubkey = %pubkey.to_hex(), + "Server transport started" + ); + + self.base + .subscribe_for_pubkey(&pubkey) + .await + .map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + pubkey = %pubkey.to_hex(), + "Failed to subscribe server transport for pubkey" + ); + error + })?; + + // Spawn event loop with cancellation support + let relay_pool = Arc::clone(&self.base.relay_pool); + let sessions = self.sessions.clone(); + let event_routes = self.event_routes.clone(); + let request_wrap_kinds = self.request_wrap_kinds.clone(); + let tx = self + .message_tx + .as_ref() + .expect("message_tx must exist before start()") + .clone(); + let allowed = self.config.allowed_public_keys.clone(); + let excluded = self.config.excluded_capabilities.clone(); + let encryption_mode = self.config.encryption_mode; + let gift_wrap_mode = self.config.gift_wrap_mode; + let is_announced_server = self.config.is_announced_server; + let oversized_enabled = self.config.oversized_transfer.enabled; + let oversized_receiver = self.oversized_receiver.clone(); + let transfer_policy: TransferPolicy = (&self.config.oversized_transfer).into(); + let common_tags_snapshot = self.announcement_manager.common_tags_snapshot(); + let seen_gift_wrap_ids = self.seen_gift_wrap_ids.clone(); + let event_loop_token = self.cancellation_token.child_token(); + + let event_loop_handle = tokio::spawn(async move { + Self::event_loop( + relay_pool, + sessions, + event_routes, + request_wrap_kinds, + tx, + allowed, + excluded, + encryption_mode, + gift_wrap_mode, + is_announced_server, + oversized_enabled, + oversized_receiver, + transfer_policy, + common_tags_snapshot, + seen_gift_wrap_ids, + event_loop_token, + ) + .await; + }); + + // Spawn session cleanup with cancellation support + let sessions_cleanup = self.sessions.clone(); + let event_routes_cleanup = self.event_routes.clone(); + let request_wrap_kinds_cleanup = self.request_wrap_kinds.clone(); + let cleanup_interval = self.config.cleanup_interval; + let session_timeout = self.config.session_timeout; + let request_timeout = self.config.request_timeout; + let cleanup_token = self.cancellation_token.child_token(); + + let cleanup_handle = tokio::spawn(async move { + let mut interval = tokio::time::interval(cleanup_interval); + loop { + tokio::select! { + _ = cleanup_token.cancelled() => { + tracing::info!( + target: LOG_TARGET, + "Server cleanup task cancelled" + ); + break; + } + _ = interval.tick() => { + let cleaned = Self::cleanup_sessions( + &sessions_cleanup, + &event_routes_cleanup, + &request_wrap_kinds_cleanup, + session_timeout, + ) + .await; + if cleaned > 0 { + tracing::info!( + target: LOG_TARGET, + cleaned_sessions = cleaned, + "Cleaned up inactive sessions" + ); + } + } + } + + // Sweep stale route entries in active sessions (rmcp handles timeout errors). + let swept_event_ids = event_routes_cleanup + .sweep_stale_routes(request_timeout) + .await; + if !swept_event_ids.is_empty() { + let mut kinds_w = request_wrap_kinds_cleanup.write().await; + for event_id in &swept_event_ids { + kinds_w.remove(event_id); + } + drop(kinds_w); + tracing::warn!( + target: LOG_TARGET, + swept = swept_event_ids.len(), + timeout_secs = request_timeout.as_secs(), + "Swept stale event routes (rmcp handles timeout errors)" + ); + } + } + }); + + self.task_handles.push(event_loop_handle); + self.task_handles.push(cleanup_handle); + + tracing::info!( + target: LOG_TARGET, + relay_count = self.config.relay_urls.len(), + cleanup_interval_secs = self.config.cleanup_interval.as_secs(), + session_timeout_secs = self.config.session_timeout.as_secs(), + "Server transport loops spawned" + ); + Ok(()) + } + + /// Close the transport — cancels event loop and cleanup tasks, then disconnects. + pub async fn close(&mut self) -> Result<()> { + self.cancellation_token.cancel(); + for handle in self.task_handles.drain(..) { + let _ = handle.await; + } + self.announcement_manager.shutdown(); + self.message_tx.take(); + self.base.disconnect().await?; + self.sessions.clear().await; + self.event_routes.clear().await; + self.oversized_receiver.write().await.clear(); + Ok(()) + } + + /// Send a response back to the client that sent the original request. + pub async fn send_response(&self, event_id: &str, mut response: JsonRpcMessage) -> Result<()> { + // Consume the route up-front so only one concurrent responder can proceed + // for a given event_id. + let route = self.event_routes.pop(event_id).await.ok_or_else(|| { + tracing::error!( + target: LOG_TARGET, + event_id = %event_id, + "No client found for response correlation" + ); + Error::Other(format!("No client found for event {event_id}")) + })?; + + let client_pubkey_hex = route.client_pubkey; + let original_request_id = route.original_request_id; + let progress_token = route.progress_token; + + let mut sessions_w = self.sessions.write().await; + let session = sessions_w.get_mut(&client_pubkey_hex).ok_or_else(|| { + tracing::error!( + target: LOG_TARGET, + client_pubkey = %client_pubkey_hex, + "No session for correlated client" + ); + Error::Other(format!("No session for client {client_pubkey_hex}")) + })?; + + // Restore original request ID + match &mut response { + JsonRpcMessage::Response(r) => r.id = original_request_id.clone(), + JsonRpcMessage::ErrorResponse(r) => r.id = original_request_id.clone(), + _ => {} + } + + // CEP-22: serialize once, *after* id restoration. The threshold check and + // the oversized split must both derive from this exact post-restoration + // string so the client reassembles bytes whose `id` matches the digest. + let serialized = serde_json::to_string(&response)?; + + let is_encrypted = session.is_encrypted; + // CEP-22: capture the peer's oversized support while the session lock is held. + let supports_oversized_transfer = session.supports_oversized_transfer; + + // CEP-35: include discovery tags on first response to this client + let discovery_tags = self.take_pending_server_discovery_tags(session); + drop(sessions_w); + + // CEP-19: Look up the incoming wrap kind for mirroring + let mirrored_wrap_kind = self + .request_wrap_kinds + .read() + .await + .get(event_id) + .copied() + .flatten(); + + let client_pubkey = PublicKey::from_hex(&client_pubkey_hex).map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + client_pubkey = %client_pubkey_hex, + "Invalid client pubkey in session map" + ); + Error::Other(error.to_string()) + })?; + + let event_id_parsed = EventId::from_hex(event_id).map_err(|error| { + tracing::error!( + target: LOG_TARGET, + error = %error, + event_id = %event_id, + "Invalid event id while sending response" + ); + Error::Other(error.to_string()) + })?; + + let base_tags = BaseTransport::create_response_tags(&client_pubkey, &event_id_parsed); + let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]); + let gift_wrap_kind = Self::select_outbound_gift_wrap_kind( + self.config.gift_wrap_mode, + is_encrypted, + mirrored_wrap_kind, + ); + + // CEP-22: a response is eligible for oversized fragmentation only when the + // feature is enabled, the peer advertised support, and the request carried a + // progressToken to address the frames with. + let oversized_eligible = self.config.oversized_transfer.enabled + && progress_token.is_some() + && supports_oversized_transfer; + let threshold = self.config.oversized_transfer.threshold; + + // CEP-22: relay size limits apply to the *published* Nostr event, so decide + // on the published byte size, not the raw payload (mirrors TS + // `measurePublishedMcpMessageSize`). The raw serialized length is a cheap + // lower bound: at/above the threshold the response is conclusively oversized + // and we fragment without building a single event — an escape-heavy payload + // could otherwise overflow NIP-44's plaintext limit while measuring. Below + // it, build the single event once, measure it, and reuse it when it fits. + let mut reuse_event: Option = None; + let fragment = if !oversized_eligible { + false + } else if serialized.len() >= threshold { + true + } else { + match self + .base + .prepare_mcp_message( + &response, + &client_pubkey, + CTXVM_MESSAGES_KIND, + tags.clone(), + Some(is_encrypted), + gift_wrap_kind, + ) + .await + { + Ok((_id, publishable)) => { + let published_len = serde_json::to_string(&publishable) + .map(|s| s.len()) + .unwrap_or(usize::MAX); + if published_len > threshold { + true + } else { + reuse_event = Some(publishable); + false + } + } + // Could not build one event (e.g. NIP-44 plaintext overflow from an + // escape-heavy payload) → it cannot be sent as a single event. + Err(error) => { + tracing::debug!( + target: LOG_TARGET, + error = %error, + event_id = %event_id, + "Single-event build failed; sending response as oversized transfer" + ); + true + } + } + }; + + // Both paths converge on the cleanup tail below — neither early-returns on + // success. + let send_result: Result<()> = if fragment { + self.send_oversized_response( + &serialized, + progress_token.as_deref().unwrap_or_default(), + &client_pubkey, + &base_tags, + tags, + is_encrypted, + gift_wrap_kind, + ) + .await + } else if let Some(publishable) = reuse_event { + // Reuse the event already built for the size check — no re-encryption. + self.base + .relay_pool + .publish_event(&publishable) + .await + .map(|_| ()) + } else { + self.base + .send_mcp_message( + &response, + &client_pubkey, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + gift_wrap_kind, + ) + .await + .map(|_| ()) + }; + + if let Err(error) = send_result { + tracing::error!( + target: LOG_TARGET, + error = %error, + client_pubkey = %client_pubkey_hex, + event_id = %event_id, + "Failed to publish response message" + ); + + // Re-register route on publish failure so caller can retry. + self.event_routes + .register( + event_id.to_string(), + client_pubkey_hex, + original_request_id, + progress_token, + ) + .await; + + return Err(error); + } + + // Clean up wrap-kind tracking + self.request_wrap_kinds.write().await.remove(event_id); + + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.get_mut(&client_pubkey_hex) { + // Clean up progress token + if let Some(token) = progress_token { + session.pending_requests.remove(&token); + } + session.event_to_progress_token.remove(event_id); + session.pending_requests.remove(event_id); + } + drop(sessions); + + tracing::debug!( + target: LOG_TARGET, + client_pubkey = %client_pubkey_hex, + event_id = %event_id, + encrypted = is_encrypted, + "Sent server response and cleaned correlation state" + ); + Ok(()) + } + + /// CEP-22: publish a response as an ordered oversized-transfer frame sequence. + /// + /// Splits the post-restoration `serialized` string into `start → chunks… → + /// end` frames (digest and split both derived from that exact string) and + /// publishes each as a `notifications/progress` event to `recipient`. The + /// server never reserves the `accept` slot or waits for a handshake — it only + /// fragments for peers already known to support the feature. One-shot + /// discovery tags ride the `start` frame only (`start_tags`); every later + /// frame carries bare recipient + `e`-tags (`base_tags`). + #[allow(clippy::too_many_arguments)] + async fn send_oversized_response( + &self, + serialized: &str, + progress_token: &str, + recipient: &PublicKey, + base_tags: &[Tag], + start_tags: Vec, + is_encrypted: bool, + gift_wrap_kind: Option, + ) -> Result<()> { + // CEP-22: derive a per-chunk payload budget so every published frame stays + // under the threshold even after the JSON-RPC envelope, signature, and + // (when encrypted) gift-wrap expansion. Mirrors TS `resolveSafeOversizedChunkSize`. + // Continuation (chunk) frames carry the response `p`+`e` tags (`base_tags`), + // so size against those — not the bare recipient tag — or the budget would + // be ~70 bytes optimistic. + let chunk_size = resolve_safe_chunk_size( + self.config.oversized_transfer.chunk_size, + &self.base, + recipient, + base_tags, + is_encrypted, + Kind::Custom(gift_wrap_kind.unwrap_or(GIFT_WRAP_KIND)), + self.config.oversized_transfer.threshold, + ) + .await?; + let options = OversizedSenderOptions::new(progress_token).with_chunk_size(chunk_size); + let frames = build_oversized_frames(serialized, &options)?.into_ordered(); + + // Discovery tags ride the start frame; `take` yields them once, then the + // remaining frames fall back to bare recipient + `e`-tags. + let mut start_tags = Some(start_tags); + for frame in frames { + let tags = start_tags.take().unwrap_or_else(|| base_tags.to_vec()); + let message = JsonRpcMessage::Notification(frame); + self.base + .send_mcp_message( + &message, + recipient, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + gift_wrap_kind, + ) + .await?; + } + Ok(()) + } + + /// Send a notification to a specific client. + pub async fn send_notification( + &self, + client_pubkey_hex: &str, + notification: &JsonRpcMessage, + correlated_event_id: Option<&str>, + ) -> Result<()> { + let mut sessions = self.sessions.write().await; + let session = sessions + .get_mut(client_pubkey_hex) + .ok_or_else(|| Error::Other(format!("No session for {client_pubkey_hex}")))?; + let is_encrypted = session.is_encrypted; + let supports_ephemeral = session.supports_ephemeral_gift_wrap; + + // CEP-35: include discovery tags on first message to this client + let discovery_tags = self.take_pending_server_discovery_tags(session); + drop(sessions); + + let client_pubkey = + PublicKey::from_hex(client_pubkey_hex).map_err(|e| Error::Other(e.to_string()))?; + + let mut base_tags = BaseTransport::create_recipient_tags(&client_pubkey); + if let Some(eid) = correlated_event_id { + let event_id = EventId::from_hex(eid).map_err(|e| Error::Other(e.to_string()))?; + base_tags.push(Tag::event(event_id)); + } + + let tags = BaseTransport::compose_outbound_tags(&base_tags, &discovery_tags, &[]); + + // CEP-19: Look up mirrored wrap kind from correlated request + let correlated_wrap_kind = if let Some(event_id) = correlated_event_id { + self.request_wrap_kinds + .read() + .await + .get(event_id) + .copied() + .flatten() + } else { + None + }; + + self.base + .send_mcp_message( + notification, + &client_pubkey, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + Self::select_outbound_notification_gift_wrap_kind( + self.config.gift_wrap_mode, + is_encrypted, + correlated_wrap_kind, + supports_ephemeral, + ), + ) + .await?; + + Ok(()) + } + + /// Broadcast a notification to all initialized clients. + pub async fn broadcast_notification(&self, notification: &JsonRpcMessage) -> Result<()> { + let sessions = self.sessions.read().await; + let initialized: Vec = sessions + .iter() + .filter(|(_, s)| s.is_initialized) + .map(|(k, _)| k.clone()) + .collect(); + drop(sessions); + + for pubkey in initialized { + if let Err(error) = self.send_notification(&pubkey, notification, None).await { + tracing::error!( + target: LOG_TARGET, + error = %error, + client_pubkey = %pubkey, + "Failed to send notification" + ); + } + } + Ok(()) + } + + /// Take the message receiver for consuming incoming requests. + pub fn take_message_receiver( + &mut self, + ) -> Option> { + self.message_rx.take() + } + + /// Read-only snapshot of a client's learned session state, or `None` if no + /// session exists for that public key (hex). Exposes learned peer + /// capabilities (encryption, ephemeral encryption, CEP-22 oversized transfer). + pub async fn session_snapshot(&self, client_pubkey: &str) -> Option { + self.sessions.get_session(client_pubkey).await + } + + /// Sets extra discovery tags to include in announcements and first-response discovery replay. + pub fn set_announcement_extra_tags(&mut self, tags: Vec) { + self.announcement_manager.set_extra_common_tags(tags); + } + + /// Sets pricing tags to include in announcement/list events and capability list responses. + pub fn set_announcement_pricing_tags(&mut self, tags: Vec) { + self.announcement_manager.set_pricing_tags(tags); + } + + /// Publish server announcement (kind 11316). + pub async fn announce(&self) -> Result { + self.announcement_manager.announce().await + } + + /// Publish tools list (kind 11317). + pub async fn publish_tools(&self, tools: Vec) -> Result { + self.announcement_manager.publish_tools(tools).await + } + + /// Publish resources list (kind 11318). + pub async fn publish_resources(&self, resources: Vec) -> Result { + self.announcement_manager.publish_resources(resources).await + } + + /// Publish prompts list (kind 11320). + pub async fn publish_prompts(&self, prompts: Vec) -> Result { + self.announcement_manager.publish_prompts(prompts).await + } + + /// Publish resource templates list (kind 11319). + pub async fn publish_resource_templates( + &self, + templates: Vec, + ) -> Result { + self.announcement_manager + .publish_resource_templates(templates) + .await + } + + /// Delete server announcements (NIP-09 kind 5). + pub async fn delete_announcements(&self, reason: &str) -> Result<()> { + self.announcement_manager.delete_announcements(reason).await + } + + /// Spawn the CEP-6 auto-publish task if `is_announced_server` is set. + /// + /// Called by the rmcp worker after `start()` — not in `start()` itself — + /// because the auto-publish flow injects synthetic MCP requests that + /// require an rmcp handler to produce responses. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) fn spawn_announcements(&mut self) { + if self.config.is_announced_server { + let handle = self + .announcement_manager + .spawn_publish_public_announcements(self.cancellation_token.child_token()); + self.task_handles.push(handle); + } + self.spawn_discoverability_publication(); + } + + /// Spawn profile metadata and relay-list publication for direct transport users. + /// + /// This publishes kind 0 and kind 10002 discoverability events when configured. + /// It intentionally does not spawn CEP-6 capability announcement tasks because + /// those inject synthetic MCP requests that require an rmcp worker. + pub fn spawn_discoverability_publication(&mut self) { + let handle = self.announcement_manager.spawn_publish_discoverability(); + self.task_handles.push(handle); + } + + /// Forward an announcement response to the announcement manager for publishing. + /// + /// Called by the worker when a response with the announcement sentinel ID arrives. + #[cfg_attr(not(feature = "rmcp"), allow(dead_code))] + pub(crate) async fn handle_announcement_response( + &self, + response: JsonRpcMessage, + ) -> Result<()> { + self.announcement_manager + .handle_announcement_response(response) + .await + } + + /// Publish tools list from rmcp typed tool descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_tools_typed(&self, tools: Vec) -> Result { + self.announcement_manager.publish_tools_typed(tools).await + } + + /// Publish resources list from rmcp typed resource descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_resources_typed( + &self, + resources: Vec, + ) -> Result { + self.announcement_manager + .publish_resources_typed(resources) + .await + } + + /// Publish prompts list from rmcp typed prompt descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_prompts_typed( + &self, + prompts: Vec, + ) -> Result { + self.announcement_manager + .publish_prompts_typed(prompts) + .await + } + + /// Publish resource templates list from rmcp typed template descriptors. + #[cfg(feature = "rmcp")] + pub async fn publish_resource_templates_typed( + &self, + templates: Vec, + ) -> Result { + self.announcement_manager + .publish_resource_templates_typed(templates) + .await + } + + // ── CEP-35 discovery tag helpers ────────────────────────────── + + /// One-shot: returns common tags if not yet sent to this client, empty otherwise. + fn take_pending_server_discovery_tags(&self, session: &mut ClientSession) -> Vec { + if session.has_sent_common_tags { + return vec![]; + } + session.has_sent_common_tags = true; + self.announcement_manager.get_common_tags() + } + + // ── Internal ──────────────────────────────────────────────── + + fn is_capability_excluded( + excluded: &[CapabilityExclusion], + method: &str, + name: Option<&str>, + ) -> bool { + // Always allow fundamental MCP methods + if method == "initialize" || method == "notifications/initialized" { + return true; + } + + excluded.iter().any(|excl| { + if excl.method != method { + return false; + } + match (&excl.name, name) { + (Some(excl_name), Some(req_name)) => excl_name == req_name, + (None, _) => true, // method-only match + _ => false, + } + }) + } + + /// CEP-22: one watchdog sweep over the per-peer reassembly engines. Reaps + /// transfers past their hard deadline — local-only (no abort frame is + /// emitted): the requester's own timeout fails the call, and late frames + /// are orphan-ignored — then drops now-empty receivers so long-gone peers + /// stop pinning LRU slots (admission recreates them on demand). + async fn sweep_oversized_receivers( + oversized_receiver: &Arc>>, + ) { + let mut receivers = oversized_receiver.write().await; + let mut empty_peers: Vec = Vec::new(); + for (peer, receiver) in receivers.iter_mut() { + for token in receiver.remove_expired() { + tracing::warn!( + target: LOG_TARGET, + client_pubkey = %peer, + token = %token, + "Oversized transfer reaped by watchdog" + ); + } + if receiver.active_transfer_count() == 0 { + empty_peers.push(peer.clone()); + } + } + // Keys collected first, popped after: never mutate mid-iteration. + for peer in empty_peers { + receivers.pop(&peer); + } + } + + #[allow(clippy::too_many_arguments)] + async fn event_loop( + relay_pool: Arc, + sessions: SessionStore, + event_routes: ServerEventRouteStore, + request_wrap_kinds: Arc>>>, + tx: tokio::sync::mpsc::UnboundedSender, + allowed_pubkeys: Vec, + excluded_capabilities: Vec, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + is_announced_server: bool, + oversized_enabled: bool, + oversized_receiver: Arc>>, + transfer_policy: TransferPolicy, + common_tags_snapshot: announcement_manager::CommonTagsSnapshot, + seen_gift_wrap_ids: Arc>>, + cancel: CancellationToken, + ) { + let mut notifications = relay_pool.notifications(); + + // CEP-22: receiver-side watchdog sweep. Same clamp formula as the + // client's correlation sweep; the arm is disabled entirely when the + // feature is off or the deadline is 0 (no watchdog). + let watchdog_enabled = oversized_enabled && transfer_policy.transfer_timeout_ms != 0; + let sweep_interval = (Duration::from_millis(transfer_policy.transfer_timeout_ms) / 2) + .clamp(Duration::from_secs(1), Duration::from_secs(30)); + let mut sweep_timer = + tokio::time::interval_at(tokio::time::Instant::now() + sweep_interval, sweep_interval); + + loop { + let notification = tokio::select! { + _ = cancel.cancelled() => { + tracing::info!( + target: LOG_TARGET, + "Server event loop cancelled" + ); + break; + } + _ = sweep_timer.tick(), if watchdog_enabled => { + Self::sweep_oversized_receivers(&oversized_receiver).await; + continue; + } + result = notifications.recv() => { + match result { + Ok(n) => n, + Err(tokio::sync::broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!( + target: LOG_TARGET, + skipped = n, + "Relay broadcast lagged, skipping missed events" + ); + continue; + } + Err(tokio::sync::broadcast::error::RecvError::Closed) => break, + } + } + }; + if let RelayPoolNotification::Event { event, .. } = notification { + let is_gift_wrap = event.kind == Kind::Custom(GIFT_WRAP_KIND) + || event.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND); + let outer_kind: u16 = event.kind.as_u16(); + + // CEP-19: Drop gift-wraps that violate the configured gift-wrap mode + if is_gift_wrap && !gift_wrap_mode.allows_kind(outer_kind) { + tracing::warn!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + event_kind = outer_kind, + configured_mode = ?gift_wrap_mode, + "Dropping gift-wrap because it violates gift_wrap_mode policy" + ); + continue; + } + + let (content, sender_pubkey, event_id, is_encrypted, inner_tags) = if is_gift_wrap { + if encryption_mode == EncryptionMode::Disabled { + tracing::warn!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + sender_pubkey = %event.pubkey.to_hex(), + "Received encrypted message but encryption is disabled" + ); + continue; + } + { + let guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + if guard.contains(&event.id) { + tracing::debug!( + target: LOG_TARGET, + event_id = %event.id.to_hex(), + "Skipping duplicate gift-wrap (outer id)" + ); + continue; + } + } + // Single-layer NIP-44 decrypt (matches JS/TS SDK) + let signer = match relay_pool.signer().await { + Ok(s) => s, + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to get signer" + ); + continue; + } + }; + match encryption::decrypt_gift_wrap_single_layer(&signer, &event).await { + Ok(decrypted_json) => { + // The decrypted content is JSON of the inner signed event. + // Use the INNER event's ID for correlation — the client + // registers the inner event ID in its correlation store. + match serde_json::from_str::(&decrypted_json) { + Ok(inner) => { + if let Err(e) = inner.verify() { + tracing::warn!( + "Inner event signature verification failed: {e}" + ); + continue; + } + { + let mut guard = match seen_gift_wrap_ids.lock() { + Ok(g) => g, + Err(poisoned) => poisoned.into_inner(), + }; + guard.put(event.id, ()); + } + let inner_tags: Vec = inner.tags.to_vec(); + ( + inner.content, + inner.pubkey.to_hex(), + inner.id.to_hex(), + true, + inner_tags, + ) + } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to parse inner event" + ); + continue; + } + } + } + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to decrypt" + ); + continue; + } + } + } else { + if encryption_mode == EncryptionMode::Required { + tracing::warn!( + target: LOG_TARGET, + sender_pubkey = %event.pubkey.to_hex(), + "Received unencrypted message but encryption is required" + ); + continue; + } + ( + event.content.clone(), + event.pubkey.to_hex(), + event.id.to_hex(), + false, + event.tags.to_vec(), + ) + }; + + // Parse MCP message + let mcp_msg = match validation::validate_and_parse(&content) { + Some(msg) => msg, + None => { + tracing::warn!( + target: LOG_TARGET, + sender_pubkey = %sender_pubkey, + "Invalid MCP message" + ); + continue; + } + }; + + // Authorization check + if !allowed_pubkeys.is_empty() { + let method = mcp_msg.method().unwrap_or(""); + let name = match &mcp_msg { + JsonRpcMessage::Request(r) => r + .params + .as_ref() + .and_then(|p| p.get("name")) + .and_then(|n| n.as_str()), + _ => None, + }; + + let is_excluded = + Self::is_capability_excluded(&excluded_capabilities, method, name); + + if !allowed_pubkeys.contains(&sender_pubkey) && !is_excluded { + tracing::warn!( + target: LOG_TARGET, + sender_pubkey = %sender_pubkey, + method = method, + "Unauthorized request" + ); + + // Send a JSON-RPC error back for Request messages so the + // client doesn't hang indefinitely (announced servers only). + if is_announced_server { + if let JsonRpcMessage::Request(ref req) = mcp_msg { + if let Ok(client_pk) = PublicKey::from_hex(&sender_pubkey) { + let event_id_parsed = EventId::from_hex(&event_id) + .unwrap_or(EventId::all_zeros()); + let mut tags = BaseTransport::create_response_tags( + &client_pk, + &event_id_parsed, + ); + + // CEP-19: Inject common discovery tags on first response + let has_sent = sessions + .get_session(&sender_pubkey) + .await + .is_some_and(|s| s.has_sent_common_tags); + if !has_sent { + common_tags_snapshot.append_common_response_tags(&mut tags); + sessions.mark_common_tags_sent(&sender_pubkey).await; + } + + let error_response = + JsonRpcMessage::ErrorResponse(JsonRpcErrorResponse { + jsonrpc: "2.0".to_string(), + id: req.id.clone(), + error: JsonRpcError { + code: -32000, + message: "Unauthorized".to_string(), + data: None, + }, + }); + + let base = BaseTransport { + relay_pool: Arc::clone(&relay_pool), + encryption_mode, + is_connected: true, + }; + if let Err(e) = base + .send_mcp_message( + &error_response, + &client_pk, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + Self::select_outbound_gift_wrap_kind( + gift_wrap_mode, + is_encrypted, + if is_gift_wrap { Some(outer_kind) } else { None }, + ), + ) + .await + { + tracing::error!( + target: LOG_TARGET, + error = %e, + sender_pubkey = %sender_pubkey, + "Failed to send unauthorized error response" + ); + } + } + } + } // if is_announced_server + + continue; + } + } + + // Session management + let on_evicted_cb = sessions.eviction_callback(); + let mut sessions_w = sessions.write().await; + if !sessions_w.contains(&sender_pubkey) { + let evicted = + sessions_w.push(sender_pubkey.clone(), ClientSession::new(is_encrypted)); + SessionStore::handle_eviction( + &sender_pubkey, + evicted, + &mut sessions_w, + on_evicted_cb.as_ref(), + &event_routes, + ) + .await; + } + let session = sessions_w.get_mut(&sender_pubkey).unwrap(); + session.update_activity(); + session.is_encrypted = is_encrypted; + + // CEP-19: Mark ephemeral support if client used kind 21059 + if is_gift_wrap && outer_kind == EPHEMERAL_GIFT_WRAP_KIND { + session.supports_ephemeral_gift_wrap = true; + } + + // CEP-35: learn client capabilities from inner event tags + let discovered = learn_peer_capabilities(&inner_tags); + session.supports_encryption |= discovered.supports_encryption; + session.supports_ephemeral_encryption |= discovered.supports_ephemeral_encryption; + // CEP-22: snapshot the flag BEFORE the learning gate mutates + // it — the very `start` frame carries the client's support tag, so + // without this snapshot the first transfer would never get an `accept`. + let client_already_supported = session.supports_oversized_transfer; + // CEP-22: only learn oversized support if it is enabled on this server. + session.supports_oversized_transfer |= + oversized_enabled && discovered.supports_oversized_transfer; + + // CEP-22: intercept oversized-transfer frames before request + // correlation/dispatch. A disabled server forwards raw progress + // notifications as before. + if oversized_enabled { + if let JsonRpcMessage::Notification(ref n) = mcp_msg { + if OversizedTransferReceiver::is_oversized_frame(n) { + drop(sessions_w); + Self::handle_oversized_frame( + n, + &sender_pubkey, + &event_id, + is_encrypted, + is_gift_wrap, + outer_kind, + client_already_supported, + &oversized_receiver, + transfer_policy, + &relay_pool, + encryption_mode, + gift_wrap_mode, + &event_routes, + &request_wrap_kinds, + &tx, + ) + .await; + continue; + } + } + } + + // Track request for correlation + if let JsonRpcMessage::Request(ref req) = mcp_msg { + let original_id = req.id.clone(); + + // Extract progress token from _meta if present. String or + // number (rmcp issues numbers): without numeric acceptance + // the response eligibility gate in `send_response` never + // opens for rmcp clients. Normalized to its stringified form + // for routing and frame addressing (the wire keeps emitting + // string tokens). + let progress_token = req + .params + .as_ref() + .and_then(|p| p.get("_meta")) + .and_then(|m| m.get("progressToken")) + .and_then(progress_token_string); + + // Duplicate into session fields (kept for backward compat). + session + .pending_requests + .insert(event_id.clone(), original_id.clone()); + if let Some(ref token) = progress_token { + session + .pending_requests + .insert(token.clone(), serde_json::json!(event_id)); + session + .event_to_progress_token + .insert(event_id.clone(), token.clone()); + } + + drop(sessions_w); + + // CEP-19: Record the incoming wrap kind for response mirroring + { + let mut kinds_w = request_wrap_kinds.write().await; + kinds_w.insert( + event_id.clone(), + if is_gift_wrap { Some(outer_kind) } else { None }, + ); + } + + event_routes + .register( + event_id.clone(), + sender_pubkey.clone(), + original_id, + progress_token, + ) + .await; + } else { + drop(sessions_w); + } + + // Handle initialized notification (re-acquire for write) + if let JsonRpcMessage::Notification(ref n) = mcp_msg { + if n.method == "notifications/initialized" { + let mut sessions_w2 = sessions.write().await; + if let Some(session) = sessions_w2.get_mut(&sender_pubkey) { + session.is_initialized = true; + } + } + } + + // Forward to consumer + let _ = tx.send(IncomingRequest { + message: mcp_msg, + client_pubkey: sender_pubkey, + event_id, + is_encrypted, + }); + } + } + } + + /// CEP-22 server inbound: process one oversized-transfer frame. + /// + /// Emits an `accept` on the opening frame when the client's support is not yet + /// known, feeds the frame to this peer's reassembler, and — on the + /// `end` frame — registers a response route and dispatches the reassembled + /// request as a synthetic [`IncomingRequest`] (keyed by the end frame's real + /// carrying event id, collision-free against the reserved sentinels). + #[allow(clippy::too_many_arguments)] + async fn handle_oversized_frame( + frame: &JsonRpcNotification, + sender_pubkey: &str, + event_id: &str, + is_encrypted: bool, + is_gift_wrap: bool, + outer_kind: u16, + client_already_supported: bool, + oversized_receiver: &Arc>>, + transfer_policy: TransferPolicy, + relay_pool: &Arc, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + event_routes: &ServerEventRouteStore, + request_wrap_kinds: &Arc>>>, + tx: &tokio::sync::mpsc::UnboundedSender, + ) { + // The outer progressToken keys the transfer (needed for accept + route). + // String or number — defensive only: every known sender stringifies + // tokens into frames. + let token = frame + .params + .as_ref() + .and_then(|p| p.get("progressToken")) + .and_then(progress_token_string); + + // 1. Emit `accept` on the opening frame if support is not yet known. + let is_start = frame + .params + .as_ref() + .and_then(|p| p.get("cvm")) + .and_then(OversizedFrame::from_cvm_value) + .is_some_and(|f| matches!(f, OversizedFrame::Start { .. })); + let issued_accept = is_start && !client_already_supported && token.is_some(); + if issued_accept { + if let Some(ref token) = token { + Self::emit_accept_frame( + token, + sender_pubkey, + event_id, + is_encrypted, + is_gift_wrap, + outer_kind, + relay_pool, + encryption_mode, + gift_wrap_mode, + ) + .await; + } + } + + // 2. Feed the frame to this peer's reassembler (process_frame is sync; the + // write guard is held only across the sync call, never an await). When we + // issued an `accept`, the sender reserved progress slot 2 and its chunks + // begin at slot 3 — but we never *receive* an `accept`, so feed a synthetic + // one into our own receiver to align chunk-slot tracking with the handshake + // layout (otherwise the frontier sticks at slot 2 and chunks pile up as + // out-of-order until the gap exceeds the window). + let outcome = { + let mut store = oversized_receiver.write().await; + if !store.contains(sender_pubkey) { + store.put( + sender_pubkey.to_string(), + OversizedTransferReceiver::with_policy(transfer_policy), + ); + } + let receiver = store.get_mut(sender_pubkey).unwrap(); + let outcome = receiver.process_frame(frame); + if issued_accept && matches!(outcome, Ok(None)) { + if let Some(ref token) = token { + if let Ok(accept) = OversizedFrame::Accept.into_progress_notification( + token, + ACCEPT_PROGRESS, + None, + ) { + let _ = receiver.process_frame(&accept); + } + } + } + outcome + }; + + match outcome { + // start/accept/chunk consumed — nothing to dispatch yet. + Ok(None) => {} + // The `end` frame: reassembled request ready to dispatch. + Ok(Some(message)) => { + let original_id = message.id().cloned().unwrap_or(serde_json::Value::Null); + // Mirror the incoming wrap kind for the eventual response (CEP-19). + { + let mut kinds_w = request_wrap_kinds.write().await; + kinds_w.insert( + event_id.to_string(), + if is_gift_wrap { Some(outer_kind) } else { None }, + ); + } + event_routes + .register( + event_id.to_string(), + sender_pubkey.to_string(), + original_id, + token, + ) + .await; + let _ = tx.send(IncomingRequest { + message, + client_pubkey: sender_pubkey.to_string(), + event_id: event_id.to_string(), + is_encrypted, + }); + } + // Clean up locally, let the peer's own timeout fire. + Err(error) => { + tracing::warn!( + target: LOG_TARGET, + error = %error, + sender_pubkey = %sender_pubkey, + "Oversized transfer frame rejected; cleaning up locally" + ); + } + } + } + + /// CEP-22: publish a single `accept` frame back to `sender_pubkey`, e-tagged to + /// the `start` frame's carrying event. Best-effort — failures are logged only + /// (the sender falls back to its own accept timeout). + #[allow(clippy::too_many_arguments)] + async fn emit_accept_frame( + token: &str, + sender_pubkey: &str, + start_event_id: &str, + is_encrypted: bool, + is_gift_wrap: bool, + outer_kind: u16, + relay_pool: &Arc, + encryption_mode: EncryptionMode, + gift_wrap_mode: GiftWrapMode, + ) { + let client_pk = match PublicKey::from_hex(sender_pubkey) { + Ok(pk) => pk, + Err(_) => return, + }; + let event_id_parsed = EventId::from_hex(start_event_id).unwrap_or(EventId::all_zeros()); + let accept = match OversizedFrame::Accept.into_progress_notification( + token, + ACCEPT_PROGRESS, + Some("oversized request accepted"), + ) { + Ok(n) => JsonRpcMessage::Notification(n), + Err(error) => { + tracing::error!( + target: LOG_TARGET, + error = %error, + "Failed to build oversized-transfer accept frame" + ); + return; + } + }; + let tags = BaseTransport::create_response_tags(&client_pk, &event_id_parsed); + let base = BaseTransport { + relay_pool: Arc::clone(relay_pool), + encryption_mode, + is_connected: true, + }; + if let Err(error) = base + .send_mcp_message( + &accept, + &client_pk, + CTXVM_MESSAGES_KIND, + tags, + Some(is_encrypted), + Self::select_outbound_gift_wrap_kind( + gift_wrap_mode, + is_encrypted, + if is_gift_wrap { Some(outer_kind) } else { None }, + ), + ) + .await + { + tracing::error!( + target: LOG_TARGET, + error = %error, + sender_pubkey = %sender_pubkey, + "Failed to send oversized-transfer accept frame" + ); + } + } + + async fn cleanup_sessions( + sessions: &SessionStore, + event_routes: &ServerEventRouteStore, + request_wrap_kinds: &Arc>>>, + timeout: Duration, + ) -> usize { + let mut sessions_w = sessions.write().await; + let mut cleaned = 0; + let mut stale_event_ids = Vec::new(); + + // LruCache has no retain(); collect expired keys then pop each one. + let expired_keys: Vec = sessions_w + .iter() + .filter(|(_, session)| session.last_activity.elapsed() > timeout) + .map(|(k, _)| k.clone()) + .collect(); + + for key in &expired_keys { + if let Some(session) = sessions_w.pop(key) { + stale_event_ids.extend(session.pending_requests.keys().cloned()); + stale_event_ids.extend(session.event_to_progress_token.keys().cloned()); + tracing::debug!( + target: LOG_TARGET, + client_pubkey = %key, + "Session expired" + ); + cleaned += 1; + } + } + drop(sessions_w); + + { + let mut kinds_w = request_wrap_kinds.write().await; + for event_id in &stale_event_ids { + kinds_w.remove(event_id); + } + } + + for event_id in &stale_event_ids { + event_routes.pop(event_id).await; + } + + cleaned + } + + /// CEP-19: Choose outbound gift-wrap kind for responses. + /// If `is_encrypted` is false, return None (send plaintext). + /// Otherwise mirror the kind used by the client, falling back to the mode default. + fn select_outbound_gift_wrap_kind( + mode: GiftWrapMode, + is_encrypted: bool, + mirrored_kind: Option, + ) -> Option { + if !is_encrypted { + return None; + } + if let Some(kind) = mirrored_kind { + if mode.allows_kind(kind) { + return Some(kind); + } + } + match mode { + GiftWrapMode::Persistent => Some(GIFT_WRAP_KIND), + GiftWrapMode::Ephemeral => Some(EPHEMERAL_GIFT_WRAP_KIND), + GiftWrapMode::Optional => Some(GIFT_WRAP_KIND), + } + } + + /// CEP-19: Choose outbound gift-wrap kind for notifications. + fn select_outbound_notification_gift_wrap_kind( + mode: GiftWrapMode, + is_encrypted: bool, + correlated_wrap_kind: Option, + client_supports_ephemeral: bool, + ) -> Option { + if !is_encrypted { + return None; + } + // Mirror correlated request kind if available + if let Some(kind) = correlated_wrap_kind { + if mode.allows_kind(kind) { + return Some(kind); + } + } + // Fall back based on learned ephemeral support + if client_supports_ephemeral && mode.supports_ephemeral() { + return Some(EPHEMERAL_GIFT_WRAP_KIND); + } + match mode { + GiftWrapMode::Persistent => Some(GIFT_WRAP_KIND), + GiftWrapMode::Ephemeral => Some(EPHEMERAL_GIFT_WRAP_KIND), + GiftWrapMode::Optional => Some(GIFT_WRAP_KIND), + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::relay::mock::MockRelayPool; + use std::thread; + + // ── Session management ────────────────────────────────────── + + #[test] + fn test_client_session_creation() { + let session = ClientSession::new(true); + assert!(!session.is_initialized); + assert!(session.is_encrypted); + assert!(!session.has_sent_common_tags); + assert!(!session.supports_ephemeral_gift_wrap); + assert!(session.pending_requests.is_empty()); + assert!(session.event_to_progress_token.is_empty()); + } + + #[test] + fn test_client_session_update_activity() { + let mut session = ClientSession::new(false); + let first = session.last_activity; + thread::sleep(Duration::from_millis(10)); + session.update_activity(); + assert!(session.last_activity > first); + } + + #[tokio::test] + async fn test_cleanup_sessions_removes_expired() { + let sessions = SessionStore::new(); + let event_routes = ServerEventRouteStore::new(); + + // Insert a session with an old activity time + let mut session = ClientSession::new(false); + session + .pending_requests + .insert("evt1".to_string(), serde_json::json!(1)); + sessions.write().await.put("pubkey1".to_string(), session); + event_routes + .register( + "evt1".to_string(), + "pubkey1".to_string(), + serde_json::json!(1), + None, + ) + .await; + + let request_wrap_kinds = Arc::new(RwLock::new(HashMap::new())); + + // With a long timeout, nothing should be cleaned + let cleaned = NostrServerTransport::cleanup_sessions( + &sessions, + &event_routes, + &request_wrap_kinds, + Duration::from_secs(300), + ) + .await; + assert_eq!(cleaned, 0); + assert_eq!(sessions.session_count().await, 1); + + // With zero timeout, it should be cleaned + thread::sleep(Duration::from_millis(5)); + let cleaned = NostrServerTransport::cleanup_sessions( + &sessions, + &event_routes, + &request_wrap_kinds, + Duration::from_millis(1), + ) + .await; + assert_eq!(cleaned, 1); + assert_eq!(sessions.session_count().await, 0); + assert!(event_routes.pop("evt1").await.is_none()); + } + + #[tokio::test] + async fn test_cleanup_preserves_active_sessions() { + let sessions = SessionStore::new(); + let event_routes = ServerEventRouteStore::new(); + let request_wrap_kinds = Arc::new(RwLock::new(HashMap::new())); + + sessions + .get_or_create_session("active", false, &event_routes) + .await; + + let cleaned = NostrServerTransport::cleanup_sessions( + &sessions, + &event_routes, + &request_wrap_kinds, + Duration::from_secs(300), + ) + .await; + assert_eq!(cleaned, 0); + assert_eq!(sessions.session_count().await, 1); + } + + // ── Request ID correlation ────────────────────────────────── + + #[test] + fn test_pending_request_tracking() { + let mut session = ClientSession::new(false); + session + .pending_requests + .insert("event_abc".to_string(), serde_json::json!(42)); + assert_eq!( + session.pending_requests.get("event_abc"), + Some(&serde_json::json!(42)) + ); + } + + #[test] + fn test_progress_token_tracking() { + let mut session = ClientSession::new(false); + session + .event_to_progress_token + .insert("evt1".to_string(), "token1".to_string()); + session + .pending_requests + .insert("token1".to_string(), serde_json::json!("evt1")); + assert_eq!( + session.event_to_progress_token.get("evt1"), + Some(&"token1".to_string()) + ); + } + + // ── Authorization (is_capability_excluded) ────────────────── + + #[test] + fn test_initialize_always_excluded() { + assert!(NostrServerTransport::is_capability_excluded( + &[], + "initialize", + None + )); + assert!(NostrServerTransport::is_capability_excluded( + &[], + "notifications/initialized", + None + )); + } + + #[test] + fn test_method_excluded_without_name() { + let exclusions = vec![CapabilityExclusion { + method: "tools/list".to_string(), + name: None, + }]; + assert!(NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/list", + None + )); + assert!(NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/list", + Some("anything") + )); + } + + #[test] + fn test_method_excluded_with_name() { + let exclusions = vec![CapabilityExclusion { + method: "tools/call".to_string(), + name: Some("get_weather".to_string()), + }]; + assert!(NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/call", + Some("get_weather") + )); + assert!(!NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/call", + Some("other_tool") + )); + assert!(!NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/call", + None + )); + } + + #[test] + fn test_non_excluded_method() { + let exclusions = vec![CapabilityExclusion { + method: "tools/list".to_string(), + name: None, + }]; + assert!(!NostrServerTransport::is_capability_excluded( + &exclusions, + "tools/call", + None + )); + assert!(!NostrServerTransport::is_capability_excluded( + &exclusions, + "resources/list", + None + )); + } + + #[test] + fn test_empty_exclusions_non_init_method() { + assert!(!NostrServerTransport::is_capability_excluded( + &[], + "tools/list", + None + )); + assert!(!NostrServerTransport::is_capability_excluded( + &[], + "tools/call", + Some("x") + )); + } + + // ── Encryption mode enforcement ───────────────────────────── + + #[test] + fn test_encryption_mode_default() { + let config = NostrServerTransportConfig::default(); + assert_eq!(config.encryption_mode, EncryptionMode::Optional); + } + + // ── Config defaults ───────────────────────────────────────── + + #[test] + fn test_config_defaults() { + let config = NostrServerTransportConfig::default(); + assert_eq!(config.relay_urls, vec!["wss://relay.damus.io".to_string()]); + assert!(!config.is_announced_server); + assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional); + assert!(config.allowed_public_keys.is_empty()); + assert!(config.excluded_capabilities.is_empty()); + assert_eq!(config.max_sessions, 1000); + assert_eq!(config.cleanup_interval, Duration::from_secs(60)); + assert_eq!(config.session_timeout, Duration::from_secs(300)); + assert_eq!(config.request_timeout, Duration::from_secs(60)); + assert!(config.server_info.is_none()); + assert!(config.relay_list_urls.is_none()); + assert!(config.bootstrap_relay_urls.is_none()); + assert!(config.publish_relay_list); + assert!(config.profile_metadata.is_none()); + } + + #[tokio::test] + async fn spawn_discoverability_publication_publishes_kind_0_and_10002_only() { + let pool = Arc::new(MockRelayPool::new()); + let relay_pool: Arc = pool.clone(); + let config = NostrServerTransportConfig::default() + .with_relay_urls(vec!["wss://relay.example.com".to_string()]) + .with_profile_metadata(ProfileMetadata::default().with_name("ffi-server")) + .with_publish_relay_list(true); + let mut transport = NostrServerTransport::with_relay_pool(config, relay_pool) + .await + .expect("transport should build"); + + transport.spawn_discoverability_publication(); + for handle in transport.task_handles.drain(..) { + handle.await.expect("discoverability task should not panic"); + } + + let events = pool.stored_events().await; + assert!( + events.iter().any(|e| e.kind == Kind::Custom(0)), + "profile metadata should be published" + ); + assert!( + events + .iter() + .any(|e| e.kind == Kind::Custom(RELAY_LIST_METADATA_KIND)), + "relay list should be published" + ); + assert!( + events + .iter() + .all(|e| e.kind != Kind::Custom(SERVER_ANNOUNCEMENT_KIND)), + "direct discoverability publication must not emit CEP-6 announcements" + ); + } + + // ── CEP-19 helper logic ────────────────────────────────────── + + #[test] + fn test_select_outbound_gift_wrap_kind_plaintext() { + assert_eq!( + NostrServerTransport::select_outbound_gift_wrap_kind( + GiftWrapMode::Optional, + false, + Some(GIFT_WRAP_KIND), + ), + None + ); + } + + #[test] + fn test_select_outbound_gift_wrap_kind_mirrors_incoming() { + assert_eq!( + NostrServerTransport::select_outbound_gift_wrap_kind( + GiftWrapMode::Optional, + true, + Some(EPHEMERAL_GIFT_WRAP_KIND), + ), + Some(EPHEMERAL_GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_select_outbound_gift_wrap_kind_persistent_mode_overrides_ephemeral() { + assert_eq!( + NostrServerTransport::select_outbound_gift_wrap_kind( + GiftWrapMode::Persistent, + true, + Some(EPHEMERAL_GIFT_WRAP_KIND), + ), + Some(GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_append_common_response_tags_includes_encryption_when_optional() { + let snapshot = announcement_manager::CommonTagsSnapshot { + server_info: None, + extra_common_tags: vec![], + internal_common_tags: vec![], + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + }; + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let kinds: Vec = tags.iter().map(|t| format!("{:?}", t.kind())).collect(); + assert!( + kinds.iter().any(|k| k.contains("support_encryption")), + "should include support_encryption tag" + ); + } + + #[test] + fn test_append_common_response_tags_no_encryption_when_disabled() { + let snapshot = announcement_manager::CommonTagsSnapshot { + server_info: None, + extra_common_tags: vec![], + internal_common_tags: vec![], + encryption_mode: EncryptionMode::Disabled, + gift_wrap_mode: GiftWrapMode::Optional, + }; + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + assert!( + tags.is_empty(), + "should not include encryption tags when encryption disabled" + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_plaintext() { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Optional, + false, + Some(EPHEMERAL_GIFT_WRAP_KIND), + true, + ), + None + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_mirrors_correlated() { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Optional, + true, + Some(EPHEMERAL_GIFT_WRAP_KIND), + false, + ), + Some(EPHEMERAL_GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_falls_back_to_mode_if_correlated_not_allowed( + ) { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Ephemeral, + true, + Some(GIFT_WRAP_KIND), + false, + ), + Some(EPHEMERAL_GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_uses_ephemeral_if_supported() { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Optional, + true, + None, + true, + ), + Some(EPHEMERAL_GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_uses_persistent_if_ephemeral_supported_but_mode_persistent( + ) { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Persistent, + true, + None, + true, + ), + Some(GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_select_outbound_notification_gift_wrap_kind_uses_default_mode_if_ephemeral_not_supported( + ) { + assert_eq!( + NostrServerTransport::select_outbound_notification_gift_wrap_kind( + GiftWrapMode::Optional, + true, + None, + false, + ), + Some(GIFT_WRAP_KIND) + ); + } + + #[test] + fn test_append_common_response_tags_includes_ephemeral_tag() { + let snapshot = announcement_manager::CommonTagsSnapshot { + server_info: None, + extra_common_tags: vec![], + internal_common_tags: vec![], + encryption_mode: EncryptionMode::Optional, + gift_wrap_mode: GiftWrapMode::Optional, + }; + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let kinds: Vec = tags.iter().map(|t| format!("{:?}", t.kind())).collect(); + assert!( + kinds + .iter() + .any(|k| k.contains("support_encryption_ephemeral")), + "should include support_encryption_ephemeral tag" + ); + } + + #[test] + fn test_append_common_response_tags_includes_server_info() { + let server_info = ServerInfo { + name: Some("TestServer".to_string()), + ..Default::default() + }; + let snapshot = announcement_manager::CommonTagsSnapshot { + server_info: Some(server_info), + extra_common_tags: vec![], + internal_common_tags: vec![], + encryption_mode: EncryptionMode::Disabled, + gift_wrap_mode: GiftWrapMode::Optional, + }; + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let tag_value = tags + .iter() + .find(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("name")) + .and_then(|t| t.clone().to_vec().get(1).cloned()); + assert_eq!(tag_value.as_deref(), Some("TestServer")); + } + + #[test] + fn test_append_common_response_tags_extra_tags() { + let extra_tags = vec![Tag::custom( + TagKind::Custom("custom_tag".into()), + vec!["value".to_string()], + )]; + let snapshot = announcement_manager::CommonTagsSnapshot { + server_info: None, + extra_common_tags: extra_tags, + internal_common_tags: vec![], + encryption_mode: EncryptionMode::Disabled, + gift_wrap_mode: GiftWrapMode::Optional, + }; + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let tag_value = tags + .iter() + .find(|t| (*t).clone().to_vec().first().map(|s| s.as_str()) == Some("custom_tag")) + .and_then(|t| t.clone().to_vec().get(1).cloned()); + assert_eq!(tag_value.as_deref(), Some("value")); + } + + // ── CEP-35 discovery tag helpers ──────────────────────────── + + #[test] + fn test_cep35_client_session_new_fields_default_false() { + let session = ClientSession::new(false); + assert!(!session.has_sent_common_tags); + assert!(!session.supports_encryption); + assert!(!session.supports_ephemeral_encryption); + assert!(!session.supports_oversized_transfer); + } + + #[test] + fn test_cep35_capability_or_assign() { + let mut session = ClientSession::new(false); + + session.supports_encryption |= true; + session.supports_ephemeral_encryption |= false; + + session.supports_encryption |= false; + session.supports_ephemeral_encryption |= true; + + assert!(session.supports_encryption, "OR-assign must not downgrade"); + assert!(session.supports_ephemeral_encryption); + assert!(!session.supports_oversized_transfer); + } + + #[test] + fn test_config_gift_wrap_mode_default() { + let config = NostrServerTransportConfig::default(); + assert_eq!(config.gift_wrap_mode, GiftWrapMode::Optional); + } + + // ── CEP-22 oversized transfer capability advertisement ────── + + fn first_tag_values(tags: &[Tag]) -> Vec { + tags.iter().map(|t| t.clone().to_vec()[0].clone()).collect() + } + + async fn make_server_with_oversized(enabled: bool) -> NostrServerTransport { + let config = NostrServerTransportConfig { + oversized_transfer: OversizedTransferConfig::default().with_enabled(enabled), + ..Default::default() + }; + let pool: Arc = Arc::new(crate::relay::mock::MockRelayPool::new()); + NostrServerTransport::with_relay_pool(config, pool) + .await + .expect("server transport construction") + } + + #[test] + fn test_oversized_enabled_by_default() { + let config = NostrServerTransportConfig::default(); + assert!(config.oversized_transfer.enabled); + } + + #[test] + fn test_oversized_support_tags_helper() { + // Start from an explicit opt-out: the default is now enabled. + let mut config = NostrServerTransportConfig::default().with_oversized_enabled(false); + assert!(oversized_support_tags(&config).is_empty()); + config.oversized_transfer.enabled = true; + let names = first_tag_values(&oversized_support_tags(&config)); + assert_eq!(names, vec!["support_oversized_transfer"]); + } + + #[test] + fn test_oversized_builders() { + let config = NostrServerTransportConfig::default().with_oversized_enabled(true); + assert!(config.oversized_transfer.enabled); + let config = NostrServerTransportConfig::default() + .with_oversized_transfer(OversizedTransferConfig::enabled().with_threshold(123)); + assert!(config.oversized_transfer.enabled); + assert_eq!(config.oversized_transfer.threshold, 123); + } + + #[tokio::test] + async fn test_announcement_includes_oversized_tag_when_enabled() { + let server = make_server_with_oversized(true).await; + let names = first_tag_values(&server.announcement_manager.get_common_tags()); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "announcement common tags must advertise oversized support when enabled" + ); + } + + #[tokio::test] + async fn test_announcement_omits_oversized_tag_when_disabled() { + let server = make_server_with_oversized(false).await; + let names = first_tag_values(&server.announcement_manager.get_common_tags()); + assert!( + !names.contains(&"support_oversized_transfer".to_string()), + "announcement must not advertise oversized support when disabled" + ); + } + + #[tokio::test] + async fn test_first_response_snapshot_includes_oversized_tag_when_enabled() { + let server = make_server_with_oversized(true).await; + let snapshot = server.announcement_manager.common_tags_snapshot(); + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let names = first_tag_values(&tags); + assert!( + names.contains(&"support_oversized_transfer".to_string()), + "first-response replay must carry the oversized tag when enabled" + ); + } + + #[tokio::test] + async fn test_first_response_snapshot_omits_oversized_tag_when_disabled() { + let server = make_server_with_oversized(false).await; + let snapshot = server.announcement_manager.common_tags_snapshot(); + let mut tags = Vec::new(); + snapshot.append_common_response_tags(&mut tags); + let names = first_tag_values(&tags); + assert!(!names.contains(&"support_oversized_transfer".to_string())); + } + + #[test] + fn test_server_learns_client_oversized_only_when_enabled() { + // Unit-level check that `learn_peer_capabilities` parses the client tag and + // the `enabled && supports` truth table holds. The production gate in + // `event_loop` is exercised end-to-end by the integration tests + // `server_gate_allows_oversized_when_enabled` / + // `server_gate_blocks_oversized_when_disabled` in tests/transport_integration.rs. + let oversized_tag = Tag::custom( + TagKind::Custom(tags::SUPPORT_OVERSIZED_TRANSFER.into()), + Vec::::new(), + ); + let discovered = learn_peer_capabilities(&[oversized_tag]); + assert!(discovered.supports_oversized_transfer); + + // Disabled server: client flag must be ignored. + let mut session = ClientSession::new(false); + let oversized_enabled = false; + session.supports_oversized_transfer |= + oversized_enabled && discovered.supports_oversized_transfer; + assert!(!session.supports_oversized_transfer); + + // Enabled server: client flag is learned. + let oversized_enabled = true; + session.supports_oversized_transfer |= + oversized_enabled && discovered.supports_oversized_transfer; + assert!(session.supports_oversized_transfer); + } +} diff --git a/src/transport/server/session_store.rs b/src/transport/server/session_store.rs new file mode 100644 index 0000000..12dfb67 --- /dev/null +++ b/src/transport/server/session_store.rs @@ -0,0 +1,592 @@ +//! Server-side session store for managing client sessions. +//! +//! Uses an LRU cache bounded by `max_sessions` (default 1000, matching the TS SDK +//! server session store). When a new session would exceed capacity the +//! least-recently-used session is evicted. If the evicted session still has +//! active routes in the correlation store it is recreated with clean state +//! (eviction safety, matching TS SDK's `hasActiveRoutesForClient` check), and +//! the optional eviction callback fires so external code can clean up resources. + +use std::num::NonZeroUsize; +use std::sync::Arc; + +use lru::LruCache; +use tokio::sync::RwLock; + +use crate::core::types::ClientSession; +use crate::transport::server::ServerEventRouteStore; + +const LOG_TARGET: &str = "contextvm_sdk::transport::server::session_store"; + +/// Default maximum number of concurrent client sessions. +/// +/// Matches the TS SDK's `SessionStore` default (`maxSessions ?? 1000`), not +/// the broader `DEFAULT_LRU_SIZE` constant (5000) used elsewhere in the TS SDK. +pub const DEFAULT_MAX_SESSIONS: usize = 1000; + +/// Callback invoked when a session is evicted from the LRU cache. +/// Receives the evicted client's public key (hex). +pub type EvictionCallback = Arc; + +/// Manages client sessions keyed by public key (hex). +/// +/// Backed by an LRU cache so memory usage is bounded. +#[derive(Clone)] +pub struct SessionStore { + sessions: Arc>>, + on_evicted: Option, +} + +impl Default for SessionStore { + fn default() -> Self { + Self::new() + } +} + +impl SessionStore { + /// Create a store with the default capacity ([`DEFAULT_MAX_SESSIONS`]). + pub fn new() -> Self { + Self::with_capacity(DEFAULT_MAX_SESSIONS) + } + + /// Create a store with a specific maximum number of sessions. + pub fn with_capacity(max_sessions: usize) -> Self { + Self { + sessions: Arc::new(RwLock::new(LruCache::new( + NonZeroUsize::new(max_sessions).unwrap_or(NonZeroUsize::new(1).unwrap()), + ))), + on_evicted: None, + } + } + + /// Register a callback that fires when a session is evicted from the LRU. + pub fn set_eviction_callback(&mut self, cb: EvictionCallback) { + self.on_evicted = Some(cb); + } + + /// Clone the eviction callback (cheap Arc clone) for use outside the lock. + pub fn eviction_callback(&self) -> Option { + self.on_evicted.clone() + } + + /// Get an existing session or create a new one. Returns `true` if a new session was created. + /// + /// `event_routes` is consulted during eviction safety: if the evicted client + /// still has active routes, the session is recreated with clean state + /// (matching TS SDK's `hasActiveRoutesForClient` check). + pub async fn get_or_create_session( + &self, + client_pubkey: &str, + is_encrypted: bool, + event_routes: &ServerEventRouteStore, + ) -> bool { + let on_evicted = self.on_evicted.clone(); + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.get_mut(client_pubkey) { + session.is_encrypted = is_encrypted; + false + } else { + let new_session = ClientSession::new(is_encrypted); + let evicted = sessions.push(client_pubkey.to_string(), new_session); + Self::handle_eviction( + client_pubkey, + evicted, + &mut sessions, + on_evicted.as_ref(), + event_routes, + ) + .await; + true + } + } + + /// Get a read-only snapshot of session fields. + /// Returns `None` if the session does not exist. + pub async fn get_session(&self, client_pubkey: &str) -> Option { + let sessions = self.sessions.read().await; + sessions.peek(client_pubkey).map(|s| SessionSnapshot { + is_initialized: s.is_initialized, + is_encrypted: s.is_encrypted, + has_sent_common_tags: s.has_sent_common_tags, + supports_ephemeral_gift_wrap: s.supports_ephemeral_gift_wrap, + supports_encryption: s.supports_encryption, + supports_ephemeral_encryption: s.supports_ephemeral_encryption, + supports_oversized_transfer: s.supports_oversized_transfer, + supports_open_stream: s.supports_open_stream, + }) + } + + /// Mark a session as initialized. Returns `true` if the session existed. + pub async fn mark_initialized(&self, client_pubkey: &str) -> bool { + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.get_mut(client_pubkey) { + session.is_initialized = true; + true + } else { + false + } + } + + /// Mark that common tags have been sent for this session. + pub async fn mark_common_tags_sent(&self, client_pubkey: &str) -> bool { + let mut sessions = self.sessions.write().await; + if let Some(session) = sessions.get_mut(client_pubkey) { + session.has_sent_common_tags = true; + true + } else { + false + } + } + + /// Remove a session. Returns `true` if it existed. + pub async fn remove_session(&self, client_pubkey: &str) -> bool { + self.sessions.write().await.pop(client_pubkey).is_some() + } + + /// Remove all sessions. + pub async fn clear(&self) { + self.sessions.write().await.clear(); + } + + /// Number of active sessions. + pub async fn session_count(&self) -> usize { + self.sessions.read().await.len() + } + + /// Return a snapshot of all sessions as `(client_pubkey, snapshot)` pairs. + pub async fn get_all_sessions(&self) -> Vec<(String, SessionSnapshot)> { + let sessions = self.sessions.read().await; + sessions + .iter() + .map(|(k, s)| { + ( + k.clone(), + SessionSnapshot { + is_initialized: s.is_initialized, + is_encrypted: s.is_encrypted, + has_sent_common_tags: s.has_sent_common_tags, + supports_ephemeral_gift_wrap: s.supports_ephemeral_gift_wrap, + supports_encryption: s.supports_encryption, + supports_ephemeral_encryption: s.supports_ephemeral_encryption, + supports_oversized_transfer: s.supports_oversized_transfer, + supports_open_stream: s.supports_open_stream, + }, + ) + }) + .collect() + } + + /// Acquire write access to the underlying LRU cache (transport internals only). + pub(crate) async fn write( + &self, + ) -> tokio::sync::RwLockWriteGuard<'_, LruCache> { + self.sessions.write().await + } + + /// Acquire read access to the underlying LRU cache (transport internals only). + pub(crate) async fn read( + &self, + ) -> tokio::sync::RwLockReadGuard<'_, LruCache> { + self.sessions.read().await + } + + /// Handle a potential LRU eviction after inserting a session. + /// + /// If the evicted client still has active routes in the correlation store, + /// a clean session is re-inserted (eviction safety, matching TS SDK's + /// `hasActiveRoutesForClient` check). The eviction callback fires only + /// for genuine, non-vetoed evictions. + pub(crate) async fn handle_eviction( + inserted_key: &str, + evicted: Option<(String, ClientSession)>, + sessions: &mut LruCache, + on_evicted: Option<&EvictionCallback>, + event_routes: &ServerEventRouteStore, + ) { + if let Some((evicted_key, evicted_session)) = evicted { + // `push` also returns the old value when the *same* key is updated; + // only act when a *different* key was evicted due to capacity. + if evicted_key != inserted_key { + if event_routes + .has_active_routes_for_client(&evicted_key) + .await + { + tracing::warn!( + target: LOG_TARGET, + client_pubkey = %evicted_key, + "LRU eviction of session with active routes; recreating with clean state" + ); + // Re-insert with clean state so the client isn't orphaned. + // Skip the external callback — the session still exists + // (matches TS SDK: vetoed evictions don't fire the callback). + let _ = sessions.push( + evicted_key.clone(), + ClientSession::new(evicted_session.is_encrypted), + ); + } else if let Some(cb) = on_evicted { + cb(evicted_key); + } + } + } + } +} + +/// A lightweight snapshot of session state (avoids exposing the full `ClientSession` +/// through the async API boundary). +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct SessionSnapshot { + /// Whether the MCP `initialize` handshake has completed + pub is_initialized: bool, + /// Whether the session is using NIP-44 encrypted transport + pub is_encrypted: bool, + /// Whether common discovery tags have been sent for this session + pub has_sent_common_tags: bool, + /// Whether the peer advertised support for ephemeral gift wraps (CEP-19) + pub supports_ephemeral_gift_wrap: bool, + /// Whether the peer advertised encryption support (CEP-35 learned capability) + pub supports_encryption: bool, + /// Whether the peer advertised ephemeral-encryption support (CEP-35 learned capability) + pub supports_ephemeral_encryption: bool, + /// Whether the peer advertised CEP-22 oversized-transfer support (learned, gated by server config) + pub supports_oversized_transfer: bool, + /// Whether the peer advertised CEP-41 open-stream support (learned, gated by server config) + pub supports_open_stream: bool, +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json::json; + + fn routes() -> ServerEventRouteStore { + ServerEventRouteStore::new() + } + + #[tokio::test] + async fn create_and_retrieve_session() { + let store = SessionStore::new(); + let r = routes(); + + let created = store.get_or_create_session("client-1", true, &r).await; + assert!(created); + + let snap = store.get_session("client-1").await.unwrap(); + assert!(snap.is_encrypted); + assert!(!snap.is_initialized); + } + + #[tokio::test] + async fn get_or_create_returns_existing() { + let store = SessionStore::new(); + let r = routes(); + + let created = store.get_or_create_session("client-1", false, &r).await; + assert!(created); + + let created2 = store.get_or_create_session("client-1", true, &r).await; + assert!(!created2); + + let snap = store.get_session("client-1").await.unwrap(); + assert!(snap.is_encrypted); + } + + #[tokio::test] + async fn mark_initialized() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + assert!(store.mark_initialized("client-1").await); + let snap = store.get_session("client-1").await.unwrap(); + assert!(snap.is_initialized); + } + + #[tokio::test] + async fn mark_initialized_unknown_returns_false() { + let store = SessionStore::new(); + assert!(!store.mark_initialized("unknown").await); + } + + #[tokio::test] + async fn remove_session() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + assert!(store.remove_session("client-1").await); + assert!(store.get_session("client-1").await.is_none()); + } + + #[tokio::test] + async fn remove_unknown_returns_false() { + let store = SessionStore::new(); + assert!(!store.remove_session("unknown").await); + } + + #[tokio::test] + async fn clear_all_sessions() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + store.get_or_create_session("client-2", true, &r).await; + + store.clear().await; + + assert_eq!(store.session_count().await, 0); + assert!(store.get_session("client-1").await.is_none()); + assert!(store.get_session("client-2").await.is_none()); + } + + #[tokio::test] + async fn get_all_sessions() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + store.get_or_create_session("client-2", true, &r).await; + + let all = store.get_all_sessions().await; + assert_eq!(all.len(), 2); + + let keys: Vec<&str> = all.iter().map(|(k, _)| k.as_str()).collect(); + assert!(keys.contains(&"client-1")); + assert!(keys.contains(&"client-2")); + } + + // ── CEP-35 capability fields ──────────────────────────────── + + #[tokio::test] + async fn new_session_capability_fields_default_false() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + let sessions = store.read().await; + let session = sessions.peek("client-1").unwrap(); + assert!(!session.has_sent_common_tags); + assert!(!session.supports_encryption); + assert!(!session.supports_ephemeral_encryption); + assert!(!session.supports_oversized_transfer); + } + + #[tokio::test] + async fn snapshot_surfaces_learned_capabilities() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + // A fresh snapshot reports every capability as false. + let snap = store.get_session("client-1").await.unwrap(); + assert!(!snap.supports_encryption); + assert!(!snap.supports_ephemeral_encryption); + assert!(!snap.supports_oversized_transfer); + assert!(!snap.supports_open_stream); + + // Learned capabilities must round-trip through the snapshot. + { + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + session.supports_encryption = true; + session.supports_ephemeral_encryption = true; + session.supports_oversized_transfer = true; + session.supports_open_stream = true; + } + + let snap = store.get_session("client-1").await.unwrap(); + assert!(snap.supports_encryption); + assert!(snap.supports_ephemeral_encryption); + assert!(snap.supports_oversized_transfer); + assert!(snap.supports_open_stream); + + // get_all_sessions exposes the same fields. + let all = store.get_all_sessions().await; + let (_, snap_all) = all.iter().find(|(k, _)| k == "client-1").unwrap(); + assert!(snap_all.supports_encryption); + assert!(snap_all.supports_ephemeral_encryption); + assert!(snap_all.supports_oversized_transfer); + assert!(snap_all.supports_open_stream); + } + + #[tokio::test] + async fn has_sent_common_tags_flag() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + assert!(!session.has_sent_common_tags); + session.has_sent_common_tags = true; + assert!(session.has_sent_common_tags); + } + + #[tokio::test] + async fn capability_or_assign_persists() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + { + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + session.supports_encryption |= true; + session.supports_ephemeral_encryption |= false; + } + + { + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + session.supports_encryption |= false; + session.supports_ephemeral_encryption |= true; + } + + let sessions = store.read().await; + let session = sessions.peek("client-1").unwrap(); + assert!(session.supports_encryption, "OR-assign must not downgrade"); + assert!(session.supports_ephemeral_encryption); + assert!(!session.supports_oversized_transfer); + } + + #[tokio::test] + async fn capability_fields_independent_per_client() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-a", false, &r).await; + store.get_or_create_session("client-b", false, &r).await; + + { + let mut sessions = store.write().await; + let sa = sessions.get_mut("client-a").unwrap(); + sa.supports_encryption = true; + sa.has_sent_common_tags = true; + } + + let sessions = store.read().await; + let sa = sessions.peek("client-a").unwrap(); + let sb = sessions.peek("client-b").unwrap(); + assert!(sa.supports_encryption); + assert!(sa.has_sent_common_tags); + assert!(!sb.supports_encryption); + assert!(!sb.has_sent_common_tags); + } + + #[tokio::test] + async fn get_or_create_preserves_capability_fields() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + { + let mut sessions = store.write().await; + let session = sessions.get_mut("client-1").unwrap(); + session.supports_encryption = true; + session.has_sent_common_tags = true; + } + + let created = store.get_or_create_session("client-1", true, &r).await; + assert!(!created); + + let sessions = store.read().await; + let session = sessions.peek("client-1").unwrap(); + assert!(session.supports_encryption); + assert!(session.has_sent_common_tags); + } + + #[tokio::test] + async fn clear_resets_capability_fields() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + { + let mut sessions = store.write().await; + let s = sessions.get_mut("client-1").unwrap(); + s.supports_encryption = true; + } + + store.clear().await; + store.get_or_create_session("client-1", false, &r).await; + + let sessions = store.read().await; + let session = sessions.peek("client-1").unwrap(); + assert!(!session.supports_encryption); + assert!(!session.has_sent_common_tags); + } + + // ── LRU eviction ──────────────────────────────────────────── + + #[tokio::test] + async fn lru_eviction_drops_oldest_session() { + let store = SessionStore::with_capacity(3); + let r = routes(); + store.get_or_create_session("a", false, &r).await; + store.get_or_create_session("b", false, &r).await; + store.get_or_create_session("c", false, &r).await; + + store.get_or_create_session("d", false, &r).await; + + assert!( + store.get_session("a").await.is_none(), + "a should be evicted" + ); + assert!(store.get_session("b").await.is_some()); + assert!(store.get_session("c").await.is_some()); + assert!(store.get_session("d").await.is_some()); + assert_eq!(store.session_count().await, 3); + } + + #[tokio::test] + async fn eviction_callback_fires_on_lru_eviction() { + let evicted = Arc::new(std::sync::Mutex::new(Vec::::new())); + let evicted_clone = evicted.clone(); + let r = routes(); + + let mut store = SessionStore::with_capacity(2); + store.set_eviction_callback(Arc::new(move |pubkey| { + evicted_clone.lock().unwrap().push(pubkey); + })); + + store.get_or_create_session("a", false, &r).await; + store.get_or_create_session("b", false, &r).await; + store.get_or_create_session("c", false, &r).await; + + let evicted = evicted.lock().unwrap(); + assert_eq!(evicted.len(), 1); + assert_eq!(evicted[0], "a"); + } + + #[tokio::test] + async fn eviction_safety_recreates_session_with_active_routes() { + let store = SessionStore::with_capacity(2); + let r = routes(); + store.get_or_create_session("a", true, &r).await; + store.get_or_create_session("b", false, &r).await; + + // Register an active route for client "a" in the correlation store + r.register("evt1".into(), "a".into(), json!(1), None).await; + + // Adding "c" would normally evict "a", but eviction safety recreates it + // because "a" has active routes. + store.get_or_create_session("c", false, &r).await; + + let snap = store.get_session("a").await; + assert!( + snap.is_some(), + "session with active routes must survive eviction" + ); + // "b" was evicted instead (next LRU after "a" was re-inserted) + assert!( + store.get_session("b").await.is_none(), + "b should be evicted" + ); + } + + #[tokio::test] + async fn with_capacity_sets_limit() { + let store = SessionStore::with_capacity(5); + let r = routes(); + for i in 0..10 { + store + .get_or_create_session(&format!("client-{i}"), false, &r) + .await; + } + assert_eq!(store.session_count().await, 5); + } +} diff --git a/tests/conformance_cep22_wire_format.rs b/tests/conformance_cep22_wire_format.rs new file mode 100644 index 0000000..5fa8c1c --- /dev/null +++ b/tests/conformance_cep22_wire_format.rs @@ -0,0 +1,323 @@ +//! Conformance tests: CEP-22 oversized-transfer wire format. +//! +//! A CEP-22 frame is an MCP `notifications/progress` JSON-RPC notification whose +//! `params.cvm` object carries the transfer envelope. These tests pin the exact +//! on-wire JSON — method, progress-slot layout, camelCase field names, the +//! `oversized-transfer` type tag, and the `sha256:` digest format — so the +//! serialization cannot drift from the spec and stays byte-compatible with the +//! TypeScript SDK, which emits the identical shape. +//! +//! Frames are serialized through `JsonRpcMessage` (the type both transports +//! publish, `#[serde(untagged)]`), so the asserted JSON is exactly what lands in +//! a kind-25910 event's `content`. No transport or I/O — pure serialization. + +use serde_json::Value; + +use contextvm_sdk::transport::oversized_transfer::{ + build_oversized_frames, sha256_digest, BuiltOversizedFrames, OversizedFrame, + OversizedSenderOptions, ACCEPT_PROGRESS, START_PROGRESS, +}; +use contextvm_sdk::{JsonRpcMessage, JsonRpcNotification}; + +const TOKEN: &str = "tok-1"; + +// ── helpers ─────────────────────────────────────────────────────────────────── + +/// Serialize a frame exactly as a transport publishes it. `JsonRpcMessage` is +/// `#[serde(untagged)]`, so this is the literal kind-25910 `content`. +fn wire(notif: &JsonRpcNotification) -> Value { + serde_json::to_value(JsonRpcMessage::Notification(notif.clone())) + .expect("frame must serialize to JSON") +} + +/// The `params.cvm` object of a frame's wire form. +fn cvm(notif: &JsonRpcNotification) -> Value { + wire(notif)["params"]["cvm"].clone() +} + +/// The `params.progress` slot of a frame's wire form. +fn progress(notif: &JsonRpcNotification) -> Option { + wire(notif)["params"]["progress"].as_u64() +} + +/// The canonical 3-chunk transfer of `"hello world"` (11 bytes, chunk size 4). +fn three_chunk_transfer(handshake: bool) -> BuiltOversizedFrames { + let opts = OversizedSenderOptions::new(TOKEN) + .with_chunk_size(4) + .with_accept_handshake(handshake); + build_oversized_frames("hello world", &opts).expect("build frames") +} + +/// A standalone `accept` frame on its reserved slot (the server emits this; the +/// codec's `build_oversized_frames` lays out start/chunk/end but never accept). +fn accept_frame() -> JsonRpcNotification { + OversizedFrame::Accept + .into_progress_notification(TOKEN, ACCEPT_PROGRESS, None) + .expect("build accept frame") +} + +/// A standalone `abort` frame. +fn abort_frame() -> JsonRpcNotification { + OversizedFrame::Abort { + reason: Some("peer cancelled".to_string()), + } + .into_progress_notification(TOKEN, 9, None) + .expect("build abort frame") +} + +// ── frame envelope ────────────────────────────────────────────────────────── + +#[test] +fn frame_is_a_notifications_progress_notification() { + let frames = three_chunk_transfer(false); + let w = wire(&frames.start); + + assert_eq!( + w["jsonrpc"], + Value::String("2.0".to_string()), + "frame must be JSON-RPC 2.0" + ); + assert_eq!( + w["method"], + Value::String("notifications/progress".to_string()), + "frame method must be notifications/progress" + ); + + let params = &w["params"]; + assert!(params.is_object(), "params must be an object"); + assert_eq!( + params["progressToken"], + Value::String(TOKEN.to_string()), + "params must carry the progressToken" + ); + assert!( + params["progress"].is_number(), + "params.progress must be a number" + ); + assert!(params["cvm"].is_object(), "params.cvm must be an object"); +} + +// ── cvm.type ────────────────────────────────────────────────────────────────── + +#[test] +fn every_frame_carries_cvm_type_oversized_transfer() { + let frames = three_chunk_transfer(false); + let accept = accept_frame(); + let abort = abort_frame(); + + for notif in [ + &frames.start, + &frames.chunks[0], + &frames.end, + &accept, + &abort, + ] { + assert_eq!( + cvm(notif)["type"], + Value::String("oversized-transfer".to_string()), + "every cvm object must carry type=oversized-transfer" + ); + } +} + +// ── cvm.frameType ───────────────────────────────────────────────────────────── + +#[test] +fn cvm_frame_type_discriminates_each_variant_in_lowercase() { + let frames = three_chunk_transfer(false); + + assert_eq!( + cvm(&frames.start)["frameType"], + Value::String("start".into()) + ); + assert_eq!( + cvm(&accept_frame())["frameType"], + Value::String("accept".into()) + ); + assert_eq!( + cvm(&frames.chunks[0])["frameType"], + Value::String("chunk".into()) + ); + assert_eq!(cvm(&frames.end)["frameType"], Value::String("end".into())); + assert_eq!( + cvm(&abort_frame())["frameType"], + Value::String("abort".into()) + ); + + // The discriminator key is camelCase `frameType`, never snake_case. + let start_cvm = cvm(&frames.start); + let obj = start_cvm.as_object().unwrap(); + assert!( + obj.contains_key("frameType"), + "must use camelCase frameType" + ); + assert!( + !obj.contains_key("frame_type"), + "snake_case frame_type must not appear on the wire" + ); +} + +// ── start frame fields ──────────────────────────────────────────────────────── + +#[test] +fn start_frame_fields_are_camelcase_and_correctly_typed() { + // "hello world" is 11 bytes; chunk size 4 → 3 chunks. + let c = cvm(&three_chunk_transfer(false).start); + + assert_eq!( + c["completionMode"], + Value::String("render".to_string()), + "v1 completion mode must be render" + ); + assert_eq!( + c["totalBytes"].as_u64(), + Some(11), + "totalBytes must equal the payload's UTF-8 byte length" + ); + assert_eq!( + c["totalChunks"].as_u64(), + Some(3), + "totalChunks must equal the chunk count" + ); + assert!( + c["digest"].as_str().unwrap().starts_with("sha256:"), + "digest must carry the sha256: prefix" + ); + + // Field names are camelCase, never snake_case. + let obj = c.as_object().unwrap(); + for camel in ["completionMode", "digest", "totalBytes", "totalChunks"] { + assert!( + obj.contains_key(camel), + "start cvm missing camelCase key {camel}" + ); + } + for snake in ["completion_mode", "total_bytes", "total_chunks"] { + assert!( + !obj.contains_key(snake), + "snake_case key {snake} must not appear on the wire" + ); + } +} + +// ── chunk frame fields ──────────────────────────────────────────────────────── + +#[test] +fn chunk_frames_carry_data_that_reconstructs_the_payload() { + let frames = three_chunk_transfer(false); + + let mut reassembled = String::new(); + for chunk in &frames.chunks { + let data = cvm(chunk)["data"] + .as_str() + .expect("chunk cvm.data must be a string") + .to_string(); + reassembled.push_str(&data); + } + assert_eq!( + reassembled, "hello world", + "concatenated chunk data must reproduce the exact payload" + ); +} + +// ── progress slots ──────────────────────────────────────────────────────────── + +#[test] +fn canonical_progress_slots_match_spec() { + assert_eq!(START_PROGRESS, 1, "start frame occupies progress slot 1"); + assert_eq!(ACCEPT_PROGRESS, 2, "accept frame occupies progress slot 2"); +} + +#[test] +fn progress_slots_without_handshake() { + // start=1; with no accept, chunks reuse the reserved slot 2; end = start + N + 1. + let frames = three_chunk_transfer(false); + assert_eq!(frames.chunks.len(), 3); + + assert_eq!(progress(&frames.start), Some(1)); + assert_eq!(progress(&frames.chunks[0]), Some(2)); + assert_eq!(progress(&frames.chunks[1]), Some(3)); + assert_eq!(progress(&frames.chunks[2]), Some(4)); + assert_eq!( + progress(&frames.end), + Some(5), + "end = start(1) + chunks(3) + 1" + ); +} + +#[test] +fn progress_slots_with_handshake() { + // start=1; accept reserved at slot 2; first chunk shifts to 3; end follows. + let frames = three_chunk_transfer(true); + assert_eq!(frames.chunks.len(), 3); + + assert_eq!(progress(&frames.start), Some(1)); + assert_eq!(progress(&accept_frame()), Some(2)); + assert_eq!(progress(&frames.chunks[0]), Some(3)); + assert_eq!(progress(&frames.chunks[1]), Some(4)); + assert_eq!(progress(&frames.chunks[2]), Some(5)); + assert_eq!(progress(&frames.end), Some(6), "end follows the last chunk"); +} + +// ── digest format ───────────────────────────────────────────────────────────── + +#[test] +fn digest_is_sha256_prefix_plus_lowercase_hex() { + let digest = cvm(&three_chunk_transfer(false).start)["digest"] + .as_str() + .unwrap() + .to_string(); + + assert!(digest.starts_with("sha256:"), "must start with sha256:"); + let hex = &digest["sha256:".len()..]; + assert_eq!(hex.len(), 64, "SHA-256 hex must be 64 characters"); + assert!( + hex.chars() + .all(|ch| ch.is_ascii_hexdigit() && !ch.is_ascii_uppercase()), + "digest hex must be lowercase" + ); +} + +#[test] +fn digest_is_over_the_full_payload_not_per_chunk() { + // Known-answer vector: SHA-256("abc"). Even fragmented into 3 single-byte + // chunks, the start digest is the hash of the WHOLE payload. + let abc = build_oversized_frames( + "abc", + &OversizedSenderOptions::new(TOKEN).with_chunk_size(1), + ) + .expect("build frames"); + assert_eq!( + abc.chunks.len(), + 3, + "chunk size 1 must yield 3 chunks for abc" + ); + assert_eq!( + cvm(&abc.start)["digest"].as_str(), + Some("sha256:ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad"), + "digest must be SHA-256 of the full payload \"abc\", independent of chunking" + ); + + // For an arbitrary multi-chunk payload, the start digest equals the hash of + // the payload reconstructed from the chunk data (i.e. the full message). + let payload = "the quick brown fox jumps"; + let frames = build_oversized_frames( + payload, + &OversizedSenderOptions::new(TOKEN).with_chunk_size(5), + ) + .expect("build frames"); + let reconstructed: String = frames + .chunks + .iter() + .map(|c| cvm(c)["data"].as_str().unwrap().to_string()) + .collect(); + assert_eq!( + reconstructed, payload, + "chunks must reconstruct the payload" + ); + assert_eq!( + cvm(&frames.start)["digest"].as_str(), + Some(sha256_digest(payload).as_str()), + "digest must hash the full payload" + ); +} diff --git a/tests/conformance_dedup.rs b/tests/conformance_dedup.rs new file mode 100644 index 0000000..3f53050 --- /dev/null +++ b/tests/conformance_dedup.rs @@ -0,0 +1,113 @@ +//! Conformance tests for gift-wrap deduplication via LRU cache. +//! +//! Both the client and server transports use an `LruCache` to skip +//! duplicate outer gift-wrap event IDs. The dedup check happens *before* decrypt +//! and the insert happens only *after* successful decrypt + inner `verify()`. +//! These tests exercise the LRU cache logic in isolation — no async, no transport. + +use std::num::NonZeroUsize; + +use lru::LruCache; +use nostr_sdk::prelude::*; + +use contextvm_sdk::core::constants::DEFAULT_LRU_SIZE; + +/// Helper: build a cache with the same capacity used by both transports. +fn new_dedup_cache() -> LruCache { + LruCache::new(NonZeroUsize::new(DEFAULT_LRU_SIZE).expect("DEFAULT_LRU_SIZE must be non-zero")) +} + +fn event_id_from_byte(b: u8) -> EventId { + EventId::from_byte_array([b; 32]) +} + +// ── Gift-wrap kind 1059 dedup ───────────────────────────────────────────────── + +#[test] +fn client_dedup_skips_duplicate_outer_gift_wrap_id() { + let mut cache = new_dedup_cache(); + let outer_id = event_id_from_byte(0x01); + + // First delivery: not yet seen, decrypt succeeds, insert into cache. + assert!( + !cache.contains(&outer_id), + "first delivery must not be in cache yet" + ); + cache.put(outer_id, ()); + + // Second delivery: same outer id is already cached, skip before decrypt. + assert!( + cache.contains(&outer_id), + "second delivery of the same outer id must be rejected" + ); +} + +#[test] +fn client_dedup_ephemeral_gift_wrap_skips_duplicate() { + let mut cache = new_dedup_cache(); + let ephemeral_outer_id = event_id_from_byte(0xE1); + + // First delivery of an ephemeral gift-wrap (kind 21059). + assert!( + !cache.contains(&ephemeral_outer_id), + "first delivery must not be in cache yet" + ); + cache.put(ephemeral_outer_id, ()); + + // Second delivery: same outer id is already cached, skip before decrypt. + assert!( + cache.contains(&ephemeral_outer_id), + "second delivery of the same ephemeral outer id must be rejected" + ); +} + +// ── Server dedup ────────────────────────────────────────────────────────────── + +#[test] +fn server_dedup_ephemeral_gift_wrap_skips_duplicate() { + let mut cache = new_dedup_cache(); + let ephemeral_outer_id = event_id_from_byte(0xE2); + + // First delivery of an ephemeral gift-wrap (kind 21059). + assert!( + !cache.contains(&ephemeral_outer_id), + "first delivery must not be in cache yet" + ); + cache.put(ephemeral_outer_id, ()); + + // Second delivery: same outer id is already cached, skip before decrypt. + assert!( + cache.contains(&ephemeral_outer_id), + "second delivery of the same ephemeral outer id must be rejected" + ); +} + +#[test] +fn server_dedup_lru_evicts_oldest_when_capacity_reached() { + let capacity = 3; + let mut cache: LruCache = + LruCache::new(NonZeroUsize::new(capacity).expect("non-zero")); + + let id_0 = event_id_from_byte(0x00); + let id_1 = event_id_from_byte(0x01); + let id_2 = event_id_from_byte(0x02); + let id_3 = event_id_from_byte(0x03); + + cache.put(id_0, ()); + cache.put(id_1, ()); + cache.put(id_2, ()); + + // Cache is at capacity (3). Inserting a fourth must evict the oldest (id_0). + cache.put(id_3, ()); + + assert!( + !cache.contains(&id_0), + "oldest entry must be evicted when capacity is exceeded" + ); + assert!(cache.contains(&id_1), "second entry must still be present"); + assert!(cache.contains(&id_2), "third entry must still be present"); + assert!( + cache.contains(&id_3), + "newly inserted entry must be present" + ); +} diff --git a/tests/conformance_signer.rs b/tests/conformance_signer.rs new file mode 100644 index 0000000..5b7c661 --- /dev/null +++ b/tests/conformance_signer.rs @@ -0,0 +1,75 @@ +//! Conformance tests for signer behavior (hex `from_sk`, `generate`, NIP-44, signing). +//! +//! Same layout as `conformance_wire_format.rs`; scenarios follow the TS SDK +//! `private-key-signer.test.ts` alongside `src/signer/mod.rs` / `src/encryption/mod.rs`. + +use contextvm_sdk::encryption::{decrypt_nip44, encrypt_nip44}; +use contextvm_sdk::signer::{self, Keys}; +use nostr_sdk::prelude::*; + +/// Secret `1`, x-only pubkey of secp256k1 `G`. +const FIXTURE_SK_HEX: &str = "0000000000000000000000000000000000000000000000000000000000000001"; +const FIXTURE_PK_HEX: &str = "79be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"; + +fn fixture_keys() -> Keys { + signer::from_sk(FIXTURE_SK_HEX).expect("fixture SK hex parses") +} + +// ── Key derivation ─────────────────────────────────────────────────────────── + +#[test] +fn signer_generates_keypair_from_secret_key() { + let keys = fixture_keys(); + assert_eq!(keys.public_key().to_hex(), FIXTURE_PK_HEX); +} + +// ── Random generation ──────────────────────────────────────────────────────── + +#[test] +fn signer_generates_random_keypair_when_no_secret_provided() { + let keys = signer::generate(); + assert_eq!(keys.public_key().to_hex().len(), 64); +} + +// ── NIP-44 ─────────────────────────────────────────────────────────────────── + +#[tokio::test] +async fn signer_nip44_encrypt_decrypt_roundtrip() { + let sender_keys = Keys::generate(); + let recipient_keys = Keys::generate(); + let plaintext = "Hello Encryption!"; + + let ciphertext = encrypt_nip44(&sender_keys, &recipient_keys.public_key(), plaintext) + .await + .expect("nip44 encrypt"); + + assert_ne!(ciphertext, plaintext); + + let decrypted = decrypt_nip44(&recipient_keys, &sender_keys.public_key(), &ciphertext) + .await + .expect("nip44 decrypt"); + + assert_eq!(decrypted, plaintext); +} + +// ── Public key ─────────────────────────────────────────────────────────────── + +#[test] +fn signer_get_public_key_returns_correct_key() { + let keys = fixture_keys(); + let expected_pk = PublicKey::parse(FIXTURE_PK_HEX).expect("fixture PK hex parses"); + assert_eq!(keys.public_key(), expected_pk); +} + +// ── Signed events ──────────────────────────────────────────────────────────── + +#[test] +fn signer_signed_event_has_valid_signature() { + let keys = fixture_keys(); + let event = EventBuilder::new(Kind::TextNote, "Hello Nostr!") + .sign_with_keys(&keys) + .expect("sign text note"); + + assert_eq!(event.pubkey, keys.public_key()); + event.verify().expect("verify signed event"); +} diff --git a/tests/conformance_stateless_mode.rs b/tests/conformance_stateless_mode.rs new file mode 100644 index 0000000..0e30a65 --- /dev/null +++ b/tests/conformance_stateless_mode.rs @@ -0,0 +1,194 @@ +//! Stateless-mode conformance tests for the client transport. + +use std::time::Duration; + +use contextvm_sdk::core::constants::{mcp_protocol_version, INITIALIZE_METHOD}; +use contextvm_sdk::core::types::{ + EncryptionMode, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, +}; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::{signer, GiftWrapMode}; +use tokio::time::timeout; + +async fn make_stateless_transport() -> ( + NostrClientTransport, + tokio::sync::mpsc::UnboundedReceiver, +) { + let server_keys = signer::generate(); + let client_keys = signer::generate(); + + let config = NostrClientTransportConfig::default() + .with_relay_urls(Vec::new()) + .with_server_pubkey(server_keys.public_key().to_hex()) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_stateless(true) + .with_timeout(Duration::from_secs(1)); + + let mut transport = NostrClientTransport::new(client_keys, config) + .await + .expect("transport should be constructed"); + let rx = transport + .take_message_receiver() + .expect("message receiver should be available once"); + + (transport, rx) +} + +#[tokio::test] +async fn create_emulated_response_returns_correct_request_id() { + let (transport, mut rx) = make_stateless_transport().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("test-id"), + method: INITIALIZE_METHOD.to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "conformance-test", "version": "0.0.0" } + })), + }); + + transport + .send(&request) + .await + .expect("initialize should be emulated in stateless mode"); + + let msg = timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("should receive emulated response promptly") + .expect("channel should contain response"); + + match msg { + JsonRpcMessage::Response(resp) => { + assert_eq!(resp.id, serde_json::json!("test-id")); + assert_eq!(resp.jsonrpc, "2.0"); + assert_eq!( + resp.result + .get("protocolVersion") + .and_then(serde_json::Value::as_str), + Some(mcp_protocol_version()) + ); + assert_eq!( + resp.result + .get("serverInfo") + .and_then(|v| v.get("name")) + .and_then(serde_json::Value::as_str), + Some("Emulated-Stateless-Server") + ); + assert_eq!( + resp.result + .get("serverInfo") + .and_then(|v| v.get("version")) + .and_then(serde_json::Value::as_str), + Some("1.0.0") + ); + assert_eq!( + resp.result + .get("capabilities") + .and_then(|v| v.get("tools")) + .and_then(|v| v.get("listChanged")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resp.result + .get("capabilities") + .and_then(|v| v.get("prompts")) + .and_then(|v| v.get("listChanged")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resp.result + .get("capabilities") + .and_then(|v| v.get("resources")) + .and_then(|v| v.get("subscribe")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert_eq!( + resp.result + .get("capabilities") + .and_then(|v| v.get("resources")) + .and_then(|v| v.get("listChanged")) + .and_then(serde_json::Value::as_bool), + Some(true) + ); + } + other => panic!("expected Response, got {other:?}"), + } + + let duplicate = timeout(Duration::from_millis(100), rx.recv()).await; + assert!( + duplicate.is_err(), + "initialize request should emit exactly one emulated response" + ); +} + +#[tokio::test] +async fn should_handle_statelessly_returns_true_for_initialize() { + let (transport, mut rx) = make_stateless_transport().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: INITIALIZE_METHOD.to_string(), + params: None, + }); + + transport + .send(&request) + .await + .expect("initialize should be handled statelessly"); + + let msg = timeout(Duration::from_millis(200), rx.recv()) + .await + .expect("initialize should produce local emulated response") + .expect("response should be delivered"); + + assert_eq!(msg.id(), Some(&serde_json::json!(1))); +} + +#[tokio::test] +async fn should_handle_statelessly_returns_false_for_other_methods() { + let (transport, mut rx) = make_stateless_transport().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: None, + }); + + let _send_result = transport.send(&request).await; + + let recv_result = timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + recv_result.is_err(), + "non-initialize request should not create a local emulated response" + ); +} + +#[tokio::test] +async fn notifications_initialized_swallowed_in_stateless_mode() { + let (transport, mut rx) = make_stateless_transport().await; + + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + + transport + .send(¬ification) + .await + .expect("notifications/initialized should be accepted in stateless mode"); + + let recv_result = timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + recv_result.is_err(), + "notifications/initialized must be swallowed in stateless mode" + ); +} diff --git a/tests/conformance_stores.rs b/tests/conformance_stores.rs new file mode 100644 index 0000000..8216403 --- /dev/null +++ b/tests/conformance_stores.rs @@ -0,0 +1,828 @@ +//! Conformance tests for store abstractions. +//! +//! Ported from the TS SDK: +//! - `src/transport/nostr-client/correlation-store.test.ts` +//! - `src/transport/nostr-server/session-store.test.ts` +//! - `src/transport/nostr-server/correlation-store.test.ts` + +use contextvm_sdk::{ClientCorrelationStore, ServerEventRouteStore, SessionStore}; +use serde_json::json; + +// ════════════════════════════════════════════════════════════════════ +// Client Correlation Store +// ════════════════════════════════════════════════════════════════════ + +mod client_correlation_store { + use super::*; + + // ── registerRequest ─────────────────────────────────────────── + + #[tokio::test] + async fn stores_request_with_event_id() { + let store = ClientCorrelationStore::new(); + store + .register("event123".into(), json!("req1"), false) + .await; + assert!(store.contains("event123").await); + } + + #[tokio::test] + async fn stores_and_resolves_original_request_id() { + let store = ClientCorrelationStore::new(); + store + .register("event456".into(), json!("req2"), false) + .await; + + // Retrieve the stored original ID. + let original = store.get_original_id("event456").await.unwrap(); + assert_eq!(original, json!("req2")); + + // After removal the entry is fully gone. + assert!(store.remove("event456").await); + assert!(store.get_original_id("event456").await.is_none()); + } + + #[tokio::test] + async fn register_request_flags_initialize_requests() { + let store = ClientCorrelationStore::new(); + store.register("e_init".into(), json!("r1"), true).await; + store.register("e_normal".into(), json!("r2"), false).await; + + assert!(store.is_initialize_request("e_init").await); + assert!(!store.is_initialize_request("e_normal").await); + assert!(!store.is_initialize_request("unknown").await); + } + + // ── resolveResponse (get_original_id + remove) ──────────────── + + #[tokio::test] + async fn restores_original_request_id() { + let store = ClientCorrelationStore::new(); + store.register("event789".into(), json!(42), false).await; + let original = store.get_original_id("event789").await.unwrap(); + assert_eq!(original, json!(42)); + } + + #[tokio::test] + async fn returns_none_for_unknown_event_id() { + let store = ClientCorrelationStore::new(); + assert!(store.get_original_id("unknown").await.is_none()); + } + + #[tokio::test] + async fn get_and_remove_roundtrip() { + let store = ClientCorrelationStore::new(); + store.register("event1".into(), json!("req1"), false).await; + + // Lookup succeeds before removal. + let original = store.get_original_id("event1").await.unwrap(); + assert_eq!(original, json!("req1")); + + // Remove returns true and cleans up completely. + assert!(store.remove("event1").await); + assert!(!store.contains("event1").await); + assert!(store.get_original_id("event1").await.is_none()); + } + + // ── removePendingRequest ────────────────────────────────────── + + #[tokio::test] + async fn removes_existing_request() { + let store = ClientCorrelationStore::new(); + store.register("event1".into(), json!(null), false).await; + assert!(store.remove("event1").await); + assert!(!store.contains("event1").await); + } + + #[tokio::test] + async fn returns_false_for_unknown_request() { + let store = ClientCorrelationStore::new(); + assert!(!store.remove("unknown").await); + } + + // ── clear ───────────────────────────────────────────────────── + + #[tokio::test] + async fn removes_all_pending_requests() { + let store = ClientCorrelationStore::new(); + store.register("event1".into(), json!(null), false).await; + store.register("event2".into(), json!(null), false).await; + store.clear().await; + assert_eq!(store.count().await, 0); + } + + // ── LRU eviction (TS SDK client test 9) ─────────────────────── + + #[tokio::test] + async fn evicts_oldest_when_capacity_reached() { + let store = ClientCorrelationStore::with_max_pending(2); + for i in 0..5 { + store + .register(format!("event{i}"), json!(null), false) + .await; + } + assert_eq!(store.count().await, 2); + // Only the two most recent entries survive. + assert!(!store.contains("event0").await); + assert!(!store.contains("event1").await); + assert!(!store.contains("event2").await); + assert!(store.contains("event3").await); + assert!(store.contains("event4").await); + } +} + +// ════════════════════════════════════════════════════════════════════ +// Server Session Store +// ════════════════════════════════════════════════════════════════════ + +mod server_session_store { + use super::*; + + fn routes() -> ServerEventRouteStore { + ServerEventRouteStore::new() + } + + #[tokio::test] + async fn create_and_retrieve_sessions() { + let store = SessionStore::new(); + let r = routes(); + + let created = store.get_or_create_session("client-1", true, &r).await; + assert!(created); + + let session = store.get_session("client-1").await.unwrap(); + assert!(session.is_encrypted); + assert!(!session.is_initialized); + + // Retrieving same key should return it + assert!(store.get_session("client-1").await.is_some()); + } + + #[tokio::test] + async fn mark_sessions_as_initialized() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + let result = store.mark_initialized("client-1").await; + assert!(result); + + let session = store.get_session("client-1").await.unwrap(); + assert!(session.is_initialized); + } + + #[tokio::test] + async fn remove_sessions() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + + let result = store.remove_session("client-1").await; + assert!(result); + assert!(store.get_session("client-1").await.is_none()); + } + + #[tokio::test] + async fn clear_all_sessions() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + store.get_or_create_session("client-2", true, &r).await; + + store.clear().await; + + assert_eq!(store.session_count().await, 0); + assert!(store.get_session("client-1").await.is_none()); + assert!(store.get_session("client-2").await.is_none()); + } + + #[tokio::test] + async fn iterate_over_all_sessions() { + let store = SessionStore::new(); + let r = routes(); + store.get_or_create_session("client-1", false, &r).await; + store.get_or_create_session("client-2", true, &r).await; + + let sessions = store.get_all_sessions().await; + assert_eq!(sessions.len(), 2); + + let keys: Vec<&str> = sessions.iter().map(|(k, _)| k.as_str()).collect(); + assert!(keys.contains(&"client-1")); + assert!(keys.contains(&"client-2")); + } +} + +// ════════════════════════════════════════════════════════════════════ +// Server Correlation Store (ServerEventRouteStore) +// ════════════════════════════════════════════════════════════════════ + +mod server_correlation_store { + use super::*; + + // ── registerEventRoute ──────────────────────────────────────── + + #[tokio::test] + async fn registers_route_with_all_fields() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + + let route = store.get_route("event1").await.unwrap(); + assert_eq!(route.client_pubkey, "client1"); + assert_eq!(route.original_request_id, json!("req1")); + assert_eq!(route.progress_token.as_deref(), Some("token1")); + } + + #[tokio::test] + async fn registers_route_without_progress_token() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + + let route = store.get_route("event1").await.unwrap(); + assert!(route.progress_token.is_none()); + } + + #[tokio::test] + async fn registers_route_with_numeric_request_id() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!(42), None) + .await; + + let route = store.get_route("event1").await.unwrap(); + assert_eq!(route.original_request_id, json!(42)); + } + + #[tokio::test] + async fn updates_client_index_when_registering() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client1".into(), json!("req2"), None) + .await; + + assert!(store.has_active_routes_for_client("client1").await); + } + + #[tokio::test] + async fn registers_progress_token_mapping() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + + assert_eq!( + store + .get_event_id_by_progress_token("token1") + .await + .as_deref(), + Some("event1") + ); + assert!(store.has_progress_token("token1").await); + } + + // ── getEventRoute ───────────────────────────────────────────── + + #[tokio::test] + async fn returns_none_for_unknown_event_id() { + let store = ServerEventRouteStore::new(); + assert!(store.get_route("unknown").await.is_none()); + } + + // ── popEventRoute ───────────────────────────────────────────── + + #[tokio::test] + async fn returns_and_removes_route_atomically() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + + let route = store.pop("event1").await.unwrap(); + assert_eq!(route.client_pubkey, "client1"); + assert_eq!(route.original_request_id, json!("req1")); + assert_eq!(route.progress_token.as_deref(), Some("token1")); + + // Route + token mapping should be gone. + assert!(!store.has_event_route("event1").await); + assert!(!store.has_progress_token("token1").await); + + // Second pop is a no-op. + assert!(store.pop("event1").await.is_none()); + } + + // ── getEventIdByProgressToken ───────────────────────────────── + + #[tokio::test] + async fn returns_none_for_unknown_token() { + let store = ServerEventRouteStore::new(); + assert!(store + .get_event_id_by_progress_token("unknown") + .await + .is_none()); + } + + #[tokio::test] + async fn returns_correct_event_id_for_token() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + store + .register( + "event2".into(), + "client2".into(), + json!("req2"), + Some("token2".into()), + ) + .await; + + assert_eq!( + store + .get_event_id_by_progress_token("token1") + .await + .as_deref(), + Some("event1") + ); + assert_eq!( + store + .get_event_id_by_progress_token("token2") + .await + .as_deref(), + Some("event2") + ); + } + + // ── removeRoutesForClient ───────────────────────────────────── + + #[tokio::test] + async fn removes_all_routes_for_client() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client1".into(), json!("req2"), None) + .await; + store + .register("event3".into(), "client2".into(), json!("req3"), None) + .await; + + let removed = store.remove_for_client("client1").await; + assert_eq!(removed, 2); + + assert!(!store.has_event_route("event1").await); + assert!(!store.has_event_route("event2").await); + assert!(store.has_event_route("event3").await); + } + + #[tokio::test] + async fn returns_zero_for_unknown_client() { + let store = ServerEventRouteStore::new(); + assert_eq!(store.remove_for_client("unknown").await, 0); + } + + #[tokio::test] + async fn cleans_up_progress_tokens_for_removed_routes() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + store + .register( + "event2".into(), + "client1".into(), + json!("req2"), + Some("token2".into()), + ) + .await; + + store.remove_for_client("client1").await; + + assert!(!store.has_progress_token("token1").await); + assert!(!store.has_progress_token("token2").await); + } + + #[tokio::test] + async fn removes_client_from_index_after_cleanup() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + + store.remove_for_client("client1").await; + + assert!(!store.has_active_routes_for_client("client1").await); + } + + // ── hasEventRoute ───────────────────────────────────────────── + + #[tokio::test] + async fn has_event_route_true_for_existing() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + assert!(store.has_event_route("event1").await); + } + + #[tokio::test] + async fn has_event_route_false_for_unknown() { + let store = ServerEventRouteStore::new(); + assert!(!store.has_event_route("unknown").await); + } + + // ── hasProgressToken ────────────────────────────────────────── + + #[tokio::test] + async fn has_progress_token_true_for_existing() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + assert!(store.has_progress_token("token1").await); + } + + #[tokio::test] + async fn has_progress_token_false_for_unknown() { + let store = ServerEventRouteStore::new(); + assert!(!store.has_progress_token("unknown").await); + } + + // ── hasActiveRoutesForClient ────────────────────────────────── + + #[tokio::test] + async fn has_active_routes_true_when_routes_exist() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + assert!(store.has_active_routes_for_client("client1").await); + } + + #[tokio::test] + async fn has_active_routes_false_when_no_routes() { + let store = ServerEventRouteStore::new(); + assert!(!store.has_active_routes_for_client("client1").await); + } + + #[tokio::test] + async fn has_active_routes_false_after_all_popped() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store.pop("event1").await; + assert!(!store.has_active_routes_for_client("client1").await); + } + + // ── eventRouteCount ─────────────────────────────────────────── + + #[tokio::test] + async fn event_route_count_zero_for_empty() { + let store = ServerEventRouteStore::new(); + assert_eq!(store.event_route_count().await, 0); + } + + #[tokio::test] + async fn event_route_count_after_registrations() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client1".into(), json!("req2"), None) + .await; + assert_eq!(store.event_route_count().await, 2); + } + + #[tokio::test] + async fn event_route_count_after_removals() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client1".into(), json!("req2"), None) + .await; + store.pop("event1").await; + assert_eq!(store.event_route_count().await, 1); + } + + // ── progressTokenCount ──────────────────────────────────────── + + #[tokio::test] + async fn progress_token_count_zero_for_empty() { + let store = ServerEventRouteStore::new(); + assert_eq!(store.progress_token_count().await, 0); + } + + #[tokio::test] + async fn progress_token_count_after_registrations() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + store + .register( + "event2".into(), + "client1".into(), + json!("req2"), + Some("token2".into()), + ) + .await; + store + .register("event3".into(), "client1".into(), json!("req3"), None) + .await; + assert_eq!(store.progress_token_count().await, 2); + } + + #[tokio::test] + async fn progress_token_count_after_removals() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + store + .register( + "event2".into(), + "client1".into(), + json!("req2"), + Some("token2".into()), + ) + .await; + store.pop("event1").await; + assert_eq!(store.progress_token_count().await, 1); + } + + // ── clear ───────────────────────────────────────────────────── + + #[tokio::test] + async fn clear_removes_all_routes() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client2".into(), json!("req2"), None) + .await; + + store.clear().await; + + assert_eq!(store.event_route_count().await, 0); + assert!(!store.has_event_route("event1").await); + } + + #[tokio::test] + async fn clear_removes_all_progress_tokens() { + let store = ServerEventRouteStore::new(); + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + + store.clear().await; + + assert_eq!(store.progress_token_count().await, 0); + assert!(!store.has_progress_token("token1").await); + } + + #[tokio::test] + async fn clear_cleans_up_client_index() { + let store = ServerEventRouteStore::new(); + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + + store.clear().await; + + assert!(!store.has_active_routes_for_client("client1").await); + } + + // ── complex scenarios ───────────────────────────────────────── + + #[tokio::test] + async fn handles_multiple_clients_with_multiple_routes() { + let store = ServerEventRouteStore::new(); + + // Client 1: 2 routes + store + .register( + "c1e1".into(), + "client1".into(), + json!("r1"), + Some("t1".into()), + ) + .await; + store + .register( + "c1e2".into(), + "client1".into(), + json!("r2"), + Some("t2".into()), + ) + .await; + + // Client 2: 1 route + store + .register( + "c2e1".into(), + "client2".into(), + json!("r3"), + Some("t3".into()), + ) + .await; + + assert_eq!(store.event_route_count().await, 3); + assert_eq!(store.progress_token_count().await, 3); + assert!(store.has_active_routes_for_client("client1").await); + assert!(store.has_active_routes_for_client("client2").await); + + // Remove one of client1's routes + store.pop("c1e1").await; + + assert!(store.has_active_routes_for_client("client1").await); + assert!(!store.has_progress_token("t1").await); + assert!(store.has_progress_token("t2").await); + } + + #[tokio::test] + async fn handles_route_replacement_with_same_progress_token() { + let store = ServerEventRouteStore::new(); + + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + assert_eq!( + store + .get_event_id_by_progress_token("token1") + .await + .as_deref(), + Some("event1") + ); + + // Register new route with same token (overwrites mapping) + store + .register( + "event2".into(), + "client1".into(), + json!("req2"), + Some("token1".into()), + ) + .await; + assert_eq!( + store + .get_event_id_by_progress_token("token1") + .await + .as_deref(), + Some("event2") + ); + } + + #[tokio::test] + async fn maintains_consistency_through_mixed_operations() { + let store = ServerEventRouteStore::new(); + + // Add routes + store + .register("e1".into(), "c1".into(), json!("r1"), Some("t1".into())) + .await; + store + .register("e2".into(), "c1".into(), json!("r2"), Some("t2".into())) + .await; + store + .register("e3".into(), "c2".into(), json!("r3"), Some("t3".into())) + .await; + + // Remove one + store.pop("e2").await; + + // Verify consistency + assert!(store.has_event_route("e1").await); + assert!(!store.has_event_route("e2").await); + assert!(store.has_event_route("e3").await); + + assert!(store.has_progress_token("t1").await); + assert!(!store.has_progress_token("t2").await); + assert!(store.has_progress_token("t3").await); + + assert!(store.has_active_routes_for_client("c1").await); + assert!(store.has_active_routes_for_client("c2").await); + } + + // ── LRU eviction (TS SDK server tests 28–30) ───────────────── + + #[tokio::test] + async fn evicts_oldest_route_when_capacity_reached() { + let store = ServerEventRouteStore::with_max_routes(2); + + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client1".into(), json!("req2"), None) + .await; + store + .register("event3".into(), "client1".into(), json!("req3"), None) + .await; + + // event1 should have been evicted. + assert!(!store.has_event_route("event1").await); + assert_eq!(store.event_route_count().await, 2); + } + + #[tokio::test] + async fn cleans_up_progress_tokens_on_eviction() { + let store = ServerEventRouteStore::with_max_routes(1); + + store + .register( + "event1".into(), + "client1".into(), + json!("req1"), + Some("token1".into()), + ) + .await; + store + .register( + "event2".into(), + "client1".into(), + json!("req2"), + Some("token2".into()), + ) + .await; + + assert!(!store.has_progress_token("token1").await); + assert!(store.has_progress_token("token2").await); + } + + #[tokio::test] + async fn cleans_up_client_index_on_eviction() { + let store = ServerEventRouteStore::with_max_routes(1); + + store + .register("event1".into(), "client1".into(), json!("req1"), None) + .await; + store + .register("event2".into(), "client2".into(), json!("req2"), None) + .await; + + // client1's only route was evicted. + assert!(!store.has_active_routes_for_client("client1").await); + assert!(store.has_active_routes_for_client("client2").await); + } +} diff --git a/tests/conformance_wire_format.rs b/tests/conformance_wire_format.rs new file mode 100644 index 0000000..828ac38 --- /dev/null +++ b/tests/conformance_wire_format.rs @@ -0,0 +1,463 @@ +//! Conformance tests for ContextVM wire format: MCP JSON-RPC carried in Nostr kind 25910 events. +//! +//! These mirror the layering style of `src/rmcp_transport/pipeline_tests.rs`: build the JSON-RPC +//! payload, serialize through the same helpers the transport uses (`mcp_to_nostr_event`, tag +//! builders from [`BaseTransport`]), sign with nostr-sdk, then assert on kind, tags, and content. + +use contextvm_sdk::core::constants::{ + mcp_protocol_version, tags, CTXVM_MESSAGES_KIND, INITIALIZE_METHOD, + NOTIFICATIONS_INITIALIZED_METHOD, SERVER_ANNOUNCEMENT_KIND, +}; +use contextvm_sdk::core::serializers; +use contextvm_sdk::core::types::{ + JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, +}; +use contextvm_sdk::transport::base::BaseTransport; +use nostr_sdk::prelude::*; + +fn assert_ctxvm_message_kind(event: &Event) { + assert_eq!( + event.kind, + Kind::Custom(CTXVM_MESSAGES_KIND), + "ContextVM MCP messages must use kind {}", + CTXVM_MESSAGES_KIND + ); +} + +fn p_tag_hex(event: &Event) -> Option { + serializers::get_tag_value(&event.tags, tags::PUBKEY) +} + +fn e_tag_hex(event: &Event) -> Option { + serializers::get_tag_value(&event.tags, tags::EVENT_ID) +} + +// ── Initialize request ─────────────────────────────────────────────────────── + +#[test] +fn ctxvm_initialize_request_has_kind_p_tag_and_jsonrpc_initialize() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: INITIALIZE_METHOD.to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "conformance-test", "version": "0.0.0" } + })), + }); + + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let builder = serializers::mcp_to_nostr_event(&init_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("initialize request should serialize to event content"); + + let client_keys = Keys::generate(); + let event = builder + .sign_with_keys(&client_keys) + .expect("sign initialize request event"); + + assert_ctxvm_message_kind(&event); + assert_eq!( + p_tag_hex(&event), + Some(server_pk.to_hex()), + "initialize request must target the server via p tag" + ); + + let msg = serializers::nostr_event_to_mcp_message(&event.content) + .expect("event content should be valid JSON-RPC"); + assert!(msg.is_request()); + assert_eq!(msg.method(), Some(INITIALIZE_METHOD)); + + // Parse at the raw JSON level to verify wire format independently of the typed deserializer. + let v: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON object"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!(1)); +} + +// ── Initialize response ────────────────────────────────────────────────────── + +#[test] +fn ctxvm_initialize_response_has_kind_e_tag_and_result_protocol_version() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + let client_keys = Keys::generate(); + let client_pk = client_keys.public_key(); + + // Signed request event provides the Nostr event id referenced by e on the response. + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-1"), + method: INITIALIZE_METHOD.to_string(), + params: Some(serde_json::json!({})), + }); + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let request_event = + serializers::mcp_to_nostr_event(&init_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("request event for response correlation should serialize") + .sign_with_keys(&client_keys) + .expect("sign request event for correlation"); + + let init_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-1"), + result: serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { + "name": "conformance-test-server", + "version": "0.0.0" + }, + "capabilities": {} + }), + }); + + let response_tags = BaseTransport::create_response_tags(&client_pk, &request_event.id); + let response_event = + serializers::mcp_to_nostr_event(&init_resp, CTXVM_MESSAGES_KIND, response_tags) + .expect("initialize response should serialize") + .sign_with_keys(&server_keys) + .expect("sign initialize response event"); + + assert_ctxvm_message_kind(&response_event); + assert_eq!( + p_tag_hex(&response_event), + Some(client_pk.to_hex()), + "initialize response must route back to the client via p tag" + ); + assert_eq!( + e_tag_hex(&response_event), + Some(request_event.id.to_hex()), + "initialize response must correlate to the request Nostr event via e tag" + ); + + let v: serde_json::Value = + serde_json::from_str(&response_event.content).expect("content must be JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!("corr-1")); + assert!(v["result"]["protocolVersion"].is_string()); + assert!(v["result"]["serverInfo"]["name"].is_string()); +} + +// ── notifications/initialized ────────────────────────────────────────────── + +#[test] +fn ctxvm_notifications_initialized_has_kind_p_tag_and_method() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + let client_keys = Keys::generate(); + + let notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: NOTIFICATIONS_INITIALIZED_METHOD.to_string(), + params: None, + }); + + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let event = serializers::mcp_to_nostr_event(¬if, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("notification should serialize") + // Client sends this to the server; signer must differ from `p` so the tag is not stripped. + .sign_with_keys(&client_keys) + .expect("sign initialized notification"); + + assert_ctxvm_message_kind(&event); + assert_eq!( + p_tag_hex(&event), + Some(server_pk.to_hex()), + "initialized notification must include server p tag" + ); + + let msg = serializers::nostr_event_to_mcp_message(&event.content).expect("parse content"); + assert!(msg.is_notification()); + assert_eq!(msg.method(), Some(NOTIFICATIONS_INITIALIZED_METHOD)); + + // Parse at the raw JSON level to verify wire format independently of the typed deserializer. + let v: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON object"); + assert_eq!(v["jsonrpc"], "2.0"); + assert!( + v.get("id").is_none_or(serde_json::Value::is_null), + "JSON-RPC notifications must not include an id" + ); +} + +// ── tools/list request ─────────────────────────────────────────────────────── + +#[test] +fn ctxvm_tools_list_request_has_kind_p_tag_and_method() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + + let list_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let builder = serializers::mcp_to_nostr_event(&list_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("tools/list request should serialize to event content"); + + let client_keys = Keys::generate(); + let event = builder + .sign_with_keys(&client_keys) + .expect("sign tools/list request event"); + + assert_ctxvm_message_kind(&event); + assert_eq!( + p_tag_hex(&event), + Some(server_pk.to_hex()), + "tools/list request must target the server via p tag" + ); + + let msg = serializers::nostr_event_to_mcp_message(&event.content) + .expect("event content should be valid JSON-RPC"); + assert!(msg.is_request()); + assert_eq!(msg.method(), Some("tools/list")); + + let v: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON object"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!(2)); +} + +// ── tools/call request ─────────────────────────────────────────────────────── + +#[test] +fn ctxvm_tools_call_request_has_kind_p_tag_method_and_params() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + + let call_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(3), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "name": "add", + "arguments": { "a": 5, "b": 3 } + })), + }); + + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let builder = serializers::mcp_to_nostr_event(&call_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("tools/call request should serialize to event content"); + + let client_keys = Keys::generate(); + let event = builder + .sign_with_keys(&client_keys) + .expect("sign tools/call request event"); + + assert_ctxvm_message_kind(&event); + assert_eq!( + p_tag_hex(&event), + Some(server_pk.to_hex()), + "tools/call request must target the server via p tag" + ); + + let msg = serializers::nostr_event_to_mcp_message(&event.content) + .expect("event content should be valid JSON-RPC"); + assert!(msg.is_request()); + assert_eq!(msg.method(), Some("tools/call")); + + let v: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON object"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!(3)); + assert_eq!(v["params"]["name"], "add"); + assert!( + v["params"]["arguments"].is_object(), + "tools/call params.arguments must be an object on the wire" + ); +} + +// ── tools/list response ─────────────────────────────────────────────────────── + +#[test] +fn ctxvm_tools_list_response_has_kind_e_tag_and_result() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + let client_keys = Keys::generate(); + let client_pk = client_keys.public_key(); + + let list_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-tools-list"), + method: "tools/list".to_string(), + params: Some(serde_json::json!({})), + }); + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let request_event = + serializers::mcp_to_nostr_event(&list_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("tools/list request for response correlation should serialize") + .sign_with_keys(&client_keys) + .expect("sign tools/list request event for correlation"); + + let list_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-tools-list"), + result: serde_json::json!({ "tools": [] }), + }); + + let response_tags = BaseTransport::create_response_tags(&client_pk, &request_event.id); + let response_event = + serializers::mcp_to_nostr_event(&list_resp, CTXVM_MESSAGES_KIND, response_tags) + .expect("tools/list response should serialize") + .sign_with_keys(&server_keys) + .expect("sign tools/list response event"); + + assert_ctxvm_message_kind(&response_event); + assert_eq!( + p_tag_hex(&response_event), + Some(client_pk.to_hex()), + "tools/list response must route back to the client via p tag" + ); + assert_eq!( + e_tag_hex(&response_event), + Some(request_event.id.to_hex()), + "tools/list response must correlate to the request Nostr event via e tag" + ); + + let v: serde_json::Value = + serde_json::from_str(&response_event.content).expect("content must be JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!("corr-tools-list")); + assert!(v["result"]["tools"].is_array()); +} + +// ── tools/call response ─────────────────────────────────────────────────────── + +#[test] +fn ctxvm_tools_call_response_has_kind_e_tag_and_result() { + let server_keys = Keys::generate(); + let server_pk = server_keys.public_key(); + let client_keys = Keys::generate(); + let client_pk = client_keys.public_key(); + + let call_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-tools-call"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "name": "add", + "arguments": { "a": 5, "b": 3 } + })), + }); + let recipient_tags = BaseTransport::create_recipient_tags(&server_pk); + let request_event = + serializers::mcp_to_nostr_event(&call_req, CTXVM_MESSAGES_KIND, recipient_tags) + .expect("tools/call request for response correlation should serialize") + .sign_with_keys(&client_keys) + .expect("sign tools/call request event for correlation"); + + let call_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-tools-call"), + result: serde_json::json!({ + "content": [{ "type": "text", "text": "8" }], + "isError": false + }), + }); + + let response_tags = BaseTransport::create_response_tags(&client_pk, &request_event.id); + let response_event = + serializers::mcp_to_nostr_event(&call_resp, CTXVM_MESSAGES_KIND, response_tags) + .expect("tools/call response should serialize") + .sign_with_keys(&server_keys) + .expect("sign tools/call response event"); + + assert_ctxvm_message_kind(&response_event); + assert_eq!( + p_tag_hex(&response_event), + Some(client_pk.to_hex()), + "tools/call response must route back to the client via p tag" + ); + assert_eq!( + e_tag_hex(&response_event), + Some(request_event.id.to_hex()), + "tools/call response must correlate to the request Nostr event via e tag" + ); + + let v: serde_json::Value = + serde_json::from_str(&response_event.content).expect("content must be JSON"); + assert_eq!(v["jsonrpc"], "2.0"); + assert_eq!(v["id"], serde_json::json!("corr-tools-call")); + assert!(v["result"]["content"].is_array()); + assert_eq!(v["result"]["isError"], serde_json::json!(false)); +} + +// ── Server announcement (kind 11316) ────────────────────────────────────────── + +#[test] +fn ctxvm_server_announcement_has_kind_and_required_tags() { + let server_keys = Keys::generate(); + + // MCP-flavoured JSON for wire conformance; not the same content shape as `NostrServerTransport::announce` (flat `ServerInfo` only). + let content = serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { "name": "Test Server" }, + "capabilities": {}, + }); + let content_str = serde_json::to_string(&content).expect("announcement content must serialize"); + + let announcement_tags = vec![ + Tag::custom( + TagKind::Custom(tags::NAME.into()), + vec!["Test Server".to_string()], + ), + Tag::custom( + TagKind::Custom(tags::ABOUT.into()), + vec!["A test server".to_string()], + ), + Tag::custom( + TagKind::Custom(tags::WEBSITE.into()), + vec!["http://localhost".to_string()], + ), + Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + ), + ]; + + let event = EventBuilder::new(Kind::Custom(SERVER_ANNOUNCEMENT_KIND), content_str) + .tags(announcement_tags) + .sign_with_keys(&server_keys) + .expect("sign server announcement event"); + + assert_eq!( + event.kind, + Kind::Custom(SERVER_ANNOUNCEMENT_KIND), + "server announcement must use kind {}", + SERVER_ANNOUNCEMENT_KIND + ); + assert_eq!(event.pubkey, server_keys.public_key()); + + assert_eq!( + serializers::get_tag_value(&event.tags, tags::NAME).as_deref(), + Some("Test Server") + ); + assert_eq!( + serializers::get_tag_value(&event.tags, tags::ABOUT).as_deref(), + Some("A test server") + ); + assert_eq!( + serializers::get_tag_value(&event.tags, tags::WEBSITE).as_deref(), + Some("http://localhost") + ); + + assert!( + event.tags.iter().any(|t| { + let parts = t.clone().to_vec(); + parts.len() == 1 && parts.first().map(|s| s.as_str()) == Some(tags::SUPPORT_ENCRYPTION) + }), + "support_encryption must be present as a single-element tag" + ); + + let v: serde_json::Value = + serde_json::from_str(&event.content).expect("announcement content must be JSON"); + assert_eq!(v["protocolVersion"], mcp_protocol_version()); + assert_eq!(v["serverInfo"]["name"], "Test Server"); + assert!(v["capabilities"].is_object()); +} diff --git a/tests/e2e_happy_path.rs b/tests/e2e_happy_path.rs new file mode 100644 index 0000000..529dea8 --- /dev/null +++ b/tests/e2e_happy_path.rs @@ -0,0 +1,436 @@ +//! End-to-end happy path integration tests. +//! +//! Exercises the full SDK stack in-memory: RMCP handler → NostrServerWorker → +//! NostrServerTransport → MockRelayPool → NostrClientTransport → +//! NostrClientWorker → RMCP client. No network required. + +#![cfg(feature = "rmcp")] + +use std::sync::Arc; +use std::time::Duration; + +use contextvm_sdk::core::types::EncryptionMode; +use contextvm_sdk::relay::mock::MockRelayPool; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::transport::server::{NostrServerTransport, NostrServerTransportConfig}; +use contextvm_sdk::RelayPoolTrait; + +use rmcp::{ + handler::server::wrapper::Parameters, model::*, schemars, service::RequestContext, tool, + tool_handler, tool_router, ClientHandler, RoleServer, ServerHandler, ServiceExt, +}; +use tokio::sync::Mutex; + +// ── Fixtures ────────────────────────────────────────────────────────────── + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i64, + b: i64, +} + +#[derive(Clone)] +struct DemoServer { + echo_count: Arc>, +} + +impl DemoServer { + fn new() -> Self { + Self { + echo_count: Arc::new(Mutex::new(0)), + } + } +} + +#[tool_router] +impl DemoServer { + #[tool(description = "Echo a message back")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + let mut n = self.echo_count.lock().await; + *n += 1; + Ok(CallToolResult::success(vec![Content::text(format!( + "Echo #{n}: {message}" + ))])) + } + + #[tool(description = "Add two integers")] + fn add( + &self, + Parameters(AddParams { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(format!( + "{a} + {b} = {}", + a + b + ))])) + } + + #[tool(description = "Return total echo calls")] + async fn get_echo_count(&self) -> Result { + let n = self.echo_count.lock().await; + Ok(CallToolResult::success(vec![Content::text(format!( + "Total echo calls: {n}" + ))])) + } +} + +#[tool_handler] +impl ServerHandler for DemoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new("e2e-test-server", "0.1.0")) + } + + async fn list_resources( + &self, + _req: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("demo://readme", "Demo README".to_string()).no_annotation() + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + req: ReadResourceRequestParams, + _ctx: RequestContext, + ) -> Result { + match req.uri.as_str() { + "demo://readme" => Ok(ReadResourceResult::new(vec![ResourceContents::text( + "Demo content.", + req.uri, + )])), + other => Err(ErrorData::resource_not_found( + "not_found", + Some(serde_json::json!({ "uri": other })), + )), + } + } +} + +#[derive(Clone, Default)] +struct DemoClient; +impl ClientHandler for DemoClient {} + +// ── Helpers ─────────────────────────────────────────────────────────────── + +fn as_pool(pool: MockRelayPool) -> Arc { + Arc::new(pool) +} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|c| match &c.raw { + RawContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .unwrap_or_default() +} + +fn call_params(name: &'static str, args: Option) -> CallToolRequestParams { + let mut params = CallToolRequestParams::new(name); + if let Some(v) = args.and_then(|v| serde_json::from_value(v).ok()) { + params = params.with_arguments(v); + } + params +} + +// ── Core scenario runner ────────────────────────────────────────────────── + +async fn run_e2e_scenario(mode: EncryptionMode) { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + + // Extract server pubkey before as_pool() takes ownership + let server_pubkey_hex = server_pool.mock_public_key().to_hex(); + + let server_transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(mode), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let client_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_server_pubkey(server_pubkey_hex) + .with_encryption_mode(mode) + .with_relay_urls(vec!["wss://mock.relay".to_string()]), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let server_handle = tokio::spawn(async move { + DemoServer::new() + .serve(server_transport) + .await + .expect("server serve failed") + .waiting() + .await + .expect("server error"); + }); + + // Let server event loop establish subscriptions + tokio::time::sleep(Duration::from_millis(10)).await; + + // rmcp handles the initialize handshake + let client = tokio::time::timeout(Duration::from_secs(5), DemoClient.serve(client_transport)) + .await + .expect("client startup timed out") + .expect("client init failed"); + + let peer = client + .peer_info() + .expect("peer_info should be available after serve()"); + assert_eq!( + peer.server_info.name, "e2e-test-server", + "server name mismatch" + ); + + let tools = client.list_all_tools().await.expect("list_all_tools"); + assert_eq!(tools.len(), 3, "expected 3 tools"); + let tool_names: Vec<&str> = tools.iter().map(|t| t.name.as_ref()).collect(); + assert!(tool_names.contains(&"echo"), "missing echo tool"); + assert!(tool_names.contains(&"add"), "missing add tool"); + assert!( + tool_names.contains(&"get_echo_count"), + "missing get_echo_count tool" + ); + + let echo1 = client + .call_tool(call_params( + "echo", + Some(serde_json::json!({"message": "hello"})), + )) + .await + .expect("echo call"); + let echo1_text = first_text(&echo1); + assert!( + echo1_text.contains("hello"), + "echo should contain 'hello', got: {echo1_text}" + ); + assert!( + echo1_text.contains("#1"), + "first echo should be #1, got: {echo1_text}" + ); + + let add = client + .call_tool(call_params( + "add", + Some(serde_json::json!({"a": 7, "b": 5})), + )) + .await + .expect("add call"); + let add_text = first_text(&add); + assert!( + add_text.contains("12"), + "add result should contain 12, got: {add_text}" + ); + + let count = client + .call_tool(call_params("get_echo_count", None)) + .await + .expect("get_echo_count call"); + let count_text = first_text(&count); + assert!( + count_text.contains("calls: 1"), + "echo count should be 1 after one echo call, got: {count_text}" + ); + + let echo2 = client + .call_tool(call_params( + "echo", + Some(serde_json::json!({"message": "world"})), + )) + .await + .expect("second echo call"); + let echo2_text = first_text(&echo2); + assert!( + echo2_text.contains("#2"), + "second echo should be #2, got: {echo2_text}" + ); + + let count2 = client + .call_tool(call_params("get_echo_count", None)) + .await + .expect("get_echo_count after second echo"); + let count2_text = first_text(&count2); + assert!( + count2_text.contains("calls: 2"), + "echo count should be 2, got: {count2_text}" + ); + + let resources = client + .list_all_resources() + .await + .expect("list_all_resources"); + assert_eq!(resources.len(), 1, "expected 1 resource"); + assert_eq!(resources[0].uri.as_str(), "demo://readme"); + assert_eq!(resources[0].name.as_str(), "Demo README"); + + let read_result = client + .read_resource(ReadResourceRequestParams::new("demo://readme")) + .await + .expect("read_resource"); + assert_eq!(read_result.contents.len(), 1); + match &read_result.contents[0] { + ResourceContents::TextResourceContents { text, uri, .. } => { + assert_eq!(uri, "demo://readme"); + assert!( + text.contains("Demo content."), + "unexpected resource text: {text}" + ); + } + _ => panic!("expected TextResourceContents"), + } + + match client.call_tool(call_params("no_such_tool", None)).await { + Err(_) => {} + Ok(r) if r.is_error.unwrap_or(false) => {} + Ok(_) => panic!("expected unknown tool to fail"), + } + + client.cancel().await.expect("client cancel"); + server_handle.abort(); +} + +// ── Tests ───────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_happy_path_encryption_disabled() { + // Plaintext full-stack works since MockRelayPool started respecting + // per-subscription filters (commit a0f9413): the server's own responses + // are p-tagged to the client, so they no longer echo back into the + // server's subscription as they did with the old broadcast-to-all mock. + run_e2e_scenario(EncryptionMode::Disabled).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_happy_path_encryption_optional() { + run_e2e_scenario(EncryptionMode::Optional).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_happy_path_encryption_required() { + run_e2e_scenario(EncryptionMode::Required).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn e2e_multi_client_no_crosstalk() { + let mut pools = MockRelayPool::create_linked_group(3); + let server_pool = pools.remove(0); + let client1_pool = pools.remove(0); + let client2_pool = pools.remove(0); + + // Extract server pubkey BEFORE as_pool() takes ownership + let server_pubkey_hex = server_pool.mock_public_key().to_hex(); + + let server_transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let server_handle = tokio::spawn(async move { + DemoServer::new() + .serve(server_transport) + .await + .expect("server serve failed") + .waiting() + .await + .expect("server error"); + }); + + tokio::time::sleep(Duration::from_millis(10)).await; + + // Create two independent clients + let client1_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_server_pubkey(server_pubkey_hex.clone()) + .with_encryption_mode(EncryptionMode::Optional) + .with_relay_urls(vec!["wss://mock.relay".to_string()]), + as_pool(client1_pool), + ) + .await + .expect("create client1 transport"); + + let client2_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_server_pubkey(server_pubkey_hex) + .with_encryption_mode(EncryptionMode::Optional) + .with_relay_urls(vec!["wss://mock.relay".to_string()]), + as_pool(client2_pool), + ) + .await + .expect("create client2 transport"); + + let client1 = tokio::time::timeout(Duration::from_secs(5), DemoClient.serve(client1_transport)) + .await + .expect("client1 timed out") + .expect("client1 init"); + + let client2 = tokio::time::timeout(Duration::from_secs(5), DemoClient.serve(client2_transport)) + .await + .expect("client2 timed out") + .expect("client2 init"); + + // Both clients call echo — responses must route correctly + let r1 = client1 + .call_tool(call_params( + "echo", + Some(serde_json::json!({"message": "from-client1"})), + )) + .await + .expect("client1 echo"); + assert!( + first_text(&r1).contains("from-client1"), + "client1 should get its own response" + ); + + let r2 = client2 + .call_tool(call_params( + "echo", + Some(serde_json::json!({"message": "from-client2"})), + )) + .await + .expect("client2 echo"); + assert!( + first_text(&r2).contains("from-client2"), + "client2 should get its own response" + ); + + // Shared stateful counter should reflect calls from both clients + let count = client1 + .call_tool(call_params("get_echo_count", None)) + .await + .expect("client1 get_echo_count"); + assert!( + first_text(&count).contains("calls: 2"), + "echo count should be 2 after calls from both clients" + ); + + // Cleanup + client1.cancel().await.expect("client1 cancel"); + client2.cancel().await.expect("client2 cancel"); + server_handle.abort(); +} diff --git a/tests/integration.rs b/tests/integration.rs new file mode 100644 index 0000000..c9cafa3 --- /dev/null +++ b/tests/integration.rs @@ -0,0 +1,177 @@ +//! Local RMCP integration test (in-process duplex I/O, no relay required). +//! Relay-dependent scenarios live in `examples/rmcp_integration_test.rs` +//! and run via the `integration.yml` workflow against a local relay container. + +#![cfg(feature = "rmcp")] + +use rmcp::{ + handler::server::wrapper::Parameters, model::*, schemars, service::RequestContext, tool, + tool_handler, tool_router, ClientHandler, RoleServer, ServerHandler, ServiceExt, +}; +use std::sync::Arc; +use tokio::sync::Mutex; + +// Minimal fixture: same tools as examples/rmcp_integration_test.rs + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct EchoParams { + message: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct AddParams { + a: i64, + b: i64, +} + +#[derive(Clone)] +struct DemoServer { + echo_count: Arc>, +} + +impl DemoServer { + fn new() -> Self { + Self { + echo_count: Arc::new(Mutex::new(0)), + } + } +} + +#[tool_router] +impl DemoServer { + #[tool(description = "Echo a message back")] + async fn echo( + &self, + Parameters(EchoParams { message }): Parameters, + ) -> Result { + let mut n = self.echo_count.lock().await; + *n += 1; + Ok(CallToolResult::success(vec![Content::text(format!( + "Echo #{n}: {message}" + ))])) + } + + #[tool(description = "Add two integers")] + fn add( + &self, + Parameters(AddParams { a, b }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text(format!( + "{a} + {b} = {}", + a + b + ))])) + } + + #[tool(description = "Return total echo calls")] + async fn get_echo_count(&self) -> Result { + let n = self.echo_count.lock().await; + Ok(CallToolResult::success(vec![Content::text(format!( + "Total echo calls: {n}" + ))])) + } +} + +#[tool_handler] +impl ServerHandler for DemoServer { + fn get_info(&self) -> ServerInfo { + ServerInfo::new( + ServerCapabilities::builder() + .enable_tools() + .enable_resources() + .build(), + ) + .with_server_info(Implementation::new("integration-test", "0.1.0")) + } + + async fn list_resources( + &self, + _req: Option, + _ctx: RequestContext, + ) -> Result { + Ok(ListResourcesResult { + resources: vec![ + RawResource::new("demo://readme", "Demo README".to_string()).no_annotation() + ], + next_cursor: None, + meta: None, + }) + } + + async fn read_resource( + &self, + req: ReadResourceRequestParams, + _ctx: RequestContext, + ) -> Result { + match req.uri.as_str() { + "demo://readme" => Ok(ReadResourceResult::new(vec![ResourceContents::text( + "Demo content.", + req.uri, + )])), + other => Err(ErrorData::resource_not_found( + "not_found", + Some(serde_json::json!({ "uri": other })), + )), + } + } +} + +#[derive(Clone, Default)] +struct DemoClient; +impl ClientHandler for DemoClient {} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|c| match &c.raw { + RawContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .unwrap_or_default() +} + +// ── Test ───────────────────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn test_local_rmcp() { + let (server_io, client_io) = tokio::io::duplex(65536); + + let server_handle = tokio::spawn(async move { + DemoServer::new() + .serve(server_io) + .await + .expect("serve") + .waiting() + .await + .expect("server error"); + }); + + let client = DemoClient.serve(client_io).await.expect("client init"); + + let tools = client.list_all_tools().await.expect("list tools"); + assert_eq!(tools.len(), 3); + + let add = + client + .call_tool(CallToolRequestParams::new("add").with_arguments( + serde_json::from_value(serde_json::json!({ "a": 7, "b": 5 })).unwrap(), + )) + .await + .expect("call add"); + assert!(first_text(&add).contains("12")); + + let resources = client.list_all_resources().await.expect("list resources"); + assert_eq!(resources.len(), 1); + + match client + .call_tool(CallToolRequestParams::new("no_such_tool")) + .await + { + Err(_) => {} + Ok(r) if r.is_error.unwrap_or(false) => {} + Ok(_) => panic!("expected unknown tool to fail"), + } + + client.cancel().await.expect("cancel"); + server_handle.abort(); +} diff --git a/tests/oversized_timeout_e2e.rs b/tests/oversized_timeout_e2e.rs new file mode 100644 index 0000000..108905e --- /dev/null +++ b/tests/oversized_timeout_e2e.rs @@ -0,0 +1,1046 @@ +//! CEP-22: rmcp-level oversized-transfer timeout e2e tests. +//! +//! Covers progress-aware idle/max-total timeout semantics, stripped-progress +//! forwarding with token-type restoration, watchdog reaping, and the +//! default-on roundtrip. +//! +//! Declared in `Cargo.toml` with `required-features = ["rmcp", "test-utils"]` +//! (same as `e2e_happy_path`) so plain `cargo test` skips it and stays green. + +use std::sync::Arc; +use std::time::Duration; + +use async_trait::async_trait; +use contextvm_sdk::core::constants::CTXVM_MESSAGES_KIND; +use contextvm_sdk::core::types::EncryptionMode; +use contextvm_sdk::relay::mock::MockRelayPool; +use contextvm_sdk::transport::base::BaseTransport; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::transport::oversized_transfer::{ + build_oversized_frames, OversizedFrame, OversizedSenderOptions, OversizedTransferConfig, +}; +use contextvm_sdk::transport::server::{NostrServerTransport, NostrServerTransportConfig}; +use contextvm_sdk::{ + progress_aware_options, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + PeerRequestOptionsExt, RelayPoolTrait, +}; +use nostr_sdk::prelude::*; +use rmcp::handler::server::wrapper::Parameters; +use rmcp::model::{ + CallToolRequestParams, CallToolResult, Content, ErrorData, Implementation, RawContent, + ServerCapabilities, +}; +use rmcp::service::ServiceError; +use rmcp::{schemars, tool, tool_handler, tool_router, ClientHandler, ServerHandler, ServiceExt}; + +// ── harness ────────────────────────────────────────────────────────────────── + +/// Let spawned event loops call `notifications()` before we publish anything. +async fn let_event_loops_start() { + tokio::time::sleep(Duration::from_millis(10)).await; +} + +/// A started client transport over `client_pool` with the given config — plus +/// its message receiver. +async fn start_client_with( + client_pool: MockRelayPool, + config: NostrClientTransportConfig, +) -> ( + NostrClientTransport, + tokio::sync::mpsc::UnboundedReceiver, +) { + let mut client = NostrClientTransport::with_relay_pool( + config, + Arc::new(client_pool) as Arc, + ) + .await + .expect("create client transport"); + let rx = client.take_message_receiver().expect("client rx"); + client.start().await.expect("client start"); + let_event_loops_start().await; + (client, rx) +} + +/// [`start_client_with`] for the common case: plaintext, default timeouts, +/// the given oversized config. +async fn start_client( + client_pool: MockRelayPool, + server_pubkey: &PublicKey, + oversized: OversizedTransferConfig, +) -> ( + NostrClientTransport, + tokio::sync::mpsc::UnboundedReceiver, +) { + start_client_with( + client_pool, + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + ) + .await +} + +/// A plaintext `BaseTransport` over the (greybox) server's pool, used to +/// hand-publish frames to the client. +fn greybox_server_base(server_pool: &Arc) -> BaseTransport { + BaseTransport { + relay_pool: Arc::clone(server_pool) as Arc, + encryption_mode: EncryptionMode::Disabled, + is_connected: true, + } +} + +/// Publish one frame as a plaintext kind-25910 event with the given tags. +async fn publish_frame( + base: &BaseTransport, + recipient: &PublicKey, + tags: &[Tag], + frame: JsonRpcNotification, +) { + base.send_mcp_message( + &JsonRpcMessage::Notification(frame), + recipient, + CTXVM_MESSAGES_KIND, + tags.to_vec(), + Some(false), + None, + ) + .await + .expect("publish frame"); +} + +/// Poll the shared store until an event matching `pred` lands; return its id. +async fn poll_for_event( + pool: &MockRelayPool, + what: &str, + pred: impl Fn(&Event) -> bool, +) -> EventId { + for _ in 0..200 { + if let Some(event) = pool + .stored_events() + .await + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) && pred(e)) + { + return event.id; + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("{what} never reached the relay store"); +} + +/// Count stored kind-25910 events carrying an oversized `cvm` frame. +async fn count_oversized_frames(pool: &MockRelayPool) -> usize { + pool.stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .filter(|e| { + serde_json::from_str::(&e.content) + .ok() + .is_some_and(|v| v.get("params").and_then(|p| p.get("cvm")).is_some()) + }) + .count() +} + +/// `true` when the event carries an oversized frame of the given `frameType`. +fn is_frame_of_type(event: &Event, frame_type: &str) -> bool { + serde_json::from_str::(&event.content) + .ok() + .and_then(|v| { + v.get("params") + .and_then(|p| p.get("cvm")) + .and_then(|c| c.get("frameType")) + .and_then(|f| f.as_str().map(|f| f == frame_type)) + }) + .unwrap_or(false) +} + +/// Receive the next client message within `ms`, panicking on timeout/close. +async fn recv_within( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ms: u64, + what: &str, +) -> JsonRpcMessage { + tokio::time::timeout(Duration::from_millis(ms), rx.recv()) + .await + .unwrap_or_else(|_| panic!("timed out waiting for {what}")) + .expect("client channel closed") +} + +/// Drain client messages for up to `ms`, returning the first *response* and +/// skipping stripped progress forwards. `None` when the window closes first. +async fn try_recv_response( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, + ms: u64, +) -> Option { + let deadline = tokio::time::Instant::now() + Duration::from_millis(ms); + loop { + match tokio::time::timeout_at(deadline, rx.recv()).await { + Err(_) => return None, + Ok(None) => panic!("client channel closed"), + Ok(Some(msg)) if msg.is_response() => return Some(msg), + Ok(Some(_)) => continue, + } + } +} + +/// Assert `msg` is a stripped progress forward: right method, token restored +/// to `expected_token`, the expected `progress` slot, and no `cvm` payload. +fn assert_stripped_forward( + msg: JsonRpcMessage, + expected_token: &serde_json::Value, + expected_progress: u64, +) { + let JsonRpcMessage::Notification(n) = msg else { + panic!("expected a stripped progress notification"); + }; + assert_eq!(n.method, "notifications/progress"); + let params = n.params.expect("forwarded progress has params"); + assert_eq!( + ¶ms["progressToken"], expected_token, + "token must be restored to the original JSON value, got {params}" + ); + assert_eq!(params["progress"], serde_json::json!(expected_progress)); + assert!( + params.get("cvm").is_none(), + "cvm payload must be stripped, got {params}" + ); +} + +// ── rmcp full-stack fixtures ───────────────────────────────────────────────── + +/// Wraps a pool so every publish lands `publish_delay` apart — deterministic +/// inter-frame gaps for an oversized response (à la `transport_integration`'s +/// `TestRelayPool::with_publish_delay`). +struct DelayedRelayPool { + inner: Arc, + publish_delay: Duration, +} + +#[async_trait] +impl RelayPoolTrait for DelayedRelayPool { + async fn connect(&self, relay_urls: &[String]) -> contextvm_sdk::Result<()> { + self.inner.connect(relay_urls).await + } + + async fn disconnect(&self) -> contextvm_sdk::Result<()> { + self.inner.disconnect().await + } + + async fn publish_event(&self, event: &Event) -> contextvm_sdk::Result { + tokio::time::sleep(self.publish_delay).await; + self.inner.publish_event(event).await + } + + async fn publish(&self, builder: EventBuilder) -> contextvm_sdk::Result { + tokio::time::sleep(self.publish_delay).await; + self.inner.publish(builder).await + } + + async fn sign(&self, builder: EventBuilder) -> contextvm_sdk::Result { + self.inner.sign(builder).await + } + + async fn signer(&self) -> contextvm_sdk::Result> { + self.inner.signer().await + } + + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + self.inner.notifications() + } + + async fn public_key(&self) -> contextvm_sdk::Result { + self.inner.public_key().await + } + + async fn subscribe(&self, filters: Vec) -> contextvm_sdk::Result<()> { + self.inner.subscribe(filters).await + } + + async fn publish_to( + &self, + urls: &[String], + builder: EventBuilder, + ) -> contextvm_sdk::Result { + self.inner.publish_to(urls, builder).await + } + + async fn fetch_events( + &self, + filters: Vec, + timeout: Duration, + ) -> contextvm_sdk::Result> { + self.inner.fetch_events(filters, timeout).await + } +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +struct BigParams { + len: usize, +} + +/// rmcp server with one tool: `big(len)` returns a `len`-byte text, driving +/// the response over the oversized threshold on demand. +#[derive(Clone)] +struct BigServer {} + +impl BigServer { + fn new() -> Self { + Self {} + } +} + +#[tool_router] +impl BigServer { + #[tool(description = "Return a text payload of `len` bytes")] + fn big( + &self, + Parameters(BigParams { len }): Parameters, + ) -> Result { + Ok(CallToolResult::success(vec![Content::text( + "B".repeat(len), + )])) + } +} + +#[tool_handler] +impl ServerHandler for BigServer { + fn get_info(&self) -> rmcp::model::ServerInfo { + rmcp::model::ServerInfo::new(ServerCapabilities::builder().enable_tools().build()) + .with_server_info(Implementation::new("oversized-e2e-server", "0.1.0")) + } +} + +#[derive(Clone, Default)] +struct DemoClient; +impl ClientHandler for DemoClient {} + +fn first_text(result: &CallToolResult) -> String { + result + .content + .iter() + .find_map(|c| match &c.raw { + RawContent::Text(t) => Some(t.text.clone()), + _ => None, + }) + .unwrap_or_default() +} + +fn call_params(name: &'static str, args: serde_json::Value) -> CallToolRequestParams { + let mut params = CallToolRequestParams::new(name); + if let Ok(v) = serde_json::from_value(args) { + params = params.with_arguments(v); + } + params +} + +/// Greybox: wait for the rmcp client's `tools/call` request event; return its +/// id (for response e-tagging) and its `_meta.progressToken` in wire-string +/// form (rmcp stamps JSON numbers; frames carry the stringified token). +async fn wait_for_rmcp_request(pool: &MockRelayPool) -> (EventId, String) { + for _ in 0..500 { + for event in pool.stored_events().await { + if event.kind != Kind::Custom(CTXVM_MESSAGES_KIND) { + continue; + } + let Ok(v) = serde_json::from_str::(&event.content) else { + continue; + }; + if v.get("method").and_then(|m| m.as_str()) != Some("tools/call") { + continue; + } + let token = match v + .get("params") + .and_then(|p| p.get("_meta")) + .and_then(|m| m.get("progressToken")) + { + Some(serde_json::Value::Number(n)) => n.to_string(), + Some(serde_json::Value::String(s)) => s.clone(), + _ => continue, + }; + return (event.id, token); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("rmcp tools/call request never reached the relay store"); +} + +/// Assert the requester published a `notifications/cancelled` carrying +/// `reason` (the fork's timeout-cancel publication). +async fn assert_cancelled_with_reason(pool: &MockRelayPool, reason: &str) { + for _ in 0..200 { + for event in pool.stored_events().await { + if event.kind != Kind::Custom(CTXVM_MESSAGES_KIND) { + continue; + } + let Ok(v) = serde_json::from_str::(&event.content) else { + continue; + }; + if v.get("method").and_then(|m| m.as_str()) == Some("notifications/cancelled") + && v.get("params") + .and_then(|p| p.get("reason")) + .and_then(|r| r.as_str()) + == Some(reason) + { + return; + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("no notifications/cancelled with reason {reason:?} observed"); +} + +/// Greybox harness for the stalled/trickle timeout tests: a stateless rmcp +/// client (initialize emulated locally, so the test-driven "server" only ever +/// sees `tools/call`), with the +/// `call_tool_with_options` future spawned and timed from issue to settle. +async fn spawn_greybox_call( + client_pool: MockRelayPool, + server_pubkey: &PublicKey, + idle: Duration, + max_total: Duration, +) -> tokio::task::JoinHandle<(Result, Duration)> { + let client_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_stateless(true) + .with_oversized_transfer(OversizedTransferConfig::enabled()), + Arc::new(client_pool) as Arc, + ) + .await + .expect("create client transport"); + + let client = DemoClient + .serve(client_transport) + .await + .expect("client init (stateless)"); + + tokio::spawn(async move { + let started = tokio::time::Instant::now(); + let result = client + .peer() + .call_tool_with_options( + call_params("big", serde_json::json!({ "len": 1 })), + progress_aware_options(idle, max_total), + ) + .await; + // Keep the running service alive until the call settles. + drop(client); + (result, started.elapsed()) + }) +} + +// ── harness smoke test ─────────────────────────────────────────────────────── + +/// Smoke test pinning the target's harness wiring: feature gates plus a linked +/// mock relay pair with distinct signing identities. +#[test] +fn harness_mock_relay_pair_is_linked() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + assert_ne!( + client_pool.mock_public_key(), + server_pool.mock_public_key(), + "paired mock pools must have distinct signing identities" + ); +} + +// ── stripped-progress forwarding ───────────────────────────────────────────── + +/// Inbound start/chunk frames of an oversized response are forwarded to the +/// local consumer as stripped progress notifications whose `progressToken` is +/// restored to the JSON **number** recorded at send time (the wire carries the +/// stringified token, as every real sender emits); the `end` frame yields only +/// the reassembled response. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_progress_token_restored() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let (client, mut client_rx) = start_client( + client_pool, + &server_pubkey, + OversizedTransferConfig::enabled(), + ) + .await; + + // Request with the JSON number 7 as its token — the rmcp shape. The + // transport records the original keyed by "7". Small request → single event. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("e4-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })), + }); + client.send(&request).await.expect("send request"); + + // Greybox server: pick up the request event for response correlation. + let request_event_id = poll_for_event(&server_pool, "request event", |e| { + serde_json::from_str::(&e.content) + .ok() + .is_some_and(|v| v.get("method").and_then(|m| m.as_str()) == Some("tools/call")) + }) + .await; + + // A 3-chunk response whose frames carry the realistic WIRE-STRING token + // "7" (`into_progress_notification` stringifies; TS does the same). + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("e4-1"), + result: serde_json::json!({ "blob": "Z".repeat(90) }), + }); + let serialized = serde_json::to_string(&response).unwrap(); + let frames = build_oversized_frames( + &serialized, + &OversizedSenderOptions::new("7").with_chunk_size(serialized.len().div_ceil(3)), + ) + .unwrap(); + assert_eq!(frames.chunks.len(), 3, "harness wants exactly 3 chunks"); + + let base = greybox_server_base(&server_pool); + let tags = BaseTransport::create_response_tags(&client_pubkey, &request_event_id); + for frame in frames.into_ordered() { + publish_frame(&base, &client_pubkey, &tags, frame).await; + } + + // Forward scope: start + each chunk (progress slots 1..=4, no handshake), + // each restored to Number(7) — a verbatim wire clone would surface + // String("7") and fail here. + for expected_progress in 1u64..=4 { + let msg = recv_within(&mut client_rx, 1000, "stripped progress forward").await; + assert_stripped_forward(msg, &serde_json::json!(7), expected_progress); + } + + // The end frame yields exactly the reassembled response… + let msg = recv_within(&mut client_rx, 1000, "reassembled response").await; + assert!(msg.is_response()); + assert_eq!(msg.id(), Some(&serde_json::json!("e4-1"))); + + let extra = tokio::time::timeout(Duration::from_millis(150), client_rx.recv()).await; + assert!( + extra.is_err(), + "no extra forward expected for the end frame" + ); +} + +/// The server's `accept` for a client upload forwards exactly one stripped +/// progress notification (re-arming the requester's idle timer for the +/// response-wait phase), with the original JSON-number token restored from the +/// recorded value, and the blocked oversized send completes. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn accept_frame_forwards_one_progress_reset() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Low threshold so a ~2 KB request fragments; server support is unknown + // (no discovery yet) so the send blocks on the accept handshake. + let (client, mut client_rx) = start_client( + client_pool, + &server_pubkey, + OversizedTransferConfig::enabled() + .with_threshold(600) + .with_chunk_size(600), + ) + .await; + let client = Arc::new(client); + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("e5-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": 8 }, + "blob": "Q".repeat(2000), + })), + }); + let send_task = { + let client = Arc::clone(&client); + tokio::spawn(async move { client.send(&request).await }) + }; + + // Greybox server: wait for the start frame, echo `accept` e-tagged to it. + // The wire token is the stringified echo — exactly what a real server + // emits (`into_progress_notification`). + let start_event_id = poll_for_event(&server_pool, "start frame", |e| { + is_frame_of_type(e, "start") + }) + .await; + let accept = OversizedFrame::Accept + .into_progress_notification("8", 2, None) + .expect("build accept frame"); + let tags = BaseTransport::create_response_tags(&client_pubkey, &start_event_id); + publish_frame( + &greybox_server_base(&server_pool), + &client_pubkey, + &tags, + accept, + ) + .await; + + // The blocked send unblocks and completes the upload. + send_task + .await + .expect("send task join") + .expect("oversized send completes after accept"); + + // Exactly one stripped forward surfaced for the accept (slot 2), token + // restored to the original JSON number. + let msg = recv_within(&mut client_rx, 1000, "accept progress forward").await; + assert_stripped_forward(msg, &serde_json::json!(8), 2); + + let extra = tokio::time::timeout(Duration::from_millis(150), client_rx.recv()).await; + assert!( + extra.is_err(), + "exactly one forward expected for the accept handshake" + ); +} + +// ── receiver-side watchdog ─────────────────────────────────────────────────── + +/// The client watchdog reaps a transfer stalled past its hard deadline; the +/// late remainder of the transfer is orphan-ignored (nothing delivered), and a +/// fresh transfer re-using the reaped token completes — proving the slot was +/// actually freed (duplicate-`start` would error if state lingered). +/// +/// Timing: 200 ms deadline, 1 s sweep tick (correlation TTL 2 s → clamp +/// floor), 3 s stall = 3 sweep ticks and 15× the deadline. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watchdog_reaps_stalled_inbound_transfer_client() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let (_client, mut client_rx) = start_client_with( + client_pool, + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_timeout(Duration::from_secs(2)) + .with_oversized_transfer( + OversizedTransferConfig::enabled().with_transfer_timeout_ms(200), + ), + ) + .await; + + // An unsolicited 3-chunk inbound message keyed by token "w6" (delivery + // does not require a pending request; frames only need the client p-tag). + let payload = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("w6-1"), + result: serde_json::json!({ "blob": "Y".repeat(90) }), + }); + let serialized = serde_json::to_string(&payload).unwrap(); + let build = || { + build_oversized_frames( + &serialized, + &OversizedSenderOptions::new("w6").with_chunk_size(serialized.len().div_ceil(3)), + ) + .expect("build frames") + }; + + let base = greybox_server_base(&server_pool); + let tags = BaseTransport::create_recipient_tags(&client_pubkey); + + // start + first chunk, then stall past the deadline and ≥ 3 sweep ticks. + let mut stalled = build().into_ordered(); + let rest = stalled.split_off(2); + for frame in stalled { + publish_frame(&base, &client_pubkey, &tags, frame).await; + } + tokio::time::sleep(Duration::from_secs(3)).await; + + // The late remainder hits a reaped slot: orphan-ignored, nothing surfaces. + for frame in rest { + publish_frame(&base, &client_pubkey, &tags, frame).await; + } + assert!( + try_recv_response(&mut client_rx, 500).await.is_none(), + "a reaped transfer must never deliver a message" + ); + + // The same token is re-admittable; a fresh full transfer completes. + for frame in build().into_ordered() { + publish_frame(&base, &client_pubkey, &tags, frame).await; + } + let delivered = try_recv_response(&mut client_rx, 1500) + .await + .expect("fresh same-token transfer must deliver after the reap"); + assert_eq!(delivered.id(), Some(&serde_json::json!("w6-1"))); +} + +/// Mirror of the client watchdog test against the server transport — its sweep +/// arm reaps the stalled per-peer transfer (and drops the now-empty receiver), +/// the late remainder is orphan-ignored, and a fresh same-token upload +/// reassembles into an `IncomingRequest` on the server receiver. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn watchdog_reaps_stalled_inbound_transfer_server() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // 200 ms hard deadline → 1 s sweep tick (clamp floor). + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer( + OversizedTransferConfig::enabled().with_transfer_timeout_ms(200), + ), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + let_event_loops_start().await; + + // Greybox client upload: handshake-layout frames (the server emits an + // accept to the unknown client; the test ignores it). + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("w7-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": "w7" }, + "blob": "Q".repeat(300), + })), + }); + let serialized = serde_json::to_string(&request).unwrap(); + let build = || { + build_oversized_frames( + &serialized, + &OversizedSenderOptions::new("w7") + .with_chunk_size(96) + .with_accept_handshake(true), + ) + .expect("build frames") + }; + + let base = BaseTransport { + relay_pool: Arc::new(client_pool) as Arc, + encryption_mode: EncryptionMode::Disabled, + is_connected: true, + }; + let tags = BaseTransport::create_recipient_tags(&server_pubkey); + + // start + first chunk, then stall past the deadline and ≥ 3 sweep ticks. + let mut stalled = build().into_ordered(); + let rest = stalled.split_off(2); + for frame in stalled { + publish_frame(&base, &server_pubkey, &tags, frame).await; + } + tokio::time::sleep(Duration::from_secs(3)).await; + + // Late remainder → reaped slot → orphan-ignored; no request surfaces. + for frame in rest { + publish_frame(&base, &server_pubkey, &tags, frame).await; + } + let nothing = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()).await; + assert!( + nothing.is_err(), + "a reaped transfer must never deliver a request" + ); + + // Fresh same-token upload completes (the swept-empty receiver was dropped + // and is recreated on admission). + for frame in build().into_ordered() { + publish_frame(&base, &server_pubkey, &tags, frame).await; + } + let incoming = tokio::time::timeout(Duration::from_millis(1500), server_rx.recv()) + .await + .expect("fresh same-token transfer must deliver after the reap") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/call")); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("w7-1"))); +} + +// ── progress-aware request timeouts ────────────────────────────────────────── + +/// Full-stack: a `call_tool_with_options` whose oversized response outlasts the +/// idle timeout several times over still succeeds, because every forwarded +/// chunk resets the idle timer. Without the forwarding seam this exact call +/// fails with `Timeout{400ms}`. +/// +/// Timing: 150 ms publish delay vs 400 ms idle (≥ 2.5×); ~240 KB payload = ≥ 5 +/// chunks at the 48 000 B default chunk size, pinned above the 65 535 B +/// single-event NIP-44 cap so an unfragmented path cannot pass. +/// +/// Runs on **paused virtual time** so the publish/idle relationship is exact +/// (each 150 ms publish always fires before the 400 ms idle timer) and the test +/// is deterministic regardless of host speed or CPU contention. The sibling +/// `oversized_trickle_trips_max_total_timeout` proves the same machinery under +/// paused time; this is the success-path counterpart. +#[tokio::test(start_paused = true)] +async fn oversized_response_progress_resets_idle_timeout() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // The server publishes through a 150 ms-delay pool: response frames land + // one delay apart, so the full transfer takes ≥ 5 × 150 ms > idle. + let delayed: Arc = Arc::new(DelayedRelayPool { + inner: Arc::clone(&server_pool), + publish_delay: Duration::from_millis(150), + }); + let server_transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(OversizedTransferConfig::enabled()), + delayed, + ) + .await + .expect("create server transport"); + + let client_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(OversizedTransferConfig::enabled()), + Arc::new(client_pool) as Arc, + ) + .await + .expect("create client transport"); + + let server_handle = tokio::spawn(async move { + let running = BigServer::new() + .serve(server_transport) + .await + .expect("server serve failed"); + let _ = running.waiting().await; + }); + tokio::time::sleep(Duration::from_millis(20)).await; + + let client = tokio::time::timeout(Duration::from_secs(10), DemoClient.serve(client_transport)) + .await + .expect("client startup timed out") + .expect("client init failed"); + + let result = client + .peer() + .call_tool_with_options( + call_params("big", serde_json::json!({ "len": 240_000 })), + progress_aware_options(Duration::from_millis(400), Duration::from_secs(10)), + ) + .await + .expect("oversized call must succeed via per-chunk idle resets"); + assert_eq!(first_text(&result).len(), 240_000); + + // Fragmentation actually happened (not a single-event fluke). + let frames = count_oversized_frames(&server_pool).await; + assert!(frames >= 5, "expected ≥5 oversized frames, got {frames}"); + + server_handle.abort(); +} + +/// Greybox, paused time: start + 2 chunks then silence — the idle timer, reset +/// by each forwarded frame, trips ~idle after the LAST chunk (not after request +/// issue), with `Timeout{timeout == idle}` and a published +/// `notifications/cancelled` carrying the idle reason. +#[tokio::test(start_paused = true)] +async fn oversized_stalled_transfer_trips_idle_timeout() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let idle = Duration::from_millis(400); + let call = spawn_greybox_call(client_pool, &server_pubkey, idle, Duration::from_secs(10)).await; + + let (request_event_id, wire_token) = wait_for_rmcp_request(&server_pool).await; + + // A 5-chunk response; publish start + 2 chunks 200 ms apart, then stall. + let payload = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(0), + result: serde_json::json!({ "blob": "S".repeat(200) }), + }); + let serialized = serde_json::to_string(&payload).unwrap(); + let frames = build_oversized_frames( + &serialized, + &OversizedSenderOptions::new(&wire_token).with_chunk_size(serialized.len().div_ceil(5)), + ) + .expect("build frames"); + let base = greybox_server_base(&server_pool); + let tags = BaseTransport::create_response_tags(&client_pubkey, &request_event_id); + for frame in frames.into_ordered().into_iter().take(3) { + publish_frame(&base, &client_pubkey, &tags, frame).await; + tokio::time::sleep(Duration::from_millis(200)).await; + } + + let (result, elapsed) = call.await.expect("join call task"); + match result { + Err(ServiceError::Timeout { timeout }) => assert_eq!(timeout, idle, "idle timer fired"), + other => panic!("expected idle Timeout, got {other:?}"), + } + // Two 200 ms-spaced resets pushed expiry well past issue+idle: the error + // lands ~idle after the LAST frame (≈ 0.6 s publish phase + 0.4 s idle). + assert!( + elapsed >= Duration::from_millis(700), + "resets must precede expiry; elapsed {elapsed:?}" + ); + assert_cancelled_with_reason(&server_pool, "request timeout").await; +} + +/// Greybox, paused time: chunks trickle every 150 ms — idle (400 ms) never +/// fires, but the max-total cap (1 s) does, despite continuous progress, with +/// `Timeout{timeout == max_total}` and the +/// max-total cancellation reason. +#[tokio::test(start_paused = true)] +async fn oversized_trickle_trips_max_total_timeout() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let idle = Duration::from_millis(400); + let max_total = Duration::from_secs(1); + let call = spawn_greybox_call(client_pool, &server_pubkey, idle, max_total).await; + + let (request_event_id, wire_token) = wait_for_rmcp_request(&server_pool).await; + + // ≥ 12 chunks available; publish start + chunks every 150 ms — the + // trickle outlasts max_total while every gap stays under idle. + let payload = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(0), + result: serde_json::json!({ "blob": "T".repeat(500) }), + }); + let serialized = serde_json::to_string(&payload).unwrap(); + let frames = build_oversized_frames( + &serialized, + &OversizedSenderOptions::new(&wire_token).with_chunk_size(40), + ) + .expect("build frames"); + let base = greybox_server_base(&server_pool); + let tags = BaseTransport::create_response_tags(&client_pubkey, &request_event_id); + for frame in frames.into_ordered().into_iter().take(10) { + publish_frame(&base, &client_pubkey, &tags, frame).await; + tokio::time::sleep(Duration::from_millis(150)).await; + } + + let (result, elapsed) = call.await.expect("join call task"); + match result { + Err(ServiceError::Timeout { timeout }) => { + assert_eq!(timeout, max_total, "max-total timer fired") + } + other => panic!("expected max-total Timeout, got {other:?}"), + } + // Fired at ~max_total despite continuous progress (publishes kept going + // past it; the call settled on its own cap). + assert!( + elapsed >= Duration::from_millis(900) && elapsed <= Duration::from_millis(1400), + "max-total should cap the call at ~1 s; elapsed {elapsed:?}" + ); + assert_cancelled_with_reason(&server_pool, "maximum total timeout exceeded").await; +} + +// ── default-on e2e roundtrip ───────────────────────────────────────────────── + +/// `true` when the (plaintext) event carries a tag whose name is `name`. +fn event_has_tag(event: &Event, name: &str) -> bool { + event + .tags + .iter() + .any(|t| t.clone().to_vec().first().map(String::as_str) == Some(name)) +} + +/// With **no oversized config at all** — both transports on +/// defaults — a > 65 535-byte tool result roundtrips through the full rmcp +/// stack. That size exceeds the single-event NIP-44 plaintext cap, so the +/// roundtrip succeeding proves the server fragmented, which it only does after +/// learning support from the client's default-advertised tag. Capability +/// discovery is asserted on the wire (both directions); the transport's +/// `discovered_server_capabilities()` accessor itself is consumed by +/// `serve()`, and its transport-level behavior is already pinned by +/// `oversized_response_roundtrip_server_to_client`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_default_on_e2e_roundtrip() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Default configs: oversized transfer untouched (default-on is the point). + // Plaintext mode so the stored events' discovery tags are inspectable. + let server_transport = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + let client_transport = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + Arc::new(client_pool) as Arc, + ) + .await + .expect("create client transport"); + + let server_handle = tokio::spawn(async move { + let running = BigServer::new() + .serve(server_transport) + .await + .expect("server serve failed"); + let _ = running.waiting().await; + }); + tokio::time::sleep(Duration::from_millis(20)).await; + + let client = tokio::time::timeout(Duration::from_secs(10), DemoClient.serve(client_transport)) + .await + .expect("client startup timed out") + .expect("client init failed"); + + // Plain `call_tool` — no helper, no options: the pure default experience. + // (External timeout only bounds the test; plain calls have none.) + let result = tokio::time::timeout( + Duration::from_secs(15), + client.call_tool(call_params("big", serde_json::json!({ "len": 80_000 }))), + ) + .await + .expect("default-on oversized roundtrip timed out") + .expect("call_tool failed"); + assert_eq!( + first_text(&result).len(), + 80_000, + "the >65 535-byte payload must roundtrip intact" + ); + + // Fragmentation actually happened (a single event cannot carry it). + let frames = count_oversized_frames(&server_pool).await; + assert!( + frames >= 3, + "expected at least start+chunk+end cvm frames, got {frames}" + ); + + // Both sides advertised by default: the client's first request and the + // server's first response each carried the discovery tag — the learning + // inputs for the response-fragmentation gate and the client capability + // snapshot respectively. + let events = server_pool.stored_events().await; + assert!( + events + .iter() + .any(|e| e.pubkey == client_pubkey && event_has_tag(e, "support_oversized_transfer")), + "client's first request must advertise support_oversized_transfer" + ); + assert!( + events + .iter() + .any(|e| e.pubkey == server_pubkey && event_has_tag(e, "support_oversized_transfer")), + "server's first response must advertise support_oversized_transfer" + ); + + server_handle.abort(); +} diff --git a/tests/transport_integration.rs b/tests/transport_integration.rs new file mode 100644 index 0000000..4868c0e --- /dev/null +++ b/tests/transport_integration.rs @@ -0,0 +1,4945 @@ +//! Integration tests — transport-level flows using MockRelayPool. +//! +//! Each test wires client and/or server transports to an in-memory mock relay +//! network so that the full event-loop logic (subscription, publish, routing, +//! encryption-mode enforcement, and authorization) is exercised without +//! connecting to real relays. + +use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, +}; +use std::time::Duration; + +use async_trait::async_trait; +use contextvm_sdk::core::constants::tags; +use contextvm_sdk::core::constants::{ + mcp_protocol_version, CTXVM_MESSAGES_KIND, EPHEMERAL_GIFT_WRAP_KIND, GIFT_WRAP_KIND, + MAX_MESSAGE_SIZE, PROMPTS_LIST_KIND, RESOURCES_LIST_KIND, RESOURCETEMPLATES_LIST_KIND, + SERVER_ANNOUNCEMENT_KIND, TOOLS_LIST_KIND, +}; +use contextvm_sdk::core::types::{EncryptionMode, GiftWrapMode}; +use contextvm_sdk::relay::mock::MockRelayPool; +use contextvm_sdk::transport::base::BaseTransport; +use contextvm_sdk::transport::client::{NostrClientTransport, NostrClientTransportConfig}; +use contextvm_sdk::transport::oversized_transfer::{ + build_oversized_frames, OversizedFrame, OversizedSenderOptions, OversizedTransferConfig, +}; +use contextvm_sdk::transport::server::{ + IncomingRequest, NostrServerTransport, NostrServerTransportConfig, +}; +use contextvm_sdk::{ + CapabilityExclusion, JsonRpcMessage, JsonRpcNotification, JsonRpcRequest, JsonRpcResponse, + RelayPoolTrait, ServerInfo, +}; +use nostr_sdk::prelude::*; + +fn as_pool(pool: MockRelayPool) -> Arc { + Arc::new(pool) +} + +struct TestRelayPool { + inner: Arc, + publish_delay: Duration, + failures_remaining: AtomicUsize, + publish_attempts: AtomicUsize, +} + +impl TestRelayPool { + fn with_publish_delay(inner: Arc, publish_delay: Duration) -> Self { + Self { + inner, + publish_delay, + failures_remaining: AtomicUsize::new(0), + publish_attempts: AtomicUsize::new(0), + } + } + + fn with_publish_failures(inner: Arc, failures: usize) -> Self { + Self { + inner, + publish_delay: Duration::ZERO, + failures_remaining: AtomicUsize::new(failures), + publish_attempts: AtomicUsize::new(0), + } + } + + fn publish_attempts(&self) -> usize { + self.publish_attempts.load(Ordering::SeqCst) + } +} + +#[async_trait] +impl RelayPoolTrait for TestRelayPool { + async fn connect(&self, relay_urls: &[String]) -> contextvm_sdk::Result<()> { + self.inner.connect(relay_urls).await + } + + async fn disconnect(&self) -> contextvm_sdk::Result<()> { + self.inner.disconnect().await + } + + async fn publish_event(&self, event: &Event) -> contextvm_sdk::Result { + if !self.publish_delay.is_zero() { + tokio::time::sleep(self.publish_delay).await; + } + self.publish_attempts.fetch_add(1, Ordering::SeqCst); + let should_fail = self + .failures_remaining + .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| { + remaining.checked_sub(1) + }) + .is_ok(); + + if should_fail { + return Err(contextvm_sdk::Error::Transport( + "injected publish failure".to_string(), + )); + } + + self.inner.publish_event(event).await + } + + async fn publish(&self, builder: EventBuilder) -> contextvm_sdk::Result { + if !self.publish_delay.is_zero() { + tokio::time::sleep(self.publish_delay).await; + } + self.inner.publish(builder).await + } + + async fn sign(&self, builder: EventBuilder) -> contextvm_sdk::Result { + self.inner.sign(builder).await + } + + async fn signer(&self) -> contextvm_sdk::Result> { + self.inner.signer().await + } + + fn notifications(&self) -> tokio::sync::broadcast::Receiver { + self.inner.notifications() + } + + async fn public_key(&self) -> contextvm_sdk::Result { + self.inner.public_key().await + } + + async fn subscribe(&self, filters: Vec) -> contextvm_sdk::Result<()> { + self.inner.subscribe(filters).await + } + + async fn publish_to( + &self, + urls: &[String], + builder: EventBuilder, + ) -> contextvm_sdk::Result { + self.inner.publish_to(urls, builder).await + } + + async fn fetch_events( + &self, + filters: Vec, + timeout: Duration, + ) -> contextvm_sdk::Result> { + self.inner.fetch_events(filters, timeout).await + } +} + +/// Let spawned event loops call `notifications()` before we publish anything. +/// Without this, broadcast messages can be lost on slow CI runners. +async fn let_event_loops_start() { + tokio::time::sleep(Duration::from_millis(10)).await; +} + +// ── 1. Full initialization handshake ──────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn full_initialization_handshake() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client sends initialize request. + let init_request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "0.0.0" } + })), + }); + client + .send(&init_request) + .await + .expect("client send initialize"); + + // Server should receive the initialize request. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive init request") + .expect("server channel closed"); + + assert_eq!( + incoming.message.method(), + Some("initialize"), + "server must receive initialize request" + ); + + // Server sends initialize response. + let init_response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { "name": "test-server", "version": "0.0.0" }, + "capabilities": {} + }), + }); + server + .send_response(&incoming.event_id, init_response) + .await + .expect("server send response"); + + // Client should receive the initialize response. + let response = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to receive init response") + .expect("client channel closed"); + + assert!(response.is_response(), "client must receive a response"); + assert_eq!(response.id(), Some(&serde_json::json!(1))); +} + +// ── 2. Server announcement publishing ─────────────────────────────────────── + +#[tokio::test] +async fn server_announcement_publishing() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_server_info(ServerInfo::default().with_name("Phase3-Test-Server".to_string())) + .with_announced_server(true), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server.announce().await.expect("server announce"); + + let events = pool.stored_events().await; + let announcement = events + .iter() + .find(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)); + + assert!( + announcement.is_some(), + "kind {} event must be published after announce()", + SERVER_ANNOUNCEMENT_KIND + ); + + let ann = announcement.unwrap(); + let content: serde_json::Value = + serde_json::from_str(&ann.content).expect("announcement content must be JSON"); + assert_eq!( + content["name"], "Phase3-Test-Server", + "announcement content must include server name" + ); +} + +// ── 3. Encryption mode Optional accepts plaintext ─────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encryption_mode_optional_accepts_plaintext() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Server uses Optional — should accept both encrypted and plaintext. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + // Client uses Disabled — sends plaintext kind 25910. + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("plain-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send plaintext request"); + + // Server must receive and process the plaintext message. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive plaintext request") + .expect("server channel closed"); + + assert_eq!( + incoming.message.method(), + Some("tools/list"), + "Optional-mode server must accept plaintext kind 25910" + ); + assert!( + !incoming.is_encrypted, + "plaintext request must not be marked as encrypted" + ); +} + +// ── 4. Auth allowlist blocks disallowed pubkey ────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auth_allowlist_blocks_disallowed_pubkey() { + let allowed_keys = Keys::generate(); // a DIFFERENT pubkey + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Server allows only `allowed_keys` — client_keys is NOT allowed. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_allowed_public_keys(vec![allowed_keys.public_key().to_hex()]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Send a non-initialize request (those are always allowed). + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // The server should NOT forward the request (pubkey is disallowed). + let result = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()).await; + assert!( + result.is_err(), + "disallowed pubkey request must not reach the server handler" + ); +} + +// ── 5. Encryption mode Required drops plaintext ───────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encryption_mode_required_drops_plaintext() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Server requires encryption — plaintext must be dropped. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Required), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + // Client sends plaintext (Disabled mode). + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("drop-me"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send plaintext request"); + + // Server must NOT receive the plaintext message. + let result = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()).await; + assert!( + result.is_err(), + "Required-mode server must drop plaintext kind 25910 events" + ); +} + +// ── 6. Encrypted gift-wrap roundtrip ──────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encrypted_gift_wrap_roundtrip() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Required), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Required), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client sends encrypted request. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("enc-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send encrypted request"); + + // Verify the published event is a gift-wrap (kind 1059). + let events = server_pool.stored_events().await; + assert!( + events + .iter() + .any(|e| e.kind == Kind::Custom(GIFT_WRAP_KIND)), + "client must publish a kind 1059 gift-wrap event" + ); + + // Server should decrypt and receive the request. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to decrypt gift-wrap request") + .expect("server channel closed"); + + assert_eq!(incoming.message.method(), Some("tools/list")); + assert!(incoming.is_encrypted, "message must be marked encrypted"); + + // Server sends an encrypted response back. + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("enc-1"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("server send encrypted response"); + + // Client should decrypt and receive the response. + let client_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to decrypt gift-wrap response") + .expect("client channel closed"); + + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("enc-1"))); +} + +// ── 7. Gift-wrap dedup skips duplicate delivery ───────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn gift_wrap_dedup_skips_duplicate_delivery() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Required), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Required), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client sends a gift-wrapped request. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("dedup-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // Server receives the first delivery. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for first delivery") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/list")); + assert!(incoming.is_encrypted); + + // Re-deliver the same gift-wrap event (simulates relay redelivery). + let events = server_pool.stored_events().await; + let gift_wrap = events + .iter() + .find(|e| e.kind == Kind::Custom(GIFT_WRAP_KIND)) + .expect("gift-wrap event must exist") + .clone(); + server_pool + .publish_event(&gift_wrap) + .await + .expect("re-inject duplicate"); + + // Server must NOT process the duplicate. + let result = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()).await; + assert!( + result.is_err(), + "duplicate gift-wrap (same outer event id) must be skipped" + ); +} + +// ── 8. Correlated notification has e tag ───────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn correlated_notification_has_e_tag() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client sends a tools/list request. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("notif-corr"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // Server receives the request and captures the event_id. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/list")); + let request_event_id = incoming.event_id.clone(); + + // Server sends a correlated notifications/progress notification. + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(serde_json::json!({ + "progressToken": "tok-1", + "progress": 50, + "total": 100 + })), + }); + server + .send_notification( + &incoming.client_pubkey, + ¬ification, + Some(&request_event_id), + ) + .await + .expect("send correlated notification"); + + // Client should receive the notification. + let client_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to receive notification") + .expect("client channel closed"); + + assert!(client_msg.is_notification()); + assert_eq!(client_msg.method(), Some("notifications/progress")); + + // The published notification event must carry an e tag referencing the request. + let events = server_pool.stored_events().await; + let notif_event = events + .iter() + .find(|e| e.pubkey == server_pubkey && e.content.contains("notifications/progress")) + .expect("notification event must be in stored events"); + + let e_tag = contextvm_sdk::core::serializers::get_tag_value(¬if_event.tags, "e"); + assert_eq!( + e_tag.as_deref(), + Some(request_event_id.as_str()), + "notification event must have e tag referencing the original request event id" + ); +} + +// ── 9. Encryption Required client, Optional server ────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encryption_required_client_optional_server() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Required), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("enc-opt-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send encrypted request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive encrypted request") + .expect("server channel closed"); + + assert_eq!( + incoming.message.method(), + Some("tools/list"), + "Optional-mode server must accept encrypted messages from Required-mode client" + ); + assert!( + incoming.is_encrypted, + "message from Required-mode client must be marked encrypted" + ); +} + +// ── 10. Encryption Optional both sides, encrypted path ────────────────────── +// Optional client defaults to encrypting (unwrap_or(true)), Optional server +// accepts encrypted messages. Tests the Optional/Optional negotiation path. + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encryption_optional_both_sides_encrypted_path() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Optional), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("opt-both-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive request") + .expect("server channel closed"); + + assert_eq!(incoming.message.method(), Some("tools/list")); + assert!( + incoming.is_encrypted, + "Optional client defaults to encrypting; Optional server must accept" + ); +} + +// ── 11. Announce includes encryption tags ──────────────────────────────────── + +#[tokio::test] +async fn announce_includes_encryption_tags() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Required) + .with_server_info(ServerInfo::default().with_name("Encrypted-Server".to_string())) + .with_announced_server(true), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server.announce().await.expect("server announce"); + + let events = pool.stored_events().await; + let announcement = events + .iter() + .find(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)) + .expect("kind 11316 event must be published"); + + // support_encryption is a valueless tag — check tag name directly. + let has_support_encryption = announcement + .tags + .iter() + .any(|t| t.clone().to_vec().first().map(|s| s.as_str()) == Some("support_encryption")); + let has_support_encryption_ephemeral = announcement.tags.iter().any(|t| { + t.clone().to_vec().first().map(|s| s.as_str()) == Some("support_encryption_ephemeral") + }); + + assert!( + has_support_encryption, + "announcement must include support_encryption tag" + ); + assert!( + has_support_encryption_ephemeral, + "announcement must include support_encryption_ephemeral tag" + ); +} + +// ── 12. Announce includes server metadata tags ────────────────────────────── + +#[tokio::test] +async fn announce_includes_server_metadata_tags() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_server_info( + ServerInfo::default() + .with_name("Meta-Server".to_string()) + .with_about("A test server".to_string()) + .with_website("https://example.com".to_string()) + .with_picture("https://example.com/pic.png".to_string()), + ) + .with_announced_server(true), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server.announce().await.expect("server announce"); + + let events = pool.stored_events().await; + let announcement = events + .iter() + .find(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)) + .expect("kind 11316 event must be published"); + + let name_tag = contextvm_sdk::core::serializers::get_tag_value(&announcement.tags, "name"); + let about_tag = contextvm_sdk::core::serializers::get_tag_value(&announcement.tags, "about"); + let website_tag = + contextvm_sdk::core::serializers::get_tag_value(&announcement.tags, "website"); + let picture_tag = + contextvm_sdk::core::serializers::get_tag_value(&announcement.tags, "picture"); + + assert_eq!( + name_tag.as_deref(), + Some("Meta-Server"), + "name tag must be present" + ); + assert_eq!( + about_tag.as_deref(), + Some("A test server"), + "about tag must be present" + ); + assert_eq!( + website_tag.as_deref(), + Some("https://example.com"), + "website tag must be present" + ); + assert_eq!( + picture_tag.as_deref(), + Some("https://example.com/pic.png"), + "picture tag must be present" + ); +} + +// ── 13. Publish tools produces correct kind ───────────────────────────────── + +#[tokio::test] +async fn publish_tools_produces_correct_kind() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_server_info(ServerInfo::default().with_name("Tools-Server".to_string())) + .with_announced_server(true), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server.announce().await.expect("server announce"); + + let tools = vec![serde_json::json!({ + "name": "get_weather", + "description": "Get the weather", + "inputSchema": { "type": "object" } + })]; + server.publish_tools(tools).await.expect("publish tools"); + + let events = pool.stored_events().await; + let tools_event = events + .iter() + .find(|e| e.kind == Kind::Custom(TOOLS_LIST_KIND)) + .expect("kind 11317 event must be published"); + + let content: serde_json::Value = + serde_json::from_str(&tools_event.content).expect("tools content must be JSON"); + assert!( + content.get("tools").is_some(), + "tools event content must contain 'tools' key" + ); + let tools_arr = content["tools"].as_array().expect("tools must be an array"); + assert_eq!(tools_arr.len(), 1); + assert_eq!(tools_arr[0]["name"], "get_weather"); +} + +// ── 14. Broadcast notification reaches initialized client ───────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn broadcast_notification_reaches_initialized_client() { + let (c1_pool, s_pool) = MockRelayPool::create_pair(); + let server_pk = s_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(s_pool), + ) + .await + .expect("create server transport"); + + let mut srv_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pk.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(c1_pool), + ) + .await + .expect("create client transport"); + let mut c_rx = client + .take_message_receiver() + .expect("client message receiver"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client sends initialize request. + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "c1", "version": "0.0.0" } + })), + }); + client + .send(&init_req) + .await + .expect("client send initialize"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), srv_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Server responds to initialize. + let init_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { "name": "test-server", "version": "0.0.0" }, + "capabilities": {} + }), + }); + server + .send_response(&incoming.event_id, init_resp) + .await + .expect("send init response"); + + // Client receives the init response. + let _ = tokio::time::timeout(Duration::from_millis(500), c_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Client sends notifications/initialized → session becomes initialized. + let init_notif = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/initialized".to_string(), + params: None, + }); + client + .send(&init_notif) + .await + .expect("send initialized notification"); + + // Drain srv_rx until we see notifications/initialized (skipping any + // echoed events from the shared mock relay broadcast channel). + loop { + let msg = tokio::time::timeout(Duration::from_millis(500), srv_rx.recv()) + .await + .expect("timeout waiting for notifications/initialized on server") + .expect("server channel closed"); + if msg.message.method() == Some("notifications/initialized") { + break; + } + } + + // Now broadcast — only the initialized client session should receive it. + let broadcast = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(serde_json::json!({ "progressToken": "bc-1", "progress": 1, "total": 1 })), + }); + server + .broadcast_notification(&broadcast) + .await + .expect("broadcast notification"); + + let msg = tokio::time::timeout(Duration::from_millis(500), c_rx.recv()) + .await + .expect("timeout waiting for client to receive broadcast") + .expect("client channel closed"); + + assert_eq!(msg.method(), Some("notifications/progress")); +} + +// ── 15. Uncorrelated notification passes through ──────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn uncorrelated_notification_passes_through() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("unc-init"), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "unc-test", "version": "0.0.0" } + })), + }); + client.send(&init_req).await.expect("send initialize"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + let init_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("unc-init"), + result: serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { "name": "test", "version": "0.0.0" }, + "capabilities": {} + }), + }); + server + .send_response(&incoming.event_id, init_resp) + .await + .expect("send init response"); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Uncorrelated notification (no e tag) must pass through to client. + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(serde_json::json!({ "progressToken": "unc-1", "progress": 50, "total": 100 })), + }); + server + .send_notification(&incoming.client_pubkey, ¬ification, None) + .await + .expect("send uncorrelated notification"); + + let client_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to receive notification") + .expect("client channel closed"); + + assert!(client_msg.is_notification()); + assert_eq!(client_msg.method(), Some("notifications/progress")); +} + +// ── 16. Correlated notification with unknown e tag is dropped ─────────────── +// NOTE: The Rust SDK drops ANY server event whose e-tag references an unknown +// pending request, including notifications. The TS SDK may forward such events. +// This test documents the Rust SDK's stricter correlation enforcement. + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn correlated_notification_unknown_e_tag_is_dropped() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let init_req = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-init"), + method: "initialize".to_string(), + params: Some(serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "capabilities": {}, + "clientInfo": { "name": "corr-test", "version": "0.0.0" } + })), + }); + client.send(&init_req).await.expect("send initialize"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + let init_resp = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("corr-init"), + result: serde_json::json!({ + "protocolVersion": mcp_protocol_version(), + "serverInfo": { "name": "test", "version": "0.0.0" }, + "capabilities": {} + }), + }); + server + .send_response(&incoming.event_id, init_resp) + .await + .expect("send init response"); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Notification with e tag referencing unknown event id must be dropped. + let fake_event_id = "a".repeat(64); + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: Some(serde_json::json!({ "progressToken": "fake", "progress": 1, "total": 1 })), + }); + server + .send_notification(&incoming.client_pubkey, ¬ification, Some(&fake_event_id)) + .await + .expect("send notification with unknown e tag"); + + let result = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()).await; + assert!( + result.is_err(), + "notification with unknown e tag must be dropped by client" + ); +} + +// ── 17. Auth: allowed pubkey receives response ────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn auth_allowed_pubkey_receives_response() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let client_pubkey = client_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_allowed_public_keys(vec![client_pubkey.to_hex()]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("auth-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // Server should receive it (pubkey is in the allowlist). + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive request") + .expect("server channel closed"); + + assert_eq!(incoming.message.method(), Some("tools/list")); + + // Server sends response back. + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("auth-1"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("send response"); + + // Client should receive the response. + let client_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to receive response") + .expect("client channel closed"); + + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("auth-1"))); +} + +// ── 18. Excluded capability bypasses auth ─────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn excluded_capability_bypasses_auth() { + let allowed_keys = Keys::generate(); // a DIFFERENT pubkey, NOT the client + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_allowed_public_keys(vec![allowed_keys.public_key().to_hex()]) + .with_excluded_capabilities(vec![CapabilityExclusion { + method: "tools/list".to_string(), + name: None, + }]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Client's pubkey is NOT in the allowlist, but "tools/list" is excluded from auth. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("excl-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // Server should receive it because the method is in excluded_capabilities. + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive excluded-capability request") + .expect("server channel closed"); + + assert_eq!( + incoming.message.method(), + Some("tools/list"), + "excluded capability must bypass auth allowlist" + ); +} + +// ── 19. Publish resources produces correct kind ───────────────────────────── + +#[tokio::test] +async fn publish_resources_produces_correct_kind() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + + let resources = vec![serde_json::json!({ + "uri": "file:///readme.md", + "name": "readme", + "mimeType": "text/markdown" + })]; + server + .publish_resources(resources) + .await + .expect("publish resources"); + + let events = pool.stored_events().await; + let event = events + .iter() + .find(|e| e.kind == Kind::Custom(RESOURCES_LIST_KIND)) + .expect("kind 11318 event must be published"); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON"); + let arr = content["resources"] + .as_array() + .expect("resources must be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "readme"); +} + +// ── 20. Publish prompts produces correct kind ─────────────────────────────── + +#[tokio::test] +async fn publish_prompts_produces_correct_kind() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + + let prompts = vec![serde_json::json!({ + "name": "summarize", + "description": "Summarize text" + })]; + server + .publish_prompts(prompts) + .await + .expect("publish prompts"); + + let events = pool.stored_events().await; + let event = events + .iter() + .find(|e| e.kind == Kind::Custom(PROMPTS_LIST_KIND)) + .expect("kind 11320 event must be published"); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON"); + let arr = content["prompts"] + .as_array() + .expect("prompts must be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "summarize"); +} + +// ── 21. Publish resource templates produces correct kind ──────────────────── + +#[tokio::test] +async fn publish_resource_templates_produces_correct_kind() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + + let templates = vec![serde_json::json!({ + "uriTemplate": "file:///{path}", + "name": "file", + "mimeType": "application/octet-stream" + })]; + server + .publish_resource_templates(templates) + .await + .expect("publish resource templates"); + + let events = pool.stored_events().await; + let event = events + .iter() + .find(|e| e.kind == Kind::Custom(RESOURCETEMPLATES_LIST_KIND)) + .expect("kind 11319 event must be published"); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON"); + let arr = content["resourceTemplates"] + .as_array() + .expect("resourceTemplates must be an array"); + assert_eq!(arr.len(), 1); + assert_eq!(arr[0]["name"], "file"); +} + +// ── 22. Publish tools with empty list ─────────────────────────────────────── + +#[tokio::test] +async fn publish_tools_empty_list() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server + .publish_tools(vec![]) + .await + .expect("publish empty tools"); + + let events = pool.stored_events().await; + let event = events + .iter() + .find(|e| e.kind == Kind::Custom(TOOLS_LIST_KIND)) + .expect("kind 11317 event must be published for empty list"); + + let content: serde_json::Value = + serde_json::from_str(&event.content).expect("content must be JSON"); + let arr = content["tools"].as_array().expect("tools must be an array"); + assert!(arr.is_empty(), "empty tools list must produce tools: []"); +} + +// ── 23. Delete announcements uses e tags referencing published events ───────── + +#[tokio::test] +async fn delete_announcements_uses_e_tags_for_published_events() { + let pool = Arc::new(MockRelayPool::new()); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_server_info(ServerInfo::default().with_name("KTag-Server".to_string())) + .with_announced_server(true), + Arc::clone(&pool) as Arc, + ) + .await + .expect("create server transport"); + + server.start().await.expect("server start"); + server.announce().await.expect("server announce"); + server + .delete_announcements("shutting down") + .await + .expect("delete announcements"); + + let events = pool.stored_events().await; + + // Find the kind 11316 announcement that was published by announce() + let announcement = events + .iter() + .find(|e| e.kind == Kind::Custom(SERVER_ANNOUNCEMENT_KIND)) + .expect("should have a kind 11316 announcement event"); + let announcement_id = announcement.id; + + // Only 1 deletion event expected: only kind 11316 was announced + let kind5_events: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(5)) + .collect(); + assert_eq!( + kind5_events.len(), + 1, + "only one kind was announced so only one deletion event expected" + ); + + let del = &kind5_events[0]; + + // Deletion uses ["e", event_id] tags (not ["k", kind]) + let tags: Vec> = del.tags.iter().map(|t| (*t).clone().to_vec()).collect(); + assert!(!tags.is_empty(), "deletion event should have tags"); + for tag in &tags { + assert_eq!(tag[0], "e", "deletion tag should be 'e', not 'k'"); + } + + // The e tag must reference the announced event's ID + let ann_id_hex = announcement_id.to_hex(); + assert!( + tags.iter() + .any(|t| t.get(1).map(|s| s.as_str()) == Some(ann_id_hex.as_str())), + "deletion should reference the published announcement event ID" + ); + + // Content is the reason string + assert_eq!(del.content, "shutting down"); +} + +// ── 24. Encryption Disabled server rejects gift-wrap ──────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn encryption_disabled_server_rejects_gift_wrap() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Server has encryption disabled — must reject gift-wrap events. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + // Client requires encryption — sends gift-wrap (kind 1059). + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Required), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("gw-reject"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send encrypted request"); + + let result = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()).await; + assert!( + result.is_err(), + "Disabled-mode server must drop gift-wrap events" + ); +} + +// ── 25. Response mirrors client encryption format ─────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn response_mirrors_client_encryption_format() { + // Part A: Disabled client → Optional server → response must be plaintext (kind 25910). + { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("mirror-plain"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send plaintext request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + assert!(!incoming.is_encrypted); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("mirror-plain"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("send plaintext response"); + + // Client receives the response. + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Verify response event is plaintext kind 25910, not gift-wrap. + let events = server_pool.stored_events().await; + let response_events: Vec<_> = events + .iter() + .filter(|e| e.pubkey == server_pubkey && e.content.contains("mirror-plain")) + .collect(); + assert!( + !response_events.is_empty(), + "server must publish a response event" + ); + assert!( + response_events + .iter() + .all(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)), + "response to plaintext client must be kind {} (plaintext)", + CTXVM_MESSAGES_KIND + ); + } + + // Part B: Required client → Optional server → response must be gift-wrap (kind 1059). + { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Optional), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Required), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("mirror-enc"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send encrypted request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + assert!(incoming.is_encrypted); + + // Snapshot gift-wrap count before server responds. + let gw_before = server_pool + .stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(GIFT_WRAP_KIND)) + .count(); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("mirror-enc"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("send encrypted response"); + + // Client receives the response. + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + // Verify server published exactly one new gift-wrap for the response. + let gw_after = server_pool + .stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(GIFT_WRAP_KIND)) + .count(); + assert_eq!( + gw_after, + gw_before + 1, + "server must publish one new gift-wrap (kind {}) as the response", + GIFT_WRAP_KIND + ); + } +} + +// ── 26. send_response is one-shot under concurrency ──────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_response_is_one_shot_under_concurrency() { + let (client_pool, server_pool_raw) = MockRelayPool::create_pair(); + let server_pubkey = server_pool_raw.mock_public_key(); + let server_pool = Arc::new(server_pool_raw); + + // Delay publish so both concurrent responders have a chance to race. + // Correct behavior is still one-shot: exactly one send_response succeeds. + let delayed_server_pool: Arc = Arc::new(TestRelayPool::with_publish_delay( + Arc::clone(&server_pool), + Duration::from_millis(25), + )); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + delayed_server_pool, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("one-shot-req"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server to receive request") + .expect("server channel closed"); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("placeholder"), + result: serde_json::json!({ "one_shot": "ok" }), + }); + + let event_id = incoming.event_id.clone(); + let f1 = server.send_response(&event_id, response.clone()); + let f2 = server.send_response(&event_id, response); + let (r1, r2) = tokio::join!(f1, f2); + + assert_ne!( + r1.is_ok(), + r2.is_ok(), + "exactly one concurrent send_response call must succeed" + ); + + let msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for client to receive response") + .expect("client channel closed"); + assert!(msg.is_response(), "client must receive one response"); + assert_eq!( + msg.id(), + Some(&serde_json::json!("one-shot-req")), + "server must restore original request id in response" + ); + + let second = tokio::time::timeout(Duration::from_millis(200), client_rx.recv()).await; + assert!( + second.is_err(), + "client must not receive duplicate response" + ); + + let events = server_pool.stored_events().await; + let response_events = events + .iter() + .filter(|e| e.pubkey == server_pubkey && e.content.contains("\"one_shot\":\"ok\"")) + .count(); + assert_eq!( + response_events, 1, + "only one response event must be published" + ); +} + +// ── 27. send_response publish failure allows retry ───────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn send_response_publish_failure_allows_one_successful_retry() { + let (client_pool, server_pool_raw) = MockRelayPool::create_pair(); + let server_pubkey = server_pool_raw.mock_public_key(); + let server_pool = Arc::new(server_pool_raw); + let failing_server_pool = Arc::new(TestRelayPool::with_publish_failures( + Arc::clone(&server_pool), + 1, + )); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&failing_server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("retry-once"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout waiting for server request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/list")); + + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("placeholder"), + result: serde_json::json!({ "tools": [] }), + }); + + let stored_before_failure = server_pool.stored_events().await.len(); + server + .send_response(&incoming.event_id, response.clone()) + .await + .expect_err("first response publish must fail"); + + assert_eq!( + failing_server_pool.publish_attempts(), + 1, + "failed response should attempt exactly one publish" + ); + assert_eq!( + server_pool.stored_events().await.len(), + stored_before_failure, + "failed publish must not store a response event" + ); + + server + .send_response(&incoming.event_id, response.clone()) + .await + .expect("retry must still find the route and publish"); + + let client_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for retried response") + .expect("client channel closed"); + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("retry-once"))); + assert_eq!( + failing_server_pool.publish_attempts(), + 2, + "retry should perform the second and final publish" + ); + assert_eq!( + server_pool.stored_events().await.len(), + stored_before_failure + 1, + "successful retry must publish exactly one response event" + ); + + server + .send_response(&incoming.event_id, response) + .await + .expect_err("route must be consumed after the successful retry"); + assert_eq!( + failing_server_pool.publish_attempts(), + 2, + "consumed route should fail before another publish attempt" + ); + assert_eq!( + server_pool.stored_events().await.len(), + stored_before_failure + 1, + "post-success retry must not publish another response" + ); + + let second_delivery = tokio::time::timeout(Duration::from_millis(50), client_rx.recv()).await; + assert!( + second_delivery.is_err(), + "client must receive the retried response exactly once" + ); +} + +// ── 28. Announced server sends unauthorized error response ─────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn announced_server_sends_unauthorized_error_response() { + let allowed_keys = Keys::generate(); // a DIFFERENT pubkey — client is NOT in the allowlist + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Announced server with an allowlist that does NOT include the client. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_announced_server(true) + .with_allowed_public_keys(vec![allowed_keys.public_key().to_hex()]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Send a non-initialize request from the unauthorized client. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(42), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // The server handler must NOT receive the request (it's unauthorized). + let server_forward = tokio::time::timeout(Duration::from_millis(300), server_rx.recv()).await; + assert!( + server_forward.is_err(), + "unauthorized request must not reach the server handler" + ); + + // The client MUST receive a -32000 Unauthorized error response. + let error_msg = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .expect("timeout waiting for unauthorized error response") + .expect("client channel closed"); + + match error_msg { + JsonRpcMessage::ErrorResponse(err) => { + assert_eq!(err.error.code, -32000, "error code must be -32000"); + assert_eq!( + err.error.message, "Unauthorized", + "error message must be 'Unauthorized'" + ); + } + other => panic!( + "expected ErrorResponse, got: {:?}", + std::mem::discriminant(&other) + ), + } +} + +// ── 29. Private server silently drops unauthorized request ─────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn private_server_silently_drops_unauthorized_request() { + let allowed_keys = Keys::generate(); + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + // Private server (is_announced_server defaults to false). + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_allowed_public_keys(vec![allowed_keys.public_key().to_hex()]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(99), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request).await.expect("send request"); + + // Server handler must not receive it. + let server_forward = tokio::time::timeout(Duration::from_millis(300), server_rx.recv()).await; + assert!( + server_forward.is_err(), + "unauthorized request must not reach the server handler" + ); + + // Client must NOT receive any error response (private server silently drops). + let client_response = tokio::time::timeout(Duration::from_millis(300), client_rx.recv()).await; + assert!( + client_response.is_err(), + "private server must silently drop unauthorized requests without sending an error" + ); +} + +// ── 30. Announced server does not error on unauthorized notification ───────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn announced_server_does_not_error_on_unauthorized_notification() { + let allowed_keys = Keys::generate(); + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_announced_server(true) + .with_allowed_public_keys(vec![allowed_keys.public_key().to_hex()]), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + server.start().await.expect("server start"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut client_rx = client + .take_message_receiver() + .expect("client message receiver"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Send a notification (not a request) from the unauthorized client. + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: None, + }); + client.send(¬ification).await.expect("send notification"); + + // Server handler must not receive the notification. + let server_forward = tokio::time::timeout(Duration::from_millis(300), server_rx.recv()).await; + assert!( + server_forward.is_err(), + "unauthorized notification must not reach the server handler" + ); + + // Client must NOT receive an error (notifications never get error replies). + let client_response = tokio::time::timeout(Duration::from_millis(300), client_rx.recv()).await; + assert!( + client_response.is_err(), + "announced server must not send error response for unauthorized notifications" + ); +} + +// ── 31. First response includes discovery tags (upstream CEP-19) ───────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn first_response_includes_discovery_tags() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let s_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_server_info(ServerInfo::default().with_name("Disco-Server".to_string())) + .with_announced_server(true), + Arc::clone(&s_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Send first request (triggers first response with common tags) + let request1 = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request1).await.expect("send request 1"); + + let incoming1 = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + let response1 = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-1"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming1.event_id, response1) + .await + .expect("send response 1"); + + // Send second request (should NOT include common tags) + let request2 = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-2"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request2).await.expect("send request 2"); + + let incoming2 = loop { + let msg = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + if msg.message.is_request() && msg.message.id() == Some(&serde_json::json!("req-2")) { + break msg; + } + }; + + let response2 = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-2"), + result: serde_json::json!({ "tools": [] }), + }); + server + .send_response(&incoming2.event_id, response2) + .await + .expect("send response 2"); + + let events = s_pool.stored_events().await; + let responses: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .cloned() + .collect(); + + let resp1 = responses + .iter() + .find(|e| e.content.contains("req-1") && e.content.contains("result")) + .expect("resp1 missing"); + let resp2 = responses + .iter() + .find(|e| e.content.contains("req-2") && e.content.contains("result")) + .expect("resp2 missing"); + + let name1 = contextvm_sdk::core::serializers::get_tag_value(&resp1.tags, "name"); + let enc1 = resp1 + .tags + .iter() + .any(|t| t.clone().to_vec().first().map(|s| s.as_str()) == Some("support_encryption")); + + let name2 = contextvm_sdk::core::serializers::get_tag_value(&resp2.tags, "name"); + let enc2 = resp2 + .tags + .iter() + .any(|t| t.clone().to_vec().first().map(|s| s.as_str()) == Some("support_encryption")); + + assert_eq!(name1.as_deref(), Some("Disco-Server")); + assert!(enc1); + + assert_eq!(name2, None); + assert!(!enc2); +} + +// ── 32. Notification mirror selection wrt CEP 19 ────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn notification_mirror_selection_wrt_cep_19() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let s_pool = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::clone(&s_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Ephemeral), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request1 = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("req-1"), + method: "tools/list".to_string(), + params: None, + }); + client.send(&request1).await.expect("send request 1"); + + let incoming1 = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout") + .expect("channel closed"); + + let notification = JsonRpcMessage::Notification(JsonRpcNotification { + jsonrpc: "2.0".to_string(), + method: "notifications/progress".to_string(), + params: None, + }); + server + .send_notification( + &incoming1.client_pubkey, + ¬ification, + Some(&incoming1.event_id), + ) + .await + .expect("send notification"); + + let events = s_pool.stored_events().await; + let ephemeral_wraps: Vec<_> = events + .iter() + .filter(|e| e.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND)) + .cloned() + .collect(); + + assert!( + ephemeral_wraps.len() >= 2, + "Expected ephemeral wraps for both request and notification" + ); +} + +// ── CEP-35: Server-side discovery tag emission & capability learning ───────── + +fn event_tag_vecs(event: &Event) -> Vec> { + event.tags.iter().map(|t| t.clone().to_vec()).collect() +} + +fn has_tag_name(event: &Event, name: &str) -> bool { + event_tag_vecs(event) + .iter() + .any(|v| v.first().map(|s| s.as_str()) == Some(name)) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_response_includes_encryption_tags_when_enabled() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool_arc = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::clone(&server_pool_arc) as Arc, + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let events = server_pool_arc.stored_events().await; + let response_event = events + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) && has_tag_name(e, "e")) + .expect("response event must exist"); + + assert!( + has_tag_name(response_event, tags::SUPPORT_ENCRYPTION), + "first response must include support_encryption when mode != Disabled" + ); + assert!( + has_tag_name(response_event, tags::SUPPORT_ENCRYPTION_EPHEMERAL), + "first response must include support_encryption_ephemeral when GiftWrapMode != Persistent" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_response_excludes_ephemeral_tag_when_persistent() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool_arc = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Persistent), + Arc::clone(&server_pool_arc) as Arc, + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let events = server_pool_arc.stored_events().await; + let response_event = events + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) && has_tag_name(e, "e")) + .unwrap(); + + assert!( + has_tag_name(response_event, tags::SUPPORT_ENCRYPTION), + "support_encryption must be present" + ); + assert!( + !has_tag_name(response_event, tags::SUPPORT_ENCRYPTION_EPHEMERAL), + "support_encryption_ephemeral must NOT be present when GiftWrapMode is Persistent" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_learns_capabilities_from_client_request() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + assert_eq!(incoming.message.method(), Some("initialize")); + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: None, + })) + .await + .unwrap(); + let incoming2 = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + assert_eq!(incoming2.message.method(), Some("tools/list")); + assert_eq!(incoming.client_pubkey, incoming2.client_pubkey); +} + +/// CEP-22: with oversized transfer enabled on the server, the server learns the +/// client's advertised `support_oversized_transfer` flag as a request flows +/// through the real `event_loop` gate. Exercises the production gate end-to-end +/// (not the inline truth-table unit test). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_allows_oversized_when_enabled() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + // The session-state update precedes the IncomingRequest dispatch, so the + // learned flag is committed by the time we receive the request. + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + snap.supports_oversized_transfer, + "server with oversized enabled must learn the client's advertised support" + ); +} + +/// CEP-22: with oversized transfer disabled on the server, the gate drops the +/// client's advertised `support_oversized_transfer` flag — the session never +/// records support even though the client advertised it. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_blocks_oversized_when_disabled() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(false), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(true), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + !snap.supports_oversized_transfer, + "server with oversized disabled must not learn the client's advertised support" + ); +} + +/// CEP-22: even with oversized transfer enabled, the server only learns support +/// the client actually advertised. A client that does not advertise the tag +/// leaves the session flag false. Pins the `&& discovered.supports_oversized_transfer` +/// operand of the gate — a regression dropping it would learn `true` regardless. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_gate_blocks_oversized_when_client_does_not_advertise() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Server is oversized-capable with a low threshold; the only thing that should + // keep the (large) response un-fragmented is the client not advertising support. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(OversizedTransferConfig::enabled().with_threshold(2000)), + Arc::clone(&server_pool) as Arc, + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_enabled(false), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + // Request carries a progressToken so the server's only remaining fragmentation + // blocker is the missing client capability (not a missing token). + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": "tok-block" } })), + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + let snap = server + .session_snapshot(&incoming.client_pubkey) + .await + .expect("server must have a session for the client"); + assert!( + !snap.supports_oversized_transfer, + "server must not learn oversized support the client never advertised" + ); + + // Server replies with a payload well over the threshold. Because the client + // never advertised support, the gate must block fragmentation: the response is + // a single event, not start/chunk/end frames. + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({ "blob": "Z".repeat(8000) }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("server send response"); + + let server_response_frames = server_pool + .stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) && e.pubkey == server_pubkey) + .count(); + assert_eq!( + server_response_frames, 1, + "gate off (client did not advertise) must send the response as exactly one event" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_disabled_encryption_omits_encryption_tags() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool_arc = Arc::new(server_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_server_info(ServerInfo::default().with_name("NoEncrypt".to_string())), + Arc::clone(&server_pool_arc) as Arc, + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let events = server_pool_arc.stored_events().await; + let response_event = events + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND) && has_tag_name(e, "e")) + .unwrap(); + + assert!(has_tag_name(response_event, tags::NAME)); + assert!( + !has_tag_name(response_event, tags::SUPPORT_ENCRYPTION), + "encryption tags must be omitted when EncryptionMode is Disabled" + ); + assert!(!has_tag_name( + response_event, + tags::SUPPORT_ENCRYPTION_EPHEMERAL + )); +} + +// ── CEP-35: Client-side discovery tag emission & capability learning ───────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_disabled_encryption_emits_no_discovery_tags() { + // Disabled encryption: client must not emit cap tags. Positive case (Optional + // mode emits tags) is covered by unit test client_capability_tags_encryption_optional. + let pool = Arc::new(MockRelayPool::new()); + let server_keys = Keys::generate(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_keys.public_key().to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::clone(&pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + // With Disabled encryption, no cap tags are emitted (correct per spec). + // Verify the event is published with p tag but without cap tags. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = pool.stored_events().await; + let client_event = events + .iter() + .find(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .expect("client must publish a request event"); + + // p tag must be present (routing) + assert!(has_tag_name(client_event, "p")); + // No encryption tags when Disabled (the unit test covers the Optional case) + assert!( + !has_tag_name(client_event, tags::SUPPORT_ENCRYPTION), + "Disabled client must not emit support_encryption" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_second_request_carries_no_discovery_tags() { + // Second request must never carry discovery tags. One-shot flag behavior + // is covered by unit test client_discovery_tags_sent_once. + let pool = Arc::new(MockRelayPool::new()); + let server_keys = Keys::generate(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_keys.public_key().to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::clone(&pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + // First request + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + // Second request + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = pool.stored_events().await; + let ctxvm_events: Vec<&Event> = events + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .collect(); + assert!(ctxvm_events.len() >= 2); + + let second_event = ctxvm_events + .iter() + .find(|e| e.content.contains("tools/list")) + .expect("second request event must exist"); + + assert!( + !has_tag_name(second_event, tags::SUPPORT_ENCRYPTION), + "second request must NOT include discovery tags" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_learns_server_capabilities_from_first_response() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_server_info(ServerInfo::default().with_name("CapServer".to_string())), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + // Client should have learned capabilities from server's first response + let caps = client.discovered_server_capabilities(); + assert!( + caps.supports_encryption, + "client must learn support_encryption from server response tags" + ); + assert!( + caps.supports_ephemeral_encryption, + "client must learn support_encryption_ephemeral from server response tags" + ); + + let baseline = client.get_server_initialize_event(); + assert!(baseline.is_some(), "baseline event must be set"); +} + +// ── CEP-35: OR-assign, baseline-freeze, and Optional emission ──────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_or_assigns_capabilities_across_responses() { + // Server with Persistent gift-wrap emits support_encryption but NOT + // support_encryption_ephemeral on the first response. A second event + // carrying support_encryption_ephemeral must OR-assign into the client's + // learned caps without downgrading the already-learned support_encryption. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Persistent) + .with_server_info(ServerInfo::default().with_name("PersistentServer".to_string())), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + // First roundtrip — server responds with support_encryption only. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let caps_after_first = client.discovered_server_capabilities(); + assert!( + caps_after_first.supports_encryption, + "first response must teach support_encryption" + ); + assert!( + !caps_after_first.supports_ephemeral_encryption, + "Persistent server must NOT advertise ephemeral on first response" + ); + + // Inject a second plaintext event signed by the server, carrying + // support_encryption_ephemeral (simulates a capability upgrade). + let client_pubkey = client_pool.mock_public_key(); + let second_response = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/progress" + }); + let inject_event = EventBuilder::new( + Kind::Custom(CTXVM_MESSAGES_KIND), + second_response.to_string(), + ) + .tags(vec![ + Tag::public_key(client_pubkey), + Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION_EPHEMERAL.into()), + Vec::::new(), + ), + ]) + .sign_with_keys(&server_keys) + .unwrap(); + + client_pool.publish_event(&inject_event).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let caps_after_second = client.discovered_server_capabilities(); + assert!( + caps_after_second.supports_encryption, + "support_encryption must survive OR-assign (not downgraded)" + ); + assert!( + caps_after_second.supports_ephemeral_encryption, + "support_encryption_ephemeral must be OR-assigned from second event" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_baseline_event_not_replaced_by_later_responses() { + // The first inbound event carrying discovery tags becomes the baseline. + // Later events with different tags must NOT replace it. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional) + .with_server_info(ServerInfo::default().with_name("BaselineServer".to_string())), + as_pool(server_pool), + ) + .await + .unwrap(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + let mut server_rx = server.take_message_receiver().unwrap(); + let mut client_rx = client.take_message_receiver().unwrap(); + server.start().await.unwrap(); + client.start().await.unwrap(); + let_event_loops_start().await; + + // First roundtrip — establishes baseline. + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let incoming = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .unwrap() + .unwrap(); + + server + .send_response( + &incoming.event_id, + JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + result: serde_json::json!({}), + }), + ) + .await + .unwrap(); + + let _ = tokio::time::timeout(Duration::from_millis(500), client_rx.recv()) + .await + .unwrap(); + + let baseline = client.get_server_initialize_event(); + assert!( + baseline.is_some(), + "baseline must be set after first response" + ); + let baseline_id = baseline.unwrap().id; + + // Inject a second event with different discovery tags. + let client_pubkey = client_pool.mock_public_key(); + let notification = serde_json::json!({ + "jsonrpc": "2.0", + "method": "notifications/progress" + }); + let inject_event = + EventBuilder::new(Kind::Custom(CTXVM_MESSAGES_KIND), notification.to_string()) + .tags(vec![ + Tag::public_key(client_pubkey), + Tag::custom( + TagKind::Custom(tags::SUPPORT_ENCRYPTION.into()), + Vec::::new(), + ), + ]) + .sign_with_keys(&server_keys) + .unwrap(); + + client_pool.publish_event(&inject_event).await.unwrap(); + tokio::time::sleep(Duration::from_millis(50)).await; + + let baseline_after = client.get_server_initialize_event(); + assert_eq!( + baseline_after.unwrap().id, + baseline_id, + "baseline event must NOT be replaced by later events" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_optional_encryption_emits_discovery_tags() { + // Client with Optional encryption must include discovery tags in the + // inner signed event. We decrypt the published gift wrap to verify. + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_keys = server_pool.mock_keys(); + + let client_pool = Arc::new(client_pool); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Optional) + .with_gift_wrap_mode(GiftWrapMode::Optional), + Arc::clone(&client_pool) as Arc, + ) + .await + .unwrap(); + + client.start().await.unwrap(); + let_event_loops_start().await; + + client + .send(&JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "initialize".to_string(), + params: None, + })) + .await + .unwrap(); + + let events = client_pool.stored_events().await; + let gift_wrap = events + .iter() + .find(|e| { + e.kind == Kind::Custom(GIFT_WRAP_KIND) + || e.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND) + }) + .expect("Optional encryption must produce a gift-wrapped event"); + + // Decrypt using the server's keys (the recipient). + let signer: Arc = Arc::new(server_keys); + let decrypted_json = + contextvm_sdk::encryption::decrypt_gift_wrap_single_layer(&signer, gift_wrap) + .await + .expect("gift wrap must be decryptable with server keys"); + + let inner: Event = + serde_json::from_str(&decrypted_json).expect("decrypted content must be a valid Event"); + + assert!( + has_tag_name(&inner, tags::SUPPORT_ENCRYPTION), + "inner event must carry support_encryption tag" + ); + assert!( + has_tag_name(&inner, tags::SUPPORT_ENCRYPTION_EPHEMERAL), + "inner event must carry support_encryption_ephemeral tag (Optional gift-wrap mode)" + ); +} +// ── Multi-client support ───────────────────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn multi_client_concurrent_requests_both_get_responses() { + // Two different clients send requests to the same server; both must get + // their own response (the single-peer barrier is removed). + let mut pools = MockRelayPool::create_linked_group(3); + let server_pool = pools.remove(0); + let client_b_pool = pools.remove(1); + let client_a_pool = pools.remove(0); + let server_pubkey = server_pool.mock_public_key(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut client_a = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_a_pool), + ) + .await + .expect("create client A"); + + let mut client_b = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_b_pool), + ) + .await + .expect("create client B"); + + let mut server_rx = server + .take_message_receiver() + .expect("server message receiver"); + let mut client_a_rx = client_a + .take_message_receiver() + .expect("client A message receiver"); + let mut client_b_rx = client_b + .take_message_receiver() + .expect("client B message receiver"); + + server.start().await.expect("server start"); + client_a.start().await.expect("client A start"); + client_b.start().await.expect("client B start"); + let_event_loops_start().await; + + // Client A sends a request. + let req_a = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(1), + method: "tools/list".to_string(), + params: None, + }); + client_a.send(&req_a).await.expect("client A send"); + + // Client B sends a request. + let req_b = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(2), + method: "tools/list".to_string(), + params: None, + }); + client_b.send(&req_b).await.expect("client B send"); + + // Server receives both requests (order may vary). + let incoming_1 = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout rx 1") + .expect("rx closed 1"); + let incoming_2 = tokio::time::timeout(Duration::from_millis(500), server_rx.recv()) + .await + .expect("timeout rx 2") + .expect("rx closed 2"); + + // Send responses to both. + let resp_1 = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: incoming_1.message.id().unwrap().clone(), + result: serde_json::json!({"tools": []}), + }); + server + .send_response(&incoming_1.event_id, resp_1) + .await + .expect("server respond to 1"); + + let resp_2 = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: incoming_2.message.id().unwrap().clone(), + result: serde_json::json!({"tools": []}), + }); + server + .send_response(&incoming_2.event_id, resp_2) + .await + .expect("server respond to 2"); + + // Both clients must receive their respective response. + let resp_a = tokio::time::timeout(Duration::from_millis(500), client_a_rx.recv()) + .await + .expect("timeout client A response") + .expect("client A channel closed"); + let resp_b = tokio::time::timeout(Duration::from_millis(500), client_b_rx.recv()) + .await + .expect("timeout client B response") + .expect("client B channel closed"); + + assert!( + matches!(resp_a, JsonRpcMessage::Response(_)), + "client A must receive a response" + ); + assert!( + matches!(resp_b, JsonRpcMessage::Response(_)), + "client B must receive a response" + ); +} + +// ── Session store LRU tests ───────────────────────────────────────────────── + +use contextvm_sdk::transport::server::SessionStore; +use contextvm_sdk::ServerEventRouteStore; + +#[tokio::test] +async fn session_store_lru_eviction() { + let store = SessionStore::with_capacity(3); + let r = ServerEventRouteStore::new(); + store.get_or_create_session("a", false, &r).await; + store.get_or_create_session("b", false, &r).await; + store.get_or_create_session("c", false, &r).await; + + // 4th session evicts the oldest ("a") + store.get_or_create_session("d", false, &r).await; + + assert!( + store.get_session("a").await.is_none(), + "oldest session must be evicted when capacity is exceeded" + ); + assert!(store.get_session("b").await.is_some()); + assert!(store.get_session("c").await.is_some()); + assert!(store.get_session("d").await.is_some()); + assert_eq!(store.session_count().await, 3); +} + +#[tokio::test] +async fn session_store_eviction_callback_fires() { + let evicted_keys: Arc>> = + Arc::new(std::sync::Mutex::new(Vec::new())); + let captured = evicted_keys.clone(); + let r = ServerEventRouteStore::new(); + + let mut store = SessionStore::with_capacity(2); + store.set_eviction_callback(std::sync::Arc::new(move |pubkey| { + captured.lock().unwrap().push(pubkey); + })); + + store.get_or_create_session("x", false, &r).await; + store.get_or_create_session("y", false, &r).await; + // Adding "z" evicts "x" + store.get_or_create_session("z", false, &r).await; + + let keys = evicted_keys.lock().unwrap(); + assert_eq!(keys.len(), 1, "callback must fire exactly once"); + assert_eq!(keys[0], "x", "evicted key must be the oldest session"); +} + +// ── Event loop cancellation on close() ────────────────────────────────────── + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn client_close_stops_event_loop() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut rx = client.take_message_receiver().expect("message receiver"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Close should cancel the event loop, causing the rx channel to close. + client.close().await.expect("client close"); + + // The receiver must resolve to None (closed) within a short timeout. + let result = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + matches!(result, Ok(None)), + "after close(), message receiver must yield None (channel closed)" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_close_stops_event_loop() { + let (_client_pool, server_pool) = MockRelayPool::create_pair(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default().with_encryption_mode(EncryptionMode::Disabled), + as_pool(server_pool), + ) + .await + .expect("create server transport"); + + let mut rx = server.take_message_receiver().expect("message receiver"); + server.start().await.expect("server start"); + let_event_loops_start().await; + + // Close should cancel both event loop and cleanup tasks. + server.close().await.expect("server close"); + + // The receiver must resolve to None (closed) within a short timeout. + let result = tokio::time::timeout(Duration::from_millis(200), rx.recv()).await; + assert!( + matches!(result, Ok(None)), + "after close(), message receiver must yield None (channel closed)" + ); +} + +// ── CEP-22: oversized transfer end-to-end wiring ───────────────────────────── + +/// C→S: a request whose serialized size exceeds the threshold is fragmented into +/// multiple kind-25910 frames and reassembled into exactly one IncomingRequest. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_request_roundtrip_client_to_server() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // A request carrying a progressToken and a payload well over the threshold. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("big-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": "tok-req" }, + "blob": "A".repeat(10000), + })), + }); + client.send(&request).await.expect("send oversized request"); + + // The wire must carry more than one kind-25910 frame (start + chunks + end). + let frame_count = server_pool + .stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .count(); + assert!( + frame_count > 1, + "oversized request must publish multiple frames, got {frame_count}" + ); + + // Exactly one reassembled request arrives at the server. + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/call")); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("big-1"))); + + // No second message should surface. + let second = tokio::time::timeout(Duration::from_millis(100), server_rx.recv()).await; + assert!(second.is_err(), "only one reassembled request expected"); +} + +/// CEP-22 parity (TS T3.1): one-shot discovery-tag placement across an oversized +/// *request*'s frames. The client advertises `support_oversized_transfer` on the +/// `start` frame only; the continuation (`chunk`/`end`) frames carry the bare +/// recipient tags. Encryption is Disabled so the plaintext frame tags are directly +/// inspectable — and `support_oversized_transfer` is advertised independently of +/// encryption mode (unlike `support_encryption`; see `get_client_capability_tags`), +/// so Disabled is a valid mode for this assertion. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_continuation_frames_carry_no_discovery_tags() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // The client's first (and only) request is oversized, so the one-shot discovery + // tags ride its `start` frame. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("disco-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": "tok-disco" }, + "blob": "A".repeat(10000), + })), + }); + client.send(&request).await.expect("send oversized request"); + + // Confirm the transfer reassembled (so every frame has landed in the store). + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("disco-1"))); + + // Classify every cvm-bearing frame in the store by its `cvm.frameType`. The + // client authors start/chunk/end; the server's `accept` frame matches none of + // the three and is naturally excluded. + let events = server_pool.stored_events().await; + let mut start_frames: Vec<&Event> = Vec::new(); + let mut chunk_frames: Vec<&Event> = Vec::new(); + let mut end_frames: Vec<&Event> = Vec::new(); + for e in events + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + { + let frame_type = serde_json::from_str::(&e.content) + .ok() + .as_ref() + .and_then(|v| v.get("params")) + .and_then(|p| p.get("cvm")) + .and_then(|c| c.get("frameType")) + .and_then(|f| f.as_str()) + .map(str::to_string); + match frame_type.as_deref() { + Some("start") => start_frames.push(e), + Some("chunk") => chunk_frames.push(e), + Some("end") => end_frames.push(e), + _ => {} + } + } + + assert_eq!( + start_frames.len(), + 1, + "expected exactly one start frame, got {}", + start_frames.len() + ); + assert!( + !chunk_frames.is_empty(), + "expected at least one chunk frame for a fragmented request" + ); + assert_eq!( + end_frames.len(), + 1, + "expected exactly one end frame, got {}", + end_frames.len() + ); + + // The start frame advertises oversized support… + assert!( + has_tag_name(start_frames[0], tags::SUPPORT_OVERSIZED_TRANSFER), + "the start frame must carry support_oversized_transfer" + ); + // …and every continuation frame (chunk/end) must NOT repeat it. + for &frame in chunk_frames.iter().chain(end_frames.iter()) { + assert!( + !has_tag_name(frame, tags::SUPPORT_OVERSIZED_TRANSFER), + "continuation frames must not carry support_oversized_transfer" + ); + } +} + +/// Receive the next non-notification client message, skipping the stripped +/// progress forwards that precede an oversized response (their shape is pinned +/// by `oversized_progress_token_restored` in `oversized_timeout_e2e.rs`). +async fn recv_skipping_progress_forwards( + rx: &mut tokio::sync::mpsc::UnboundedReceiver, +) -> JsonRpcMessage { + loop { + let msg = tokio::time::timeout(Duration::from_millis(1000), rx.recv()) + .await + .expect("timeout waiting for client message") + .expect("client channel closed"); + if !matches!(msg, JsonRpcMessage::Notification(_)) { + return msg; + } + } +} + +/// S→C: a large response is fragmented by the server and reassembled by the +/// client into a single response delivered on its receiver. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_response_roundtrip_server_to_client() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + let mut client_rx = client.take_message_receiver().expect("client rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Small request (single event) but carrying a progressToken so the server may + // fragment its response. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("rsp-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": "tok-rsp" } })), + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for request") + .expect("server channel closed"); + + // Server replies with a payload well over the threshold. + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("rsp-1"), + result: serde_json::json!({ "blob": "B".repeat(10000) }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("server send oversized response"); + + // The client reassembles and delivers the full response (preceded by + // stripped progress forwards, skipped here). + let client_msg = recv_skipping_progress_forwards(&mut client_rx).await; + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("rsp-1"))); + if let JsonRpcMessage::Response(r) = client_msg { + assert_eq!(r.result["blob"], serde_json::json!("B".repeat(10000))); + } else { + panic!("expected a response"); + } + + // The start frame of the oversized response carried the server's discovery + // tags, so the client must have learned the server supports oversized + // transfer by the time the reassembled response is delivered. + assert!( + client + .discovered_server_capabilities() + .supports_oversized_transfer, + "client must learn server oversized support from the first oversized response" + ); +} + +/// S→C, ENCRYPTED: a server response whose *raw* size sits below the oversized +/// threshold but whose gift-wrapped single-event build overflows NIP-44's +/// 65 535-byte plaintext cap must still fragment and reassemble. This is the rs +/// analog of TS commit c71d556's `oversized-gift-wrap.e2e.test.ts` Test B, and +/// it is the path that exercises the Fix-2 error arm in `send_response` +/// (`server/mod.rs:682`): the single-event build `Err(_) => fragment`. +/// +/// The threshold is deliberately set ABOVE NIP-44's cap. With the default +/// 48 000 threshold a >65 KB payload would trip the cheap raw-length +/// short-circuit (`serialized.len() >= threshold`) and fragment WITHOUT ever +/// building the single event — never reaching the error arm. Raising the +/// threshold to 78 000 keeps the ~72 KB raw response below it (so the gate +/// builds the single encrypted event), while the gift-wrap inner plaintext +/// (~72 KB) still exceeds 65 535, so `prepare_mcp_message` errors and the gate +/// falls back to fragmentation. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_encrypted_response_roundtrip_server_to_client() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Threshold > NIP-44's 65 535-byte cap so the raw ~72 KB response does NOT + // hit the raw-length short-circuit; the gate builds the single encrypted + // event and that build overflows NIP-44 → fragments via the error arm. + let oversized = OversizedTransferConfig::enabled().with_threshold(78_000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Optional) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Optional) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + let mut client_rx = client.take_message_receiver().expect("client rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Small request carrying a progressToken (so the response is oversized- + // eligible). Optional/Optional → the client encrypts by default, so the + // server mirrors with an encrypted (gift-wrapped) response. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("enc-rsp-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": "tok-enc-rsp" } })), + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1500), server_rx.recv()) + .await + .expect("timeout waiting for request") + .expect("server channel closed"); + assert!( + incoming.is_encrypted, + "Optional/Optional: the client must encrypt its request so the response is gift-wrapped" + ); + + // Count gift-wrap frames published before the response so we can isolate the + // response's own frames (gift-wraps are signed by ephemeral keys, so they + // can't be filtered by author). + let gift_wraps_before = server_pool + .stored_events() + .await + .iter() + .filter(|e| { + e.kind == Kind::Custom(GIFT_WRAP_KIND) + || e.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND) + }) + .count(); + + // Payload: raw ≈ 72 050 B (< 78 000 threshold, so the gate builds the single + // event) but the gift-wrap inner plaintext (~72 KB) exceeds NIP-44's + // 65 535-byte cap, so the build errors and the response must fragment. + let blob = "x".repeat(72_000); + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("enc-rsp-1"), + result: serde_json::json!({ "blob": blob.clone() }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("server send encrypted oversized response"); + + // The client reassembles the gift-wrapped frames into the full response. + let client_msg = recv_skipping_progress_forwards(&mut client_rx).await; + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("enc-rsp-1"))); + if let JsonRpcMessage::Response(r) = client_msg { + assert_eq!( + r.result["blob"], + serde_json::json!(blob), + "the >65 535-byte encrypted payload must reassemble byte-for-byte" + ); + } else { + panic!("expected a response"); + } + + // Under encryption the frames are gift-wrapped (opaque content), so frame + // types can't be parsed off the wire. Count the gift-wrap events the server + // published for this response instead: a fragmented oversized transfer emits + // start + chunks + end (≥ 2 gift-wraps), whereas an un-fragmented single + // response would emit exactly one — and, at this size, that single encrypted + // event could not even be built (the NIP-44 overflow this test pins). + let gift_wraps_after = server_pool + .stored_events() + .await + .iter() + .filter(|e| { + e.kind == Kind::Custom(GIFT_WRAP_KIND) + || e.kind == Kind::Custom(EPHEMERAL_GIFT_WRAP_KIND) + }) + .count(); + let response_frames = gift_wraps_after - gift_wraps_before; + assert!( + response_frames >= 2, + "encrypted oversized response must publish ≥2 gift-wrap frames, got {response_frames}" + ); +} + +// ── CEP-22: number-or-string progressToken plumbing ────────────────────────── + +/// Collect the `params` of every cvm-bearing (oversized-transfer) frame stored +/// in `pool`. +async fn stored_oversized_frame_params(pool: &MockRelayPool) -> Vec { + pool.stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .filter_map(|e| serde_json::from_str::(&e.content).ok()) + .filter_map(|v| v.get("params").cloned()) + .filter(|p| p.get("cvm").is_some()) + .collect() +} + +/// C→S: rmcp stamps *numeric* progressTokens; a request carrying one must +/// still fragment (string-only extraction returned `None` and silently fell +/// back to the single-event path). On the wire every frame carries the +/// stringified token; the reassembled request preserves the original number. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_request_roundtrip_numeric_token() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // The token is the JSON number 7 — the shape rmcp emits. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("big-num-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": 7 }, + "blob": "A".repeat(10000), + })), + }); + client.send(&request).await.expect("send oversized request"); + + // Fragmentation must have happened, and every frame (start/chunks/end plus + // the server's accept echo) must address the transfer by the *stringified* + // token — numbers never appear on the wire. + let frame_params = stored_oversized_frame_params(&server_pool).await; + assert!( + frame_params.len() > 1, + "numeric-token oversized request must publish multiple frames, got {}", + frame_params.len() + ); + for params in &frame_params { + assert_eq!( + params.get("progressToken"), + Some(&serde_json::json!("7")), + "frames must carry the stringified token, got {params}" + ); + } + + // Exactly one reassembled request arrives, original numeric token intact. + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/call")); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("big-num-1"))); + if let JsonRpcMessage::Request(r) = &incoming.message { + assert_eq!( + r.params.as_ref().unwrap()["_meta"]["progressToken"], + serde_json::json!(7), + "reassembly must reproduce the original numeric token" + ); + } else { + panic!("expected a request"); + } + + let second = tokio::time::timeout(Duration::from_millis(100), server_rx.recv()).await; + assert!(second.is_err(), "only one reassembled request expected"); +} + +/// S→C: a request whose `_meta.progressToken` is numeric must still open +/// the server's response fragmentation gate (string-only extraction left +/// `route.progress_token = None`, so responses to rmcp clients never +/// fragmented). Response frames carry the stringified token on the wire. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_response_roundtrip_numeric_token() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + let mut client_rx = client.take_message_receiver().expect("client rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Small request (single event) carrying the JSON number 7 as its token. + let request = JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("rsp-num-1"), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ "_meta": { "progressToken": 7 } })), + }); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for request") + .expect("server channel closed"); + + // Server replies with a payload well over the threshold. + let response = JsonRpcMessage::Response(JsonRpcResponse { + jsonrpc: "2.0".to_string(), + id: serde_json::json!("rsp-num-1"), + result: serde_json::json!({ "blob": "B".repeat(10000) }), + }); + server + .send_response(&incoming.event_id, response) + .await + .expect("server send oversized response"); + + // The client reassembles and delivers the full response (preceded by + // stripped progress forwards, skipped here). + let client_msg = recv_skipping_progress_forwards(&mut client_rx).await; + assert!(client_msg.is_response()); + assert_eq!(client_msg.id(), Some(&serde_json::json!("rsp-num-1"))); + if let JsonRpcMessage::Response(r) = client_msg { + assert_eq!(r.result["blob"], serde_json::json!("B".repeat(10000))); + } else { + panic!("expected a response"); + } + + // The response was actually fragmented, addressed by the stringified token. + let frame_params = stored_oversized_frame_params(&server_pool).await; + assert!( + frame_params.len() > 1, + "numeric-token response must fragment, got {} frames", + frame_params.len() + ); + for params in &frame_params { + assert_eq!( + params.get("progressToken"), + Some(&serde_json::json!("7")), + "response frames must carry the stringified token, got {params}" + ); + } +} + +// ── CEP-22: remaining oversized-transfer tests ─────────────────────────────── + +/// Start an oversized-enabled server over `server_pool`, returning the live +/// transport (keep alive) and its request receiver. +async fn start_oversized_server( + server_pool: Arc, + encryption_mode: EncryptionMode, + oversized: OversizedTransferConfig, +) -> ( + NostrServerTransport, + tokio::sync::mpsc::UnboundedReceiver, +) { + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(encryption_mode) + .with_oversized_transfer(oversized), + server_pool as Arc, + ) + .await + .expect("create server transport"); + let rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + (server, rx) +} + +/// A plaintext `BaseTransport` over `client_pool` used to inject raw frames +/// (grey-box: bypasses `NostrClientTransport::send`). +fn greybox_client_base(client_pool: MockRelayPool) -> BaseTransport { + BaseTransport { + relay_pool: as_pool(client_pool), + encryption_mode: EncryptionMode::Disabled, + is_connected: true, + } +} + +/// Publish one frame as a plaintext kind-25910 event tagged to `server_pubkey`. +async fn publish_plain_frame( + base: &BaseTransport, + server_pubkey: &PublicKey, + tags: &[Tag], + frame: JsonRpcNotification, +) { + let message = JsonRpcMessage::Notification(frame); + base.send_mcp_message( + &message, + server_pubkey, + CTXVM_MESSAGES_KIND, + tags.to_vec(), + Some(false), + None, + ) + .await + .expect("publish frame"); +} + +/// A request whose serialized form carries `progressToken` and a payload of +/// `blob_len` bytes (used to force fragmentation). +fn oversized_request(id: &str, token: &str, blob_len: usize) -> JsonRpcMessage { + JsonRpcMessage::Request(JsonRpcRequest { + jsonrpc: "2.0".to_string(), + id: serde_json::json!(id), + method: "tools/call".to_string(), + params: Some(serde_json::json!({ + "_meta": { "progressToken": token }, + "blob": "Q".repeat(blob_len), + })), + }) +} + +/// Out-of-order delivery: chunks published in a permuted order (within the +/// out-of-order window) still reassemble into the original request. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_out_of_order_delivery_reassembles() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(64) + .with_chunk_size(32); + let (_server, mut server_rx) = start_oversized_server( + Arc::clone(&server_pool), + EncryptionMode::Disabled, + oversized, + ) + .await; + let_event_loops_start().await; + + let base = greybox_client_base(client_pool); + let tags = BaseTransport::create_recipient_tags(&server_pubkey); + + let request = oversized_request("ooo-1", "tok-ooo", 300); + let serialized = serde_json::to_string(&request).unwrap(); + let opts = OversizedSenderOptions::new("tok-ooo") + .with_chunk_size(32) + .with_accept_handshake(true); + let frames = build_oversized_frames(&serialized, &opts).unwrap(); + let start = frames.start; + let mut chunks = frames.chunks; + let end = frames.end; + assert!( + chunks.len() > 2, + "need several chunks to permute meaningfully" + ); + + // start first; then the base chunk (so the reserved-accept-slot guard is + // satisfied); then the remaining chunks reversed; then end. + publish_plain_frame(&base, &server_pubkey, &tags, start).await; + let base_chunk = chunks.remove(0); + publish_plain_frame(&base, &server_pubkey, &tags, base_chunk).await; + for chunk in chunks.into_iter().rev() { + publish_plain_frame(&base, &server_pubkey, &tags, chunk).await; + } + publish_plain_frame(&base, &server_pubkey, &tags, end).await; + + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/call")); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("ooo-1"))); +} + +/// Digest mismatch: corrupting one chunk's `cvm.data` fails reassembly, and no +/// partial message is ever surfaced (recv times out). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_digest_mismatch_surfaces_nothing() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(64) + .with_chunk_size(32); + let (_server, mut server_rx) = start_oversized_server( + Arc::clone(&server_pool), + EncryptionMode::Disabled, + oversized, + ) + .await; + let_event_loops_start().await; + + let base = greybox_client_base(client_pool); + let tags = BaseTransport::create_recipient_tags(&server_pubkey); + + let request = oversized_request("dig-1", "tok-dig", 300); + let serialized = serde_json::to_string(&request).unwrap(); + let opts = OversizedSenderOptions::new("tok-dig") + .with_chunk_size(32) + .with_accept_handshake(true); + let frames = build_oversized_frames(&serialized, &opts).unwrap(); + let start = frames.start; + let mut chunks = frames.chunks; + let end = frames.end; + + // Corrupt a middle chunk's payload so the reassembled bytes/digest mismatch. + let mid = chunks.len() / 2; + if let Some(params) = chunks[mid].params.as_mut() { + params["cvm"]["data"] = serde_json::json!("corrupted-bytes-not-original"); + } + + publish_plain_frame(&base, &server_pubkey, &tags, start).await; + for chunk in chunks { + publish_plain_frame(&base, &server_pubkey, &tags, chunk).await; + } + publish_plain_frame(&base, &server_pubkey, &tags, end).await; + + let result = tokio::time::timeout(Duration::from_millis(400), server_rx.recv()).await; + assert!( + result.is_err(), + "digest mismatch must not surface any (partial) message" + ); +} + +/// Abort: a `start` followed by an `abort` frame terminates the transfer; no +/// message surfaces. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_abort_surfaces_nothing() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(64) + .with_chunk_size(32); + let (_server, mut server_rx) = start_oversized_server( + Arc::clone(&server_pool), + EncryptionMode::Disabled, + oversized, + ) + .await; + let_event_loops_start().await; + + let base = greybox_client_base(client_pool); + let tags = BaseTransport::create_recipient_tags(&server_pubkey); + + let request = oversized_request("abt-1", "tok-abt", 300); + let serialized = serde_json::to_string(&request).unwrap(); + let opts = OversizedSenderOptions::new("tok-abt") + .with_chunk_size(32) + .with_accept_handshake(true); + let frames = build_oversized_frames(&serialized, &opts).unwrap(); + + publish_plain_frame(&base, &server_pubkey, &tags, frames.start).await; + let abort = OversizedFrame::Abort { reason: None } + .into_progress_notification("tok-abt", 2, None) + .expect("build abort frame"); + publish_plain_frame(&base, &server_pubkey, &tags, abort).await; + + let result = tokio::time::timeout(Duration::from_millis(400), server_rx.recv()).await; + assert!( + result.is_err(), + "an aborted transfer must not surface a message" + ); +} + +/// Per-frame size bound: every published kind-25910 frame stays under the 1 MiB +/// single-message cap, even for a payload far larger than one frame. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_each_frame_under_max_message_size() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Default chunk size (48_000) keeps each frame well under MAX_MESSAGE_SIZE. + let oversized = OversizedTransferConfig::enabled(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // ~200 KB payload → several ~48 KB frames. + let request = oversized_request("sz-1", "tok-sz", 200_000); + client.send(&request).await.expect("send oversized request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1500), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("sz-1"))); + + let frames: Vec<_> = server_pool + .stored_events() + .await + .into_iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .collect(); + assert!(frames.len() > 1, "payload must have been fragmented"); + for frame in &frames { + assert!( + frame.content.len() < MAX_MESSAGE_SIZE, + "every frame must stay under MAX_MESSAGE_SIZE, got {}", + frame.content.len() + ); + } +} + +/// A reassembled payload larger than the 1 MiB single-message cap is delivered +/// in full (the per-transfer cap defaults to 100 MiB). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_reassembled_over_one_mib_succeeds() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled(); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // ~1.2 MB payload, comfortably over MAX_MESSAGE_SIZE (1 MiB). + let blob_len = 1_200_000; + let request = oversized_request("big-mib", "tok-mib", blob_len); + let serialized_len = serde_json::to_string(&request).unwrap().len(); + assert!( + serialized_len > MAX_MESSAGE_SIZE, + "request must exceed 1 MiB" + ); + + client.send(&request).await.expect("send >1MiB request"); + + let incoming = tokio::time::timeout(Duration::from_millis(3000), server_rx.recv()) + .await + .expect("timeout waiting for >1MiB reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("big-mib"))); + if let JsonRpcMessage::Request(req) = incoming.message { + assert_eq!( + req.params.unwrap()["blob"], + serde_json::json!("Q".repeat(blob_len)) + ); + } else { + panic!("expected a request"); + } +} + +/// Gate off: with oversized transfer disabled on the client, an above-threshold +/// request is published as a single event (no fragmentation). +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_gate_off_sends_single_event() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + // Server is oversized-capable, but the client's gate is OFF. + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(EncryptionMode::Disabled) + .with_oversized_transfer(OversizedTransferConfig::enabled().with_threshold(256)), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(EncryptionMode::Disabled) + // Explicitly disabled gate (the default flipped to enabled); + // threshold present only to prove it is above-threshold. + .with_oversized_transfer( + OversizedTransferConfig::default() + .with_enabled(false) + .with_threshold(256), + ), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + // Above the 256-byte threshold, but below the 1 MiB single-event cap. + let request = oversized_request("gate-off", "tok-gate", 2000); + client.send(&request).await.expect("send request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1000), server_rx.recv()) + .await + .expect("timeout waiting for request") + .expect("server channel closed"); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("gate-off"))); + + let frame_count = server_pool + .stored_events() + .await + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .count(); + assert_eq!( + frame_count, 1, + "gate off must publish exactly one single event, got {frame_count}" + ); +} + +/// Oversized request roundtrip under a given encryption mode. +async fn run_oversized_request_roundtrip_mode(encryption_mode: EncryptionMode) { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(6000) + .with_chunk_size(6000); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(encryption_mode) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(encryption_mode) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + let request = oversized_request("mode-1", "tok-mode", 10000); + client.send(&request).await.expect("send oversized request"); + + let incoming = tokio::time::timeout(Duration::from_millis(1500), server_rx.recv()) + .await + .expect("timeout waiting for reassembled request") + .expect("server channel closed"); + assert_eq!(incoming.message.method(), Some("tools/call")); + assert_eq!(incoming.message.id(), Some(&serde_json::json!("mode-1"))); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_request_roundtrip_encryption_disabled() { + run_oversized_request_roundtrip_mode(EncryptionMode::Disabled).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_request_roundtrip_encryption_optional() { + run_oversized_request_roundtrip_mode(EncryptionMode::Optional).await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_request_roundtrip_encryption_required() { + run_oversized_request_roundtrip_mode(EncryptionMode::Required).await; +} + +// ── CEP-22 Gap #1: oversized decision measures the published event ──────────── + +/// Send `request` through a fresh client/server pair under `encryption_mode` with +/// the given oversized `threshold`, then return how many kind-25910 (plaintext +/// frames) and kind-1059 (gift-wrapped frames) events the client published. A +/// single non-oversized send is one event; a fragmented send is several. +async fn oversized_published_frame_counts( + encryption_mode: EncryptionMode, + threshold: usize, + request: &JsonRpcMessage, +) -> (usize, usize) { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled().with_threshold(threshold); + + let mut server = NostrServerTransport::with_relay_pool( + NostrServerTransportConfig::default() + .with_encryption_mode(encryption_mode) + .with_oversized_transfer(oversized.clone()), + Arc::clone(&server_pool) as Arc, + ) + .await + .expect("create server transport"); + + let mut client = NostrClientTransport::with_relay_pool( + NostrClientTransportConfig::default() + .with_relay_urls(vec!["wss://mock.relay".to_string()]) + .with_server_pubkey(server_pubkey.to_hex()) + .with_encryption_mode(encryption_mode) + .with_oversized_transfer(oversized), + as_pool(client_pool), + ) + .await + .expect("create client transport"); + + let mut server_rx = server.take_message_receiver().expect("server rx"); + server.start().await.expect("server start"); + client.start().await.expect("client start"); + let_event_loops_start().await; + + client.send(request).await.expect("send request"); + + // Wait for the request to surface (single event or fully reassembled transfer), + // then settle briefly so every frame has landed in the shared store. + let _ = tokio::time::timeout(Duration::from_millis(1500), server_rx.recv()).await; + tokio::time::sleep(Duration::from_millis(50)).await; + + let events = server_pool.stored_events().await; + let kind_25910 = events + .iter() + .filter(|e| e.kind == Kind::Custom(CTXVM_MESSAGES_KIND)) + .count(); + let kind_1059 = events + .iter() + .filter(|e| e.kind == Kind::Custom(GIFT_WRAP_KIND)) + .count(); + (kind_25910, kind_1059) +} + +/// Gap #1 regression: the oversized decision is made on the *published* Nostr +/// event size, not the raw payload. A request whose raw serialized length is below +/// the threshold is sent as a single event in plaintext, but the SAME payload must +/// be fragmented once gift-wrap encryption inflates the published event past the +/// threshold (base64 ×1.333 + NIP-44 padding). Mirrors TS +/// `measurePublishedMcpMessageSize`; the old raw-length check sent both as a single +/// (over-cap) event. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn oversized_decision_accounts_for_encryption_inflation() { + // Margins (measured): payload=5900, threshold=8000. + // raw serialized ≈ 6013 bytes (< threshold — old check fragmented neither) + // plaintext published ≈ 6455 bytes (< threshold → single event) + // encrypted published ≈ 10065 bytes (> threshold → fragments) + // The 5900 payload is chosen so gift-wrap inflation (base64 ×1.333 + NIP-44 + // padding) is what crosses the threshold; adjust it if the envelope changes. + let threshold = 8000; + let request = oversized_request("enc-infl", "tok-infl", 5900); + + // Precondition: the raw payload is below the threshold, so the *old* raw-length + // decision would have sent a single event in BOTH modes. + let raw_len = serde_json::to_string(&request) + .expect("serialize request") + .len(); + assert!( + raw_len < threshold, + "precondition: raw payload ({raw_len}) must be below the threshold ({threshold})" + ); + + // Plaintext: the published event stays under the threshold → exactly one event. + let (plain_25910, _) = + oversized_published_frame_counts(EncryptionMode::Disabled, threshold, &request).await; + assert_eq!( + plain_25910, 1, + "a below-threshold plaintext request must publish exactly one kind-25910 event, got {plain_25910}" + ); + + // Encrypted: gift-wrap inflation pushes the published event over the threshold, + // so the SAME payload is now fragmented into multiple gift-wrapped frames. + let (_, enc_1059) = + oversized_published_frame_counts(EncryptionMode::Required, threshold, &request).await; + assert!( + enc_1059 > 1, + "the same request must fragment once gift-wrap inflates it past the threshold, got {enc_1059} gift-wrap frames" + ); +} + +// ── CEP-22: server accept-frame emission shape ─────────────────────────────── + +/// Poll the shared event store for the server's emitted `accept` frame (a +/// plaintext kind-25910 notification authored by the server whose `cvm.frameType` +/// is `accept`). Panics if none appears within the window. +async fn poll_for_accept_frame( + server_pool: &Arc, + server_pubkey: &PublicKey, +) -> JsonRpcNotification { + for _ in 0..50 { + for event in server_pool.stored_events().await { + if event.kind != Kind::Custom(CTXVM_MESSAGES_KIND) || event.pubkey != *server_pubkey { + continue; + } + if let Ok(notif) = serde_json::from_str::(&event.content) { + let is_accept = notif + .params + .as_ref() + .and_then(|p| p.get("cvm")) + .and_then(|cvm| cvm.get("frameType")) + .and_then(|ft| ft.as_str()) + == Some("accept"); + if is_accept { + return notif; + } + } + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + panic!("server did not emit an accept frame within the poll window"); +} + +/// When a client sends an oversized `start` before the server knows it supports +/// oversized transfer, the server emits an `accept` frame with `progress == 2` +/// and `cvm.frameType == "accept"`. +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn server_emits_accept_frame_with_expected_shape() { + let (client_pool, server_pool) = MockRelayPool::create_pair(); + let server_pubkey = server_pool.mock_public_key(); + let server_pool = Arc::new(server_pool); + + let oversized = OversizedTransferConfig::enabled() + .with_threshold(2000) + .with_chunk_size(2000); + let (_server, _server_rx) = start_oversized_server( + Arc::clone(&server_pool), + EncryptionMode::Disabled, + oversized, + ) + .await; + let_event_loops_start().await; + + // Grey-box: inject only the `start` frame (handshake slot reserved) from a + // client that has not advertised oversized support. + let base = greybox_client_base(client_pool); + let tags = BaseTransport::create_recipient_tags(&server_pubkey); + let request = oversized_request("acc-1", "tok-acc", 300); + let serialized = serde_json::to_string(&request).unwrap(); + let opts = OversizedSenderOptions::new("tok-acc") + .with_chunk_size(64) + .with_accept_handshake(true); + let frames = build_oversized_frames(&serialized, &opts).unwrap(); + publish_plain_frame(&base, &server_pubkey, &tags, frames.start).await; + + let accept = poll_for_accept_frame(&server_pool, &server_pubkey).await; + let params = accept.params.as_ref().expect("accept frame has params"); + assert_eq!( + params["progress"].as_u64(), + Some(2), + "accept frame must be published at progress slot 2" + ); + assert_eq!( + params["cvm"]["frameType"].as_str(), + Some("accept"), + "frame must be an oversized-transfer accept" + ); + assert_eq!( + params["cvm"]["type"].as_str(), + Some("oversized-transfer"), + "accept frame must carry the oversized-transfer cvm type" + ); + assert_eq!( + params["progressToken"].as_str(), + Some("tok-acc"), + "accept frame must echo the transfer's progressToken" + ); + assert_eq!( + params["message"].as_str(), + Some("oversized request accepted"), + "accept frame must carry the TS-matching human-readable message" + ); +}