diff --git a/.changelog/kind-bees-pack.md b/.changelog/kind-bees-pack.md new file mode 100644 index 0000000..ae4d349 --- /dev/null +++ b/.changelog/kind-bees-pack.md @@ -0,0 +1,5 @@ +--- +mpp: minor +--- + +Added a Stripe Shared Payment Token (SPT) example demonstrating the full 402 → challenge → credential → retry flow using Stripe's payment method. Includes a server with SPT proxy endpoint and a headless client using a test card. diff --git a/examples/README.md b/examples/README.md index ace2040..1c90ef8 100644 --- a/examples/README.md +++ b/examples/README.md @@ -6,7 +6,8 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow. | Example | Description | |---------|-------------| -| [basic](./basic/) | Payment-gated Fortune Teller API | +| [basic](./basic/) | Payment-gated Fortune Teller API (Tempo) | +| [stripe](./stripe/) | Payment-gated Fortune Teller API (Stripe SPT) | | [axum-extractor](./axum-extractor/) | Axum extractors with per-route pricing (`MppCharge`) | | [session/multi-fetch](./session/multi-fetch/) | Multiple paid requests over a single payment channel | | [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE | @@ -16,11 +17,16 @@ Standalone, runnable examples demonstrating the mpp HTTP 402 payment flow. Each example is a standalone Cargo crate with a server and client binary. ```bash -# Basic example +# Basic example (Tempo) cd examples/basic cargo run --bin basic-server # Terminal 1 cargo run --bin basic-client # Terminal 2 +# Stripe example +cd examples/stripe +STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server # Terminal 1 +cargo run --bin stripe-client # Terminal 2 + # Axum extractor (per-route pricing) cd examples/axum-extractor cargo run --bin axum-server # Terminal 1 diff --git a/examples/stripe/Cargo.toml b/examples/stripe/Cargo.toml new file mode 100644 index 0000000..bdac4ab --- /dev/null +++ b/examples/stripe/Cargo.toml @@ -0,0 +1,23 @@ +[package] +name = "stripe-example" +version = "0.1.0" +edition = "2021" +publish = false + +[[bin]] +name = "stripe-server" +path = "src/server.rs" + +[[bin]] +name = "stripe-client" +path = "src/client.rs" + +[dependencies] +mpp = { path = "../..", features = ["server", "client", "stripe"] } +axum = "0.7" +tokio = { version = "1", features = ["full"] } +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } +serde_json = "1" +serde = { version = "1", features = ["derive"] } +rand = "0.9" +base64 = "0.22" diff --git a/examples/stripe/README.md b/examples/stripe/README.md new file mode 100644 index 0000000..11dbda5 --- /dev/null +++ b/examples/stripe/README.md @@ -0,0 +1,76 @@ +# Stripe Example + +A pay-per-fortune API using Stripe's Shared Payment Token (SPT) flow. + +## What This Demonstrates + +- Server-side payment protection with `Mpp::create_stripe()` and the Stripe method +- SPT proxy endpoint (secret key stays server-side) +- Headless client using a test card (`pm_card_visa`) +- Full 402 → challenge → credential → retry flow + +## Prerequisites + +- Rust 1.80+ +- A Stripe test-mode secret key (`sk_test_...`) + +## Running + +**Start the server:** + +```bash +export STRIPE_SECRET_KEY=sk_test_... +cargo run --bin stripe-server +``` + +The server starts at http://localhost:3000. + +**Run the client** (in another terminal): + +```bash +cargo run --bin stripe-client +# 🥠 A smooth long journey! Great expectations. +# Payment receipt: pi_3Q... +``` + +## Testing Manually + +**Without payment** (returns 402): + +```bash +curl -i http://localhost:3000/api/fortune +# HTTP/1.1 402 Payment Required +# WWW-Authenticate: Payment ... +``` + +## How It Works + +``` +Client Server Stripe + │ │ │ + │ GET /api/fortune │ │ + ├──────────────────────────────> │ │ + │ │ │ + │ 402 + WWW-Authenticate │ │ + │<────────────────────────────── │ │ + │ │ │ + │ POST /api/create-spt │ │ + ├──────────────────────────────> │ Create SPT (test helper) │ + │ ├─────────────────────────────> │ + │ spt_... │ │ + │<────────────────────────────── │<───────────────────────────── │ + │ │ │ + │ GET /api/fortune │ │ + │ Authorization: Payment │ │ + ├──────────────────────────────> │ PaymentIntent (SPT + confirm)│ + │ ├─────────────────────────────> │ + │ │ pi_... succeeded │ + │ 200 + fortune + receipt │<───────────────────────────── │ + │<────────────────────────────── │ │ +``` + +1. Client requests the fortune → server returns 402 with a payment challenge +2. mpp client calls `create_token` → POSTs to `/api/create-spt` → server creates SPT via Stripe +3. Client retries with a credential containing the SPT +4. Server creates a PaymentIntent with `shared_payment_granted_token` and `confirm=true` +5. On success, returns the fortune with a receipt diff --git a/examples/stripe/src/client.rs b/examples/stripe/src/client.rs new file mode 100644 index 0000000..f58c643 --- /dev/null +++ b/examples/stripe/src/client.rs @@ -0,0 +1,100 @@ +//! # Stripe Fortune Teller CLI Client +//! +//! A CLI client that fetches a fortune from the payment-gated Fortune Teller API +//! using Stripe's Shared Payment Token (SPT) flow. +//! +//! Uses a test card (`pm_card_visa`) for headless operation — no browser needed. +//! +//! ## Running +//! +//! ```bash +//! # First start the server: +//! STRIPE_SECRET_KEY=sk_test_... cargo run --bin stripe-server +//! +//! # Then in another terminal: +//! cargo run --bin stripe-client +//! +//! # Or target a different server: +//! cargo run --bin stripe-client -- --server http://localhost:8000 +//! ``` + +use mpp::client::{Fetch, StripeProvider}; +use mpp::protocol::methods::stripe::CreateTokenResult; +use mpp::{parse_receipt, MppError}; +use reqwest::Client; + +#[tokio::main] +async fn main() { + let server_url = std::env::args() + .skip_while(|a| a != "--server") + .nth(1) + .unwrap_or_else(|| "http://localhost:3000".to_string()); + + let server_base = server_url.trim_end_matches('/').to_string(); + let spt_url = format!("{server_base}/api/create-spt"); + + let provider = StripeProvider::new(move |params| { + let spt_url = spt_url.clone(); + Box::pin(async move { + let resp = Client::new() + .post(&spt_url) + .json(&serde_json::json!({ + "paymentMethod": "pm_card_visa", + "amount": params.amount, + "currency": params.currency, + "expiresAt": params.expires_at, + })) + .send() + .await + .map_err(|e| MppError::Http(e.to_string()))?; + + if !resp.status().is_success() { + let body = resp.text().await.unwrap_or_default(); + return Err(MppError::Http(format!("SPT creation failed: {body}"))); + } + + let json: serde_json::Value = resp + .json() + .await + .map_err(|e| MppError::Http(e.to_string()))?; + + let spt = json["spt"] + .as_str() + .ok_or_else(|| MppError::Http("missing spt in response".to_string()))? + .to_string(); + + Ok(CreateTokenResult::from(spt)) + }) + }); + + let fortune_url = format!("{server_base}/api/fortune"); + println!("Fetching {fortune_url} ..."); + + let resp = Client::new() + .get(&fortune_url) + .send_with_payment(&provider) + .await + .expect("request failed"); + + println!("Status: {}", resp.status()); + + if let Some(receipt_hdr) = resp.headers().get("payment-receipt") { + if let Ok(receipt_str) = receipt_hdr.to_str() { + if let Ok(receipt) = parse_receipt(receipt_str) { + println!("Payment receipt: {}", receipt.reference); + } + } + } + + let body = resp.text().await.expect("failed to read response body"); + + if let Ok(json) = serde_json::from_str::(&body) { + if let Some(fortune) = json.get("fortune").and_then(|v| v.as_str()) { + println!("\n🥠 {fortune}"); + } else { + println!("\nResponse: {json}"); + } + } else { + println!("\nResponse: {body}"); + } +} diff --git a/examples/stripe/src/server.rs b/examples/stripe/src/server.rs new file mode 100644 index 0000000..5c846ed --- /dev/null +++ b/examples/stripe/src/server.rs @@ -0,0 +1,208 @@ +//! # Stripe Fortune Teller API Server +//! +//! A payment-gated Fortune Teller API using Stripe's Shared Payment Token (SPT) +//! flow via the Machine Payment Protocol. +//! +//! Two endpoints: +//! +//! - `POST /api/create-spt` — proxy for SPT creation (secret key stays server-side) +//! - `GET /api/fortune` — paid endpoint ($1.00 per fortune) +//! +//! ## Running +//! +//! ```bash +//! export STRIPE_SECRET_KEY=sk_test_... +//! cargo run --bin stripe-server +//! ``` +//! +//! The server listens on `http://localhost:3000`. + +use axum::{ + extract::State, + http::{header, HeaderMap, StatusCode}, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use mpp::server::{stripe, Mpp, StripeChargeMethod, StripeConfig}; +use mpp::{format_www_authenticate, parse_authorization}; +use rand::seq::IndexedRandom; +use std::sync::Arc; + +type Payment = Mpp; + +const FORTUNES: &[&str] = &[ + "A beautiful, smart, and loving person will come into your life.", + "A dubious friend may be an enemy in camouflage.", + "A faithful friend is a strong defense.", + "A fresh start will put you on your way.", + "A golden egg of opportunity falls into your lap this month.", + "A good time to finish up old tasks.", + "A light heart carries you through all the hard times ahead.", + "A smooth long journey! Great expectations.", +]; + +struct AppState { + payment: Payment, + stripe_secret_key: String, +} + +#[tokio::main] +async fn main() { + let secret_key = + std::env::var("STRIPE_SECRET_KEY").expect("STRIPE_SECRET_KEY env var required"); + let network_id = + std::env::var("STRIPE_NETWORK_ID").unwrap_or_else(|_| "internal".to_string()); + + let payment = Mpp::create_stripe( + stripe(StripeConfig { + secret_key: &secret_key, + network_id: &network_id, + payment_method_types: &["card"], + currency: "usd", + decimals: 2, + }) + .secret_key( + &std::env::var("MPP_SECRET_KEY") + .unwrap_or_else(|_| "stripe-example-secret".to_string()), + ), + ) + .expect("failed to create Stripe payment handler"); + + let state = Arc::new(AppState { + payment, + stripe_secret_key: secret_key, + }); + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/create-spt", post(create_spt)) + .route("/api/fortune", get(fortune)) + .with_state(state); + + let listener = tokio::net::TcpListener::bind("0.0.0.0:3000") + .await + .expect("failed to bind"); + + println!("Stripe Fortune Teller API listening on http://localhost:3000"); + axum::serve(listener, app).await.expect("server error"); +} + +async fn health() -> impl IntoResponse { + Json(serde_json::json!({ "status": "ok" })) +} + +/// Proxy endpoint for SPT creation. +/// +/// The client calls this with a payment method ID and challenge details. +/// We call Stripe's test SPT endpoint using our secret key. +#[derive(serde::Deserialize)] +#[serde(rename_all = "camelCase")] +struct CreateSptRequest { + payment_method: String, + amount: String, + currency: String, + expires_at: u64, +} + +async fn create_spt( + State(state): State>, + Json(body): Json, +) -> impl IntoResponse { + let auth_value = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, format!("{}:", state.stripe_secret_key)); + + let params = [ + ("payment_method", body.payment_method), + ("usage_limits[currency]", body.currency), + ("usage_limits[max_amount]", body.amount), + ("usage_limits[expires_at]", body.expires_at.to_string()), + ]; + + let client = reqwest::Client::new(); + let resp = client + .post("https://api.stripe.com/v1/test_helpers/shared_payment/granted_tokens") + .header("Authorization", format!("Basic {auth_value}")) + .form(¶ms) + .send() + .await; + + match resp { + Ok(r) if r.status().is_success() => { + let json: serde_json::Value = r.json().await.unwrap_or_default(); + let spt = json["id"].as_str().unwrap_or_default(); + (StatusCode::OK, Json(serde_json::json!({ "spt": spt }))).into_response() + } + Ok(r) => { + let status = r.status().as_u16(); + let body = r.text().await.unwrap_or_default(); + eprintln!("Stripe SPT error ({status}): {body}"); + ( + StatusCode::BAD_GATEWAY, + Json(serde_json::json!({ "error": "SPT creation failed" })), + ) + .into_response() + } + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } +} + +async fn fortune( + State(state): State>, + headers: HeaderMap, +) -> impl IntoResponse { + // Check for payment credential in Authorization header + if let Some(auth) = headers.get(header::AUTHORIZATION) { + if let Ok(auth_str) = auth.to_str() { + if let Ok(credential) = parse_authorization(auth_str) { + match state.payment.verify_credential(&credential).await { + Ok(receipt) => { + let fortune = FORTUNES + .choose(&mut rand::rng()) + .unwrap_or(&"No fortune today."); + let receipt_header = receipt.to_header().unwrap_or_default(); + return ( + StatusCode::OK, + [("payment-receipt", receipt_header)], + Json(serde_json::json!({ + "fortune": fortune, + "receipt": receipt.reference, + })), + ) + .into_response(); + } + Err(e) => { + let body = serde_json::json!({ "error": e.to_string() }); + return (StatusCode::PAYMENT_REQUIRED, Json(body)).into_response(); + } + } + } + } + } + + // No valid credential — return 402 with challenge + match state.payment.stripe_charge("1") { + Ok(challenge) => match format_www_authenticate(&challenge) { + Ok(www_auth) => ( + StatusCode::PAYMENT_REQUIRED, + [(header::WWW_AUTHENTICATE, www_auth)], + Json(serde_json::json!({ "error": "Payment Required" })), + ) + .into_response(), + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + }, + Err(e) => ( + StatusCode::INTERNAL_SERVER_ERROR, + Json(serde_json::json!({ "error": e.to_string() })), + ) + .into_response(), + } +}