use anyhow::Result;
use axum::{
extract::State,
http::{HeaderMap, StatusCode},
response::{IntoResponse, Json, Response},
routing::{get, post},
Router,
};
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tracing::{error, info, warn};
use crate::pod_provisioning::PodProvisioningService;
#[derive(Debug, Clone)]
pub struct L402Payment {
pub token: String,
pub amount_msats: u64,
}
async fn extract_l402_payment(headers: &HeaderMap) -> Result<Option<L402Payment>, String> {
use crate::sidecar_service::extract_token_value;
let mut cashu_token: Option<String> = None;
if let Some(auth_header) = headers.get("authorization") {
if let Ok(auth_str) = auth_header.to_str() {
if auth_str.starts_with("Cashu ") || auth_str.starts_with("cashu ") {
cashu_token = Some(auth_str[6..].trim().to_string());
info!("✅ Found Cashu token in Authorization header");
}
}
}
if cashu_token.is_none() {
if let Some(x_cashu_header) = headers.get("x-cashu") {
if let Ok(x_cashu_str) = x_cashu_header.to_str() {
cashu_token = Some(x_cashu_str.trim().to_string());
info!("✅ Found Cashu token in X-Cashu header");
}
}
}
if let Some(token) = cashu_token {
match extract_token_value(&token).await {
Ok(amount_msats) => {
info!("✅ Decoded Cashu token: {} msats", amount_msats);
return Ok(Some(L402Payment {
token,
amount_msats,
}));
}
Err(e) => {
error!("❌ Failed to decode Cashu token: {}", e);
return Err(format!("Invalid Cashu token: {}", e));
}
}
}
Ok(None)
}
pub async fn run_http_l402_interface(service: Arc<PodProvisioningService>) -> Result<()> {
info!("🌐 Starting HTTP interface with L402 support...");
let bind_addr = std::env::var("HTTP_BIND_ADDR").unwrap_or_else(|_| "0.0.0.0:8080".to_string());
info!(
"✅ HTTP+L402 interface ready - listening on http://{}",
bind_addr
);
let app = Router::new()
.route("/health", get(health_check))
.route("/offers", get(get_offers))
.route("/pods/status", post(get_pod_status))
.route("/pods/spawn", post(spawn_pod_l402))
.route("/pods/topup", post(topup_pod_l402))
.with_state(service);
let listener = tokio::net::TcpListener::bind(&bind_addr)
.await
.map_err(|e| anyhow::anyhow!("Failed to bind to {}: {}", bind_addr, e))?;
axum::serve(listener, app)
.await
.map_err(|e| anyhow::anyhow!("HTTP server error: {}", e))?;
Ok(())
}
async fn health_check() -> Json<serde_json::Value> {
Json(serde_json::json!({
"status": "healthy",
"service": "paygress-l402",
"timestamp": chrono::Utc::now().to_rfc3339()
}))
}
async fn get_offers(
State(service): State<Arc<PodProvisioningService>>,
) -> Result<Json<serde_json::Value>, StatusCode> {
let request = crate::pod_provisioning::GetOffersTool {};
match service.get_offers(request).await {
Ok(response) => {
let offers_json = serde_json::json!({
"minimum_duration_seconds": response.minimum_duration_seconds,
"whitelisted_mints": response.whitelisted_mints,
"pod_specs": response.pod_specs,
"payment_info": {
"accepted_tokens": ["cashu"],
"header_format": "Authorization: Cashu <token> OR X-Cashu: <token>"
}
});
Ok(Json(offers_json))
}
Err(e) => {
error!("Failed to get offers: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn get_pod_status(
State(service): State<Arc<PodProvisioningService>>,
Json(request): Json<GetPodStatusHttpRequest>,
) -> Result<Json<serde_json::Value>, StatusCode> {
info!("📨 Received get pod status request via HTTP");
let status_tool = crate::pod_provisioning::GetPodStatusTool {
pod_npub: request.pod_npub,
};
match service.get_pod_status(status_tool).await {
Ok(response) => {
let response_json = serde_json::json!({
"success": response.success,
"message": response.message,
"pod_npub": response.pod_npub,
"found": response.found,
"created_at": response.created_at,
"expires_at": response.expires_at,
"time_remaining_seconds": response.time_remaining_seconds,
"pod_spec_name": response.pod_spec_name,
"cpu_millicores": response.cpu_millicores,
"memory_mb": response.memory_mb,
"status": response.status
});
Ok(Json(response_json))
}
Err(e) => {
error!("Failed to get pod status: {}", e);
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
async fn spawn_pod_l402(
State(service): State<Arc<PodProvisioningService>>,
headers: HeaderMap,
Json(mut request): Json<SpawnPodHttpRequest>,
) -> Response {
info!("📨 Received spawn pod request via HTTP+L402");
let extracted_payment = extract_l402_payment(&headers).await;
match extracted_payment {
Ok(Some(l402_payment)) => {
info!(
"✅ L402 payment from Authorization header: {} msats",
l402_payment.amount_msats
);
request.cashu_token = l402_payment.token.clone();
}
Ok(None) => {
if !request.cashu_token.is_empty() {
info!("✅ Using Cashu token from request body (direct call, bypassing nginx)");
} else {
error!("❌ No payment token found in headers or body");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Payment token missing",
"message": "Provide payment via Authorization header or cashu_token in body"
})),
)
.into_response();
}
}
Err(e) => {
error!("❌ Invalid payment token in header: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid Payment Token",
"message": e
})),
)
.into_response();
}
}
let spawn_tool = crate::pod_provisioning::SpawnPodTool {
cashu_token: request.cashu_token,
pod_spec_id: request.pod_spec_id,
pod_image: request.pod_image,
ssh_username: request.ssh_username,
ssh_password: request.ssh_password,
user_pubkey: request.user_pubkey,
};
match service.spawn_pod(spawn_tool).await {
Ok(response) => {
info!(
"✅ Pod spawned successfully: {}",
response.pod_npub.as_deref().unwrap_or("unknown")
);
let response_json = serde_json::json!({
"success": response.success,
"message": response.message,
"pod_npub": response.pod_npub,
"ssh_host": response.ssh_host,
"ssh_port": response.ssh_port,
"ssh_username": response.ssh_username,
"ssh_password": response.ssh_password,
"expires_at": response.expires_at,
"pod_spec_name": response.pod_spec_name,
"cpu_millicores": response.cpu_millicores,
"memory_mb": response.memory_mb,
"instructions": response.instructions
});
(StatusCode::OK, Json(response_json)).into_response()
}
Err(e) => {
error!("❌ Failed to spawn pod: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to spawn pod",
"message": e.to_string()
})),
)
.into_response()
}
}
}
async fn topup_pod_l402(
State(service): State<Arc<PodProvisioningService>>,
headers: HeaderMap,
Json(mut request): Json<TopUpPodHttpRequest>,
) -> Response {
info!("📨 Received topup pod request via HTTP+L402");
let extracted_payment = extract_l402_payment(&headers).await;
match extracted_payment {
Ok(Some(l402_payment)) => {
info!(
"✅ L402 payment from Authorization header for top-up: {} msats",
l402_payment.amount_msats
);
request.cashu_token = l402_payment.token;
}
Ok(None) => {
if !request.cashu_token.is_empty() {
info!("✅ Using Cashu token from request body for top-up (direct call, bypassing nginx)");
} else {
error!("❌ No payment token found in headers or body");
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Payment token missing",
"message": "Provide payment via Authorization header or cashu_token in body"
})),
)
.into_response();
}
}
Err(e) => {
error!("❌ Invalid payment token in header: {}", e);
return (
StatusCode::BAD_REQUEST,
Json(serde_json::json!({
"error": "Invalid Payment Token",
"message": e
})),
)
.into_response();
}
}
let topup_tool = crate::pod_provisioning::TopUpPodTool {
pod_npub: request.pod_npub,
cashu_token: request.cashu_token,
};
match service.topup_pod(topup_tool).await {
Ok(response) => {
info!("✅ Pod topped up successfully: {}", response.pod_npub);
let response_json = serde_json::json!({
"success": response.success,
"message": response.message,
"pod_npub": response.pod_npub,
"extended_duration_seconds": response.extended_duration_seconds,
"new_expires_at": response.new_expires_at
});
(StatusCode::OK, Json(response_json)).into_response()
}
Err(e) => {
error!("❌ Failed to topup pod: {}", e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({
"error": "Failed to topup pod",
"message": e.to_string()
})),
)
.into_response()
}
}
}
#[derive(Debug, Deserialize)]
struct SpawnPodHttpRequest {
#[serde(default)]
pub cashu_token: String,
pub pod_spec_id: Option<String>,
pub pod_image: String,
pub ssh_username: String,
pub ssh_password: String,
pub user_pubkey: Option<String>,
}
#[derive(Debug, Deserialize)]
struct TopUpPodHttpRequest {
pub pod_npub: String,
#[serde(default)]
pub cashu_token: String,
}
#[derive(Debug, Deserialize)]
struct GetPodStatusHttpRequest {
pub pod_npub: String,
}