Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,21 @@
the two checks can never mint a second invoice against an unclaimed paid grant.
Registration arrives with the payments configuration entry point, alongside the
transparent middleware's.
- The server payments registration entry point:
`contextvm_sdk::payments::with_server_payments(&mut transport, options)` composes
the whole server-side payment stack in one call, before `start()`. It advertises
the configured payment methods and prices on announcements (`pmi` and `cap` tags,
plus a `payment_interaction=explicit_gating` availability tag when the policy is
`Optional`), records the negotiation policy, and registers the transparent payment
middleware plus, under `Optional`, the explicit-gating middleware with a fresh
authorization store, with the payment senders built after the tags so their
captured discovery replay is complete. It refuses (with an error, before touching
any state) the two calls that would silently lose money: registration after
`start()`, which would advertise payments while gating nothing, and a second
registration, which would charge every priced request twice. The gateway joins in:
`GatewayConfig` gains `payment_options` (with a `with_payment_options` builder),
consumed when the gateway builds its transport in both `NostrMCPGateway::new` and
`serve_handler`.

### Fixed

Expand Down
4 changes: 4 additions & 0 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,10 @@ required-features = ["test-utils"]
name = "payments_explicit_gating_e2e"
required-features = ["test-utils"]

[[test]]
name = "payments_server_e2e"
required-features = ["test-utils"]

[[test]]
name = "rmcp_handshake_survival"
required-features = ["rmcp", "test-utils"]
Expand Down
50 changes: 45 additions & 5 deletions src/gateway/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

use crate::core::error::{Error, Result};
use crate::core::types::JsonRpcMessage;
use crate::payments::{with_server_payments, ServerPaymentsOptions};
use crate::transport::server::{IncomingRequest, NostrServerTransport, NostrServerTransportConfig};

/// Configuration for the gateway.
Expand All @@ -13,12 +14,26 @@ use crate::transport::server::{IncomingRequest, NostrServerTransport, NostrServe
pub struct GatewayConfig {
/// Nostr server transport configuration.
pub nostr_config: NostrServerTransportConfig,
/// Optional CEP-8 payments configuration. When set, the gateway registers
/// payments on its transport via [`with_server_payments`] between
/// constructing the transport and starting it.
pub payment_options: Option<ServerPaymentsOptions>,
}

impl GatewayConfig {
/// Create a new gateway configuration.
/// Create a new gateway configuration (payments stay opt-in via
/// [`with_payment_options`](Self::with_payment_options)).
pub fn new(nostr_config: NostrServerTransportConfig) -> Self {
Self { nostr_config }
Self {
nostr_config,
payment_options: None,
}
}

/// Set the CEP-8 payments configuration.
pub fn with_payment_options(mut self, payment_options: ServerPaymentsOptions) -> Self {
self.payment_options = Some(payment_options);
self
}
}

Expand All @@ -37,7 +52,10 @@ impl NostrMCPGateway {
where
T: nostr_sdk::prelude::IntoNostrSigner,
{
let transport = NostrServerTransport::new(signer, config.nostr_config).await?;
let mut transport = NostrServerTransport::new(signer, config.nostr_config).await?;
if let Some(payment_options) = config.payment_options {
with_server_payments(&mut transport, payment_options)?;
}

Ok(Self {
transport,
Expand Down Expand Up @@ -74,6 +92,11 @@ impl NostrMCPGateway {
}

/// Stop the gateway.
///
/// The underlying transport is not restartable after close, so a stopped
/// gateway cannot be started again (a later `start` panics at the transport's
/// message-channel expect); payments registration neither causes nor changes
/// this.
pub async fn stop(&mut self) -> Result<()> {
if !self.is_running {
return Ok(());
Expand Down Expand Up @@ -107,7 +130,10 @@ impl NostrMCPGateway {
use crate::NostrServerTransport;
use rmcp::ServiceExt;

let transport = NostrServerTransport::new(signer, config.nostr_config).await?;
let mut transport = NostrServerTransport::new(signer, config.nostr_config).await?;
if let Some(payment_options) = config.payment_options {
with_server_payments(&mut transport, payment_options)?;
}
handler
.serve(transport)
.await
Expand Down Expand Up @@ -148,12 +174,16 @@ mod tests {
open_stream: Default::default(),
};

let config = GatewayConfig { nostr_config };
let config = GatewayConfig {
nostr_config,
payment_options: None,
};

assert_eq!(
config.nostr_config.relay_urls,
vec!["wss://relay.example.com"]
);
assert!(config.payment_options.is_none());
assert_eq!(
config.nostr_config.encryption_mode,
EncryptionMode::Required
Expand All @@ -177,11 +207,21 @@ mod tests {
fn test_gateway_config_with_defaults() {
let config = GatewayConfig {
nostr_config: NostrServerTransportConfig::default(),
payment_options: None,
};
assert_eq!(
config.nostr_config.encryption_mode,
EncryptionMode::Optional
);
assert!(!config.nostr_config.is_announced_server);
}

#[test]
fn test_gateway_config_builder_sets_payment_options() {
let config =
GatewayConfig::new(NostrServerTransportConfig::default()).with_payment_options(
crate::payments::ServerPaymentsOptions::new(Vec::new(), Vec::new()),
);
assert!(config.payment_options.is_some());
}
}
8 changes: 6 additions & 2 deletions src/payments/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,10 @@
//! [`PaymentError`] taxonomy, the canonical invocation identity used for
//! explicit-gating authorization matching, the bounded [`AuthorizationStore`] of
//! pending and granted authorizations, and deterministic fakes behind the
//! `test-utils` feature. It carries no transport wiring and no network; the
//! middleware that consumes these arrives later.
//! `test-utils` feature. The one piece of transport wiring here is the
//! [`with_server_payments`](crate::payments::server_transport_payments::with_server_payments)
//! registration entry point, which composes the middlewares, tags, and senders
//! onto a server transport.
//!
//! Constants and tag builders stay reachable under their module paths
//! ([`crate::payments::constants`] / [`crate::payments::tags`]); the wire/config
Expand All @@ -22,6 +24,7 @@ pub mod errors;
pub mod server_explicit_gating;
pub mod server_payments;
pub(crate) mod server_payments_utils;
pub mod server_transport_payments;
pub mod tags;
pub mod traits;
pub mod types;
Expand All @@ -41,6 +44,7 @@ pub use server_explicit_gating::{
pub use server_payments::{
create_server_payments_middleware, ServerPaymentsMiddlewareParams, ServerPaymentsOptions,
};
pub use server_transport_payments::with_server_payments;
pub use traits::{PaymentHandler, PaymentProcessor, ResolvePrice};
pub use types::{
Meta, PaymentAcceptedParams, PaymentHandlerRequest, PaymentInteractionPolicy, PaymentOption,
Expand Down
5 changes: 3 additions & 2 deletions src/payments/server_explicit_gating.rs
Original file line number Diff line number Diff line change
Expand Up @@ -63,8 +63,9 @@ pub struct ExplicitGatingMiddlewareParams {
/// ([`NostrServerTransport::targeted_response_sender`](crate::transport::server::NostrServerTransport::targeted_response_sender)).
pub sender: TargetedResponseSender,
/// The store of pending and granted payment authorizations. Injected, not constructed
/// here, so the configuration entry point can share one store across registrations and
/// tests can observe lifecycle state.
/// here, so tests can observe lifecycle state and a hand-wiring caller that takes on
/// the composition itself may share a store (the configuration entry point constructs
/// a fresh one and registers at most once).
pub authorization_store: AuthorizationStore,
/// Pre-built PMI-to-processor map, shared across middlewares. Built locally when `None`.
pub processors_by_pmi: Option<Arc<HashMap<String, Arc<dyn PaymentProcessor>>>>,
Expand Down
Loading
Loading