solid-pod-rs-server 0.5.0-alpha.21

Drop-in Solid Pod server binary. Wraps solid-pod-rs with actix-web and a JSS-compatible layered config loader.
Documentation
//! Payment-state atomicity: concurrent writers to the shared payment state
//! (`/.well-known/webledgers/state.json`) are serialised by
//! `PAYMENT_STATE_LOCK`, so no read-modify-write loses another's update.

use std::sync::Arc;
use std::time::{SystemTime, UNIX_EPOCH};

use actix_web::{http::header, test};
use async_trait::async_trait;
use bytes::Bytes;
use serde_json::Value;
use solid_pod_rs::auth::nip98;
use solid_pod_rs::error::PodError;
use solid_pod_rs::storage::memory::MemoryBackend;
use solid_pod_rs::storage::{ResourceMeta, Storage, StorageEvent};
use solid_pod_rs_server::{build_app, AppState};

/// The in-memory backend completes every call without suspending, so two
/// handlers on one executor would never interleave and a missing lock would
/// go unnoticed. This wrapper yields before each read, which lets a second
/// request read the state between the first one's read and its commit.
struct YieldingStorage(MemoryBackend);

#[async_trait]
impl Storage for YieldingStorage {
    async fn get(&self, path: &str) -> Result<(Bytes, ResourceMeta), PodError> {
        tokio::task::yield_now().await;
        self.0.get(path).await
    }
    async fn put(&self, path: &str, body: Bytes, ct: &str) -> Result<ResourceMeta, PodError> {
        self.0.put(path, body, ct).await
    }
    async fn delete(&self, path: &str) -> Result<(), PodError> {
        self.0.delete(path).await
    }
    async fn list(&self, container: &str) -> Result<Vec<String>, PodError> {
        self.0.list(container).await
    }
    async fn head(&self, path: &str) -> Result<ResourceMeta, PodError> {
        self.0.head(path).await
    }
    async fn exists(&self, path: &str) -> Result<bool, PodError> {
        self.0.exists(path).await
    }
    async fn watch(
        &self,
        path: &str,
    ) -> Result<tokio::sync::mpsc::Receiver<StorageEvent>, PodError> {
        self.0.watch(path).await
    }
}

const SECRET: &str = "5555555555555555555555555555555555555555555555555555555555555555";

fn auth(body: &[u8], offset: u64) -> String {
    let now = SystemTime::now()
        .duration_since(UNIX_EPOCH)
        .unwrap()
        .as_secs()
        .saturating_sub(10)
        + offset;
    let token = nip98::mint_with_payload(
        "http://localhost:8080/pay/.sell",
        "POST",
        Some(body),
        SECRET,
        now,
    )
    .unwrap();
    format!("Nostr {token}")
}

/// Two orders placed at the same instant both land in the order book. An
/// unserialised read-modify-write would let the second commit overwrite the
/// first, leaving one order.
#[actix_web::test]
async fn concurrent_payment_state_writes_lose_no_update() {
    let storage = Arc::new(YieldingStorage(MemoryBackend::new()));
    // A seller may only list what they hold unlisted (c27cdfe): fund the
    // NIP-98 key with enough tbtc3 for both orders, through the legacy
    // ledger document the canonical store bootstraps from on first use.
    let seller = {
        let sk = hex::decode(SECRET).unwrap();
        let signing = k256::schnorr::SigningKey::from_slice(&sk).unwrap();
        format!(
            "did:nostr:{}",
            hex::encode(signing.verifying_key().to_bytes())
        )
    };
    let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
    ledger
        .credit_by_outpoint(&seller, "tbtc3", &"e1".repeat(32), 0, 1_000)
        .unwrap();
    storage
        .put(
            "/.well-known/webledgers/webledgers.json",
            serde_json::to_vec(&ledger).unwrap().into(),
            "application/json",
        )
        .await
        .unwrap();
    let app = test::init_service(build_app(AppState::new(storage))).await;

    let sell = |price: u64, offset: u64| {
        let body = format!(
            r#"{{"sell_currency":"tbtc3","sell_amount":10,"buy_currency":"sat","price":{price}}}"#
        );
        test::TestRequest::post()
            .uri("/pay/.sell")
            .insert_header((header::AUTHORIZATION, auth(body.as_bytes(), offset)))
            .insert_header((header::CONTENT_TYPE, "application/json"))
            .set_payload(body)
            .to_request()
    };
    let (first, second) = futures_util::future::join(
        test::call_service(&app, sell(100, 0)),
        test::call_service(&app, sell(200, 1)),
    )
    .await;
    assert_eq!(first.status().as_u16(), 200);
    assert_eq!(second.status().as_u16(), 200);

    let offers = test::call_service(
        &app,
        test::TestRequest::get().uri("/pay/.offers").to_request(),
    )
    .await;
    let offers: Value = test::read_body_json(offers).await;
    let mut prices: Vec<u64> = offers
        .as_array()
        .unwrap()
        .iter()
        .map(|o| o["price"].as_u64().unwrap())
        .collect();
    prices.sort_unstable();
    assert_eq!(prices, [100, 200], "both concurrent orders must persist");
}

/// Two concurrent sells that together exceed the seller's unlisted balance:
/// exactly one is listed. A balance check read outside the payment lock
/// would let both through and oversell.
#[actix_web::test]
async fn concurrent_sells_cannot_oversell_the_balance() {
    let storage = Arc::new(YieldingStorage(MemoryBackend::new()));
    let seller = {
        let sk = hex::decode(SECRET).unwrap();
        let signing = k256::schnorr::SigningKey::from_slice(&sk).unwrap();
        format!(
            "did:nostr:{}",
            hex::encode(signing.verifying_key().to_bytes())
        )
    };
    let mut ledger = solid_pod_rs::payments::WebLedger::new("Pod Credits");
    ledger
        .credit_by_outpoint(&seller, "tbtc3", &"e2".repeat(32), 0, 10)
        .unwrap();
    storage
        .put(
            "/.well-known/webledgers/webledgers.json",
            serde_json::to_vec(&ledger).unwrap().into(),
            "application/json",
        )
        .await
        .unwrap();
    let app = test::init_service(build_app(AppState::new(storage))).await;

    let sell = |price: u64, offset: u64| {
        let body = format!(
            r#"{{"sell_currency":"tbtc3","sell_amount":10,"buy_currency":"sat","price":{price}}}"#
        );
        test::TestRequest::post()
            .uri("/pay/.sell")
            .insert_header((header::AUTHORIZATION, auth(body.as_bytes(), offset)))
            .insert_header((header::CONTENT_TYPE, "application/json"))
            .set_payload(body)
            .to_request()
    };
    let (first, second) = futures_util::future::join(
        test::call_service(&app, sell(300, 0)),
        test::call_service(&app, sell(400, 1)),
    )
    .await;
    let mut codes = [first.status().as_u16(), second.status().as_u16()];
    codes.sort_unstable();
    assert_eq!(codes, [200, 402], "exactly one sell may list the balance");

    let offers = test::call_service(
        &app,
        test::TestRequest::get().uri("/pay/.offers").to_request(),
    )
    .await;
    let offers: Value = test::read_body_json(offers).await;
    assert_eq!(offers.as_array().unwrap().len(), 1, "{offers}");
}