diff --git a/apps/indeedhub/manifest.yml b/apps/indeedhub/manifest.yml index 2beb4f82..31605ef9 100644 --- a/apps/indeedhub/manifest.yml +++ b/apps/indeedhub/manifest.yml @@ -69,10 +69,10 @@ app: - copy_from_host: src: "web-ui/nostr-provider.js" dest: "/usr/share/nginx/html/nostr-provider.js" - - exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = \/sw.js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"] - - exec: ["sh", "-c", "grep -q nostr-provider /etc/nginx/conf.d/default.conf || sed -i 's###' /etc/nginx/conf.d/default.conf"] - - exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf"] - - exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf"] + - exec: ["sh", "-c", "grep -qF 'location = /nostr-provider.js {' /etc/nginx/conf.d/default.conf || sed -i '/location = .*sw[.]js {/i\\ location = /nostr-provider.js {\\n add_header Cache-Control \"no-cache, no-store, must-revalidate\";\\n expires off;\\n }\\n' /etc/nginx/conf.d/default.conf"] + - exec: ["sh", "-c", "if ! grep -qE ']*nostr-provider' /usr/share/nginx/html/index.html && ! grep -qE '(sub_filter|##' /usr/share/nginx/html/index.html; fi"] + - exec: ["sed", "-i", "s#tab-signer-v2#tab-signer-v4#g; s#tab-signer-v3#tab-signer-v4#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"] + - exec: ["sed", "-i", "s#src=\"/nostr-provider.js\"#src=\"/nostr-provider.js?v=tab-signer-v4\"#g", "/etc/nginx/conf.d/default.conf", "/usr/share/nginx/html/index.html"] - exec: ["nginx", "-s", "reload"] # TCP liveness on the nginx port, NOT an http GET of /. nginx binds 7777 at diff --git a/core/archipelago/src/api/handler/cloud_purchase.rs b/core/archipelago/src/api/handler/cloud_purchase.rs new file mode 100644 index 00000000..70feef3f --- /dev/null +++ b/core/archipelago/src/api/handler/cloud_purchase.rs @@ -0,0 +1,142 @@ +//! Permanent Cloud snapshot delivery. Current source path/share state cannot +//! revoke a settled immutable snapshot; no rental clock is started here. +use super::{build_response, ApiHandler}; +use crate::{ + content_purchase::{Journal, SellerPhase}, + content_server::ByteRange, +}; +use anyhow::{Context, Result}; +use hyper::{Body, HeaderMap, Response, StatusCode}; +use tokio::io::{AsyncReadExt, AsyncSeekExt}; +impl ApiHandler { + pub(super) async fn handle_cloud_purchase( + &self, + path: &str, + headers: &HeaderMap, + ) -> Result> { + let (content_id, purchase_id) = path + .strip_prefix("/content/") + .and_then(|value| value.split_once("/purchase/")) + .context("Invalid purchase delivery route")?; + anyhow::ensure!( + !content_id.contains('/') + && !content_id.starts_with("registered_") + && !purchase_id.contains('/'), + "Invalid Cloud delivery route" + ); + let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?; + let buyer = crate::content_auth::incoming( + headers, + &audience, + path, + chrono::Utc::now().timestamp(), + )? + .context("Authenticated peer proof is required")?; + let mut values = headers.get_all("x-content-capability").iter(); + let capability = values + .next() + .context("Delivery capability is required")? + .to_str()?; + anyhow::ensure!(values.next().is_none(), "Duplicate delivery capability"); + let (contract, mime) = { + let journal = Journal::open(&self.config.data_dir).await?; + let record = journal + .seller(purchase_id) + .await? + .context("Purchase settlement not found")?; + let receipt = match record.phase { + SellerPhase::ReceiptSaved(receipt) => receipt, + _ => anyhow::bail!("Purchase settlement is not durable"), + }; + anyhow::ensure!( + record.contract.buyer_did == buyer + && record.contract.seller_did == audience + && record.contract.content_id == content_id + && receipt.capability == capability, + "Purchase delivery binding changed" + ); + let envelope = journal + .protocol_envelope("seller", purchase_id) + .await? + .context("Original delivery metadata is missing")?; + anyhow::ensure!( + envelope.contract()? == record.contract, + "Delivery metadata binding changed" + ); + (record.contract, envelope.offer.mime_type) + }; + let range = headers + .get("range") + .map(|value| -> Result<_> { + crate::content_server::parse_range_header(value.to_str()?).context("Invalid range") + }) + .transpose()?; + let total = contract.content_size; + let (start, end, partial) = match range { + None => (0, total - 1, false), + Some(ByteRange::From { start, end }) => { + (start, end.unwrap_or(total - 1).min(total - 1), true) + } + Some(ByteRange::Suffix(count)) if count > 0 => { + (total.saturating_sub(count), total - 1, true) + } + _ => anyhow::bail!("Invalid range"), + }; + if start > end || start >= total { + let mut response = build_response( + StatusCode::RANGE_NOT_SATISFIABLE, + "text/plain", + Body::empty(), + ); + response + .headers_mut() + .insert("content-range", format!("bytes */{total}").parse()?); + return Ok(response); + } + let data = self.config.data_dir.clone(); + let file = tokio::task::spawn_blocking(move || { + crate::content_snapshot::open_matching( + &data, + &contract.content_id, + &contract.content_sha256, + contract.content_size, + ) + }) + .await??; + let mut file = tokio::fs::File::from_std(file.file); + file.seek(std::io::SeekFrom::Start(start)).await?; + let length = end - start + 1; + let chunks = + futures_util::stream::try_unfold((file, length), |(mut file, left)| async move { + if left == 0 { + return Ok::<_, std::io::Error>(None); + } + let mut bytes = vec![0; left.min(65536) as usize]; + let count = file.read(&mut bytes).await?; + if count == 0 { + return Err(std::io::Error::new( + std::io::ErrorKind::UnexpectedEof, + "Purchase snapshot ended early", + )); + } + bytes.truncate(count); + Ok(Some((bytes, (file, left - count as u64)))) + }); + let mut response = Response::builder() + .status(if partial { + StatusCode::PARTIAL_CONTENT + } else { + StatusCode::OK + }) + .header("content-type", mime) + .header("content-length", length) + .header("accept-ranges", "bytes") + .header("cache-control", "private, no-store") + .header("x-content-type-options", "nosniff") + .header("content-security-policy", "sandbox; default-src 'none'"); + if partial { + response = response.header("content-range", format!("bytes {start}-{end}/{total}")); + } + Ok(response.body(Body::wrap_stream(chunks))?) + } +} diff --git a/core/archipelago/src/api/handler/content.rs b/core/archipelago/src/api/handler/content.rs index aba547d5..c456f430 100644 --- a/core/archipelago/src/api/handler/content.rs +++ b/core/archipelago/src/api/handler/content.rs @@ -416,7 +416,7 @@ impl ApiHandler { path: &str, ) -> Result> { Ok(invoice_status_response(path, |hash, id| async move { - self.rpc_handler.settle_content_invoice(&hash, &id).await + self.rpc_handler.content_invoice_lifecycle(&hash, &id).await }) .await) } @@ -663,7 +663,7 @@ impl ApiHandler { async fn invoice_status_response(path: &str, settle: F) -> Response where F: FnOnce(String, String) -> Fut, - Fut: std::future::Future>, + Fut: std::future::Future>, { let parsed = path .strip_prefix("/content/") @@ -682,10 +682,10 @@ where ); }; match settle(hash.to_ascii_lowercase(), id.to_owned()).await { - Ok(paid) => build_response( + Ok(body) => build_response( StatusCode::OK, "application/json", - hyper::Body::from(serde_json::json!({"paid": paid}).to_string()), + hyper::Body::from(body.to_string()), ), Err(_) => { tracing::warn!("Peer-file payment status verification is temporarily unavailable"); @@ -721,7 +721,7 @@ mod invoice_status_tests { let response = invoice_status_response(path, |_, _| async { panic!("Invalid request reached wallet"); #[allow(unreachable_code)] - Ok(false) + Ok(serde_json::json!({"paid":false})) }) .await; assert_eq!(response.status(), StatusCode::BAD_REQUEST); @@ -741,7 +741,7 @@ mod invoice_status_tests { let response = invoice_status_response(&path, |hash, id| async move { assert_eq!(hash, "ab".repeat(32)); assert_eq!(id, "file"); - Ok(paid) + Ok(serde_json::json!({"paid":paid})) }) .await; assert_eq!(response.status(), StatusCode::OK); diff --git a/core/archipelago/src/api/handler/mod.rs b/core/archipelago/src/api/handler/mod.rs index a87b6d0f..f615912d 100644 --- a/core/archipelago/src/api/handler/mod.rs +++ b/core/archipelago/src/api/handler/mod.rs @@ -1,7 +1,10 @@ +mod purchase; +mod cloud_purchase; mod blob; mod cdp; mod content; mod registered_media; +mod rental_playback; mod dwn; mod model_proxy; mod node_message; @@ -385,6 +388,10 @@ impl ApiHandler { let path = req.uri().path().to_string(); let method = req.method().clone(); + if path.starts_with("/api/rental-playback/") { + return self.handle_local_rental_request(&method, &path, req.headers()).await; + } + // Handle CORS preflight for all routes if method == Method::OPTIONS { let mut builder = Response::builder() @@ -445,6 +452,14 @@ impl ApiHandler { .await; } + // Purchase routes bound the original body before the generic buffer. + if method == Method::POST && matches!(path.as_str(), + crate::content_purchase_protocol::OFFER_ROUTE | crate::content_purchase_protocol::ACCEPT_ROUTE + | crate::content_purchase_protocol::SETTLE_ROUTE | crate::content_purchase_protocol::STATUS_ROUTE + | crate::content_purchase_protocol::CANCEL_ROUTE) { + return self.handle_purchase_request(req).await; + } + // Convert body to bytes for non-WS routes let headers = req.headers().clone(); let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default(); @@ -587,6 +602,9 @@ impl ApiHandler { // Immutable registered rentals use durable seller receipts and their // first-open window, never legacy mutable filename shares. + (Method::GET, p) if p.starts_with("/content/") && p.contains("/purchase/") => { + self.handle_cloud_purchase(p, &headers).await + } (Method::GET, p) if p.starts_with("/content/registered_") && p.contains("/rental/") => { self.handle_registered_rental(p, &headers).await } diff --git a/core/archipelago/src/api/handler/purchase.rs b/core/archipelago/src/api/handler/purchase.rs new file mode 100644 index 00000000..1a469584 --- /dev/null +++ b/core/archipelago/src/api/handler/purchase.rs @@ -0,0 +1,170 @@ +//! Add as api/handler/purchase.rs; dispatch only exact supported POST routes. +use super::{build_response, ApiHandler}; +use crate::{ + content_purchase::Journal, content_purchase_protocol as protocol, identity::NodeIdentity, +}; +use anyhow::{Context, Result}; +use hyper::{body::HttpBody, Body, Method, Request, Response, StatusCode}; +use serde::Deserialize; + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct OfferRequest { + id: String, + content_id: String, +} +impl ApiHandler { + pub(super) async fn handle_purchase_request( + &self, + mut request: Request, + ) -> Result> { + let path = request.uri().path().to_owned(); + anyhow::ensure!( + request.method() == Method::POST + && matches!( + path.as_str(), + protocol::OFFER_ROUTE + | protocol::ACCEPT_ROUTE + | protocol::SETTLE_ROUTE + | protocol::STATUS_ROUTE + | protocol::CANCEL_ROUTE + ), + "Unsupported purchase route" + ); + let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?; + let bytes = tokio::time::timeout(std::time::Duration::from_secs(15), async { + let mut bytes = Vec::new(); + while let Some(chunk) = request.body_mut().data().await { + let chunk = chunk?; + anyhow::ensure!( + bytes + .len() + .checked_add(chunk.len()) + .is_some_and(|n| n <= 1024 * 1024), + "Purchase body too large" + ); + bytes.extend_from_slice(&chunk); + } + Ok::<_, anyhow::Error>(bytes) + }) + .await + .context("Purchase body timed out")??; + let buyer = crate::content_auth::authenticate_request( + request.headers(), + &audience, + &Method::POST, + &path, + &bytes, + chrono::Utc::now().timestamp(), + )?; + let data_dir = &self.config.data_dir; + let result = match path.as_str() { + protocol::OFFER_ROUTE => { + let body: OfferRequest = serde_json::from_slice(&bytes)?; + anyhow::ensure!( + uuid::Uuid::parse_str(&body.id)?.to_string() == body.id, + "Invalid operation identifier" + ); + let saved = { + Journal::open(data_dir) + .await? + .protocol_offer(&body.id) + .await? + }; + let offer = if let Some(saved) = saved { + anyhow::ensure!( + saved.buyer_did == buyer && saved.content_id == body.content_id, + "Original offer binding changed" + ); + saved + } else { + // Registration pins and immutable snapshot are node-owned; + // no content hash/price/path is accepted from the request. + if !body.content_id.starts_with("registered_") { + let wallet = crate::wallet::ecash::load_wallet(data_dir).await?; + let offer = crate::content_cloud_offer::offer( + data_dir, + &body.id, + &body.content_id, + &buyer, + &audience, + crate::wallet::ecash::load_network(data_dir).await?, + wallet.mint_url.trim_end_matches('/'), + crate::content_cloud_offer::SnapshotPolicy { + max_file_bytes: 64 * 1024 * 1024 * 1024, + max_total_bytes: 64 * 1024 * 1024 * 1024, + minimum_free_bytes: 512 * 1024 * 1024, + }, + ) + .await?; + return Ok(build_response( + StatusCode::OK, + "application/json", + Body::from(serde_json::to_vec(&offer)?), + )); + } + let identity = NodeIdentity::load_existing(&data_dir.join("identity")).await?; + anyhow::ensure!(identity.did_key()? == audience, "Node identity changed"); + let selected = body.content_id.clone(); + let root = data_dir.clone(); + let (receipt, terms) = tokio::task::spawn_blocking(move || { + crate::registered_media::registered_terms(&root, &identity, &selected) + }) + .await??; + anyhow::ensure!( + receipt + .payment_methods + .iter() + .any(|method| method == "cashu"), + "Content does not accept Cashu" + ); + let now = chrono::Utc::now().timestamp(); + let deadline = now.checked_add(120).context("Offer clock overflow")?; + let wallet = crate::wallet::ecash::load_wallet(data_dir).await?; + let offer = protocol::Offer { + id: body.id, + buyer_did: buyer.clone(), + seller_did: audience.clone(), + filename: receipt.content_id.clone(), + mime_type: "application/octet-stream".into(), + content_id: receipt.content_id, + content_sha256: receipt.sha256, + content_size: receipt.size_bytes.parse()?, + viewing_seconds: Some(receipt.viewing_seconds), + terms_sha256: terms, + network: crate::wallet::ecash::load_network(data_dir).await?, + mint_url: wallet.mint_url.trim_end_matches('/').to_owned(), + seller_net_sats: receipt.price_sats, + offered_at: now, + expires_at: deadline, + }; + protocol::save_offer(data_dir, &offer, &buyer, now).await? + }; + protocol::ensure_seller_mint_policy(data_dir, offer.network, &offer.mint_url) + .await?; + serde_json::to_value(offer)? + } + protocol::ACCEPT_ROUTE => serde_json::to_value( + protocol::accept(data_dir, &serde_json::from_slice(&bytes)?, &buyer, || { + chrono::Utc::now().timestamp() + }) + .await?, + )?, + protocol::SETTLE_ROUTE => serde_json::to_value( + protocol::settle(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?, + )?, + protocol::CANCEL_ROUTE => serde_json::to_value( + protocol::cancel(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?, + )?, + protocol::STATUS_ROUTE => serde_json::to_value( + protocol::status(data_dir, &serde_json::from_slice(&bytes)?, &buyer).await?, + )?, + _ => unreachable!(), + }; + Ok(build_response( + StatusCode::OK, + "application/json", + Body::from(serde_json::to_vec(&result)?), + )) + } +} diff --git a/core/archipelago/src/api/handler/rental_playback.rs b/core/archipelago/src/api/handler/rental_playback.rs new file mode 100644 index 00000000..e251f938 --- /dev/null +++ b/core/archipelago/src/api/handler/rental_playback.rs @@ -0,0 +1,548 @@ +//! Local browser playback. Only opaque local handles cross the browser boundary. +use super::{build_response, ApiHandler}; +use crate::{content_purchase::Journal, content_server::ByteRange, identity::NodeIdentity}; +use anyhow::{Context, Result}; +use hyper::{Body, HeaderMap, Method, Response, StatusCode}; +use std::{ + io, + sync::Arc, + time::{Duration, Instant}, +}; + +fn requested_bounds(headers: &HeaderMap, total: u64) -> Result> { + anyhow::ensure!(total > 0, "Empty purchased media"); + anyhow::ensure!( + headers.get_all("range").iter().count() <= 1, + "Ambiguous playback ranges" + ); + let Some(header) = headers.get("range") else { + return Ok(None); + }; + let range = crate::content_server::parse_range_header(header.to_str()?) + .context("Invalid playback byte range")?; + let last = total - 1; + let (start, end) = match range { + ByteRange::From { start, end } => (start, end.unwrap_or(last).min(last)), + ByteRange::Suffix(count) => { + anyhow::ensure!(count > 0, "Invalid byte range"); + (total.saturating_sub(count), last) + } + }; + anyhow::ensure!(start <= end && start < total, "Invalid playback byte range"); + Ok(Some((start, end))) +} +fn validate_upstream( + status: u16, + headers: &HeaderMap, + total: u64, + bounds: Option<(u64, u64)>, + now: u64, +) -> Result<(u16, u64, String, u64)> { + let expected_status = if bounds.is_some() { 206 } else { 200 }; + anyhow::ensure!( + status == expected_status, + "Seller returned another range status" + ); + let length = bounds.map_or(total, |(start, end)| end - start + 1); + anyhow::ensure!( + headers + .get("content-length") + .and_then(|v| v.to_str().ok()) + .and_then(|v| v.parse::().ok()) + == Some(length), + "Seller changed purchased byte length" + ); + if let Some((start, end)) = bounds { + let expected = format!("bytes {start}-{end}/{total}"); + anyhow::ensure!( + headers.get("content-range").and_then(|v| v.to_str().ok()) == Some(expected.as_str()), + "Seller changed purchased byte range" + ); + } + let mime = headers + .get("content-type") + .context("Missing media type")? + .to_str()? + .to_owned(); + anyhow::ensure!( + mime.starts_with("video/") || mime.starts_with("audio/"), + "Unsupported rental media type" + ); + let expires = headers + .get("x-rental-expires-at") + .context("Missing rental expiry")? + .to_str()? + .parse::()?; + anyhow::ensure!(expires > now, "Rental viewing window ended"); + Ok((expected_status, length, mime, expires)) +} + +fn installed_playback_origin( + origin: &str, + expected: &str, + host: &str, + gated_tls_port: bool, +) -> bool { + let (Ok(actual), Ok(expected), Ok(request)) = ( + reqwest::Url::parse(origin), + reqwest::Url::parse(expected), + reqwest::Url::parse(&format!("http://{host}")), + ) else { + return false; + }; + if !matches!(actual.scheme(), "http" | "https") + || actual.origin().ascii_serialization() != origin + || request.path() != "/" + || !request.username().is_empty() + || request.password().is_some() + || request.query().is_some() + || request.fragment().is_some() + { + return false; + } + if actual.origin() == expected.origin() { + return true; + } + let same_port = actual.port_or_known_default() == expected.port_or_known_default(); + let scheme = actual.scheme() == expected.scheme() + || (gated_tls_port && actual.scheme() == "https" && expected.scheme() == "http"); + let expected_loopback = matches!( + expected.host_str(), + Some("localhost" | "127.0.0.1" | "[::1]") + ); + same_port + && scheme + && actual.host_str() == request.host_str() + && (expected_loopback || actual.host_str() == expected.host_str()) +} + +fn unix_now() -> Result { + Ok(std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH)? + .as_secs()) +} +fn stream_error(message: &'static str) -> io::Error { + io::Error::new(io::ErrorKind::PermissionDenied, message) +} + +impl ApiHandler { + pub(super) async fn handle_local_rental_request( + &self, + method: &Method, + path: &str, + headers: &HeaderMap, + ) -> Result> { + // HEAD is deliberately not GET: a browser probe must never open a lease. + if method != Method::GET && method != Method::OPTIONS { + return Ok(Response::builder() + .status(StatusCode::METHOD_NOT_ALLOWED) + .header("Allow", "GET, OPTIONS") + .header("Cache-Control", "no-store") + .body(Body::empty())?); + } + let origin = headers.get("origin").map(|v| v.to_str()).transpose()?; + if let Some(origin) = origin { + let identity = + NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?; + let (state, _) = self.state_manager.get_snapshot().await; + let root = self.config.data_dir.clone(); + let context = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&root, &identity, &state) + }) + .await??; + let host = headers + .get("host") + .and_then(|value| value.to_str().ok()) + .unwrap_or(""); + let port = reqwest::Url::parse(origin) + .ok() + .and_then(|url| url.port_or_known_default()); + let ports = self.rpc_handler.app_gate.port_map().await; + let gated_tls_port = port + .and_then(|port| ports.gated(port)) + .is_some_and(|gate| gate.app_id == context.app_id && gate.declared); + if !context + .app_origins + .iter() + .any(|allowed| installed_playback_origin(origin, allowed, host, gated_tls_port)) + { + return Ok(build_response( + StatusCode::FORBIDDEN, + "text/plain", + Body::from("Playback origin is not the installed app"), + )); + } + } + let mut response = if method == Method::OPTIONS { + Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty())? + } else { + self.handle_local_rental(path, headers).await? + }; + response + .headers_mut() + .insert("Cache-Control", "private, no-store".parse()?); + response.headers_mut().insert("Vary", "Origin".parse()?); + if let Some(origin) = origin { + response + .headers_mut() + .insert("Access-Control-Allow-Origin", origin.parse()?); + response + .headers_mut() + .insert("Access-Control-Allow-Credentials", "true".parse()?); + response + .headers_mut() + .insert("Access-Control-Allow-Methods", "GET, OPTIONS".parse()?); + response + .headers_mut() + .insert("Access-Control-Allow-Headers", "Range".parse()?); + response.headers_mut().insert( + "Access-Control-Expose-Headers", + "Content-Length, Content-Range, Accept-Ranges, X-Rental-Expires-At".parse()?, + ); + } + Ok(response) + } + + /// Dispatcher accepts GET only after normal session handling. HEAD and other + /// methods never reach upstream, so metadata probes cannot start a lease. + pub(super) async fn handle_local_rental( + &self, + path: &str, + headers: &HeaderMap, + ) -> Result> { + let token = match crate::session::extract_session_cookie(headers) { + Some(token) if self.session_store.validate(&token).await => token, + _ => return Ok(Self::unauthorized()), + }; + let handle = path + .strip_prefix("/api/rental-playback/") + .context("Invalid playback route")?; + let identity = + Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?); + let (state, _) = self.state_manager.get_snapshot().await; + let root = self.config.data_dir.clone(); + let key = identity.clone(); + let context = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&root, &key, &state) + }) + .await??; + let binding = self + .rpc_handler + .playback_handles() + .lookup(handle, &token, &context)?; + let capability = { + let journal = Journal::open(&self.config.data_dir).await?; + let record = journal + .buyer(&binding.contract.id) + .await? + .context("Original purchase is missing")?; + anyhow::ensure!( + record.contract == binding.contract, + "Original purchase changed" + ); + record + .receipt() + .context("Original purchase is not settled")? + .capability + .clone() + }; + let peer = crate::federation::load_unique_payment_peer( + &self.config.data_dir, + &binding.seller_onion, + ) + .await?; + anyhow::ensure!( + peer.did == binding.contract.seller_did, + "Purchased seller identity changed" + ); + let mesh = peer + .fips_npub + .context("Seller mesh binding is unavailable")?; + let total = binding.contract.content_size; + let bounds = match requested_bounds(headers, total) { + Ok(bounds) => bounds, + Err(_) => { + return Ok(Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header("Content-Range", format!("bytes */{total}")) + .body(Body::empty())?) + } + }; + let remote_path = format!( + "/content/{}/rental/{}", + binding.contract.content_id, binding.contract.id + ); + let mut request = + crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &remote_path) + .require_fips() + .single_delivery() + .timeout(Duration::from_secs(24 * 60 * 60)) + .header("X-Content-Capability", capability); + if let Some((start, end)) = bounds { + request = request.header("Range", format!("bytes={start}-{end}")); + } + let (response, transport) = tokio::time::timeout( + Duration::from_secs(20), + request.send_content_get(&self.config.data_dir), + ) + .await + .context("Seller did not begin the original rental stream")??; + if !response.status().is_success() { + // Never forward arbitrary upstream bodies, redirects, cookies or private headers. + let status = if response.status().as_u16() == 403 { + StatusCode::FORBIDDEN + } else { + StatusCode::BAD_GATEWAY + }; + return Ok(build_response( + status, + "text/plain", + Body::from( + "Original rental is unavailable; recover this purchase without paying again", + ), + )); + } + let (expected_status, length, mime, expires) = validate_upstream( + response.status().as_u16(), + response.headers(), + total, + bounds, + unix_now()?, + )?; + self.rpc_handler + .playback_handles() + .note_expiry(handle, &binding, expires)?; + let sessions = self.session_store.clone(); + let state_manager = self.state_manager.clone(); + let data_dir = self.config.data_dir.clone(); + let chunks = futures_util::stream::try_unfold( + (response, length, None::), + move |(mut response, left, mut checked)| { + let sessions = sessions.clone(); + let token = token.clone(); + let state_manager = state_manager.clone(); + let data_dir = data_dir.clone(); + let identity = identity.clone(); + let context = context.clone(); + async move { + if unix_now().map_err(|_| stream_error("Playback clock unavailable"))? + >= expires + { + return Err(stream_error("Rental viewing window ended")); + } + if left == 0 { + return Ok::<_, io::Error>(None); + } + let waiting_since = Instant::now(); + loop { + if waiting_since.elapsed() >= Duration::from_secs(30) { + return Err(io::Error::new( + io::ErrorKind::TimedOut, + "Rental stream stalled; reopen the original purchase", + )); + } + if unix_now().map_err(|_| stream_error("Playback clock unavailable"))? + >= expires + { + return Err(stream_error("Rental viewing window ended")); + } + if checked.is_none_or(|at| at.elapsed() >= Duration::from_secs(1)) { + if !sessions.validate(&token).await { + return Err(stream_error("Playback session ended")); + } + let (state, _) = state_manager.get_snapshot().await; + let root = data_dir.clone(); + let key = identity.clone(); + let actual = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context( + &root, &key, &state, + ) + }) + .await + .map_err(|_| stream_error("Playback app context unavailable"))? + .map_err(|_| stream_error("Playback app context unavailable"))?; + if actual != context { + return Err(stream_error("Playback app context changed")); + } + checked = Some(Instant::now()); + } + // Keep checking revocation while the peer stalls; no local media cache. + let chunk = tokio::select! { + chunk=response.chunk() => chunk.map_err(|_|io::Error::new(io::ErrorKind::ConnectionAborted,"Rental stream interrupted; reopen the original purchase"))?, + _=tokio::time::sleep(Duration::from_secs(1)) => continue, + }; + let bytes = chunk.ok_or_else(|| { + io::Error::new( + io::ErrorKind::UnexpectedEof, + "Purchased media ended early", + ) + })?; + if bytes.len() as u64 > left { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "Purchased media exceeded its declared length", + )); + } + let remaining = left - bytes.len() as u64; + return Ok(Some((bytes, (response, remaining, checked)))); + } + } + }, + ); + let mut result = Response::builder() + .status(expected_status) + .header("Content-Type", mime) + .header("Content-Length", length) + .header("Accept-Ranges", "bytes") + .header("Cache-Control", "private, no-store") + .header("X-Content-Type-Options", "nosniff") + .header("X-Rental-Expires-At", expires) + .header("X-Archipelago-Transport", transport.to_string()); + if let Some((start, end)) = bounds { + result = result.header("Content-Range", format!("bytes {start}-{end}/{total}")); + } + Ok(result.body(Body::wrap_stream(chunks))?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + #[test] + fn installed_origin_maps_only_current_host_and_verified_app_port() { + assert!(installed_playback_origin( + "http://192.168.1.5:7778", + "http://127.0.0.1:7778", + "192.168.1.5", + false + )); + assert!(installed_playback_origin( + "https://192.168.1.5:7778", + "http://127.0.0.1:7778", + "192.168.1.5", + true + )); + assert!(installed_playback_origin( + "https://[fd00::5]:7778", + "https://[::1]:7778", + "[fd00::5]:443", + false + )); + for (actual, host, tls) in [ + ("https://192.168.1.5:7778", "192.168.1.5", false), + ("http://evil.test:7778", "192.168.1.5", true), + ("http://192.168.1.5:7779", "192.168.1.5", true), + ("http://192.168.1.5:7778", "evil.test", true), + ("http://192.168.1.5:7778/path", "192.168.1.5", true), + ("http://192.168.1.5:7778", "user@192.168.1.5", true), + ] { + assert!( + !installed_playback_origin(actual, "http://127.0.0.1:7778", host, tls), + "{actual} {host}" + ); + } + } + + #[test] + fn range_bounds_follow_purchased_size_and_reject_ambiguous_ranges() { + let check = |range: &str| { + let mut h = HeaderMap::new(); + h.insert("range", range.parse().unwrap()); + requested_bounds(&h, 100) + }; + assert_eq!(check("bytes=20-39").unwrap(), Some((20, 39))); + assert_eq!(check("bytes=90-").unwrap(), Some((90, 99))); + assert_eq!(check("bytes=-10").unwrap(), Some((90, 99))); + assert_eq!(check("bytes=-200").unwrap(), Some((0, 99))); + assert_eq!(check("bytes=90-500").unwrap(), Some((90, 99))); + for range in [ + "bytes=100-", + "bytes=20-10", + "bytes=-0", + "bytes=0-1,4-6", + "other=0-1", + ] { + assert!(check(range).is_err(), "{range}"); + } + assert_eq!(requested_bounds(&HeaderMap::new(), 100).unwrap(), None); + assert!(requested_bounds(&HeaderMap::new(), 0).is_err()); + } + #[test] + fn upstream_range_expiry_and_media_headers_are_bound_before_bytes_escape() { + let mut headers = HeaderMap::new(); + for (name, value) in [ + ("content-length", "20"), + ("content-range", "bytes 20-39/100"), + ("content-type", "video/mp4"), + ("x-rental-expires-at", "200"), + ] { + headers.insert(name, value.parse().unwrap()); + } + assert_eq!( + validate_upstream(206, &headers, 100, Some((20, 39)), 100).unwrap(), + (206, 20, "video/mp4".into(), 200) + ); + assert!(validate_upstream(200, &headers, 100, Some((20, 39)), 100).is_err()); + assert!(validate_upstream(206, &headers, 100, Some((20, 39)), 200).is_err()); + for (name, bad) in [ + ("content-length", "21"), + ("content-range", "bytes 21-40/100"), + ("content-type", "text/html"), + ("x-rental-expires-at", "0"), + ] { + let mut changed = headers.clone(); + changed.insert(name, bad.parse().unwrap()); + assert!( + validate_upstream(206, &changed, 100, Some((20, 39)), 100).is_err(), + "{name}" + ); + } + headers.remove("content-range"); + headers.insert("content-length", "100".parse().unwrap()); + assert!(validate_upstream(200, &headers, 100, None, 100).is_ok()); + } + + #[tokio::test] + async fn metadata_probes_and_unauthenticated_get_do_not_touch_purchase_or_identity() { + let root = tempfile::tempdir().unwrap(); + let mut config = crate::config::Config::default(); + config.data_dir = root.path().to_path_buf(); + let handler = ApiHandler::new( + config, + Arc::new(crate::state::StateManager::new()), + Arc::new(crate::monitoring::MetricsStore::new()), + None, + None, + ) + .await + .unwrap(); + // ApiHandler initialization may establish its own node identity, but the + // denied route must not need installed apps, saved receipts or any peer. + for method in [Method::HEAD, Method::POST] { + let result = handler + .handle_request( + hyper::Request::builder() + .method(method) + .uri("/api/rental-playback/invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(result.status(), StatusCode::METHOD_NOT_ALLOWED); + } + let result = handler + .handle_request( + hyper::Request::builder() + .uri("/api/rental-playback/invalid") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(result.status(), StatusCode::UNAUTHORIZED); + assert!(!root.path().join("content-purchases").exists()); + } +} diff --git a/core/archipelago/src/api/rpc/dispatcher.rs b/core/archipelago/src/api/rpc/dispatcher.rs index e743156b..e04bf941 100644 --- a/core/archipelago/src/api/rpc/dispatcher.rs +++ b/core/archipelago/src/api/rpc/dispatcher.rs @@ -332,7 +332,24 @@ impl RpcHandler { "content.download-peer-paid" => self.handle_content_download_peer_paid(params).await, "content.indeehub-projects" => self.handle_content_indeehub_projects().await, "content.browse-all-peers" => self.handle_content_browse_all_peers().await, + "content.playback-handle" => self.handle_playback_handle(params, session_token).await, + "content.playback-status" => self.handle_playback_status(params, session_token).await, + "content.rental-purchase" => self.handle_content_rental_purchase(params).await, + "content.purchase" => self.handle_content_purchase(params).await, + "content.cancel-purchase" => self.handle_content_cancel_purchase(params).await, "content.payment-status" => self.handle_content_payment_status(params).await, + "media.registration.context" => { + self.handle_media_registration_context(params.unwrap_or_default()) + .await + } + "media.registration.prepare" => { + self.handle_media_registration_prepare(params.unwrap_or_default()) + .await + } + "media.registration.resolve" => { + self.handle_media_registration_resolve(params.unwrap_or_default()) + .await + } "content.owned-list" => self.handle_content_owned_list().await, "content.owned-get" => self.handle_content_owned_get(params).await, "content.request-invoice" => self.handle_content_request_invoice(params).await, diff --git a/core/archipelago/src/api/rpc/lnd/wallet.rs b/core/archipelago/src/api/rpc/lnd/wallet.rs index c7f79e1e..b08254a0 100644 --- a/core/archipelago/src/api/rpc/lnd/wallet.rs +++ b/core/archipelago/src/api/rpc/lnd/wallet.rs @@ -7,6 +7,57 @@ use zeroize::Zeroize; use super::LND_REST_BASE_URL; impl RpcHandler { + // Add inside api/rpc/lnd/wallet.rs RpcHandler impl; seller owns this lookup. + // Wire invoice-status response to this Value instead of reducing it to paid bool. + pub(crate) async fn content_invoice_lifecycle( + &self, + hash: &str, + content_id: &str, + ) -> Result { + anyhow::ensure!( + hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()), + "Invalid payment hash" + ); + let hash = hash.to_ascii_lowercase(); + let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?; + anyhow::ensure!( + existing.as_ref().is_none_or(|(id, _)| id == content_id), + "Invoice belongs to another content item" + ); + if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await { + return Ok( + serde_json::json!({"paid":true,"state":"settled","can_switch_method":false}), + ); + } + let (client, macaroon_hex) = self.lnd_client().await?; + let response = client + .get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}")) + .header("Grpc-Metadata-macaroon", &macaroon_hex) + .send() + .await?; + if response.status() == reqwest::StatusCode::NOT_FOUND { + return Ok( + serde_json::json!({"paid":false,"state":"unknown","can_switch_method":false}), + ); + } + let body: serde_json::Value = response.error_for_status()?.json().await?; + let price = content_invoice_amount(&body, content_id) + .context("Invoice content binding is unavailable")?; + anyhow::ensure!( + existing + .as_ref() + .is_none_or(|(_, expected)| *expected == price), + "Invoice price binding changed" + ); + crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price) + .await?; + let result = content_invoice_lifecycle_body(&body, price); + if result["paid"] == true { + crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?; + } + Ok(result) + } + /// Generate a new on-chain Bitcoin address. pub(in crate::api::rpc) async fn handle_lnd_newaddress(&self) -> Result { let (client, macaroon_hex) = self.lnd_client().await.map_err(|e| { @@ -1561,3 +1612,77 @@ mod peer_file_invoice_tests { } } } + +fn content_invoice_lifecycle_body(body: &serde_json::Value, price: u64) -> serde_json::Value { + let settled = content_invoice_fully_settled(body, price); + let cancelled = !settled + && body["state"] == "CANCELED" + && body.get("settled").and_then(|value| value.as_bool()) != Some(true) + && body.get("amt_paid_sat").and_then(json_u64) == Some(0) + && body + .get("amt_paid_msat") + .is_none_or(|value| json_u64(value) == Some(0)); + let state = if settled { + "settled" + } else if cancelled { + "canceled" + } else { + match body.get("state").and_then(|value| value.as_str()) { + Some("OPEN") => "open", + Some("ACCEPTED") => "accepted", + _ => "unknown", + } + }; + let expires_at = body + .get("creation_date") + .and_then(json_u64) + .zip(body.get("expiry").and_then(json_u64)) + .and_then(|(created, expiry)| created.checked_add(expiry)); + // Expiry is informational. Only LND's terminal canceled state releases the + // cross-method block; wall-clock passage or lookup failure never does. + serde_json::json!({"paid":settled,"state":state,"can_switch_method":cancelled, + "expires_at":expires_at,"cancel_supported":false}) +} + +#[cfg(test)] +mod invoice_lifecycle_tests { + use super::*; + #[test] + fn only_authoritative_zero_paid_cancel_unlocks_and_settlement_survives_expiry() { + for state in ["OPEN", "ACCEPTED", "UNKNOWN", "CANCELED"] { + for paid in [0u64, 1] { + let result = content_invoice_lifecycle_body( + &serde_json::json!({ + "state":state,"settled":false,"amt_paid_sat":paid.to_string(), + "creation_date":"1","expiry":"1"}), + 8, + ); + assert_eq!( + result["can_switch_method"], + state == "CANCELED" && paid == 0 + ); + assert_eq!(result["paid"], false); + } + } + assert_eq!( + content_invoice_lifecycle_body(&serde_json::json!({"state":"CANCELED"}), 8) + ["can_switch_method"], + false + ); + assert_eq!( + content_invoice_lifecycle_body( + &serde_json::json!({"state":"CANCELED", "amt_paid_sat":"0", "amt_paid_msat":"1"}), + 8 + )["can_switch_method"], + false + ); + let paid = content_invoice_lifecycle_body( + &serde_json::json!({ + "state":"SETTLED","settled":true,"amt_paid_sat":"8","value":"8", + "creation_date":"1","expiry":"1"}), + 8, + ); + assert_eq!(paid["paid"], true); + assert_eq!(paid["can_switch_method"], false); + } +} diff --git a/core/archipelago/src/api/rpc/media_registration.rs b/core/archipelago/src/api/rpc/media_registration.rs new file mode 100644 index 00000000..b72dd885 --- /dev/null +++ b/core/archipelago/src/api/rpc/media_registration.rs @@ -0,0 +1,353 @@ +//! Owner-session/CSRF RPC plus producer-signed, exact media approval. +//! App callers use the native dashboard bridge; this is never an origin-only grant. +use super::RpcHandler; +use crate::media_registration::{AuthorizedSelection, Intent, Limits}; +use anyhow::{Context, Result}; +use nostr_sdk::prelude::{Event, Kind}; +use serde::Deserialize; +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; + +// Dropping the request future cancels queued locks and chunked snapshot work. +// A completed durable record is still recovered by the original operation ID. +struct RequestCancellation(Arc); +impl Drop for RequestCancellation { + fn drop(&mut self) { + self.0.store(true, Ordering::Relaxed); + } +} +fn request_cancellation() -> (RequestCancellation, Arc) { + let signal = Arc::new(AtomicBool::new(false)); + (RequestCancellation(signal.clone()), signal) +} + +const DOMAIN: &str = "archipelago.media-registration.approval.v1"; +const KIND: u16 = 27236; +const MAX_APPROVAL: usize = 32 * 1024; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct Params { + intent: Intent, + selection: AuthorizedSelection, + producer_event: Event, +} + +const RESOLUTION_DOMAIN: &str = "archipelago.media-registration.resolution.v1"; +#[derive(Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct ResolveParams { + intent: Intent, + producer_event: Event, +} +fn verified_resolution(input: serde_json::Value, now: u64) -> Result { + anyhow::ensure!( + serde_json::to_vec(&input)?.len() <= MAX_APPROVAL, + "Resolution approval is too large" + ); + let params: ResolveParams = serde_json::from_value(input)?; + let event = ¶ms.producer_event; + event + .verify() + .context("Resolution producer signature failed")?; + anyhow::ensure!( + event.kind == Kind::Custom(27237) && event.pubkey.to_hex() == params.intent.producer, + "Resolution signature purpose or producer changed" + ); + let encoded = serde_json::to_value(event)?; + anyhow::ensure!( + encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]]) + && event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30) + && event.created_at.as_u64() <= now.saturating_add(30), + "Invalid resolution signature time or scope" + ); + let content: serde_json::Value = serde_json::from_str(&event.content)?; + anyhow::ensure!( + content + == serde_json::json!({ + "action":"Recover prepared video or retire this expired incomplete registration", + "scope":RESOLUTION_DOMAIN, "intent":params.intent, + }), + "Resolution signature changed the original intent" + ); + Ok(params) +} + +fn approval_content(intent: &Intent, selection: &AuthorizedSelection) -> Result { + let path = selection + .relative_path + .to_str() + .context("Cloud file name is not UTF-8")?; + Ok(serde_json::json!({ + "action":"Register this Cloud video for an IndeeHub project", + "scope":DOMAIN, + "intent":intent, + "selection":{"cloudFile":path,"paymentMethods":selection.payment_methods}, + })) +} +fn verified_producer(params: &Params, now: u64) -> Result { + let event = ¶ms.producer_event; + anyhow::ensure!( + event.kind == Kind::Custom(KIND), + "This signature is not a media registration approval" + ); + event + .verify() + .context("Producer approval signature failed")?; + let producer = event.pubkey.to_hex(); + anyhow::ensure!( + producer == params.intent.producer, + "The signing identity differs from the project producer" + ); + let created = event.created_at.as_u64(); + anyhow::ensure!( + created >= params.intent.created_at.saturating_sub(30) + && created < params.intent.expires_at + && created <= now.saturating_add(30), + "Producer approval was not signed within this registration intent" + ); + let encoded = serde_json::to_value(event)?; + anyhow::ensure!( + encoded["tags"] == serde_json::json!([["d", DOMAIN]]), + "Media approval signature scope changed" + ); + let content: serde_json::Value = serde_json::from_str(&event.content) + .context("Producer approval is not readable registration terms")?; + anyhow::ensure!( + content == approval_content(¶ms.intent, ¶ms.selection)?, + "Approved project, Cloud selection or rental terms changed" + ); + Ok(producer) +} +fn parse(input: serde_json::Value, now: u64) -> Result<(Params, String)> { + anyhow::ensure!( + serde_json::to_vec(&input)?.len() <= MAX_APPROVAL, + "Registration approval is too large" + ); + let params: Params = serde_json::from_value(input).context("Invalid registration approval")?; + let producer = verified_producer(¶ms, now)?; + Ok((params, producer)) +} +fn registration_limit(_data_dir: &Path) -> u64 { + // Explicit per-file staging bound; shared immutable snapshot storage handles + // disk reservations separately before this route is enabled for live apps. + 16 * 1024 * 1024 * 1024 +} +impl RpcHandler { + /// Read-only public installation bindings for the native consent bridge. + /// A hidden standalone signer must compare its actual app origin with these + /// installed addresses; a caller-supplied app name is never sufficient. + pub(super) async fn handle_media_registration_context( + &self, + _input: serde_json::Value, + ) -> Result { + let (state, _) = self.state_manager.get_snapshot().await; + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let data_dir = self.config.data_dir.clone(); + let context = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&data_dir, &identity, &state) + }) + .await??; + let mut result = serde_json::to_value(context)?; + result["paymentMethods"] = serde_json::json!(["cashu"]); + Ok(result) + } + + pub(super) async fn handle_media_registration_resolve( + &self, + input: serde_json::Value, + ) -> Result { + let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?; + let params = verified_resolution(input, now)?; + let (state, _) = self.state_manager.get_snapshot().await; + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let data_dir = self.config.data_dir.clone(); + let (_cancellation, cancelled) = request_cancellation(); + tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?; + crate::registered_media::resolve_registration( + &data_dir, + &identity, + ¶ms.intent, + ¶ms.producer_event.pubkey.to_hex(), + now, + &Limits { + max_bytes: registration_limit(&data_dir), + cancelled: &cancelled, + }, + ) + }) + .await? + } + + /// Standard RPC front door already requires owner session, permitted origin + /// and CSRF. Producer signature is additional exact-scope consent, not a + /// replacement for those owner checks. A replay repeats the original UUID. + pub(super) async fn handle_media_registration_prepare( + &self, + input: serde_json::Value, + ) -> Result { + let now = u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")?; + let (params, producer) = parse(input, now)?; + let (state, _) = self.state_manager.get_snapshot().await; + anyhow::ensure!( + state + .package_data + .get("indeedhub-api") + .is_some_and(|entry| matches!( + entry.state, + crate::data_model::PackageState::Running + )), + "The installed IndeeHub API must be running to register its media" + ); + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let data_dir = self.config.data_dir.clone(); + let (_cancellation, cancelled) = request_cancellation(); + let receipt = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&data_dir, &identity, &state)?; + let project = params.intent.project_id.clone(); + let limits = Limits { + max_bytes: registration_limit(&data_dir), + cancelled: &cancelled, + }; + crate::registered_media::register_approved_selection( + &data_dir, + &data_dir.join("filebrowser"), + &identity, + &crate::registered_media::ApprovedSelection { + authenticated_producer: &producer, + authenticated_project: &project, + intent: ¶ms.intent, + selection: ¶ms.selection, + }, + now, + &limits, + |_| Ok(()), + ) + }) + .await??; + Ok(serde_json::to_value(receipt)?) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use nostr_sdk::prelude::{EventBuilder, Keys, Tag, Timestamp}; + fn fixture() -> Params { + let keys = Keys::parse(&"07".repeat(32)).unwrap(); + let intent = Intent { + version: 1, + request_id: uuid::Uuid::new_v4().to_string(), + nonce: "ab".repeat(32), + app_audience: uuid::Uuid::new_v4().to_string(), + node_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(), + producer: keys.public_key().to_hex(), + project_id: "fixture-project".into(), + price_sats: 8, + viewing_seconds: 3600, + created_at: 1000, + expires_at: 1600, + }; + let selection = AuthorizedSelection { + relative_path: "Movies/Film.mp4".into(), + payment_methods: vec!["cashu".into()], + }; + let event = EventBuilder::new( + Kind::Custom(KIND), + serde_json::to_string_pretty(&approval_content(&intent, &selection).unwrap()).unwrap(), + ) + .tag(Tag::identifier(DOMAIN)) + .custom_created_at(Timestamp::from(1200)) + .sign_with_keys(&keys) + .unwrap(); + Params { + intent, + selection, + producer_event: event, + } + } + #[test] + fn producer_signature_binds_human_readable_exact_selection_terms_node_and_installation() { + let original = fixture(); + assert_eq!( + verified_producer(&original, 1300).unwrap(), + original.intent.producer + ); + // Original consent can recover an already-completed operation after + // expiry; underlying snapshot journal refuses creating a fresh one. + assert!(verified_producer(&original, 2000).is_ok()); + let mut changed = fixture(); + changed.intent.price_sats += 1; + assert!(verified_producer(&changed, 1300).is_err()); + let mut changed = fixture(); + changed.selection.relative_path = "Other.mp4".into(); + assert!(verified_producer(&changed, 1300).is_err()); + let mut changed = fixture(); + changed.intent.app_audience = uuid::Uuid::new_v4().to_string(); + assert!(verified_producer(&changed, 1300).is_err()); + let mut changed = fixture(); + changed.intent.producer = "cd".repeat(32); + assert!(verified_producer(&changed, 1300).is_err()); + assert!(verified_producer(&fixture(), 1000).is_err()); + } + #[test] + fn request_parser_rejects_unsigned_claims_unknown_fields_and_changed_signed_content() { + let original = fixture(); + let mut encoded = serde_json::json!({"intent":original.intent,"selection":original.selection,"producerEvent":original.producer_event}); + assert!(parse(encoded.clone(), 1300).is_ok()); + encoded["producerEvent"]["content"] = serde_json::json!("approve everything"); + assert!(parse(encoded, 1300).is_err()); + assert!(parse(serde_json::json!({"producer":"cd".repeat(32)}), 1300).is_err()); + } + #[test] + fn dropped_request_signals_blocking_copy_cancellation() { + let (guard, signal) = request_cancellation(); + assert!(!signal.load(Ordering::Relaxed)); + drop(guard); + assert!(signal.load(Ordering::Relaxed)); + } + #[test] + fn resolution_signature_recovers_only_exact_intent_after_expiry_without_file_authority() { + let original = fixture(); + let keys = Keys::parse(&"07".repeat(32)).unwrap(); + let event = EventBuilder::new( + Kind::Custom(27237), + serde_json::json!({ + "action":"Recover prepared video or retire this expired incomplete registration", + "scope":RESOLUTION_DOMAIN,"intent":original.intent, + }) + .to_string(), + ) + .tag(Tag::identifier(RESOLUTION_DOMAIN)) + .custom_created_at(Timestamp::from(1700)) + .sign_with_keys(&keys) + .unwrap(); + let encoded = serde_json::json!({"intent":original.intent,"producerEvent":event}); + assert!(verified_resolution(encoded.clone(), 1800).is_ok()); + assert!(verified_resolution(encoded.clone(), 9999).is_ok()); + assert!(parse(encoded.clone(), 1800).is_err()); + let mut changed = encoded.clone(); + changed["intent"]["priceSats"] = serde_json::json!(999); + assert!(verified_resolution(changed, 1800).is_err()); + let mut changed = encoded; + changed["selection"] = + serde_json::json!({"relative_path":"film.mp4","payment_methods":["cashu"]}); + assert!(verified_resolution(changed, 1800).is_err()); + assert!(verified_resolution( + serde_json::json!({"intent":original.intent,"producerEvent":original.producer_event}), + 1800 + ) + .is_err()); + } +} diff --git a/core/archipelago/src/api/rpc/mod.rs b/core/archipelago/src/api/rpc/mod.rs index ac6a2452..a576b095 100644 --- a/core/archipelago/src/api/rpc/mod.rs +++ b/core/archipelago/src/api/rpc/mod.rs @@ -19,6 +19,9 @@ mod identity; mod interfaces; pub(crate) mod lnd; mod marketplace; +mod media_registration; +mod purchase; +mod playback; // pub(crate): 13-10's `assistant::backends::select_backend` reuses // `mesh::assistant::detect_ollama()` (D-04) rather than re-probing — // matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod @@ -97,6 +100,22 @@ fn nostr_signing_origin_allowed(headers: &hyper::HeaderMap, dev_mode: bool) -> b matches!(url.port_or_known_default(), Some(80 | 443)) } +fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_mode: bool) -> bool { + !matches!( + method, + "node.nostr-sign" + | "identity.nostr-sign" + | "media.registration.prepare" + | "media.registration.context" + | "media.registration.resolve" + | "content.rental-purchase" + | "content.purchase" + | "content.cancel-purchase" + | "content.playback-handle" + | "content.playback-status" + ) || nostr_signing_origin_allowed(headers, dev_mode) +} + /// Read-only authenticated methods may skip CSRF, but they must still exist in /// the dispatcher. The tab signer uses `system.get-hostname` as its lightweight /// session probe, so keeping the policy in one testable function protects that @@ -148,6 +167,7 @@ pub struct RpcHandler { pub(crate) app_gate: Arc, endpoint_rate_limiter: EndpointRateLimiter, response_cache: ResponseCache, + playback_handles: crate::playback_handles::PlaybackHandles, mesh_service: Arc>>, /// LoRa radio firmware-flash job state, sibling to `mesh_service` — one /// job at a time, since flashing needs exclusive access to the port. @@ -172,6 +192,10 @@ pub struct RpcHandler { } impl RpcHandler { + pub(crate) fn playback_handles(&self) -> &crate::playback_handles::PlaybackHandles { + &self.playback_handles + } + pub async fn new( config: Config, state_manager: Arc, @@ -231,6 +255,7 @@ impl RpcHandler { app_gate, endpoint_rate_limiter, response_cache: ResponseCache::new(5), + playback_handles: Default::default(), mesh_service: Arc::new(tokio::sync::RwLock::new(None)), flash_job: crate::mesh::flash::new_job_handle(), transport_router: Arc::new(tokio::sync::RwLock::new(None)), @@ -333,14 +358,10 @@ impl RpcHandler { debug!("RPC method: {}", rpc_req.method); - if matches!( - rpc_req.method.as_str(), - "node.nostr-sign" | "identity.nostr-sign" - ) && !nostr_signing_origin_allowed(&parts.headers, self.config.dev_mode) - { + if !native_consent_origin_allowed(&rpc_req.method, &parts.headers, self.config.dev_mode) { return Ok(self.error_response( 403, - "Nostr signing from app origins requires the dashboard consent bridge", + "Native signing and Cloud registration from app origins require the dashboard consent bridge", StatusCode::FORBIDDEN, )); } @@ -799,6 +820,33 @@ mod nostr_signing_origin_tests { headers } + #[test] + fn native_registration_and_purchase_use_dashboard_origin_and_keep_authentication_and_csrf() { + for method in [ + "media.registration.prepare", + "media.registration.context", + "media.registration.resolve", + "content.rental-purchase", + "content.purchase", + "content.cancel-purchase", + "content.playback-handle", + "content.playback-status", + ] { + assert!(!native_consent_origin_allowed( + method, + &headers(Some("http://node.local:7778")), + false + )); + assert!(native_consent_origin_allowed( + method, + &headers(Some("https://node.local")), + false + )); + assert!(!UNAUTHENTICATED_METHODS.contains(&method)); + assert!(!csrf_exempt_method(method)); + } + } + #[test] fn signing_accepts_dashboard_and_authenticated_non_browser_clients() { assert!(nostr_signing_origin_allowed(&headers(None), false)); diff --git a/core/archipelago/src/api/rpc/playback.rs b/core/archipelago/src/api/rpc/playback.rs new file mode 100644 index 00000000..010da04e --- /dev/null +++ b/core/archipelago/src/api/rpc/playback.rs @@ -0,0 +1,74 @@ +//! Native broker only: settled purchases become session-bound opaque media URLs. +use super::RpcHandler; +use anyhow::{Context, Result}; +use serde::Deserialize; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Issue { + purchase_id: String, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct Status { + handle: String, +} +impl RpcHandler { + async fn playback_context( + &self, + session: &Option, + ) -> Result<( + String, + crate::container::registration_pin::InstalledAppContext, + )> { + let session = session.as_ref().context("Owner session required")?; + anyhow::ensure!( + self.session_store.validate(session).await, + "Owner session expired" + ); + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let (state, _) = self.state_manager.get_snapshot().await; + let root = self.config.data_dir.clone(); + let context = tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&root, &identity, &state) + }) + .await??; + Ok((session.clone(), context)) + } + pub(super) async fn handle_playback_handle( + &self, + params: Option, + session: &Option, + ) -> Result { + let params: Issue = serde_json::from_value(params.context("Missing purchase identifier")?)?; + let (session, context) = self.playback_context(session).await?; + let handle = self + .playback_handles() + .issue( + &self.config.data_dir, + context.clone(), + &session, + ¶ms.purchase_id, + ) + .await?; + let expires = self + .playback_handles() + .expiry(&handle, &session, &context)?; + Ok( + serde_json::json!({"playback_url":format!("/api/rental-playback/{handle}"), "expires_at":expires}), + ) + } + pub(super) async fn handle_playback_status( + &self, + params: Option, + session: &Option, + ) -> Result { + let params: Status = serde_json::from_value(params.context("Missing playback handle")?)?; + let (session, context) = self.playback_context(session).await?; + let expires = self + .playback_handles() + .expiry(¶ms.handle, &session, &context)?; + Ok(serde_json::json!({"expires_at":expires})) + } +} diff --git a/core/archipelago/src/api/rpc/purchase.rs b/core/archipelago/src/api/rpc/purchase.rs new file mode 100644 index 00000000..1a613d47 --- /dev/null +++ b/core/archipelago/src/api/rpc/purchase.rs @@ -0,0 +1,216 @@ +//! Owner RPC for permanent Cloud purchases. Registered app rentals use the +//! separate native installed-app context + opaque playback-handle dispatcher. +use super::RpcHandler; +use crate::{ + content_purchase::Journal, + content_purchase_caller::{self as caller, PurchaseConsent, PurchaseTransport, ReadyPurchase}, + content_purchase_transport::FipsPurchaseTransport, +}; +use anyhow::{Context, Result}; +use serde::Deserialize; +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct PurchaseParams { + onion: String, + content_id: String, + filename: Option, + max_wallet_debit: u64, + consent: Option, +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct CancelParams { + onion: String, + operation_id: String, +} +impl RpcHandler { + pub(super) async fn handle_content_purchase( + &self, + params: Option, + ) -> Result { + let params: PurchaseParams = + serde_json::from_value(params.context("Missing purchase parameters")?)?; + anyhow::ensure!( + !params.content_id.starts_with("registered_"), + "Registered rentals require the native app purchase context" + ); + let transport = + FipsPurchaseTransport::load(self.config.data_dir.clone(), params.onion.clone()).await?; + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let buyer = identity.did_key()?; + let result = caller::purchase( + &self.config.data_dir, + &buyer, + ¶ms.content_id, + params.filename.as_deref(), + params.max_wallet_debit, + params.consent.as_ref(), + &transport, + ) + .await?; + match result { + ReadyPurchase::AwaitingConfirmation { + operation_id, + envelope_sha256, + gross_token_sats, + seller_net_sats, + wallet_debit_sats, + expires_at, + network, + mint_url, + } => Ok( + serde_json::json!({"state":"confirmation_required","operation_id":operation_id, + "envelope_sha256":envelope_sha256,"gross_token_sats":gross_token_sats, + "seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats,"expires_at":expires_at,"network":network,"mint_url":mint_url}), + ), + ReadyPurchase::Cancelled { operation_id } => { + Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id})) + } + ReadyPurchase::Cached { content_id, .. } => Ok( + serde_json::json!({"state":"delivered","owned":true,"owned_content_id":content_id}), + ), + ReadyPurchase::Entitlement { contract, receipt } => { + let item = crate::content_purchase_download::cache( + &self.config.data_dir, + ¶ms.onion, + &contract, + &receipt, + ) + .await?; + Ok( + serde_json::json!({"state":"delivered","owned":true,"operation_id":contract.id, + "owned_content_id":item.content_id,"mime_type":item.mime_type,"size_bytes":item.size_bytes}), + ) + } + } + } + pub(super) async fn handle_content_cancel_purchase( + &self, + params: Option, + ) -> Result { + let params: CancelParams = + serde_json::from_value(params.context("Missing cancellation parameters")?)?; + let transport = + FipsPurchaseTransport::load(self.config.data_dir.clone(), params.onion).await?; + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let (envelope, plan) = { + let journal = Journal::open(&self.config.data_dir).await?; + let record = journal + .buyer(¶ms.operation_id) + .await? + .context("Original purchase not found")?; + anyhow::ensure!( + record.contract.buyer_did == identity.did_key()? + && record.contract.seller_did == transport.seller_did(), + "Cancellation purchase binding changed" + ); + ( + journal + .protocol_envelope("buyer", ¶ms.operation_id) + .await? + .context("Original payment shape missing")?, + journal + .buyer_plan(¶ms.operation_id) + .await? + .context("Original wallet plan missing")?, + ) + }; + caller::cancel_purchase(&self.config.data_dir, &envelope, &plan, &transport).await?; + Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":params.operation_id})) + } +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +struct RentalParams { + seller_did: String, + content_id: String, + expected_sha256: String, + expected_price_sats: u64, + expected_viewing_seconds: u64, + max_wallet_debit: u64, + consent: Option, +} +impl RpcHandler { + pub(super) async fn handle_content_rental_purchase( + &self, + input: Option, + ) -> Result { + let params: RentalParams = + serde_json::from_value(input.context("Missing rental purchase terms")?)?; + anyhow::ensure!( + params.content_id.starts_with("registered_") + && params.expected_price_sats > 0 + && (1..=31_536_000).contains(¶ms.expected_viewing_seconds) + && params.expected_sha256.len() == 64 + && params + .expected_sha256 + .bytes() + .all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)), + "Invalid published rental terms" + ); + let identity = + crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity")) + .await?; + let buyer = identity.did_key()?; + let (state, _) = self.state_manager.get_snapshot().await; + let data = self.config.data_dir.clone(); + tokio::task::spawn_blocking(move || { + crate::container::registration_pin::installed_context(&data, &identity, &state) + }) + .await??; + let onion = crate::content_purchase_transport::seller_onion_for_did( + &self.config.data_dir, + ¶ms.seller_did, + ) + .await?; + let transport = + FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone()).await?; + let expected = caller::ExpectedRental { + seller_did: params.seller_did, + content_id: params.content_id.clone(), + sha256: params.expected_sha256, + price_sats: params.expected_price_sats, + viewing_seconds: params.expected_viewing_seconds, + }; + match caller::purchase_bound( + &self.config.data_dir, + &buyer, + ¶ms.content_id, + None, + params.max_wallet_debit, + params.consent.as_ref(), + &transport, + Some(&expected), + ) + .await? + { + ReadyPurchase::AwaitingConfirmation { + operation_id, + envelope_sha256, + gross_token_sats, + seller_net_sats, + wallet_debit_sats, + expires_at, + network, + mint_url, + } => Ok(serde_json::json!({ + "state":"confirmation_required","operation_id":operation_id,"envelope_sha256":envelope_sha256, + "gross_token_sats":gross_token_sats,"seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats, + "expires_at":expires_at,"seller_onion":onion,"network":network,"mint_url":mint_url})), + ReadyPurchase::Entitlement { contract, .. } => Ok( + serde_json::json!({"state":"entitled","operation_id":contract.id,"seller_onion":onion}), + ), + ReadyPurchase::Cancelled { operation_id } => Ok( + serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id,"seller_onion":onion}), + ), + ReadyPurchase::Cached { .. } => { + anyhow::bail!("A timed rental cannot use a permanent owned copy") + } + } + } +} diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 887e4cea..b81121f7 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -109,6 +109,24 @@ const NGINX_LND_PROXY_BLOCK: &str = "\n # LND REST proxy — backend handles /// and peer media won't play (B3). Forwards Cookie (session auth) + Range and /// disables buffering so streaming works. Kept in sync with the canonical /// block in image-recipe/configs/nginx-archipelago.conf. +const NGINX_RENTAL_PLAYBACK_BLOCK: &str = r#" + # Session-bound rental playback: never cache opaque handles or capabilities. + location /api/rental-playback/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header Origin $http_origin; + proxy_set_header Range $http_range; + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 10s; + proxy_read_timeout 40s; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } +"#; + const NGINX_PEER_CONTENT_BLOCK: &str = "\n # Peer content streaming proxy (B3) — Range-streams a peer's media file.\n # Long read timeout: this path also serves full-file downloads of large\n # media (#38), which can take minutes over Tor; 120s aborted them.\n location /api/peer-content/ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header Range $http_range;\n proxy_buffering off;\n proxy_connect_timeout 10s;\n proxy_read_timeout 900s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n"; /// Inserted into every server block lacking the Pine node-status proxy. @@ -1784,6 +1802,24 @@ fn heal_missing_nostr_signer(content: &str) -> Option { } /// Keep both authenticated catalog endpoints on the backend in every vhost. +fn heal_rental_playback_route(content: &str) -> String { + let anchor = " location /lnd-connect-info {"; + let mut output = String::new(); + for part in content.split_inclusive(anchor) { + if let Some(prefix) = part.strip_suffix(anchor) { + let current_server = prefix.rsplit("server {").next().unwrap_or(prefix); + output.push_str(prefix); + if !current_server.contains("location /api/rental-playback/ {") { + output.push_str(NGINX_RENTAL_PLAYBACK_BLOCK); + } + output.push_str(anchor); + } else { + output.push_str(part); + } + } + output +} + fn heal_node_catalog_route(content: &str) -> String { content.replace( "location /api/app-catalog {", @@ -1825,8 +1861,10 @@ async fn patch_nginx_conf(path: &str) -> Result { let missing_source_proxy = heal_missing_source_proxy(&content).is_some(); let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some(); let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some(); + let missing_rental_playback = heal_rental_playback_route(&content) != content; let legacy_catalog_route = content.contains("location /api/app-catalog {"); - if !missing_app_catalog + if !missing_rental_playback + && !missing_app_catalog && !legacy_catalog_route && !missing_bitcoin_status && !missing_lnd_proxy @@ -1844,7 +1882,7 @@ async fn patch_nginx_conf(path: &str) -> Result { return Ok(false); } - let mut patched = heal_node_catalog_route(&content); + let mut patched = heal_rental_playback_route(&heal_node_catalog_route(&content)); if let Some(p) = heal_stale_web_search_block(&patched) { patched = p; @@ -2023,6 +2061,19 @@ async fn patch_nginx_conf(path: &str) -> Result { #[cfg(test)] mod tests { + #[test] + fn rental_playback_route_repairs_each_vhost_without_enabling_cache() { + let original = "server {\n location /lnd-connect-info { proxy_pass http://127.0.0.1:5678; }\n}\nserver {\n location /lnd-connect-info { proxy_pass http://127.0.0.1:5678; }\n}"; + let fixed = super::heal_rental_playback_route(original); + assert_eq!(fixed.matches("location /api/rental-playback/ {").count(), 2); + assert_eq!(super::heal_rental_playback_route(&fixed), fixed); + assert_eq!(fixed.matches("proxy_cache off;").count(), 2); + assert_eq!(fixed.matches("proxy_set_header Range $http_range;").count(), 2); + let partial = fixed.replacen(super::NGINX_RENTAL_PLAYBACK_BLOCK, "", 1); + assert_eq!(super::heal_rental_playback_route(&partial), fixed); + } + + #[test] fn catalog_routes_upgrade_both_vhosts_without_changing_access_guards() { let old = "server { if ($guard) { return 404; } location /api/app-catalog { proxy_pass http://127.0.0.1:5678; } }\nserver { location /api/app-catalog { proxy_set_header Cookie $http_cookie; } }"; diff --git a/core/archipelago/src/container/docker_packages.rs b/core/archipelago/src/container/docker_packages.rs index 6e812627..2b341b80 100644 --- a/core/archipelago/src/container/docker_packages.rs +++ b/core/archipelago/src/container/docker_packages.rs @@ -218,7 +218,7 @@ impl DockerPackageScanner { None }, static_files: StaticFiles { - license: "MIT".to_string(), + license: String::new(), instructions: metadata.description.clone(), icon: manifest_icon.unwrap_or_else(|| metadata.icon.clone()), }, @@ -231,7 +231,7 @@ impl DockerPackageScanner { long: metadata.description.clone(), }, release_notes: "Docker container".to_string(), - license: "MIT".to_string(), + license: String::new(), wrapper_repo: metadata.repo.clone(), upstream_repo: metadata.repo.clone(), support_site: metadata.repo.clone(), @@ -512,6 +512,32 @@ mod lifecycle_regression_tests { assert_eq!(main.lan_config.as_deref(), Some("kept")); } + #[test] + fn manifest_presentation_only_reports_declared_licenses() { + let mut entry = installing_fixture(); + for (metadata, expected) in [ + (serde_json::json!({"license":"MIT"}), "MIT"), + ( + serde_json::json!({"license":" BSD-3-Clause "}), + "BSD-3-Clause", + ), + (serde_json::json!({}), ""), + (serde_json::json!({"license":null}), ""), + (serde_json::json!({"license":true}), ""), + (serde_json::json!({"license":" "}), ""), + ] { + apply_manifest_value( + &serde_json::json!({"app":{"metadata":metadata}}), + &mut entry, + ); + assert_eq!(entry.manifest.license, expected); + assert_eq!(entry.static_files.license, expected); + } + apply_manifest_value(&serde_json::json!({"app":{}}), &mut entry); + assert_eq!(entry.manifest.license, ""); + assert_eq!(entry.static_files.license, ""); + } + #[test] fn installed_manifest_entry_path_survives_scans_without_changing_runtime_origin() { let mut entry = installing_fixture(); @@ -821,6 +847,14 @@ fn apply_manifest_value(value: &serde_json::Value, entry: &mut PackageDataEntry) .filter(|s| !s.is_empty()) .map(str::to_owned) }; + // Absence is not a license grant. Empty strings are omitted by the UI. + let license = text( + app.get("metadata") + .and_then(|metadata| metadata.get("license")), + ) + .unwrap_or_default(); + entry.manifest.license = license.clone(); + entry.static_files.license = license; if let Some(name) = text(app.get("name")) { entry.manifest.title = name; } diff --git a/core/archipelago/src/container/registration_pin.rs b/core/archipelago/src/container/registration_pin.rs index e6c72508..7cc02766 100644 --- a/core/archipelago/src/container/registration_pin.rs +++ b/core/archipelago/src/container/registration_pin.rs @@ -27,6 +27,81 @@ pub struct RegistrationPin { pub node_did: String, pub app_audience: String, } +/// Fixed installed app context used by native media selection and local playback +/// handles. Caller must still authenticate owner session/CSRF or its scoped handle. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub(crate) struct InstalledAppContext { + pub app_id: String, + pub backend_id: String, + pub app_audience: String, + pub node_did: String, + pub node_public_key: String, + pub app_origins: Vec, +} + +/// Blocking lookup; never provisions a new identity/audience. No request chooses +/// app scope. Revalidate fresh installation state before using a playback handle. +pub(crate) fn installed_context( + data_dir: &Path, + identity: &crate::identity::NodeIdentity, + state: &crate::data_model::DataModel, +) -> Result { + for id in ["indeedhub", "indeedhub-api"] { + let entry = state + .package_data + .get(id) + .context("IndeeHub is not installed")?; + anyhow::ensure!( + matches!(entry.state, crate::data_model::PackageState::Running) + && entry.installed.is_some(), + "IndeeHub installation is not running" + ); + } + let app = state.package_data.get("indeedhub").unwrap(); + let installed = app.installed.as_ref().unwrap(); + let mut origins = Vec::new(); + for address in installed.interface_addresses.values() { + for text in + std::iter::once(address.tor_address.as_str()).chain(address.lan_address.as_deref()) + { + let onion_url = text + .strip_suffix(".onion") + .filter(|host| { + host.len() == 56 + && host + .bytes() + .all(|b| b.is_ascii_lowercase() || (b'2'..=b'7').contains(&b)) + }) + .map(|_| format!("http://{text}")); + if let Ok(url) = reqwest::Url::parse(onion_url.as_deref().unwrap_or(text)) { + if matches!(url.scheme(), "http" | "https") + && url.host_str().is_some() + && url.username().is_empty() + && url.password().is_none() + { + origins.push(url.origin().ascii_serialization()); + } + } + } + } + origins.sort(); + origins.dedup(); + anyhow::ensure!( + !origins.is_empty(), + "IndeeHub has no installed browser origin" + ); + let pin = load_existing(data_dir, "indeedhub-api", identity)?; + Ok(InstalledAppContext { + app_id: "indeedhub".into(), + backend_id: "indeedhub-api".into(), + app_audience: pin.app_audience, + node_did: pin.node_did, + node_public_key: pin.node_public_key, + app_origins: origins, + }) +} + #[derive(Serialize, Deserialize)] #[serde(deny_unknown_fields)] struct Marker { @@ -371,4 +446,48 @@ mod tests { manifest.app.container.media_registration_identity = false; assert!(apply_environment(&mut manifest, &pin).is_err()); } + #[tokio::test] + async fn installed_media_context_requires_both_apps_and_existing_pin_and_tracks_origin_changes() + { + let (root, identity) = fixture().await; + let mut state = crate::data_model::DataModel::default(); + for id in ["indeedhub", "indeedhub-api"] { + let entry = serde_json::from_value(serde_json::json!({ + "state":"running", "static-files":{"license":"","instructions":"","icon":""}, + "manifest":{"id":id,"title":id,"version":"fixture", + "description":{"short":"fixture","long":""},"release-notes":"","license":"", + "wrapper-repo":"","upstream-repo":"","support-site":"","marketing-site":""}, + "installed":{"current-dependents":{},"current-dependencies":{},"last-backup":null,"status":"running", + "interface-addresses":{"main":{"tor-address":"","lan-address":"https://localhost:7778/browse"}}} + })).unwrap(); + state.package_data.insert(id.into(), entry); + } + assert!(installed_context(root.path(), &identity, &state).is_err()); + let pin = ensure_for_installation(root.path(), "indeedhub-api", &identity).unwrap(); + let first = installed_context(root.path(), &identity, &state).unwrap(); + assert_eq!(first.app_audience, pin.app_audience); + assert_eq!(first.app_origins, vec!["https://localhost:7778"]); + state.package_data.get_mut("indeedhub-api").unwrap().state = + crate::data_model::PackageState::Stopped; + assert!(installed_context(root.path(), &identity, &state).is_err()); + state.package_data.get_mut("indeedhub-api").unwrap().state = + crate::data_model::PackageState::Running; + state + .package_data + .get_mut("indeedhub") + .unwrap() + .installed + .as_mut() + .unwrap() + .interface_addresses + .get_mut("main") + .unwrap() + .lan_address = Some("https://localhost:7779".into()); + assert_ne!( + installed_context(root.path(), &identity, &state).unwrap(), + first + ); + state.package_data.remove("indeedhub"); + assert!(installed_context(root.path(), &identity, &state).is_err()); + } } diff --git a/core/archipelago/src/content_cloud_offer.rs b/core/archipelago/src/content_cloud_offer.rs new file mode 100644 index 00000000..fc0df6d2 --- /dev/null +++ b/core/archipelago/src/content_cloud_offer.rs @@ -0,0 +1,137 @@ +//! Ordinary owner-shared Cloud offers reuse a retained immutable version; they +//! never copy a large file for each buyer. Snapshot copying happens off-runtime. +use crate::{ + content_purchase_protocol::Offer, + content_server::{self, AccessControl, Availability}, + wallet::ecash::EcashNetwork, +}; +use anyhow::{Context, Result}; +use std::{ + path::Path, + sync::{ + atomic::{AtomicBool, Ordering}, + Arc, + }, +}; +pub(crate) struct SnapshotPolicy { + pub max_file_bytes: u64, + pub max_total_bytes: u64, + pub minimum_free_bytes: u64, +} +fn visible(item: &content_server::ContentItem, buyer: &str) -> bool { + match &item.availability { + Availability::Nobody => false, + Availability::AllPeers => true, + Availability::Specific { peers } => peers.iter().any(|did| did == buyer), + } +} +/// Caller identities come from v2 authentication/current node identity, not body. +pub(crate) async fn offer( + data_dir: &Path, + id: &str, + content_id: &str, + buyer: &str, + seller: &str, + network: EcashNetwork, + mint: &str, + policy: SnapshotPolicy, +) -> Result { + crate::content_purchase_protocol::ensure_seller_mint_policy(data_dir, network, mint).await?; + let catalog = content_server::load_catalog(data_dir).await?; + let item = catalog + .items + .into_iter() + .find(|item| item.id == content_id) + .context("Shared content is unavailable")?; + anyhow::ensure!( + visible(&item, buyer), + "Content is not shared with this buyer" + ); + let price = match &item.access { + AccessControl::Paid { price_sats, .. } if *price_sats > 0 => *price_sats, + _ => anyhow::bail!("This shared item does not require a payment"), + }; + anyhow::ensure!( + content_server::method_accepted(&item.access, "ecash") + || content_server::method_accepted(&item.access, "cashu"), + "This shared item does not accept Cashu" + ); + content_server::ensure_payment_source_available(data_dir, &item).await?; + let source = content_server::content_file_path(data_dir, &item); + let roots = [data_dir.join("content/files"), data_dir.join("filebrowser")]; + let (root, relative): (std::path::PathBuf, std::path::PathBuf) = roots + .iter() + .find_map(|root| { + source + .strip_prefix(root) + .ok() + .map(|relative| (root.clone(), relative.to_path_buf())) + }) + .context("Content has no configured source root")?; + let data = data_dir.to_path_buf(); + let selected = content_id.to_owned(); + struct CancelCopy(Arc); + impl Drop for CancelCopy { + fn drop(&mut self) { + self.0.store(true, Ordering::SeqCst); + } + } + let cancel_copy = CancelCopy(Arc::new(AtomicBool::new(false))); + let cancelled = cancel_copy.0.clone(); + let snapshot = tokio::task::spawn_blocking(move || { + crate::content_snapshot::prepare( + &data, + &root, + &selected, + &relative, + &crate::media_registration::Limits { + max_bytes: policy.max_file_bytes, + cancelled: &cancelled, + }, + policy.max_total_bytes, + policy.minimum_free_bytes, + |_| Ok(()), + ) + }) + .await??; + anyhow::ensure!( + snapshot.size == item.size_bytes, + "Shared file changed; refresh its catalog before accepting payment" + ); + let terms = { + use sha2::{Digest, Sha256}; + hex::encode(Sha256::digest(serde_json::to_vec(&( + "archipelago-cloud-purchase-terms-v1", + seller, + content_id, + &snapshot.sha256, + snapshot.size, + price, + "permanent-download", + &item.filename, + &item.mime_type, + "cashu", + ))?)) + }; + let now = chrono::Utc::now().timestamp(); + let offer = Offer { + id: id.into(), + buyer_did: buyer.into(), + seller_did: seller.into(), + content_id: content_id.into(), + filename: item.filename.clone(), + mime_type: item.mime_type.clone(), + content_sha256: snapshot.sha256, + content_size: snapshot.size, + viewing_seconds: None, + terms_sha256: terms, + network, + mint_url: mint.into(), + seller_net_sats: price, + offered_at: now, + expires_at: now.checked_add(120).context("Offer clock overflow")?, + }; + // Recheck owner visibility/price under the catalog writer lock when publishing + // the offer, so an unshare during a large snapshot copy blocks NEW offers. + content_server::publish_snapshot_offer(data_dir, &item, &offer).await +} diff --git a/core/archipelago/src/content_purchase.rs b/core/archipelago/src/content_purchase.rs index 0d453c2b..1a29469d 100644 --- a/core/archipelago/src/content_purchase.rs +++ b/core/archipelago/src/content_purchase.rs @@ -39,7 +39,7 @@ fn validate_id(id: &str) -> Result<()> { ); Ok(()) } -fn canonical_mint(value: &str) -> Result { +pub(crate) fn canonical_mint(value: &str) -> Result { let url = reqwest::Url::parse(value).context("Invalid purchase mint")?; anyhow::ensure!( matches!(url.scheme(), "http" | "https") @@ -228,6 +228,8 @@ impl PreparedToken { pub(crate) enum BuyerPhase { Intent, AcceptanceSaved, + CancellationPending, + Cancelled, TokenPrepared, ReceiptSaved, Delivered, @@ -245,6 +247,8 @@ impl BuyerRecord { pub fn public_status(&self) -> serde_json::Value { let (state, settlement_confirmed, delivered) = match self.phase { BuyerPhase::Intent => ("intent", false, false), + BuyerPhase::CancellationPending => ("cancellation_pending_seller", false, false), + BuyerPhase::Cancelled => ("cancelled_unspent", false, false), BuyerPhase::AcceptanceSaved => ("accepted_payment_unconfirmed", false, false), BuyerPhase::TokenPrepared => ("token_prepared_settlement_unconfirmed", false, false), BuyerPhase::ReceiptSaved => ("settled_delivery_pending", true, false), @@ -260,8 +264,8 @@ impl BuyerRecord { "settlement_confirmed": settlement_confirmed, "amount_received": self.receipt().map(|receipt| receipt.amount_received), "delivered": delivered, - "recovery_required": !delivered, - "can_start_new_payment": false, + "recovery_required": !delivered && self.phase != BuyerPhase::Cancelled, + "can_start_new_payment": self.phase == BuyerPhase::Cancelled, }) } @@ -284,6 +288,8 @@ impl BuyerRecord { } anyhow::ensure!( match self.phase { + BuyerPhase::CancellationPending | BuyerPhase::Cancelled => + self.token.is_none() && self.receipt.is_none(), BuyerPhase::Intent => self.acceptance.is_none() && self.token.is_none() && self.receipt.is_none(), BuyerPhase::AcceptanceSaved => @@ -302,6 +308,7 @@ impl BuyerRecord { #[serde(deny_unknown_fields)] pub(crate) enum SellerPhase { Intent, + Cancelled, Settled { amount_received: u64 }, ReceiptSaved(Receipt), } @@ -315,6 +322,10 @@ pub(crate) struct SellerRecord { } impl SellerRecord { pub fn acceptance(&self) -> Result { + anyhow::ensure!( + !matches!(self.phase, SellerPhase::Cancelled), + "Seller cancelled this operation" + ); Ok(Acceptance { contract_hash: self.contract.context_hash()?, accepted_at: self.accepted_at, @@ -322,15 +333,22 @@ impl SellerRecord { } fn validate(&self) -> Result<()> { self.contract.validate()?; - self.acceptance()?.validate(&self.contract)?; + if !matches!(self.phase, SellerPhase::Cancelled) { + self.acceptance()?.validate(&self.contract)?; + } if let Some(token_hash) = &self.token_hash { anyhow::ensure!(valid_hash(token_hash), "Invalid seller token hash"); } anyhow::ensure!( - matches!(self.phase, SellerPhase::Intent) || self.token_hash.is_some(), + matches!(self.phase, SellerPhase::Intent | SellerPhase::Cancelled) + || self.token_hash.is_some(), "Seller token was not durably bound" ); match &self.phase { + SellerPhase::Cancelled => anyhow::ensure!( + self.token_hash.is_none(), + "Cancelled seller already has a token" + ), SellerPhase::Intent => (), SellerPhase::Settled { amount_received } => { anyhow::ensure!( @@ -355,6 +373,13 @@ struct Envelope { /// Exclusive journal access across tasks and processes. Hold this only while /// changing local purchase state; release it before transport/wallet calls. /// A later caller reopens and revalidates the immutable contract before advancing. +#[derive(Serialize, Deserialize)] +struct RetiredOffer { + id: String, + buyer_did: String, + offer_sha256: String, +} + pub(crate) struct Journal { directory: PathBuf, _lock: std::fs::File, @@ -423,7 +448,16 @@ impl Journal { fn path(&self, role: &str, id: &str) -> Result { validate_id(id)?; anyhow::ensure!( - matches!(role, "buyer" | "seller"), + matches!( + role, + "buyer" + | "seller" + | "protocol-offer" + | "offer-retired" + | "envelope-buyer" + | "envelope-seller" + | "plan-buyer" + ), "Invalid purchase journal role" ); Ok(self.directory.join(format!("{role}-{id}.json"))) @@ -511,6 +545,280 @@ impl Journal { .sync_all()?; Ok(()) } + /// Caller sealed the wallet first under its mutation guard. This phase + /// still blocks replacement until authenticated seller acknowledgement. + pub async fn begin_cancellation(&self, contract: &Contract) -> Result<()> { + let mut record = self.bound_buyer(contract).await?; + anyhow::ensure!( + matches!( + record.phase, + BuyerPhase::Intent + | BuyerPhase::AcceptanceSaved + | BuyerPhase::CancellationPending + | BuyerPhase::Cancelled + ), + "Funded purchase cannot cancel as unspent" + ); + if record.phase == BuyerPhase::Cancelled { + return Ok(()); + } + record.phase = BuyerPhase::CancellationPending; + record.validate()?; + self.write("buyer", &contract.id, &record).await + } + pub async fn finish_cancellation( + &self, + contract: &Contract, + verified_seller: &str, + ) -> Result<()> { + anyhow::ensure!( + verified_seller == contract.seller_did, + "Cancellation acknowledgement seller changed" + ); + let mut record = self.bound_buyer(contract).await?; + anyhow::ensure!( + matches!( + record.phase, + BuyerPhase::CancellationPending | BuyerPhase::Cancelled + ), + "Cancellation was not sealed locally" + ); + record.phase = BuyerPhase::Cancelled; + record.validate()?; + self.write("buyer", &contract.id, &record).await + } + /// Runs under the same journal flock as accept/token binding. A token bound + /// before this lock wins and prevents cancellation, even before settlement. + pub async fn cancel_seller(&self, contract: &Contract) -> Result<()> { + let mut record = if let Some(record) = self.seller(&contract.id).await? { + anyhow::ensure!( + record.contract == *contract, + "Seller cancellation terms changed" + ); + record + } else { + SellerRecord { + contract: contract.clone(), + accepted_at: 0, + token_hash: None, + phase: SellerPhase::Cancelled, + } + }; + anyhow::ensure!( + record.token_hash.is_none() + && matches!(record.phase, SellerPhase::Intent | SellerPhase::Cancelled), + "Seller already received this payment; recover settlement" + ); + record.phase = SellerPhase::Cancelled; + record.validate()?; + self.write("seller", &contract.id, &record).await + } + + // Add these methods inside content_purchase::Journal; extend path role allowlist + // with "protocol-offer" | "envelope-buyer" | "envelope-seller" | "plan-buyer". + // The existing same flock/checksum/private permissions/synchronous commit apply. + pub async fn protocol_offer( + &self, + id: &str, + ) -> Result> { + let value: Option = + self.read("protocol-offer", id).await?; + if let Some(offer) = &value { + anyhow::ensure!(offer.id == id, "Offer identifier changed"); + offer.validate()?; + } + Ok(value) + } + pub async fn save_protocol_offer( + &self, + offer: &crate::content_purchase_protocol::Offer, + ) -> Result<()> { + offer.validate()?; + let retired: Option = self.read("offer-retired", &offer.id).await?; + anyhow::ensure!( + retired.is_none(), + "Original offer expired without acceptance; recover cancellation before replacing it" + ); + if let Some(old) = self.protocol_offer(&offer.id).await? { + anyhow::ensure!(old == *offer, "Original offer changed"); + return Ok(()); + } + self.retire_unaccepted_offers(chrono::Utc::now().timestamp()) + .await?; + // Bound unaffiliated authenticated peers' quote storage. Existing IDs + // replay above without consuming another slot; no accepted liability GC. + let mut entries = fs::read_dir(&self.directory).await?; + let mut total = 0usize; + let mut buyer = 0usize; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let Some(name) = name.to_str() else { + continue; + }; + let Some(id) = name + .strip_prefix("protocol-offer-") + .and_then(|v| v.strip_suffix(".json")) + else { + continue; + }; + // Accepted obligations are retained, but do not consume the quota + // for new, never-accepted quotes. + if self.seller(id).await?.is_some() { + continue; + } + total += 1; + anyhow::ensure!( + total < 4096, + "Purchase offer storage limit reached; existing operations remain recoverable" + ); + if self + .protocol_offer(id) + .await? + .is_some_and(|value| value.buyer_did == offer.buyer_did) + { + buyer += 1; + anyhow::ensure!( + buyer < 128, + "Buyer offer storage limit reached; recover an existing operation" + ); + } + } + self.write("protocol-offer", &offer.id, offer).await + } + /// Retire only provably unaccepted quotes under the same journal flock as + /// acceptance/cancellation. The immutable commitment survives forever; + /// absence of history is never interpreted as permission to pay again. + pub async fn retire_unaccepted_offers(&self, now: i64) -> Result<()> { + let mut entries = fs::read_dir(&self.directory).await?; + let mut bytes = 0u64; + while let Some(entry) = entries.next_entry().await? { + if entry + .file_name() + .to_string_lossy() + .starts_with("offer-retired-") + { + bytes = bytes + .checked_add(entry.metadata().await?.len()) + .context("Offer retirement size overflow")?; + } + } + let mut entries = fs::read_dir(&self.directory).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let Some(id) = name + .to_str() + .and_then(|name| name.strip_prefix("protocol-offer-")) + .and_then(|name| name.strip_suffix(".json")) + else { + continue; + }; + let Some(offer) = self.protocol_offer(id).await? else { + continue; + }; + if offer.expires_at > now + || self.seller(id).await?.is_some() + || self.protocol_envelope("seller", id).await?.is_some() + { + continue; + } + let commitment = hash(&serde_json::to_vec(&offer)?); + let previous: Option = self.read("offer-retired", id).await?; + if let Some(previous) = previous { + anyhow::ensure!( + previous.id == id + && previous.buyer_did == offer.buyer_did + && previous.offer_sha256 == commitment, + "Retired offer binding changed" + ); + } else { + // Bound compact terminal metadata separately from accepted liability. + anyhow::ensure!(bytes < 64 * 1024 * 1024, "Quote retirement storage needs maintenance; existing purchases remain recoverable"); + self.write( + "offer-retired", + id, + &RetiredOffer { + id: id.into(), + buyer_did: offer.buyer_did, + offer_sha256: commitment, + }, + ) + .await?; + bytes = bytes + .checked_add(std::fs::metadata(self.path("offer-retired", id)?)?.len()) + .context("Retirement size overflow")?; + } + // Synchronous commit point: cancellation cannot leave an asynchronous + // deletion running after this flock is released. + std::fs::remove_file(self.path("protocol-offer", id)?)?; + std::fs::File::open(&self.directory)?.sync_all()?; + } + Ok(()) + } + pub async fn retired_offer_matches( + &self, + offer: &crate::content_purchase_protocol::Offer, + ) -> Result { + let value: Option = self.read("offer-retired", &offer.id).await?; + let commitment = hash(&serde_json::to_vec(offer)?); + Ok(value.is_some_and(|value| { + value.id == offer.id + && value.buyer_did == offer.buyer_did + && value.offer_sha256 == commitment + })) + } + pub async fn protocol_envelope( + &self, + role: &str, + id: &str, + ) -> Result> { + let role = match role { + "buyer" => "envelope-buyer", + "seller" => "envelope-seller", + _ => anyhow::bail!("Invalid envelope role"), + }; + let value: Option = self.read(role, id).await?; + if let Some(value) = &value { + anyhow::ensure!(value.contract()?.id == id, "Envelope identifier changed"); + } + Ok(value) + } + pub async fn save_protocol_envelope( + &self, + role: &str, + value: &crate::content_purchase_protocol::Envelope, + ) -> Result<()> { + let contract = value.contract()?; + if let Some(old) = self.protocol_envelope(role, &contract.id).await? { + anyhow::ensure!(old == *value, "Original payment shape changed"); + return Ok(()); + } + let role = match role { + "buyer" => "envelope-buyer", + "seller" => "envelope-seller", + _ => anyhow::bail!("Invalid envelope role"), + }; + self.write(role, &contract.id, value).await + } + pub async fn buyer_plan( + &self, + id: &str, + ) -> Result> { + self.read("plan-buyer", id).await + } + pub async fn save_buyer_plan( + &self, + id: &str, + plan: &crate::wallet::purchase_plan::PreparedPayment, + ) -> Result<()> { + if let Some(old) = self.buyer_plan(id).await? { + anyhow::ensure!( + serde_json::to_value(&old)? == serde_json::to_value(plan)?, + "Original wallet plan changed" + ); + return Ok(()); + } + self.write("plan-buyer", id, plan).await + } pub async fn buyer(&self, id: &str) -> Result> { let result: Option = self.read("buyer", id).await?; if let Some(record) = &result { @@ -527,6 +835,44 @@ impl Journal { } Ok(result) } + /// Caller holds the wallet mutation guard. Keep accepted seller liabilities + /// redeemable until settlement or authenticated cancellation is durable. + pub(crate) async fn ensure_seller_policy_change( + &self, + network: EcashNetwork, + accepted_mints: Option<&[String]>, + ) -> Result<()> { + let mut entries = fs::read_dir(&self.directory).await?; + while let Some(entry) = entries.next_entry().await? { + let name = entry.file_name(); + let Some(id) = name + .to_str() + .and_then(|v| v.strip_prefix("seller-")) + .and_then(|v| v.strip_suffix(".json")) + else { + continue; + }; + let record = self + .seller(id) + .await? + .context("Seller liability disappeared")?; + if !matches!(record.phase, SellerPhase::Intent) { + continue; + } + anyhow::ensure!( + record.contract.network == network, + "A pending accepted sale requires its original wallet network" + ); + if let Some(mints) = accepted_mints { + anyhow::ensure!( + mints.iter().any(|mint| canonical_mint(mint).ok().as_deref() + == Some(record.contract.mint_url.as_str())), + "A pending accepted sale requires its original accepted mint" + ); + } + } + Ok(()) + } /// Discover existing node-owned intent after browser storage loss. The /// journal lock makes this lookup and prepare_buyer's duplicate guard one /// serialized decision; caller-supplied fresh UUIDs cannot bypass it. @@ -584,9 +930,10 @@ impl Journal { ) .await?; anyhow::ensure!( - pending - .iter() - .all(|record| record.phase == BuyerPhase::Delivered), + pending.iter().all(|record| matches!( + record.phase, + BuyerPhase::Delivered | BuyerPhase::Cancelled + )), "An existing purchase must be recovered before a new operation is created" ); contract.validate_new_at(now)?; @@ -607,6 +954,10 @@ impl Journal { &record.contract == contract, "Seller purchase terms changed" ); + anyhow::ensure!( + !matches!(record.phase, SellerPhase::Cancelled), + "Seller cancelled this operation" + ); return Ok(record); } contract.validate_new_at(now)?; @@ -651,6 +1002,13 @@ impl Journal { "Acceptance is from another seller" ); let mut record = self.bound_buyer(contract).await?; + anyhow::ensure!( + !matches!( + record.phase, + BuyerPhase::CancellationPending | BuyerPhase::Cancelled + ), + "Buyer cancellation is sealed" + ); acceptance.validate(contract)?; if let Some(previous) = &record.acceptance { anyhow::ensure!(previous == acceptance, "Seller acceptance changed"); @@ -718,6 +1076,7 @@ impl Journal { ) -> Result { let mut record = self.bound_seller(contract).await?; match &record.phase { + SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"), SellerPhase::Intent => record.phase = SellerPhase::Settled { amount_received }, SellerPhase::Settled { amount_received: saved, @@ -744,6 +1103,7 @@ impl Journal { pub async fn issue_receipt(&self, contract: &Contract) -> Result { let mut record = self.bound_seller(contract).await?; let amount_received = match &record.phase { + SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this operation"), SellerPhase::Intent => anyhow::bail!("Seller settlement is not durable"), SellerPhase::Settled { amount_received } => *amount_received, SellerPhase::ReceiptSaved(receipt) => return Ok(receipt.clone()), diff --git a/core/archipelago/src/content_purchase_caller.rs b/core/archipelago/src/content_purchase_caller.rs new file mode 100644 index 00000000..09d85154 --- /dev/null +++ b/core/archipelago/src/content_purchase_caller.rs @@ -0,0 +1,363 @@ +//! Buyer orchestration. Transport implementation MUST use authenticated exact-body +//! single-delivery FIPS requests to the verified seller; retries replay this UUID. +use crate::{ + content_purchase::{BuyerPhase, Contract, Journal, Receipt}, + content_purchase_protocol::{Accepted, Cancelled, Envelope, Offer, SellerStatus, Settlement}, + wallet::purchase_plan, +}; +use anyhow::{Context, Result}; +use std::{future::Future, path::Path}; + +pub(crate) trait PurchaseTransport: Send + Sync { + /// This identity is the independently verified peer binding, not response JSON. + fn seller_did(&self) -> &str; + fn seller_onion(&self) -> &str; + fn offer(&self, id: &str, content_id: &str) -> impl Future> + Send; + fn accept(&self, envelope: &Envelope) -> impl Future> + Send; + fn status(&self, envelope: &Envelope) -> impl Future> + Send; + fn cancel(&self, envelope: &Envelope) -> impl Future> + Send; + fn settle(&self, request: &Settlement) -> impl Future> + Send; +} +#[derive(Clone)] +pub(crate) enum ReadyPurchase { + AwaitingConfirmation { + operation_id: String, + envelope_sha256: String, + gross_token_sats: u64, + seller_net_sats: u64, + wallet_debit_sats: u64, + expires_at: i64, + network: crate::wallet::ecash::EcashNetwork, + mint_url: String, + }, + Cancelled { + operation_id: String, + }, + Cached { + seller_onion: String, + content_id: String, + }, + Entitlement { + contract: Contract, + receipt: Receipt, + }, +} + +#[derive(Clone, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct PurchaseConsent { + pub operation_id: String, + pub envelope_sha256: String, + pub wallet_debit_sats: u64, +} + +/// Called after owner consent to content and maximum total wallet debit. Existing +/// pending purchase lookup runs before UUID allocation, even after browser loss. +/// Caller must separately reject unresolved legacy attempts; never invent their IDs. +pub(crate) async fn purchase( + data_dir: &Path, + verified_buyer: &str, + content_id: &str, + filename: Option<&str>, + max_wallet_debit: u64, + consent: Option<&PurchaseConsent>, + transport: &impl PurchaseTransport, +) -> Result { + purchase_bound( + data_dir, + verified_buyer, + content_id, + filename, + max_wallet_debit, + consent, + transport, + None, + ) + .await +} + +#[derive(Clone)] +pub(crate) struct ExpectedRental { + pub seller_did: String, + pub content_id: String, + pub sha256: String, + pub price_sats: u64, + pub viewing_seconds: u64, +} +impl ExpectedRental { + pub fn verify(&self, offer: &Offer) -> Result<()> { + anyhow::ensure!( + self.content_id.starts_with("registered_") + && offer.content_id == self.content_id + && offer.seller_did == self.seller_did + && offer.content_sha256 == self.sha256 + && offer.seller_net_sats == self.price_sats + && offer.viewing_seconds == Some(self.viewing_seconds), + "Published rental hash, price, duration or seller changed; no payment started" + ); + Ok(()) + } +} +pub(crate) async fn purchase_bound( + data_dir: &Path, + verified_buyer: &str, + content_id: &str, + filename: Option<&str>, + max_wallet_debit: u64, + consent: Option<&PurchaseConsent>, + transport: &impl PurchaseTransport, + expected: Option<&ExpectedRental>, +) -> Result { + let _purchase_lock = + crate::content_owned::lock_seller_purchases(transport.seller_onion()).await; + let owned = crate::content_owned::list_owned_checked(data_dir) + .await + .context("Could not verify prior purchases; no new payment started")?; + let mut unresolved_owned = false; + if let Some(cached) = owned.iter().find(|item| { + item.onion == transport.seller_onion() + && (item.content_id == content_id + || filename.is_some_and(|name| { + !name.is_empty() + && item.filename.trim_start_matches('/') == name.trim_start_matches('/') + })) + }) { + // Metadata without bytes remains a delivery recovery, not permission to pay. + if let Ok(Some((_, mut file))) = + crate::content_owned::open_owned(data_dir, &cached.onion, &cached.content_id).await + { + let records = { + Journal::open(data_dir) + .await? + .find_buyers(verified_buyer, transport.seller_did(), &cached.content_id) + .await? + }; + if let Some(record) = records + .iter() + .find(|record| record.phase == BuyerPhase::ReceiptSaved) + { + // Recover the narrow crash window after cache publication but + // before recording delivery. Never pay to repair this boundary. + use sha2::{Digest, Sha256}; + use tokio::io::AsyncReadExt; + let mut hash = Sha256::new(); + let mut count = 0u64; + let mut buffer = vec![0; 65536]; + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + count = count + .checked_add(read as u64) + .context("Cached size overflow")?; + anyhow::ensure!( + count <= record.contract.content_size, + "Cached purchase changed; no new payment allowed" + ); + hash.update(&buffer[..read]); + } + let sha = hex::encode(hash.finalize()); + Journal::open(data_dir) + .await? + .record_delivery(&record.contract, &sha, count) + .await?; + } + return Ok(ReadyPurchase::Cached { + seller_onion: cached.onion.clone(), + content_id: cached.content_id.clone(), + }); + } + unresolved_owned = true; + } + let previous = { + let journal = Journal::open(data_dir).await?; + let matching = journal + .find_buyers(verified_buyer, transport.seller_did(), content_id) + .await?; + let unresolved: Vec<_> = matching + .into_iter() + .filter(|record| record.phase != BuyerPhase::Cancelled) + .collect(); + anyhow::ensure!( + unresolved.len() <= 1, + "Multiple original purchases require recovery; no new payment started" + ); + unresolved.into_iter().next() + }; + let envelope = if let Some(previous) = previous { + let journal = Journal::open(data_dir).await?; + let envelope = journal + .protocol_envelope("buyer", &previous.contract.id) + .await? + .context("Original payment shape is missing; no new spend allowed")?; + anyhow::ensure!( + envelope.contract()? == previous.contract, + "Original purchase binding changed" + ); + if let Some(expected) = expected { + expected.verify(&envelope.offer)?; + } + if let Some(receipt) = previous.receipt() { + return Ok(ReadyPurchase::Entitlement { + contract: previous.contract.clone(), + receipt: receipt.clone(), + }); + } + envelope + } else { + anyhow::ensure!( + !unresolved_owned, + "Prior purchase delivery needs recovery; no new payment operation was created" + ); + let id = uuid::Uuid::new_v4().to_string(); + let offer = transport.offer(&id, content_id).await?; + offer.validate()?; + anyhow::ensure!( + offer.id == id + && offer.content_id == content_id + && offer.buyer_did == verified_buyer + && offer.seller_did == transport.seller_did(), + "Authenticated seller offer binding changed" + ); + if let Some(expected) = expected { + expected.verify(&offer)?; + } + let plan = purchase_plan::prepare( + data_dir, + &offer.mint_url, + offer.network, + offer.seller_net_sats, + max_wallet_debit, + ) + .await?; + let envelope = Envelope { + offer, + fee_plan: plan.fee_plan.clone(), + }; + purchase_plan::persist_intent(data_dir, &envelope, &plan).await?; + envelope + }; + let contract = envelope.contract()?; + let (phase, plan) = { + let journal = Journal::open(data_dir).await?; + let buyer = journal + .buyer(&contract.id) + .await? + .context("Buyer intent disappeared")?; + let plan = journal + .buyer_plan(&contract.id) + .await? + .context("Original wallet plan is missing")?; + anyhow::ensure!( + plan.fee_plan == envelope.fee_plan, + "Original wallet fee shape changed" + ); + (buyer.phase, plan) + }; + if phase == BuyerPhase::CancellationPending { + cancel_purchase(data_dir, &envelope, &plan, transport).await?; + return Ok(ReadyPurchase::Cancelled { + operation_id: contract.id, + }); + } + if phase == BuyerPhase::Intent { + let expected = envelope.commitment()?; + if let Some(consent) = consent { + anyhow::ensure!( + consent.operation_id == contract.id + && consent.envelope_sha256 == expected + && consent.wallet_debit_sats == plan.wallet_debit_sats, + "Payment confirmation does not match the saved quote" + ); + } else { + return Ok(ReadyPurchase::AwaitingConfirmation { + operation_id: contract.id, + envelope_sha256: expected, + gross_token_sats: contract.gross_token_sats, + seller_net_sats: contract.minimum_net_sats, + wallet_debit_sats: plan.wallet_debit_sats, + expires_at: contract.expires_at, + network: envelope.offer.network, + mint_url: envelope.offer.mint_url.clone(), + }); + } + } + // Replaying a prepared send journal never selects fresh wallet proofs. + purchase_plan::persist(data_dir, &contract, &plan).await?; + if phase == BuyerPhase::Intent { + let accepted = transport.accept(&envelope).await?; + anyhow::ensure!( + accepted.envelope_sha256 == envelope.commitment()?, + "Seller accepted another payment shape" + ); + let journal = Journal::open(data_dir).await?; + journal + .record_acceptance(&contract, &accepted.acceptance, transport.seller_did()) + .await?; + } + // Confirm the required authenticated FIPS route before any fresh mint + // dispatch. An outage keeps this operation; it never selects Tor/new UUID. + // Disconnect after this check is still possible and remains recoverable. + let known_receipt = if phase != BuyerPhase::TokenPrepared { + match transport.status(&envelope).await? { + SellerStatus::Accepted => None, + SellerStatus::Settled { receipt } => Some(receipt), + SellerStatus::Cancelled => anyhow::bail!( + "Seller cancelled this operation; reconcile the original unspent intent" + ), + } + } else { + None + }; + // Planned executor performs fresh-post keyset/fee validation at the wallet + // mutation boundary; original committed/restore results recover before expiry. + let token = crate::content_purchase_executor::prepare_buyer_token_planned( + data_dir, + &contract, + &envelope.fee_plan, + ) + .await?; + envelope.fee_plan.validate_token(&token)?; + let receipt = if let Some(receipt) = known_receipt { + receipt + } else { + transport.settle(&Settlement { envelope, token }).await? + }; + { + let journal = Journal::open(data_dir).await?; + journal.record_receipt(&contract, &receipt).await?; + } + Ok(ReadyPurchase::Entitlement { contract, receipt }) +} + +/// Explicit caller cancellation/expired-unfunded recovery, never an automatic +/// interpretation of a failed HTTP call or unknown mint state. +pub(crate) async fn cancel_purchase( + data_dir: &Path, + envelope: &Envelope, + plan: &purchase_plan::PreparedPayment, + transport: &impl PurchaseTransport, +) -> Result<()> { + let contract = envelope.contract()?; + anyhow::ensure!( + contract.seller_did == transport.seller_did(), + "Cancellation seller changed" + ); + purchase_plan::cancel_unspent(data_dir, &contract, plan).await?; + { + Journal::open(data_dir) + .await? + .begin_cancellation(&contract) + .await?; + } + let reply = transport.cancel(envelope).await?; + anyhow::ensure!( + reply.envelope_sha256 == envelope.commitment()?, + "Seller cancelled another operation" + ); + Journal::open(data_dir) + .await? + .finish_cancellation(&contract, transport.seller_did()) + .await +} diff --git a/core/archipelago/src/content_purchase_download.rs b/core/archipelago/src/content_purchase_download.rs new file mode 100644 index 00000000..15afe3c2 --- /dev/null +++ b/core/archipelago/src/content_purchase_download.rs @@ -0,0 +1,194 @@ +//! Permanent purchase caching. Hash verification occurs inside the stream before +//! owned-cache publication; receipt remains recoverable after any interruption. +use crate::content_purchase::{Contract, Journal, Receipt}; +use anyhow::{Context, Result}; +use futures_util::StreamExt; +use sha2::{Digest, Sha256}; +use std::path::Path; + +pub(crate) async fn cache( + data_dir: &Path, + onion: &str, + contract: &Contract, + receipt: &Receipt, +) -> Result { + anyhow::ensure!( + !contract.content_id.starts_with("registered_"), + "Rentals use the scoped playback proxy, not permanent caching" + ); + let envelope = { + let journal = Journal::open(data_dir).await?; + let buyer = journal + .buyer(&contract.id) + .await? + .context("Buyer purchase is missing")?; + anyhow::ensure!( + buyer.contract == *contract && buyer.receipt() == Some(receipt), + "Original buyer receipt changed" + ); + journal + .protocol_envelope("buyer", &contract.id) + .await? + .context("Original delivery metadata is missing")? + }; + let peer = crate::federation::load_unique_payment_peer(data_dir, onion).await?; + anyhow::ensure!(peer.did == contract.seller_did, "Delivery seller changed"); + let path = format!("/content/{}/purchase/{}", contract.content_id, contract.id); + let (response, _) = + crate::fips::dial::PeerRequest::new(peer.fips_npub.as_deref(), onion, &path) + .require_fips() + .single_delivery() + .timeout(std::time::Duration::from_secs(900)) + .header("X-Content-Capability", receipt.capability.clone()) + .send_content_get(data_dir) + .await?; + anyhow::ensure!( + response.status() == reqwest::StatusCode::OK, + "Original purchase delivery is unavailable; no new payment sent" + ); + anyhow::ensure!( + response.content_length() == Some(contract.content_size), + "Original snapshot length changed" + ); + let stream = verified_stream( + response.bytes_stream(), + contract.content_sha256.clone(), + contract.content_size, + ); + let owned = crate::content_owned::record_purchase_stream( + data_dir, + crate::content_owned::OwnedItem { + onion: onion.into(), + content_id: contract.content_id.clone(), + filename: envelope.offer.filename, + mime_type: envelope.offer.mime_type, + size_bytes: contract.content_size, + paid_sats: contract.gross_token_sats, + ecash_backend: "cashu".into(), + purchased_at: chrono::Utc::now().to_rfc3339(), + download_complete: false, + }, + Box::pin(stream), + Some(contract.content_size), + ) + .await?; + Journal::open(data_dir) + .await? + .record_delivery(contract, &contract.content_sha256, contract.content_size) + .await?; + Ok(owned) +} + +fn verified_stream( + stream: S, + expected_hash: String, + expected_size: u64, +) -> impl futures_util::Stream> +where + S: futures_util::Stream>, + E: std::error::Error + Send + Sync + 'static, +{ + futures_util::stream::try_unfold( + (Box::pin(stream), Sha256::new(), 0u64), + move |(mut stream, mut hash, total)| { + let expected_hash = expected_hash.clone(); + async move { + match stream.next().await { + Some(chunk) => { + let chunk = chunk.map_err(std::io::Error::other)?; + let total = total + .checked_add(chunk.len() as u64) + .filter(|n| *n <= expected_size) + .ok_or_else(|| { + std::io::Error::other("Snapshot exceeded accepted size") + })?; + hash.update(&chunk); + Ok(Some((chunk, (stream, hash, total)))) + } + None => { + if total != expected_size || hex::encode(hash.finalize()) != expected_hash { + return Err(std::io::Error::other( + "Snapshot did not match accepted hash/size", + )); + } + Ok::<_, std::io::Error>(None) + } + } + } + }, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::content_owned::{self, OwnedItem}; + fn item() -> OwnedItem { + OwnedItem { + onion: "seller.onion".into(), + content_id: "video".into(), + filename: "clip.mp4".into(), + mime_type: "video/mp4".into(), + size_bytes: 4, + paid_sats: 8, + ecash_backend: "cashu".into(), + purchased_at: "now".into(), + download_complete: false, + } + } + #[tokio::test] + async fn corrupted_truncated_and_excess_bytes_remain_recoverable_until_original_hash_arrives() { + let root = tempfile::tempdir().unwrap(); + let hash = hex::encode(Sha256::digest(b"good")); + for bytes in [b"evil".as_slice(), b"goo".as_slice(), b"good!".as_slice()] { + let input = futures_util::stream::iter([Ok::<_, std::io::Error>( + bytes::Bytes::copy_from_slice(bytes), + )]); + let stream = Box::pin(verified_stream(input, hash.clone(), 4)); + assert!( + content_owned::record_purchase_stream(root.path(), item(), stream, Some(4)) + .await + .is_err() + ); + let entries = content_owned::list_owned_checked(root.path()) + .await + .unwrap(); + assert_eq!(entries.len(), 1); + assert!(!entries[0].download_complete); + assert_eq!(entries[0].paid_sats, 8); + let error = content_owned::open_owned(root.path(), "seller.onion", "video") + .await + .err() + .expect("Incomplete paid bytes must not be served"); + assert!( + error.to_string().contains("delivery is incomplete"), + "{error:#}" + ); + } + let input = futures_util::stream::iter([ + Ok::<_, std::io::Error>(bytes::Bytes::from_static(b"go")), + Ok(bytes::Bytes::from_static(b"od")), + ]); + let stream = Box::pin(verified_stream(input, hash, 4)); + let completed = content_owned::record_purchase_stream(root.path(), item(), stream, Some(4)) + .await + .unwrap(); + assert!(completed.download_complete); + let (_, mut file) = content_owned::open_owned(root.path(), "seller.onion", "video") + .await + .unwrap() + .unwrap(); + let mut bytes = Vec::new(); + tokio::io::AsyncReadExt::read_to_end(&mut file, &mut bytes) + .await + .unwrap(); + assert_eq!(bytes, b"good"); + assert_eq!( + content_owned::list_owned_checked(root.path()) + .await + .unwrap() + .len(), + 1 + ); + } +} diff --git a/core/archipelago/src/content_purchase_executor.rs b/core/archipelago/src/content_purchase_executor.rs index b21039c1..aa09e0a4 100644 --- a/core/archipelago/src/content_purchase_executor.rs +++ b/core/archipelago/src/content_purchase_executor.rs @@ -70,6 +70,7 @@ pub(crate) async fn settle_seller_token( match record.phase { SellerPhase::ReceiptSaved(receipt) => return Ok(receipt), SellerPhase::Settled { .. } => return journal.issue_receipt(contract).await, + SellerPhase::Cancelled => anyhow::bail!("Seller cancelled this purchase"), SellerPhase::Intent => (), } } @@ -87,3 +88,36 @@ pub(crate) async fn settle_seller_token( journal.record_settlement(contract, received).await?; journal.issue_receipt(contract).await } + +pub(crate) async fn prepare_buyer_token_planned( + data_dir: &Path, + contract: &Contract, + plan: &crate::wallet::purchase_fee_plan::FeePlan, +) -> Result { + contract.validate()?; + { + let journal = Journal::open(data_dir).await?; + let record = journal + .buyer(&contract.id) + .await? + .context("Buyer intent is not durable")?; + anyhow::ensure!(&record.contract == contract, "Buyer purchase terms changed"); + if let Some(token) = record.token() { + return Ok(token.to_owned()); + } + anyhow::ensure!( + record.phase == BuyerPhase::AcceptanceSaved, + "Authenticated seller acceptance is not durable" + ); + } + // Deadline is enforced inside the wallet after recovering original results, + // immediately before a fresh exact send or mint POST. Do not pre-reject an + // expired contract here: a previous wallet commit may need to be recovered. + let token = ecash::send_token_preplanned_before(data_dir, contract, plan).await?; + let journal = Journal::open(data_dir).await?; + let record = journal.record_token(contract, &token).await?; + Ok(record + .token() + .context("Prepared buyer token is missing")? + .to_owned()) +} diff --git a/core/archipelago/src/content_purchase_protocol.rs b/core/archipelago/src/content_purchase_protocol.rs new file mode 100644 index 00000000..f5377e49 --- /dev/null +++ b/core/archipelago/src/content_purchase_protocol.rs @@ -0,0 +1,636 @@ +//! Typed bodies for authenticated, single-delivery purchase POST endpoints. +//! The handler verifies v2 method/path/audience/exact-body proof before calling. +use crate::{ + content_purchase::{Acceptance, Contract, Journal, Receipt, SellerPhase}, + wallet::{ecash::EcashNetwork, mint_client::MintClient, purchase_fee_plan::FeePlan}, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::path::Path; + +pub(crate) const OFFER_ROUTE: &str = "/content/purchase/v1/offer"; +pub(crate) const ACCEPT_ROUTE: &str = "/content/purchase/v1/accept"; +pub(crate) const SETTLE_ROUTE: &str = "/content/purchase/v1/settle"; +pub(crate) const STATUS_ROUTE: &str = "/content/purchase/v1/status"; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Offer { + pub id: String, + pub buyer_did: String, + pub seller_did: String, + pub content_id: String, + pub filename: String, + pub mime_type: String, + pub content_sha256: String, + pub content_size: u64, + #[serde(default)] + pub viewing_seconds: Option, + pub terms_sha256: String, + pub network: EcashNetwork, + pub mint_url: String, + pub seller_net_sats: u64, + pub offered_at: i64, + pub expires_at: i64, +} +impl Offer { + pub fn validate(&self) -> Result<()> { + anyhow::ensure!( + if self.content_id.starts_with("registered_") { + self.viewing_seconds + .is_some_and(|seconds| (1..=31_536_000).contains(&seconds)) + } else { + self.viewing_seconds.is_none() + }, + "Offer rental duration binding is invalid" + ); + anyhow::ensure!( + !self.filename.is_empty() + && self.filename.len() <= 4096 + && !self.filename.chars().any(char::is_control), + "Invalid offer filename" + ); + anyhow::ensure!( + self.mime_type.len() <= 128 + && self.mime_type.parse::().is_ok(), + "Invalid offer MIME type" + ); + let contract = Contract { + version: 1, + id: self.id.clone(), + buyer_did: self.buyer_did.clone(), + seller_did: self.seller_did.clone(), + content_id: self.content_id.clone(), + content_sha256: self.content_sha256.clone(), + content_size: self.content_size, + terms_sha256: self.terms_sha256.clone(), + network: self.network, + mint_url: self.mint_url.clone(), + gross_token_sats: self.seller_net_sats, + minimum_net_sats: self.seller_net_sats, + offered_at: self.offered_at, + expires_at: self.expires_at, + }; + contract.validate() + } + pub fn contract(&self, plan: &FeePlan) -> Result { + self.validate()?; + plan.validate()?; + anyhow::ensure!( + plan.mint_url == self.mint_url && plan.net_sats >= self.seller_net_sats, + "Payment plan does not cover this offer" + ); + let contract = Contract { + version: 1, + id: self.id.clone(), + buyer_did: self.buyer_did.clone(), + seller_did: self.seller_did.clone(), + content_id: self.content_id.clone(), + content_sha256: self.content_sha256.clone(), + content_size: self.content_size, + terms_sha256: self.terms_sha256.clone(), + network: self.network, + mint_url: self.mint_url.clone(), + gross_token_sats: plan.gross_sats, + minimum_net_sats: self.seller_net_sats, + offered_at: self.offered_at, + expires_at: self.expires_at, + }; + contract.validate()?; + Ok(contract) + } +} +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Envelope { + pub offer: Offer, + pub fee_plan: FeePlan, +} +impl Envelope { + pub fn contract(&self) -> Result { + self.offer.contract(&self.fee_plan) + } + pub fn commitment(&self) -> Result { + use sha2::{Digest, Sha256}; + let contract = self.contract()?; + // Explicit ordered components: never hash an incidental map ordering. + Ok(hex::encode(Sha256::digest(serde_json::to_vec(&( + "archipelago-purchase-envelope-v1", + contract.context_hash()?, + self.fee_plan.commitment()?, + &self.offer.filename, + &self.offer.mime_type, + self.offer.viewing_seconds, + ))?))) + } +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Accepted { + pub envelope_sha256: String, + pub acceptance: Acceptance, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Settlement { + pub envelope: Envelope, + pub token: String, +} +#[derive(Clone, Serialize, Deserialize)] +#[serde(tag = "state", rename_all = "snake_case")] +pub(crate) enum SellerStatus { + Accepted, + Cancelled, + Settled { receipt: Receipt }, +} + +/// Match the policy used by fresh seller redemption before inviting a spend. +/// Settled receipts remain recoverable independently of later wallet settings. +pub(crate) async fn ensure_seller_mint_policy( + data_dir: &Path, + network: crate::wallet::ecash::EcashNetwork, + mint_url: &str, +) -> Result<()> { + use crate::{content_purchase::canonical_mint, wallet::ecash}; + anyhow::ensure!( + ecash::load_network(data_dir).await? == network, + "Seller wallet is on another payment network; no new payment should be sent" + ); + let accepted = ecash::load_accepted_mints(data_dir).await?; + let mint = canonical_mint(mint_url)?; + anyhow::ensure!( + accepted + .mints + .iter() + .any(|candidate| canonical_mint(candidate).ok().as_deref() == Some(mint.as_str())), + "Seller does not accept the quoted mint; no new payment should be sent" + ); + Ok(()) +} + +/// Caller obtained these fields from a currently shared, immutable verified +/// snapshot (registered_terms/ordinary catalog snapshot), never from client JSON. +/// Save before returning the offer; replay always returns original terms. +pub(crate) async fn save_offer( + data_dir: &Path, + offered: &Offer, + verified_buyer: &str, + now: i64, +) -> Result { + anyhow::ensure!(offered.buyer_did == verified_buyer, "Offer buyer mismatch"); + ensure_seller_mint_policy(data_dir, offered.network, &offered.mint_url).await?; + let journal = Journal::open(data_dir).await?; + if let Some(saved) = journal.protocol_offer(&offered.id).await? { + anyhow::ensure!( + saved.buyer_did == verified_buyer && saved.content_id == offered.content_id, + "Offer operation changed buyer/content" + ); + return Ok(saved); + } + anyhow::ensure!( + now >= offered.offered_at && now < offered.expires_at, + "Offer is expired" + ); + journal.save_protocol_offer(offered).await?; + Ok(offered.clone()) +} +/// POST ACCEPT_ROUTE. Persist both fee commitment and liability before replying. +pub(crate) async fn accept( + data_dir: &Path, + envelope: &Envelope, + verified_buyer: &str, + now: impl Fn() -> i64, +) -> Result { + let contract = envelope.contract()?; + anyhow::ensure!( + contract.buyer_did == verified_buyer, + "Acceptance buyer mismatch" + ); + { + let journal = Journal::open(data_dir).await?; + let offer = journal + .protocol_offer(&contract.id) + .await? + .context("Seller offer is missing")?; + anyhow::ensure!(offer == envelope.offer, "Seller offer changed"); + if let Some(previous) = journal.protocol_envelope("seller", &contract.id).await? { + anyhow::ensure!(previous == *envelope, "Accepted payment shape changed"); + if let Some(record) = journal.seller(&contract.id).await? { + return Ok(Accepted { + envelope_sha256: envelope.commitment()?, + acceptance: record.acceptance()?, + }); + } + } + } + ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?; + // No database lock across remote mint query. Recheck durable binding/time + // when committing below, so concurrent acceptance cannot alter the plan. + let keysets = MintClient::new(&contract.mint_url)?.get_keysets().await?; + envelope.fee_plan.verify_mint_keysets(&keysets, false)?; + // Same wallet -> purchase lock order as policy updates: an accepted + // liability cannot race removal of its mint or a network switch. + let _wallet = crate::wallet::mutation::guard(data_dir).await?; + ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?; + let journal = Journal::open(data_dir).await?; + anyhow::ensure!( + journal.protocol_offer(&contract.id).await?.as_ref() == Some(&envelope.offer) + && !journal.retired_offer_matches(&envelope.offer).await?, + "Original offer retired before acceptance; recover cancellation without spending" + ); + journal.save_protocol_envelope("seller", envelope).await?; + let record = journal.prepare_seller(&contract, now()).await?; + Ok(Accepted { + envelope_sha256: envelope.commitment()?, + acceptance: record.acceptance()?, + }) +} +/// POST SETTLE_ROUTE. Accepting new liability is deliberately impossible here. +pub(crate) async fn settle( + data_dir: &Path, + request: &Settlement, + verified_buyer: &str, +) -> Result { + let contract = request.envelope.contract()?; + anyhow::ensure!( + contract.buyer_did == verified_buyer, + "Settlement buyer mismatch" + ); + { + let journal = Journal::open(data_dir).await?; + let saved = journal + .protocol_envelope("seller", &contract.id) + .await? + .context("Seller acceptance is missing")?; + anyhow::ensure!( + saved == request.envelope, + "Settlement changed accepted payment shape" + ); + } + request.envelope.fee_plan.validate_token(&request.token)?; + crate::content_purchase_executor::settle_seller_token( + data_dir, + &contract, + &request.token, + verified_buyer, + ) + .await +} +/// POST STATUS_ROUTE. Read-only, bound to buyer and exact accepted envelope. +pub(crate) async fn status( + data_dir: &Path, + envelope: &Envelope, + verified_buyer: &str, +) -> Result { + let contract = envelope.contract()?; + anyhow::ensure!( + contract.buyer_did == verified_buyer, + "Status buyer mismatch" + ); + let journal = Journal::open(data_dir).await?; + anyhow::ensure!( + journal + .protocol_envelope("seller", &contract.id) + .await? + .as_ref() + == Some(envelope), + "Accepted operation not found" + ); + let record = journal + .seller(&contract.id) + .await? + .context("Accepted operation not found")?; + match record.phase { + SellerPhase::ReceiptSaved(receipt) => Ok(SellerStatus::Settled { receipt }), + SellerPhase::Cancelled => Ok(SellerStatus::Cancelled), + _ => { + ensure_seller_mint_policy(data_dir, contract.network, &contract.mint_url).await?; + Ok(SellerStatus::Accepted) + } + } +} + +pub(crate) const CANCEL_ROUTE: &str = "/content/purchase/v1/cancel"; +#[derive(Clone, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub(crate) struct Cancelled { + pub envelope_sha256: String, +} +/// Authenticated buyer sealed its wallet before requesting cancellation. The +/// seller only seals an unfunded intent and rejects every subsequent late accept. +pub(crate) async fn cancel( + data_dir: &Path, + envelope: &Envelope, + verified_buyer: &str, +) -> Result { + let contract = envelope.contract()?; + anyhow::ensure!( + contract.buyer_did == verified_buyer, + "Cancellation buyer changed" + ); + let journal = Journal::open(data_dir).await?; + anyhow::ensure!( + journal.protocol_offer(&contract.id).await?.as_ref() == Some(&envelope.offer) + || journal.retired_offer_matches(&envelope.offer).await?, + "Original seller offer changed" + ); + journal.save_protocol_envelope("seller", envelope).await?; + journal.cancel_seller(&contract).await?; + Ok(Cancelled { + envelope_sha256: envelope.commitment()?, + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::wallet::{ + cashu::{CashuToken, Proof}, + purchase_fee_plan::KeysetPlan, + }; + fn envelope() -> Envelope { + let buyer = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap(); + let seller = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap(); + let fee_plan = FeePlan::from_shape( + "https://unused-mint.invalid", + vec![KeysetPlan { + keyset_id: "0011223344556677".into(), + denominations: vec![8], + input_fee_ppk: 0, + }], + ) + .unwrap(); + Envelope { + offer: Offer { + id: uuid::Uuid::new_v4().to_string(), + buyer_did: buyer, + seller_did: seller, + content_id: "registered_film".into(), + filename: "film.mp4".into(), + mime_type: "video/mp4".into(), + content_sha256: "ab".repeat(32), + content_size: 10, + viewing_seconds: Some(60), + terms_sha256: "cd".repeat(32), + network: EcashNetwork::Mainnet, + mint_url: "https://unused-mint.invalid".into(), + seller_net_sats: 8, + offered_at: 1000, + expires_at: 1100, + }, + fee_plan, + } + } + #[tokio::test] + async fn unaccepted_mint_cannot_publish_offer_and_cancel_releases_policy_pin() { + use crate::wallet::ecash::{ + save_accepted_mints, save_network, AcceptedMints, EcashNetwork, + }; + let root = tempfile::tempdir().unwrap(); + let value = envelope(); + let buyer = &value.offer.buyer_did; + assert!(save_offer(root.path(), &value.offer, buyer, 1001) + .await + .is_err()); + assert!(Journal::open(root.path()) + .await + .unwrap() + .protocol_offer(&value.offer.id) + .await + .unwrap() + .is_none()); + save_accepted_mints( + root.path(), + &AcceptedMints { + mints: vec![value.offer.mint_url.clone()], + }, + ) + .await + .unwrap(); + save_offer(root.path(), &value.offer, buyer, 1001) + .await + .unwrap(); + { + let journal = Journal::open(root.path()).await.unwrap(); + journal + .save_protocol_envelope("seller", &value) + .await + .unwrap(); + journal + .prepare_seller(&value.contract().unwrap(), 1001) + .await + .unwrap(); + } + assert!( + save_accepted_mints(root.path(), &AcceptedMints { mints: vec![] }) + .await + .is_err() + ); + assert!(save_network(root.path(), EcashNetwork::Testnet) + .await + .is_err()); + cancel(root.path(), &value, buyer).await.unwrap(); + save_accepted_mints(root.path(), &AcceptedMints { mints: vec![] }) + .await + .unwrap(); + save_network(root.path(), EcashNetwork::Testnet) + .await + .unwrap(); + assert!(matches!( + status(root.path(), &value, buyer).await.unwrap(), + SellerStatus::Cancelled + )); + } + #[tokio::test] + async fn seller_cancellation_replays_after_lost_reply_and_seals_late_acceptance() { + let root = tempfile::tempdir().unwrap(); + let value = envelope(); + crate::wallet::ecash::save_accepted_mints( + root.path(), + &crate::wallet::ecash::AcceptedMints { + mints: vec![value.offer.mint_url.clone()], + }, + ) + .await + .unwrap(); + let contract = value.contract().unwrap(); + save_offer(root.path(), &value.offer, &contract.buyer_did, 1001) + .await + .unwrap(); + { + let journal = Journal::open(root.path()).await.unwrap(); + journal + .save_protocol_envelope("seller", &value) + .await + .unwrap(); + journal.prepare_seller(&contract, 1001).await.unwrap(); + journal.prepare_buyer(&contract, 1001).await.unwrap(); + journal.begin_cancellation(&contract).await.unwrap(); + } + let lost = cancel(root.path(), &value, &contract.buyer_did) + .await + .unwrap(); + assert!(accept(root.path(), &value, &contract.buyer_did, || 1002) + .await + .is_err()); + let replay = cancel(root.path(), &value, &contract.buyer_did) + .await + .unwrap(); + assert_eq!(lost.envelope_sha256, replay.envelope_sha256); + let journal = Journal::open(root.path()).await.unwrap(); + journal + .finish_cancellation(&contract, &contract.seller_did) + .await + .unwrap(); + assert_eq!( + journal.buyer(&contract.id).await.unwrap().unwrap().phase, + crate::content_purchase::BuyerPhase::Cancelled + ); + let delayed = Acceptance { + contract_hash: contract.context_hash().unwrap(), + accepted_at: 1001, + }; + assert!(journal + .record_acceptance(&contract, &delayed, &contract.seller_did) + .await + .is_err()); + } + #[tokio::test] + async fn incoming_token_or_saved_settlement_prevents_seller_cancellation() { + let root = tempfile::tempdir().unwrap(); + let value = envelope(); + crate::wallet::ecash::save_accepted_mints( + root.path(), + &crate::wallet::ecash::AcceptedMints { + mints: vec![value.offer.mint_url.clone()], + }, + ) + .await + .unwrap(); + let contract = value.contract().unwrap(); + save_offer(root.path(), &value.offer, &contract.buyer_did, 1001) + .await + .unwrap(); + let token = CashuToken::new( + &contract.mint_url, + vec![Proof { + amount: 8, + id: "0011223344556677".into(), + secret: "test-original-payment".into(), + c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798".into(), + }], + ) + .serialize() + .unwrap(); + { + let journal = Journal::open(root.path()).await.unwrap(); + journal + .save_protocol_envelope("seller", &value) + .await + .unwrap(); + journal.prepare_seller(&contract, 1001).await.unwrap(); + journal + .record_incoming_token(&contract, &token) + .await + .unwrap(); + } + assert!(cancel(root.path(), &value, &contract.buyer_did) + .await + .is_err()); + let original = { + let journal = Journal::open(root.path()).await.unwrap(); + journal.record_settlement(&contract, 8).await.unwrap(); + journal.issue_receipt(&contract).await.unwrap() + }; + assert!(cancel(root.path(), &value, &contract.buyer_did) + .await + .is_err()); + crate::wallet::ecash::save_accepted_mints( + root.path(), + &crate::wallet::ecash::AcceptedMints { mints: vec![] }, + ) + .await + .unwrap(); + crate::wallet::ecash::save_network( + root.path(), + crate::wallet::ecash::EcashNetwork::Testnet, + ) + .await + .unwrap(); + match status(root.path(), &value, &contract.buyer_did) + .await + .unwrap() + { + SellerStatus::Settled { receipt } => assert!(receipt == original), + _ => panic!("Original receipt lost"), + } + } + #[tokio::test] + async fn expired_quote_cap_recovers_without_reusing_ids_or_retiring_accepted_liability() { + let root = tempfile::tempdir().unwrap(); + let now = chrono::Utc::now().timestamp(); + let mut accepted = envelope(); + accepted.offer.offered_at = now; + accepted.offer.expires_at = now + 300; + let mut expired = Vec::new(); + { + let journal = Journal::open(root.path()).await.unwrap(); + journal.save_protocol_offer(&accepted.offer).await.unwrap(); + journal + .save_protocol_envelope("seller", &accepted) + .await + .unwrap(); + journal + .prepare_seller(&accepted.contract().unwrap(), now) + .await + .unwrap(); + for _ in 0..128 { + let mut value = accepted.clone(); + value.offer.id = uuid::Uuid::new_v4().to_string(); + journal.save_protocol_offer(&value.offer).await.unwrap(); + expired.push(value); + } + let mut next = accepted.clone(); + next.offer.id = uuid::Uuid::new_v4().to_string(); + assert!(journal.save_protocol_offer(&next.offer).await.is_err()); + journal.retire_unaccepted_offers(now + 301).await.unwrap(); + assert!(journal + .protocol_offer(&accepted.offer.id) + .await + .unwrap() + .is_some()); + assert!(journal.seller(&accepted.offer.id).await.unwrap().is_some()); + assert!(journal + .protocol_offer(&expired[0].offer.id) + .await + .unwrap() + .is_none()); + assert!(journal + .retired_offer_matches(&expired[0].offer) + .await + .unwrap()); + journal.save_protocol_offer(&next.offer).await.unwrap(); + let mut changed = expired[0].offer.clone(); + changed.expires_at += 300; + assert!(journal.save_protocol_offer(&changed).await.is_err()); + assert!(!journal.retired_offer_matches(&changed).await.unwrap()); + } + let original = &expired[0]; + let ack = cancel(root.path(), original, &original.offer.buyer_did) + .await + .unwrap(); + assert_eq!(ack.envelope_sha256, original.commitment().unwrap()); + assert_eq!( + cancel(root.path(), original, &original.offer.buyer_did) + .await + .unwrap() + .envelope_sha256, + ack.envelope_sha256 + ); + assert!( + accept(root.path(), original, &original.offer.buyer_did, || now) + .await + .is_err() + ); + } +} diff --git a/core/archipelago/src/content_purchase_transport.rs b/core/archipelago/src/content_purchase_transport.rs new file mode 100644 index 00000000..b689c9d0 --- /dev/null +++ b/core/archipelago/src/content_purchase_transport.rs @@ -0,0 +1,105 @@ +//! Concrete buyer transport. Never redirects, changes route, or automatically +//! resends after an ambiguous delivery; the durable caller owns all replay. +use crate::{ + content_purchase::Receipt, + content_purchase_caller::PurchaseTransport, + content_purchase_protocol::{ + self as protocol, Accepted, Cancelled, Envelope, Offer, SellerStatus, Settlement, + }, +}; +use anyhow::{Context, Result}; +use serde::{de::DeserializeOwned, Serialize}; +use std::{path::PathBuf, time::Duration}; +pub(crate) struct FipsPurchaseTransport { + data_dir: PathBuf, + onion: String, + seller_did: String, + fips_npub: String, +} +impl FipsPurchaseTransport { + pub async fn load(data_dir: PathBuf, onion: String) -> Result { + let peer = crate::federation::load_unique_payment_peer(&data_dir, &onion).await?; + let fips_npub = peer + .fips_npub + .context("Purchase seller has no authenticated mesh binding")?; + anyhow::ensure!( + !fips_npub.is_empty(), + "Purchase seller has no authenticated mesh binding" + ); + Ok(Self { + data_dir, + onion, + seller_did: peer.did, + fips_npub, + }) + } + async fn post( + &self, + route: &str, + body: &B, + ) -> Result { + let (mut response, _) = + crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, route) + .require_fips() + .single_delivery() + .timeout(Duration::from_secs(45)) + .send_content_json(&self.data_dir, &self.seller_did, body) + .await?; + let status = response.status(); + anyhow::ensure!( + status.is_success(), + "Purchase endpoint returned {}; recover the original operation", + status.as_u16() + ); + let mut bytes = Vec::new(); + while let Some(chunk) = response.chunk().await? { + anyhow::ensure!( + bytes + .len() + .checked_add(chunk.len()) + .is_some_and(|n| n <= 65536), + "Purchase response is too large" + ); + bytes.extend_from_slice(&chunk); + } + serde_json::from_slice(&bytes) + .context("Invalid purchase response; original operation remains recoverable") + } +} +impl PurchaseTransport for FipsPurchaseTransport { + fn seller_onion(&self) -> &str { + &self.onion + } + fn seller_did(&self) -> &str { + &self.seller_did + } + async fn offer(&self, id: &str, content_id: &str) -> Result { + #[derive(Serialize)] + struct Request<'a> { + id: &'a str, + content_id: &'a str, + } + self.post(protocol::OFFER_ROUTE, &Request { id, content_id }) + .await + } + async fn accept(&self, envelope: &Envelope) -> Result { + self.post(protocol::ACCEPT_ROUTE, envelope).await + } + async fn status(&self, envelope: &Envelope) -> Result { + self.post(protocol::STATUS_ROUTE, envelope).await + } + async fn cancel(&self, envelope: &Envelope) -> Result { + self.post(protocol::CANCEL_ROUTE, envelope).await + } + async fn settle(&self, request: &Settlement) -> Result { + self.post(protocol::SETTLE_ROUTE, request).await + } +} + +pub(crate) async fn seller_onion_for_did(data_dir: &std::path::Path, did: &str) -> Result { + Ok( + crate::federation::load_unique_payment_peer_by_did(data_dir, did) + .await? + .onion, + ) +} diff --git a/core/archipelago/src/content_server.rs b/core/archipelago/src/content_server.rs index 6fa0ab41..42c65ebe 100644 --- a/core/archipelago/src/content_server.rs +++ b/core/archipelago/src/content_server.rs @@ -1897,3 +1897,29 @@ mod payment_source_tests { } } } + +// Make existing content_file_path pub(crate). Add this method in content_server. +pub(crate) async fn publish_snapshot_offer( + data_dir: &Path, + original: &ContentItem, + offer: &crate::content_purchase_protocol::Offer, +) -> Result { + let _held = CATALOG_WRITES.lock().await; + let current = load_catalog(data_dir).await?; + let item = current + .items + .iter() + .find(|item| item.id == original.id) + .context("Content was unshared before this offer")?; + anyhow::ensure!( + serde_json::to_value(item)? == serde_json::to_value(original)?, + "Shared content terms changed before offer publication" + ); + crate::content_purchase_protocol::save_offer( + data_dir, + offer, + &offer.buyer_did, + chrono::Utc::now().timestamp(), + ) + .await +} diff --git a/core/archipelago/src/content_snapshot.rs b/core/archipelago/src/content_snapshot.rs index 7b8b80ed..1d4105f5 100644 --- a/core/archipelago/src/content_snapshot.rs +++ b/core/archipelago/src/content_snapshot.rs @@ -105,6 +105,7 @@ pub(crate) fn prepare( "Shared snapshot version budget is full; retain existing purchases" ); let version = io::private_directory(&root, &key)?; + let budget_operation = format!("shared:{key}"); if let Some(record) = read_manifest(&version)? { anyhow::ensure!( record.version == 1 && record.content_id == content_id && record.source == source_stamp, @@ -117,6 +118,7 @@ pub(crate) fn prepare( && file.metadata()?.len() == record.size, "Retained shared snapshot changed; preserve accepted purchases" ); + crate::snapshot_budget::finish_completed(data_dir, &budget_operation, record.size, limits)?; return Ok(Snapshot { file, key, @@ -158,6 +160,7 @@ pub(crate) fn prepare( io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?; use std::io::{Seek, SeekFrom}; file.seek(SeekFrom::Start(0))?; + crate::snapshot_budget::finish_completed(data_dir, &budget_operation, size, limits)?; return Ok(Snapshot { file, key, @@ -174,24 +177,14 @@ pub(crate) fn prepare( } Err(error) => return Err(error), } - let used = storage_bytes(&root)?; - anyhow::ensure!( - used.checked_add(size) - .and_then(|v| v.checked_add(128 * 1024)) - .is_some_and(|total| total <= max_total_bytes), - "Shared snapshot storage budget is full; no new purchase accepted" - ); - let mut stat = std::mem::MaybeUninit::::uninit(); - anyhow::ensure!( - unsafe { libc::fstatvfs(root.as_raw_fd(), stat.as_mut_ptr()) } == 0, - "Could not verify snapshot disk space" - ); - let stat = unsafe { stat.assume_init() }; - let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64); - anyhow::ensure!( - free >= size.saturating_add(minimum_free_bytes), - "Not enough free storage for a retained purchase snapshot" - ); + let reservation = crate::snapshot_budget::reserve( + data_dir, + &budget_operation, + size, + max_total_bytes, + minimum_free_bytes, + limits, + )?; let (name, mut destination) = io::temporary(&version)?; let mut temporary = Temporary { directory: &version, @@ -218,6 +211,7 @@ pub(crate) fn prepare( }; let checksum = hash(&serde_json::to_vec(&record)?); io::save_record(&version, "snapshot.json", &Envelope { record, checksum })?; + reservation.finish(limits)?; Ok(Snapshot { file, key, @@ -279,35 +273,6 @@ pub(crate) fn open_matching( anyhow::bail!("Accepted content snapshot is unavailable; retain purchase for recovery") } -fn storage_bytes(directory: &File) -> Result { - let mut total = 0u64; - for entry in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? { - let entry = entry?; - let name = entry.file_name(); - let name = name.to_str().context("Invalid snapshot filename")?; - let metadata = std::fs::symlink_metadata(entry.path())?; - anyhow::ensure!( - !metadata.file_type().is_symlink(), - "Snapshot storage contains an unexpected symlink" - ); - let size = if metadata.is_dir() { - storage_bytes(&io::open_at( - directory, - name, - libc::O_RDONLY | libc::O_DIRECTORY, - 0, - )?)? - } else { - anyhow::ensure!(metadata.is_file(), "Unexpected snapshot storage entry"); - metadata.len() - }; - total = total - .checked_add(size) - .context("Snapshot storage accounting overflow")?; - } - Ok(total) -} - struct Temporary<'a> { directory: &'a File, name: String, diff --git a/core/archipelago/src/federation/mod.rs b/core/archipelago/src/federation/mod.rs index f175c020..59ceed05 100644 --- a/core/archipelago/src/federation/mod.rs +++ b/core/archipelago/src/federation/mod.rs @@ -20,7 +20,7 @@ pub(crate) use invites::notify_join; // Crate-internal: peer-joined resolves the granted trust level by matching // the acceptor's invite_token against our stored outgoing invites. pub(crate) use storage::load_invites; -pub(crate) use storage::load_unique_payment_peer; +pub(crate) use storage::{load_unique_payment_peer, load_unique_payment_peer_by_did}; #[allow(unused_imports)] pub use storage::{ add_node, fips_npub_for_onion, load_nodes, load_removed_dids, record_peer_transport, diff --git a/core/archipelago/src/federation/storage.rs b/core/archipelago/src/federation/storage.rs index 494f9b90..cf65a5e0 100644 --- a/core/archipelago/src/federation/storage.rs +++ b/core/archipelago/src/federation/storage.rs @@ -101,6 +101,41 @@ pub(crate) async fn load_unique_payment_peer( Ok(peer) } +/// Resolve a purchase seller by raw identity before any display deduplication. +pub(crate) async fn load_unique_payment_peer_by_did( + data_dir: &Path, + did: &str, +) -> Result { + let _guard = FEDERATION_STORE_LOCK.lock().await; + let content = fs::read(data_dir.join(FEDERATION_DIR).join(NODES_FILE)) + .await + .context("Could not read payment peer bindings")?; + let file: NodesFile = + serde_json::from_slice(&content).context("Invalid payment peer bindings")?; + let matching: Vec<_> = file.nodes.iter().filter(|peer| peer.did == did).collect(); + anyhow::ensure!( + matching.len() == 1, + "Payment seller identity binding is missing or ambiguous" + ); + let peer = matching[0]; + let onion = peer.onion.strip_suffix(".onion").unwrap_or(&peer.onion); + anyhow::ensure!( + !onion.is_empty() + && file + .nodes + .iter() + .filter(|node| node.onion.strip_suffix(".onion").unwrap_or(&node.onion) == onion) + .count() + == 1, + "Payment seller address binding is missing or ambiguous" + ); + anyhow::ensure!( + crate::identity::did_key_from_pubkey_hex(&peer.pubkey)? == peer.did, + "Payment seller identity does not match its public key" + ); + Ok(peer.clone()) +} + /// Lock-free body of `load_nodes`. Callers that already hold /// `FEDERATION_STORE_LOCK` (i.e. other functions in this module composing a /// multi-step critical section) must call this instead of `load_nodes` to @@ -547,6 +582,41 @@ mod tests { } } + #[tokio::test] + async fn payment_did_resolution_rejects_duplicates_hidden_by_display_merging() { + let dir = tempfile::tempdir().unwrap(); + let key = hex::encode([1u8; 32]); + let did = crate::identity::did_key_from_pubkey_hex(&key).unwrap(); + let mut original = make_node(&did, "a.onion"); + original.pubkey = key; + save_nodes(dir.path(), &[original.clone()]).await.unwrap(); + assert_eq!( + load_unique_payment_peer_by_did(dir.path(), &did) + .await + .unwrap() + .onion, + "a.onion" + ); + let mut alternate = original.clone(); + alternate.onion = "b.onion".into(); + save_nodes(dir.path(), &[original.clone(), alternate]) + .await + .unwrap(); + assert!(load_unique_payment_peer_by_did(dir.path(), &did) + .await + .is_err()); + let mut collision = original.clone(); + collision.did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap(); + collision.pubkey = hex::encode([2u8; 32]); + save_nodes(dir.path(), &[collision, original]) + .await + .unwrap(); + assert_eq!(load_nodes(dir.path()).await.unwrap().len(), 1); + assert!(load_unique_payment_peer_by_did(dir.path(), &did) + .await + .is_err()); + } + #[test] fn test_dedup_nodes_by_onion_collapses_same_onion() { // Two entries share an onion (same physical node under two dids) — must diff --git a/core/archipelago/src/main.rs b/core/archipelago/src/main.rs index 07b3f634..dc12616a 100644 --- a/core/archipelago/src/main.rs +++ b/core/archipelago/src/main.rs @@ -47,12 +47,8 @@ mod content_invoice; mod content_owned; mod content_purchase; mod content_purchase_executor; -mod content_snapshot; -mod media_stream; -mod media_registration; -mod registered_media; -mod prepared_media; mod content_server; +mod content_snapshot; mod crash_recovery; mod credentials; mod data_model; @@ -68,6 +64,8 @@ mod host_ip; mod identity; mod identity_manager; mod marketplace; +mod media_registration; +mod media_stream; mod mesh; mod mesh_ports; mod monitoring; @@ -82,11 +80,14 @@ mod nostr_relays; mod nostr_security_tests; mod peers; mod port_allocator; +mod prepared_media; mod rate_limit; +mod registered_media; pub mod seed; mod server; mod session; mod settings; +mod snapshot_budget; mod state; mod storage_crypto; mod streaming; @@ -584,3 +585,15 @@ async fn main() -> Result<()> { // crash in `systemctl status`. std::process::exit(0); } + +mod content_purchase_protocol; + +mod content_purchase_caller; + +mod content_purchase_transport; + +mod content_purchase_download; + +mod content_cloud_offer; + +mod playback_handles; diff --git a/core/archipelago/src/media_registration.rs b/core/archipelago/src/media_registration.rs index 549c652b..50f03450 100644 --- a/core/archipelago/src/media_registration.rs +++ b/core/archipelago/src/media_registration.rs @@ -12,7 +12,7 @@ use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::ffi::CString; use std::fs::File; -use std::io::{Read, Write}; +use std::io::{Read, Seek, SeekFrom, Write}; use std::os::fd::{AsRawFd, FromRawFd}; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::{MetadataExt, PermissionsExt}; @@ -184,9 +184,8 @@ fn lower_hex(value: &str, length: usize) -> bool { .bytes() .all(|v| v.is_ascii_digit() || (b'a'..=b'f').contains(&v)) } -fn validate( +fn validate_intent( intent: &Intent, - selection: &AuthorizedSelection, pin: &InstallationPin, identity: &crate::identity::NodeIdentity, now: u64, @@ -221,6 +220,16 @@ fn validate( && intent.expires_at - intent.created_at <= 600, "Invalid registration terms or timestamps" ); + Ok(()) +} +fn validate( + intent: &Intent, + selection: &AuthorizedSelection, + pin: &InstallationPin, + identity: &crate::identity::NodeIdentity, + now: u64, +) -> Result<()> { + validate_intent(intent, pin, identity, now)?; anyhow::ensure!( !selection.relative_path.as_os_str().is_empty() && selection @@ -507,6 +516,206 @@ fn receipt_for(operation: &Operation, hash: String, size: u64) -> Receipt { } } +const RETIREMENT_DOMAIN: &str = "archipelago.indeehub.media-retirement.v1"; +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct Retirement { + pub version: u8, + pub intent: Intent, + pub retired_at: u64, + pub signature: String, +} +impl Retirement { + pub fn preimage(&self) -> Result> { + let i = &self.intent; + Ok(serde_json::to_vec(&serde_json::json!([ + RETIREMENT_DOMAIN, + i.request_id, + i.nonce, + i.app_audience, + i.node_did, + i.producer, + i.project_id, + i.price_sats, + i.viewing_seconds, + i.created_at, + i.expires_at, + self.retired_at + ]))?) + } + fn verify(&self, intent: &Intent, identity: &crate::identity::NodeIdentity) -> Result<()> { + anyhow::ensure!( + self.version == 1 + && self.intent == *intent + && self.retired_at >= intent.expires_at + && self.retired_at <= MAX_SAFE_INTEGER + && lower_hex(&self.signature, 128), + "Registration retirement terms changed" + ); + identity.signing_key().verifying_key().verify_strict( + &self.preimage()?, + &Signature::from_slice(&hex::decode(&self.signature)?)?, + )?; + Ok(()) + } +} +pub enum Resolution { + Prepared { + prepared: PreparedRegistration, + selection: AuthorizedSelection, + }, + Retired(Retirement), + Pending { + request_id: String, + expires_at: u64, + }, +} +/// Caller authenticates the producer's intent-only recovery/retirement consent. +/// Under the original operation lock, either verify the completed bytes/receipt, +/// or commit retirement. No source path from this request is opened or copied. +pub fn resolve( + data_dir: &Path, + identity: &crate::identity::NodeIdentity, + pin: &InstallationPin, + intent: &Intent, + now: u64, + limits: &Limits<'_>, +) -> Result { + validate_intent(intent, pin, identity, now)?; + let data_dir = data_dir.canonicalize()?; + let data = open_directory(&data_dir)?; + let root = private_directory(&data, PRIVATE_DIRECTORY)?; + let dir = private_directory(&root, &intent.request_id)?; + lock_operation(&dir, limits, Instant::now() + Duration::from_secs(30))?; + if let Some(retired) = read_record::(&dir, "retirement.json")? { + retired.verify(intent, identity)?; + dir.sync_all()?; + if let Some(operation) = read_record::(&dir, "operation.json")? { + anyhow::ensure!( + operation.version == 1 && operation.binding.intent == *intent, + "Retired operation terms changed" + ); + crate::snapshot_budget::finish_completed( + &data_dir, + &format!("registered:{}", intent.request_id), + operation.source.size, + limits, + )?; + } + return Ok(Resolution::Retired(retired)); + } + let operation: Option = read_record(&dir, "operation.json")?; + if let Some(operation) = &operation { + anyhow::ensure!( + operation.version == 1 && operation.binding.intent == *intent, + "Original registration intent changed" + ); + validate(intent, &operation.binding.selection, pin, identity, now)?; + } + if let Some(receipt) = read_record::(&dir, "receipt.json")? { + let operation = + operation.context("Receipt has no original operation; preserve recovery data")?; + let saved: SnapshotRecord = + read_record(&dir, "snapshot.json")?.context("Snapshot commitment missing")?; + let mut snapshot = open_at(&dir, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0)?; + anyhow::ensure!( + snapshot.metadata()?.mode() & 0o7777 == 0o400, + "Snapshot permissions changed" + ); + let before = SourceStamp::read(&snapshot)?; + let (sha256, size) = hash_file(&mut snapshot, None, limits, &mut |_| Ok(()))?; + anyhow::ensure!( + SourceStamp::read(&snapshot)? == before + && size == operation.source.size + && sha256 == saved.sha256 + && size == saved.size, + "Completed registration bytes changed" + ); + let mut expected = receipt_for(&operation, sha256, size); + anyhow::ensure!( + lower_hex(&receipt.signature, 128), + "Invalid completed signature" + ); + identity.signing_key().verifying_key().verify_strict( + &receipt.preimage()?, + &Signature::from_slice(&hex::decode(&receipt.signature)?)?, + )?; + expected.signature = receipt.signature.clone(); + anyhow::ensure!( + expected == receipt, + "Completed registration receipt changed" + ); + snapshot.seek(SeekFrom::Start(0))?; + dir.sync_all()?; + crate::snapshot_budget::finish_completed( + &data_dir, + &format!("registered:{}", intent.request_id), + operation.source.size, + limits, + )?; + return Ok(Resolution::Prepared { + prepared: PreparedRegistration { + receipt, + snapshot, + snapshot_path: data_dir + .join(PRIVATE_DIRECTORY) + .join(&intent.request_id) + .join("media"), + }, + selection: operation.binding.selection, + }); + } + if now < intent.expires_at { + return Ok(Resolution::Pending { + request_id: intent.request_id.clone(), + expires_at: intent.expires_at, + }); + } + // Missing operation metadata alongside media is damaged state, not proof + // that an earlier completion never happened. Preserve it for investigation. + if operation.is_none() { + anyhow::ensure!( + read_record::(&dir, "snapshot.json")?.is_none(), + "Snapshot exists without original intent; preserve recovery data" + ); + match open_at(&dir, "media", libc::O_RDONLY | libc::O_NONBLOCK, 0) { + Ok(_) => anyhow::bail!("Media exists without original intent; preserve recovery data"), + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => + { + () + } + Err(error) => return Err(error), + } + } + let mut retirement = Retirement { + version: 1, + intent: intent.clone(), + retired_at: now, + signature: String::new(), + }; + retirement.signature = hex::encode( + identity + .signing_key() + .sign(&retirement.preimage()?) + .to_bytes(), + ); + save_record(&dir, "retirement.json", &retirement)?; + if let Some(operation) = operation { + // Tombstone is durable under the copy lock: no writer can resume. Any + // retained partial bytes stay counted; no media or source is deleted. + crate::snapshot_budget::finish_completed( + &data_dir, + &format!("registered:{}", intent.request_id), + operation.source.size, + limits, + )?; + } + Ok(Resolution::Retired(retirement)) +} + /// Performs disk I/O and cross-process locking; run on a blocking worker. /// `now` is caller-supplied trusted UTC seconds, never a request-body timestamp. /// `progress` may return an error to cancel; it must not change authorized terms. @@ -554,6 +763,10 @@ pub fn prepare( limits, started + Duration::from_secs(wait_seconds), )?; + if let Some(retired) = read_record::(&operation_dir, "retirement.json")? { + retired.verify(intent, identity)?; + anyhow::bail!("Registration was authoritatively retired; recover that result before starting a new intent"); + } let operation: Operation = if let Some(saved) = read_record::(&operation_dir, "operation.json")? { anyhow::ensure!( @@ -611,6 +824,19 @@ pub fn prepare( SourceStamp::read(&source)? == operation.source, "Selected Cloud file changed; use a new reviewed intent" ); + let _reservation = crate::snapshot_budget::reserve_until( + &data_dir, + &format!("registered:{}", intent.request_id), + operation.source.size, + crate::snapshot_budget::DEFAULT_MAX_TOTAL_BYTES, + crate::snapshot_budget::DEFAULT_MIN_FREE_BYTES, + limits, + started + Duration::from_secs(intent.expires_at.saturating_sub(now).min(30)), + )?; + anyhow::ensure!( + now.saturating_add(started.elapsed().as_secs()) < intent.expires_at, + "Registration expired while awaiting snapshot capacity" + ); let (name, mut destination) = temporary(&operation_dir)?; let (hash, size) = hash_file(&mut source, Some(&mut destination), limits, &mut progress)?; @@ -690,6 +916,12 @@ pub fn prepare( save_record(&operation_dir, "receipt.json", &receipt)?; } operation_dir.sync_all()?; + crate::snapshot_budget::finish_completed( + &data_dir, + &format!("registered:{}", intent.request_id), + operation.source.size, + limits, + )?; // Reopen from the held directory to return a descriptor positioned at zero. let snapshot = open_at( &operation_dir, @@ -832,6 +1064,51 @@ mod tests { assert_eq!(fixture.run(1000).unwrap().receipt, expected); } + #[tokio::test] + async fn independent_nodejs_retirement_wire_matches_terminal_record() { + let vector: serde_json::Value = serde_json::from_str(include_str!( + "media_registration/fixtures/retirement-v1.json" + )) + .unwrap(); + let mut fixture = Fixture::new().await; + std::fs::write( + fixture.root.path().join("identity/node_key"), + hex::decode(vector["testSeedHex"].as_str().unwrap()).unwrap(), + ) + .unwrap(); + fixture.identity = + crate::identity::NodeIdentity::load_existing(&fixture.root.path().join("identity")) + .await + .unwrap(); + fixture.pin = InstallationPin { + node_did: vector["pin"]["nodeDid"].as_str().unwrap().into(), + app_audience: vector["pin"]["appAudience"].as_str().unwrap().into(), + }; + fixture.intent = serde_json::from_value(vector["intent"].clone()).unwrap(); + let expected: Retirement = serde_json::from_value(vector["retirement"].clone()).unwrap(); + assert_eq!( + expected.preimage().unwrap(), + vector["preimageUtf8"].as_str().unwrap().as_bytes() + ); + expected.verify(&fixture.intent, &fixture.identity).unwrap(); + let result = resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &fixture.intent, + expected.retired_at, + &Limits { + max_bytes: 1024, + cancelled: &fixture.cancelled, + }, + ) + .unwrap(); + match result { + Resolution::Retired(actual) => assert_eq!(actual, expected), + _ => panic!("expected retirement"), + } + } + #[test] fn expired_lock_deadline_returns_without_waiting() { let root = tempfile::tempdir().unwrap(); @@ -1085,4 +1362,180 @@ mod tests { b"damaged fixture" ); } + #[tokio::test] + async fn completed_registration_releases_crashed_reservation_without_source_or_new_admission() { + let fixture = Fixture::new().await; + let original = fixture.run(1100).unwrap(); + let operation = format!("registered:{}", fixture.intent.request_id); + let limits = Limits { + max_bytes: 1_000_000, + cancelled: &fixture.cancelled, + }; + let reservation = crate::snapshot_budget::reserve( + fixture.root.path(), + &operation, + 150_000, + 4 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + drop(reservation); // Crash after durable receipt, before reservation cleanup. + let name = format!("{}.json", hex::encode(Sha256::digest(operation.as_bytes()))); + let ledger = fixture.root.path().join("snapshot-reservations").join(name); + assert!(ledger.exists()); + std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap(); + let recovered = fixture.run(1700).unwrap(); + assert_eq!(recovered.receipt, original.receipt); + assert!(!ledger.exists()); + } + #[tokio::test] + async fn retirement_is_durable_exact_and_prevents_clock_rollback_or_late_prepare_resurrection() + { + let fixture = Fixture::new().await; + let limits = Limits { + max_bytes: 1_000_000, + cancelled: &fixture.cancelled, + }; + assert!(matches!( + resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &fixture.intent, + 1100, + &limits + ) + .unwrap(), + Resolution::Pending { .. } + )); + let first = match resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &fixture.intent, + 1700, + &limits, + ) + .unwrap() + { + Resolution::Retired(v) => v, + _ => panic!("expected retirement"), + }; + first.verify(&fixture.intent, &fixture.identity).unwrap(); + let again = match resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &fixture.intent, + 1800, + &limits, + ) + .unwrap() + { + Resolution::Retired(v) => v, + _ => panic!("expected original retirement"), + }; + assert_eq!(first, again); + assert!(fixture.run(1100).is_err()); // even a later clock rollback cannot reopen it + assert!(!fixture.operation_dir().join("media").exists()); + let mut changed = fixture.intent.clone(); + changed.price_sats += 1; + assert!(resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &changed, + 1800, + &limits + ) + .is_err()); + } + #[tokio::test] + async fn completed_resolution_after_expiry_never_retires_or_copies_removed_source() { + let fixture = Fixture::new().await; + let original = fixture.run(1100).unwrap(); + std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap(); + for now in [1700, 1800] { + let resolved = resolve( + fixture.root.path(), + &fixture.identity, + &fixture.pin, + &fixture.intent, + now, + &Limits { + max_bytes: 1_000_000, + cancelled: &fixture.cancelled, + }, + ) + .unwrap(); + match resolved { + Resolution::Prepared { + prepared, + selection, + } => { + assert_eq!(prepared.receipt, original.receipt); + assert_eq!(selection, fixture.selection); + } + _ => panic!("completed registration must not be retired"), + } + } + assert!(!fixture.operation_dir().join("retirement.json").exists()); + } + #[tokio::test] + async fn concurrent_prepare_and_retire_choose_completed_receipt_or_one_durable_retirement() { + use std::sync::mpsc; + for abort_copy in [false, true] { + let fixture = Arc::new(Fixture::new().await); + let (entered_tx, entered_rx) = mpsc::channel(); + let (release_tx, release_rx) = mpsc::channel(); + let copying = fixture.clone(); + let worker = std::thread::spawn(move || { + let mut entered = false; + copying.with_progress(1100, |_| { + if !entered { + entered = true; + entered_tx.send(()).unwrap(); + release_rx.recv().unwrap(); + } + anyhow::ensure!(!abort_copy, "fixture interrupted copy"); + Ok(()) + }) + }); + entered_rx.recv().unwrap(); + let resolving = fixture.clone(); + let (resolving_tx, resolving_rx) = mpsc::channel(); + let resolver = std::thread::spawn(move || { + resolving_tx.send(()).unwrap(); + resolve( + resolving.root.path(), + &resolving.identity, + &resolving.pin, + &resolving.intent, + 1700, + &Limits { + max_bytes: 1_000_000, + cancelled: &resolving.cancelled, + }, + ) + }); + resolving_rx.recv().unwrap(); + release_tx.send(()).unwrap(); + let prepared = worker.join().unwrap(); + let result = resolver.join().unwrap().unwrap(); + if abort_copy { + assert!(prepared.is_err()); + assert!(matches!(result, Resolution::Retired(_))); + assert!(fixture.run(1100).is_err()); + assert!(!fixture.operation_dir().join("receipt.json").exists()); + } else { + let expected = prepared.unwrap().receipt; + match result { + Resolution::Prepared { prepared, .. } => assert_eq!(prepared.receipt, expected), + _ => panic!("completion must win"), + } + assert!(!fixture.operation_dir().join("retirement.json").exists()); + } + } + } } diff --git a/core/archipelago/src/media_registration/fixtures/retirement-v1.json b/core/archipelago/src/media_registration/fixtures/retirement-v1.json new file mode 100644 index 00000000..9892c7b4 --- /dev/null +++ b/core/archipelago/src/media_registration/fixtures/retirement-v1.json @@ -0,0 +1,41 @@ +{ + "description": "Public deterministic test vector only. Seed 07 repeated 32 times is never a node or user key.", + "testSeedHex": "0707070707070707070707070707070707070707070707070707070707070707", + "pin": { + "publicKey": "ea4a6c63e29c520abef5507b132ec5f9954776aebebe7b92421eea691446d22c", + "nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z", + "appAudience": "fixture-indeehub" + }, + "intent": { + "version": 1, + "requestId": "00000000-0000-4000-8000-000000000001", + "nonce": "abababababababababababababababababababababababababababababababab", + "appAudience": "fixture-indeehub", + "nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z", + "producer": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "projectId": "fixture-project", + "priceSats": 15, + "viewingSeconds": 3600, + "createdAt": 1000, + "expiresAt": 1600 + }, + "preimageUtf8": "[\"archipelago.indeehub.media-retirement.v1\",\"00000000-0000-4000-8000-000000000001\",\"abababababababababababababababababababababababababababababababab\",\"fixture-indeehub\",\"did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z\",\"cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd\",\"fixture-project\",15,3600,1000,1600,1610]", + "retirement": { + "version": 1, + "intent": { + "version": 1, + "requestId": "00000000-0000-4000-8000-000000000001", + "nonce": "abababababababababababababababababababababababababababababababab", + "appAudience": "fixture-indeehub", + "nodeDid": "did:key:z6MkvDqGT54cXesYGvABpF1UapVNwjCqRcafi4Px6Thv5T3Z", + "producer": "cdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcdcd", + "projectId": "fixture-project", + "priceSats": 15, + "viewingSeconds": 3600, + "createdAt": 1000, + "expiresAt": 1600 + }, + "retiredAt": 1610, + "signature": "1ee7c8de044b4bb254a8a659d322958c332aa3a6bfe3f1432c234448996d97b2b1f3827e3d10114a50bf84f13a12edfcb9346bd119593c3e0463a65eea8a5e02" + } +} diff --git a/core/archipelago/src/playback_handles.rs b/core/archipelago/src/playback_handles.rs new file mode 100644 index 00000000..01a847e5 --- /dev/null +++ b/core/archipelago/src/playback_handles.rs @@ -0,0 +1,308 @@ +//! Process-local media handles; recovery reissues a handle for the same receipt. +//! No seller capability, wallet token or peer proof is sent to the browser. +use crate::{ + container::registration_pin::InstalledAppContext, + content_purchase::{Contract, Journal}, +}; +use anyhow::{Context, Result}; +use rand::RngCore; +use sha2::{Digest, Sha256}; +use std::{ + collections::HashMap, + path::Path, + sync::Mutex, + time::{Duration, Instant}, +}; + +const MAX_HANDLES: usize = 1024; +const HANDLE_LIFETIME: Duration = Duration::from_secs(24 * 60 * 60); + +#[derive(Clone, PartialEq, Eq)] +pub(crate) struct Binding { + pub context: InstalledAppContext, + pub seller_onion: String, + pub contract: Contract, + session: [u8; 32], +} +struct Entry { + binding: Binding, + until: Instant, + lease_expires: Option, +} +#[derive(Default)] +pub(crate) struct PlaybackHandles { + entries: Mutex>, +} + +fn session_fingerprint(session: &str) -> [u8; 32] { + let mut digest = Sha256::new(); + digest.update(b"archipelago-local-playback-session-v1\0"); + digest.update(session.as_bytes()); + digest.finalize().into() +} +fn valid_handle(value: &str) -> bool { + value.len() == 64 + && value + .bytes() + .all(|c| c.is_ascii_digit() || (b'a'..=b'f').contains(&c)) +} +impl PlaybackHandles { + /// Invoked only after normal owner-session/CSRF checks and the native broker's + /// verified installed-app/account authorization for this saved purchase. + /// It does not contact the seller, spend, open bytes, or start a rental lease. + pub async fn issue( + &self, + data_dir: &Path, + context: InstalledAppContext, + session: &str, + purchase_id: &str, + ) -> Result { + anyhow::ensure!(!session.is_empty(), "Owner session required"); + let record = Journal::open(data_dir) + .await? + .buyer(purchase_id) + .await? + .context("Original purchase is missing; recover it without paying again")?; + let seller_onion = crate::content_purchase_transport::seller_onion_for_did( + data_dir, + &record.contract.seller_did, + ) + .await?; + let peer = crate::federation::load_unique_payment_peer(data_dir, &seller_onion).await?; + anyhow::ensure!( + record.receipt().is_some(), + "Original purchase is not settled" + ); + anyhow::ensure!( + record.contract.buyer_did == context.node_did + && record.contract.seller_did == peer.did + && record.contract.content_id.starts_with("registered_"), + "Purchase does not match the current node and seller" + ); + record.contract.validate()?; + self.insert( + Binding { + context, + seller_onion: seller_onion.trim_end_matches(".onion").to_owned(), + contract: record.contract, + session: session_fingerprint(session), + }, + Instant::now(), + ) + } + fn insert(&self, binding: Binding, now: Instant) -> Result { + let mut entries = self + .entries + .lock() + .map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?; + entries.retain(|_, entry| now < entry.until); + if let Some((handle, _)) = entries.iter().find(|(_, entry)| entry.binding == binding) { + return Ok(handle.clone()); + } + anyhow::ensure!( + entries.len() < MAX_HANDLES, + "Too many active playback handles" + ); + let until = now + .checked_add(HANDLE_LIFETIME) + .context("Playback clock overflow")?; + let handle = loop { + let mut bytes = [0u8; 32]; + rand::rngs::OsRng.fill_bytes(&mut bytes); + let handle = hex::encode(bytes); + if !entries.contains_key(&handle) { + break handle; + } + }; + entries.insert( + handle.clone(), + Entry { + binding, + until, + lease_expires: None, + }, + ); + Ok(handle) + } + /// Session validity is checked independently on GET and during streaming. + /// Context must be freshly loaded from installed runtime state and its pin. + pub fn lookup( + &self, + handle: &str, + session: &str, + context: &InstalledAppContext, + ) -> Result { + anyhow::ensure!(valid_handle(handle), "Invalid playback handle"); + let mut entries = self + .entries + .lock() + .map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?; + let now = Instant::now(); + entries.retain(|_, entry| now < entry.until); + let entry = entries + .get(handle) + .context("Playback handle expired; reopen the original purchase")?; + anyhow::ensure!( + entry.binding.session == session_fingerprint(session) + && &entry.binding.context == context, + "Playback session or installed app changed" + ); + Ok(entry.binding.clone()) + } + /// Called only after the authenticated seller response matches receipt/size/range. + pub fn note_expiry(&self, handle: &str, binding: &Binding, expires: u64) -> Result<()> { + let mut entries = self + .entries + .lock() + .map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?; + let entry = entries.get_mut(handle).context("Playback handle expired")?; + anyhow::ensure!( + &entry.binding == binding && Instant::now() < entry.until && expires > 0, + "Playback handle changed" + ); + anyhow::ensure!( + entry.lease_expires.is_none_or(|old| old == expires), + "Seller changed the original viewing window" + ); + entry.lease_expires = Some(expires); + Ok(()) + } + /// Owner-session/native-broker status lookup; only public expiry leaves node. + pub fn expiry( + &self, + handle: &str, + session: &str, + context: &InstalledAppContext, + ) -> Result> { + self.lookup(handle, session, context)?; + let entries = self + .entries + .lock() + .map_err(|_| anyhow::anyhow!("Playback handles unavailable"))?; + Ok(entries + .get(handle) + .context("Playback handle expired")? + .lease_expires) + } +} + +#[cfg(test)] +mod tests { + use super::*; + fn binding() -> Binding { + let buyer = crate::identity::did_key_from_pubkey_hex(&hex::encode([1; 32])).unwrap(); + Binding { + context: InstalledAppContext { + app_id: "indeedhub".into(), + backend_id: "indeedhub-api".into(), + app_audience: "installed-audience".into(), + node_did: buyer.clone(), + node_public_key: hex::encode([1; 32]), + app_origins: vec!["http://node:7777".into()], + }, + seller_onion: "seller".into(), + session: session_fingerprint("original-session"), + contract: Contract { + version: 1, + id: uuid::Uuid::new_v4().to_string(), + buyer_did: buyer, + seller_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([2; 32])) + .unwrap(), + content_id: "registered_media".into(), + content_sha256: "ab".repeat(32), + content_size: 1024, + terms_sha256: "cd".repeat(32), + network: crate::wallet::ecash::EcashNetwork::Mainnet, + mint_url: "https://mint.invalid".into(), + gross_token_sats: 8, + minimum_net_sats: 7, + offered_at: 1000, + expires_at: 2000, + }, + } + } + #[test] + fn reissue_keeps_original_contract_and_fixed_expiry_without_extending_handle_lifetime() { + let handles = PlaybackHandles::default(); + let binding = binding(); + let now = Instant::now(); + let handle = handles.insert(binding.clone(), now).unwrap(); + handles.note_expiry(&handle, &binding, 12345).unwrap(); + assert_eq!( + handles + .insert(binding.clone(), now + Duration::from_secs(3)) + .unwrap(), + handle + ); + assert_eq!( + handles + .expiry(&handle, "original-session", &binding.context) + .unwrap(), + Some(12345) + ); + assert!(handles.note_expiry(&handle, &binding, 12346).is_err()); + let refreshed = handles + .insert(binding.clone(), now + HANDLE_LIFETIME) + .unwrap(); + assert_ne!(refreshed, handle); + assert!(handles + .lookup(&handle, "original-session", &binding.context) + .is_err()); + assert_eq!( + handles + .lookup(&refreshed, "original-session", &binding.context) + .unwrap() + .contract, + binding.contract + ); + // No new seller lease is implied by a process-local handle: expiry stays + // unknown until the original receipt's authenticated GET reports it. + assert_eq!( + handles + .expiry(&refreshed, "original-session", &binding.context) + .unwrap(), + None + ); + } + #[test] + fn handles_reject_other_sessions_installations_and_malformed_tokens() { + let handles = PlaybackHandles::default(); + let binding = binding(); + let handle = handles.insert(binding.clone(), Instant::now()).unwrap(); + assert!(handles + .lookup(&handle, "other-session", &binding.context) + .is_err()); + let mut changed = binding.context.clone(); + changed.app_audience = "reinstalled".into(); + assert!(handles + .lookup(&handle, "original-session", &changed) + .is_err()); + for value in ["", "../secret", &"A".repeat(64), &"a".repeat(63)] { + assert!(handles + .lookup(value, "original-session", &binding.context) + .is_err()); + } + assert!(handles + .lookup(&handle, "original-session", &binding.context) + .is_ok()); + } + #[tokio::test] + async fn owner_session_revocation_does_not_become_a_new_handle_authorization() { + let root = tempfile::tempdir().unwrap(); + let sessions = + crate::session::SessionStore::new_for_tests(root.path().join("sessions.json")); + let token = sessions.create().await; + let mut binding = binding(); + binding.session = session_fingerprint(&token); + let handles = PlaybackHandles::default(); + let handle = handles.insert(binding.clone(), Instant::now()).unwrap(); + assert!(sessions.validate(&token).await); + assert!(handles.lookup(&handle, &token, &binding.context).is_ok()); + sessions.remove(&token).await; + assert!(!sessions.validate(&token).await); + let replacement = sessions.create().await; + assert!(handles + .lookup(&handle, &replacement, &binding.context) + .is_err()); + } +} diff --git a/core/archipelago/src/registered_media.rs b/core/archipelago/src/registered_media.rs index 82056d6e..0299d789 100644 --- a/core/archipelago/src/registered_media.rs +++ b/core/archipelago/src/registered_media.rs @@ -317,9 +317,18 @@ fn persist_verified(held: &Held, record: &Registered) -> Result<()> { Ok(()) } fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Result<()> { + ensure_verified_for_use(data_dir, record, file, false) +} +fn ensure_verified_for_use( + data_dir: &Path, + record: &Registered, + file: &mut File, + first_use: bool, +) -> Result<()> { // This per-registration lock does not hold the mapping/global directory or - // any buyer lease lock while hashing. Normally registration already saved - // the verified stamp, so first-open needs no second read of a large movie. + // any buyer lease lock while hashing. Range opens can reuse the saved + // verification, but a new lease always checks bytes before starting its clock: + // same-size writes within a filesystem timestamp tick can share a stamp. let held = keyed(data_dir, "verify", &record.receipt.request_id)?; let path = held .path @@ -330,10 +339,13 @@ fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Res saved == expected && Stamp::from_file(file)? == record.stamp, "Immutable snapshot verification binding changed" ); - return Ok(()); + if !first_use { + return Ok(()); + } } - // Recover a missing cache by streaming the original signed hash. Never + // A new lease or missing cache requires the original signed byte hash. Never // manufacture a positive cache entry from metadata alone after restart. + file.seek(SeekFrom::Start(0))?; let mut digest = Sha256::new(); let mut buffer = [0u8; 64 * 1024]; loop { @@ -462,6 +474,16 @@ pub(crate) fn register_approved_selection( limits, progress, )?; + commit_prepared(data_dir, identity, &pin, prepared, mime_type) +} + +fn commit_prepared( + data_dir: &Path, + identity: &NodeIdentity, + pin: ®istration_pin::RegistrationPin, + prepared: media_registration::PreparedRegistration, + mime_type: String, +) -> Result { let record = Registered { version: 1, terms_sha256: terms(&prepared.receipt)?, @@ -485,6 +507,57 @@ pub(crate) fn register_approved_selection( Ok(record.receipt) } +/// Intent-only resolution preserves original file selection; the request cannot +/// choose a new path. Serving metadata is durable before recovered receipt return. +pub(crate) fn resolve_registration( + data_dir: &Path, + identity: &NodeIdentity, + intent: &Intent, + authenticated_producer: &str, + now: u64, + limits: &Limits<'_>, +) -> Result { + anyhow::ensure!( + authenticated_producer == intent.producer, + "Resolution producer changed" + ); + let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; + match media_registration::resolve( + data_dir, + identity, + &media_registration::InstallationPin { + node_did: pin.node_did.clone(), + app_audience: pin.app_audience.clone(), + }, + intent, + now, + limits, + )? { + media_registration::Resolution::Prepared { + prepared, + selection, + } => { + let receipt = commit_prepared( + data_dir, + identity, + &pin, + prepared, + selected_mime(&selection)?.into(), + )?; + Ok(serde_json::json!({"phase":"completed", "receipt":receipt})) + } + media_registration::Resolution::Retired(retirement) => { + Ok(serde_json::json!({"phase":"retired", "retirement":retirement})) + } + media_registration::Resolution::Pending { + request_id, + expires_at, + } => Ok( + serde_json::json!({"phase":"pending", "requestId":request_id, "expiresAt":expires_at}), + ), + } +} + /// No request chooses app scope or storage path. This is an offer prerequisite, /// not advertisement: the future offer creator must authenticate the peer and /// bind all returned terms into the purchase contract before seller acceptance. @@ -498,8 +571,12 @@ pub(crate) fn registered_terms( let held = held(data_dir, false)?; let record: Registered = read(&held.path.join(format!("{id}.json")))? .context("Registered content is unavailable")?; + drop(held); verify(&record, &pin, identity)?; - let _file = open_snapshot(data_dir, &record)?; + let mut file = open_snapshot(data_dir, &record)?; + // A quote must not invite payment for altered bytes, including a same-tick + // metadata collision. This scan completes before any offer is accepted. + ensure_verified_for_use(data_dir, &record, &mut file, true)?; Ok((record.receipt, record.terms_sha256)) } @@ -579,7 +656,11 @@ fn open_settled( // Open before recording a first use: unreadable or altered media does not // start a rental. No bytes leave this descriptor until the lease is durable. let mut file = open_snapshot(data_dir, &record)?; - ensure_verified(data_dir, &record, &mut file)?; + let lease_path = data_dir + .join(STORE) + .join(format!("lease-{}.json", contract.id)); + let first_use = read::(&lease_path)?.is_none(); + ensure_verified_for_use(data_dir, &record, &mut file, first_use)?; let held = keyed(data_dir, "lease", &contract.id)?; let path = held.path.join(format!("lease-{}.json", contract.id)); let contract_hash = contract.context_hash()?; @@ -1008,6 +1089,32 @@ mod tests { .join(format!("{}.json", receipt.request_id)); let mut record: Registered = read(&mapping).unwrap().unwrap(); record.stamp = Stamp::from_file(&File::open(&media).unwrap()).unwrap(); + // Model an indistinguishable metadata stamp deterministically: both + // unsigned cache/mapping stamps match, while signed bytes do not. A + // positive metadata cache must not authorize an offer or first lease. + std::fs::write(&mapping, serde_json::to_vec(&record).unwrap()).unwrap(); + let verified = fixture + .root + .path() + .join(STORE) + .join(format!("verified-{}.json", receipt.request_id)); + std::fs::write( + &verified, + serde_json::to_vec(&verification(&record).unwrap()).unwrap(), + ) + .unwrap(); + assert!( + registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err() + ); + assert!(open_settled( + fixture.root.path(), + &fixture.identity, + &contract, + &"ab".repeat(32), + || Ok(2000) + ) + .is_err()); + assert!(!lease.exists()); std::fs::remove_file( fixture .root @@ -1141,4 +1248,34 @@ mod tests { .join(format!("lease-{}.json", contract.id)) .exists()); } + #[tokio::test] + async fn offer_preflight_rebuilds_verified_cache_without_creating_rental_and_rejects_corruption( + ) { + let fixture = Fixture::new().await; + let receipt = fixture.register(1100).unwrap(); + let path = fixture + .root + .path() + .join(STORE) + .join(format!("verified-{}.json", receipt.request_id)); + let original = std::fs::read(&path).unwrap(); + std::fs::remove_file(&path).unwrap(); + let (terms, _) = + registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap(); + assert_eq!(terms, receipt); + assert_eq!(std::fs::read(&path).unwrap(), original); + assert!(std::fs::read_dir(fixture.root.path().join(STORE)) + .unwrap() + .all(|entry| !entry + .unwrap() + .file_name() + .to_string_lossy() + .starts_with("lease-"))); + let mut cache: VerifiedSnapshot = read(&path).unwrap().unwrap(); + cache.sha256 = "ff".repeat(32); + std::fs::write(&path, serde_json::to_vec(&cache).unwrap()).unwrap(); + assert!( + registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err() + ); + } } diff --git a/core/archipelago/src/snapshot_budget.rs b/core/archipelago/src/snapshot_budget.rs new file mode 100644 index 00000000..b14ece63 --- /dev/null +++ b/core/archipelago/src/snapshot_budget.rs @@ -0,0 +1,419 @@ +//! Shared admission budget for immutable paid-content snapshots. +//! Blocking worker only. Reservations are durable before copying and intentionally +//! survive process death; orphan cleanup needs operation-aware reconciliation. +use crate::media_registration::{self as io, Limits}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use std::{ + fs::File, + os::unix::io::AsRawFd, + path::Path, + time::{Duration, Instant}, +}; + +pub(crate) const DEFAULT_MAX_TOTAL_BYTES: u64 = 64 * 1024 * 1024 * 1024; +pub(crate) const DEFAULT_MIN_FREE_BYTES: u64 = 512 * 1024 * 1024; + +#[derive(Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +struct Record { + version: u8, + operation: String, + bytes: u64, +} +pub(crate) struct Reservation { + _claim: File, + root: File, + name: String, +} +fn count(directory: &File) -> Result { + let mut bytes = 0u64; + for item in std::fs::read_dir(format!("/proc/self/fd/{}", directory.as_raw_fd()))? { + let item = item?; + let name = item.file_name(); + let name = name.to_str().context("Invalid snapshot storage filename")?; + let metadata = std::fs::symlink_metadata(item.path())?; + anyhow::ensure!( + !metadata.file_type().is_symlink(), + "Unexpected snapshot symlink" + ); + let size = if metadata.is_dir() { + count(&io::open_at( + directory, + name, + libc::O_RDONLY | libc::O_DIRECTORY, + 0, + )?)? + } else { + anyhow::ensure!(metadata.is_file(), "Unexpected snapshot storage entry"); + metadata.len() + }; + bytes = bytes + .checked_add(size) + .context("Snapshot accounting overflow")?; + } + Ok(bytes) +} +fn reserved(root: &File) -> Result { + let mut total = 0u64; + for item in std::fs::read_dir(format!("/proc/self/fd/{}", root.as_raw_fd()))? { + let item = item?; + let name = item.file_name(); + let name = name.to_str().context("Invalid reservation filename")?; + if name == "claims" { + continue; + } + anyhow::ensure!( + name.ends_with(".json"), + "Unknown reservation state; preserve for recovery" + ); + let record: Record = io::read_record(root, name)?.context("Reservation disappeared")?; + anyhow::ensure!( + record.version == 1 && record.bytes > 0, + "Invalid reservation; preserve for recovery" + ); + total = total + .checked_add(record.bytes) + .context("Reservation accounting overflow")?; + } + Ok(total) +} +/// Operation must be a durable journal identifier selected by the authenticated +/// caller. Do not release an orphan reservation solely because its client left. +pub(crate) fn reserve( + data_dir: &Path, + operation: &str, + bytes: u64, + maximum_total: u64, + minimum_free: u64, + limits: &Limits<'_>, +) -> Result { + reserve_until( + data_dir, + operation, + bytes, + maximum_total, + minimum_free, + limits, + Instant::now() + Duration::from_secs(30), + ) +} + +pub(crate) fn reserve_until( + data_dir: &Path, + operation: &str, + bytes: u64, + maximum_total: u64, + minimum_free: u64, + limits: &Limits<'_>, + deadline: Instant, +) -> Result { + anyhow::ensure!( + !operation.is_empty() && operation.len() <= 256 && bytes > 0 && bytes <= limits.max_bytes, + "Invalid snapshot admission request" + ); + let data = io::open_directory(&data_dir.canonicalize()?)?; + let root = io::private_directory(&data, "snapshot-reservations")?; + let key = hex::encode(Sha256::digest(operation.as_bytes())); + let claims = io::private_directory(&root, "claims")?; + let claim = io::private_directory(&claims, &key)?; + // Wait for this operation without holding the shared admission lock. The + // claim survives as a harmless empty directory; its flock ends on process exit. + io::lock_operation(&claim, limits, deadline)?; + io::lock_operation(&root, limits, deadline)?; + let name = format!("{key}.json"); + let existing: Option = io::read_record(&root, &name)?; + let additional = bytes + .checked_add(128 * 1024) + .context("Reservation overflow")?; + if let Some(saved) = &existing { + anyhow::ensure!( + saved.version == 1 && saved.operation == operation && saved.bytes == additional, + "Original snapshot reservation terms changed; preserve for recovery" + ); + } + let newly_reserved = if existing.is_some() { 0 } else { additional }; + let mut stored = 0u64; + for name in ["content-snapshots", "media-registration"] { + match io::open_at(&data, name, libc::O_RDONLY | libc::O_DIRECTORY, 0) { + Ok(directory) => { + stored = stored + .checked_add(count(&directory)?) + .context("Storage accounting overflow")? + } + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => + { + () + } + Err(error) => return Err(error), + } + } + let outstanding = reserved(&root)?; + anyhow::ensure!( + stored + .checked_add(outstanding) + .and_then(|v| v.checked_add(newly_reserved)) + .is_some_and(|total| total <= maximum_total), + "Immutable media storage budget is full" + ); + let mut stat = std::mem::MaybeUninit::::uninit(); + anyhow::ensure!( + unsafe { libc::fstatvfs(data.as_raw_fd(), stat.as_mut_ptr()) } == 0, + "Cannot inspect snapshot storage capacity" + ); + let stat = unsafe { stat.assume_init() }; + let free = (stat.f_bavail as u64).saturating_mul(stat.f_frsize as u64); + let required = outstanding + .checked_add(newly_reserved) + .and_then(|v| v.checked_add(minimum_free)) + .context("Snapshot free-space requirement overflow")?; + anyhow::ensure!( + free >= required, + "Not enough free storage for immutable media" + ); + if existing.is_none() { + io::save_record( + &root, + &name, + &Record { + version: 1, + operation: operation.into(), + bytes: additional, + }, + )?; + } + // flock is on this open description; release before expensive media copying. + anyhow::ensure!( + unsafe { libc::flock(root.as_raw_fd(), libc::LOCK_UN) } == 0, + "Cannot release admission lock" + ); + Ok(Reservation { + _claim: claim, + root, + name, + }) +} +/// Call only after the original operation's immutable bytes and durable record +/// have been verified. This permits recovery/cleanup even when current admission +/// capacity is exhausted; it authorizes no new copy and touches no media bytes. +pub(crate) fn finish_completed( + data_dir: &Path, + operation: &str, + bytes: u64, + limits: &Limits<'_>, +) -> Result<()> { + let data = io::open_directory(&data_dir.canonicalize()?)?; + let root = match io::open_at( + &data, + "snapshot-reservations", + libc::O_RDONLY | libc::O_DIRECTORY, + 0, + ) { + Ok(root) => root, + Err(error) + if error + .downcast_ref::() + .is_some_and(|e| e.kind() == std::io::ErrorKind::NotFound) => + { + return Ok(()) + } + Err(error) => return Err(error), + }; + let key = hex::encode(Sha256::digest(operation.as_bytes())); + let claims = io::private_directory(&root, "claims")?; + let claim = io::private_directory(&claims, &key)?; + io::lock_operation(&claim, limits, Instant::now() + Duration::from_secs(30))?; + io::lock_operation(&root, limits, Instant::now() + Duration::from_secs(30))?; + let name = format!("{key}.json"); + if let Some(record) = io::read_record::(&root, &name)? { + anyhow::ensure!( + record.version == 1 + && record.operation == operation + && bytes.checked_add(128 * 1024) == Some(record.bytes), + "Completed snapshot reservation terms changed; preserve for recovery" + ); + let name = std::ffi::CString::new(name)?; + anyhow::ensure!( + unsafe { libc::unlinkat(root.as_raw_fd(), name.as_ptr(), 0) } == 0, + "Cannot release completed snapshot reservation" + ); + root.sync_all()?; + } + Ok(()) +} + +impl Reservation { + /// After success OR a stopped copy, retained/partial files count against the + /// stored-byte budget. Caller must stop writing before returning reservation. + pub(crate) fn finish(self, limits: &Limits<'_>) -> Result<()> { + io::lock_operation(&self.root, limits, Instant::now() + Duration::from_secs(30))?; + let name = std::ffi::CString::new(self.name)?; + anyhow::ensure!( + unsafe { libc::unlinkat(self.root.as_raw_fd(), name.as_ptr(), 0) } == 0, + "Cannot release snapshot reservation; preserve operation for recovery" + ); + self.root.sync_all()?; + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::atomic::AtomicBool; + #[test] + fn reservations_share_both_storage_roots_and_do_not_hold_copy_lock() { + let temp = tempfile::tempdir().unwrap(); + let cancelled = AtomicBool::new(false); + let limits = Limits { + max_bytes: 8 * 1024 * 1024, + cancelled: &cancelled, + }; + for name in ["content-snapshots", "media-registration"] { + std::fs::create_dir(temp.path().join(name)).unwrap(); + File::create(temp.path().join(name).join("retained-media")) + .unwrap() + .set_len(1024 * 1024) + .unwrap(); + } + // Two stores already occupy 2MiB. Both guards coexist, proving the + // admission lock does not serialize the subsequent expensive copies. + let first = reserve( + temp.path(), + "first", + 1024 * 1024, + 5 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + let second = reserve( + temp.path(), + "second", + 1024 * 1024, + 5 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + assert!(reserve( + temp.path(), + "third", + 1024 * 1024, + 5 * 1024 * 1024, + 0, + &limits + ) + .is_err()); + first.finish(&limits).unwrap(); + let third = reserve( + temp.path(), + "third", + 1024 * 1024, + 5 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + second.finish(&limits).unwrap(); + third.finish(&limits).unwrap(); + } + #[test] + fn interrupted_reservation_stays_counted_and_symlink_storage_rejects() { + let temp = tempfile::tempdir().unwrap(); + let cancelled = AtomicBool::new(false); + let limits = Limits { + max_bytes: 8 * 1024 * 1024, + cancelled: &cancelled, + }; + let held = reserve( + temp.path(), + "interrupted", + 2 * 1024 * 1024, + 3 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + drop(held); // process death does not erase a durable outstanding liability + let resumed = reserve( + temp.path(), + "interrupted", + 2 * 1024 * 1024, + 3 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + drop(resumed); + assert!(reserve( + temp.path(), + "interrupted", + 1024 * 1024, + 3 * 1024 * 1024, + 0, + &limits + ) + .is_err()); + assert!(reserve( + temp.path(), + "other", + 1024 * 1024, + 3 * 1024 * 1024, + 0, + &limits + ) + .is_err()); + std::os::unix::fs::symlink(temp.path(), temp.path().join("media-registration")).unwrap(); + assert!(reserve(temp.path(), "symlink", 1, 16 * 1024 * 1024, 0, &limits).is_err()); + } + #[test] + fn completed_cleanup_works_without_new_admission_and_checks_original_size() { + let temp = tempfile::tempdir().unwrap(); + let cancelled = AtomicBool::new(false); + let limits = Limits { + max_bytes: 8 * 1024 * 1024, + cancelled: &cancelled, + }; + let original = reserve( + temp.path(), + "complete", + 1024 * 1024, + 2 * 1024 * 1024, + 0, + &limits, + ) + .unwrap(); + drop(original); + assert!(finish_completed(temp.path(), "complete", 2 * 1024 * 1024, &limits).is_err()); + finish_completed(temp.path(), "complete", 1024 * 1024, &limits).unwrap(); + finish_completed(temp.path(), "complete", 1024 * 1024, &limits).unwrap(); + let root = io::open_directory(&temp.path().join("snapshot-reservations")).unwrap(); + assert_eq!(reserved(&root).unwrap(), 0); + } + #[test] + fn expired_admission_deadline_never_records_a_new_reservation() { + let temp = tempfile::tempdir().unwrap(); + let cancelled = AtomicBool::new(false); + let limits = Limits { + max_bytes: 1024, + cancelled: &cancelled, + }; + assert!(reserve_until( + temp.path(), + "expired", + 10, + 1024 * 1024, + 0, + &limits, + Instant::now() + ) + .is_err()); + let root = io::open_directory(&temp.path().join("snapshot-reservations")).unwrap(); + assert_eq!(reserved(&root).unwrap(), 0); + } +} diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 9ae1f22b..d06be16e 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -290,6 +290,10 @@ pub async fn load_network(data_dir: &Path) -> Result { /// disk untouched, so switching is reversible and loses nothing. pub async fn save_network(data_dir: &Path, network: EcashNetwork) -> Result<()> { let _mutation = super::mutation::guard(data_dir).await?; + crate::content_purchase::Journal::open(data_dir) + .await? + .ensure_seller_policy_change(network, None) + .await?; let dir = data_dir.join("wallet"); fs::create_dir_all(&dir) .await @@ -443,6 +447,10 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result { /// Save accepted mints list. pub async fn save_accepted_mints(data_dir: &Path, mints: &AcceptedMints) -> Result<()> { let _mutation = super::mutation::guard(data_dir).await?; + crate::content_purchase::Journal::open(data_dir) + .await? + .ensure_seller_policy_change(load_network(data_dir).await?, Some(&mints.mints)) + .await?; let dir = data_dir.join("wallet"); fs::create_dir_all(&dir) .await @@ -823,6 +831,7 @@ pub async fn send_token_recoverable( context_hash, None, || chrono::Utc::now().timestamp(), + None, ) .await } @@ -849,6 +858,7 @@ pub async fn send_token_recoverable_before( context_hash, Some(expires_at), || chrono::Utc::now().timestamp(), + None, ) .await } @@ -868,6 +878,7 @@ async fn send_token_recoverable_with_deadline( context_hash: &str, expires_at: Option, now: impl Fn() -> i64 + Send + Sync, + plan: Option<&super::purchase_fee_plan::FeePlan>, ) -> Result { use super::send_journal::{Binding, Journal, Outcome, Phase, Request}; let held = super::mutation::guard(data_dir).await?; @@ -884,6 +895,17 @@ async fn send_token_recoverable_with_deadline( }; let journal = Journal::new(&held); let previous = journal.load(operation_id).await?; + if let Some(plan) = plan { + plan.validate()?; + anyhow::ensure!( + previous.is_some(), + "Original planned inputs are missing; no new proof selection allowed" + ); + anyhow::ensure!( + plan.mint_url == mint_url && plan.gross_sats == amount_sats, + "Planned amount or mint changed" + ); + } let recovering = previous.is_some(); let record = if let Some(record) = previous { anyhow::ensure!( @@ -927,12 +949,26 @@ async fn send_token_recoverable_with_deadline( ensure_fresh_payment_allowed(expires_at, now())?; journal.prepare(binding.clone(), request).await? }; + anyhow::ensure!( + !matches!(record.phase, Phase::Cancelled), + "Payment operation was cancelled" + ); if matches!(record.phase, Phase::Result(_) | Phase::Committed(_)) { return journal.commit_wallet(&binding).await; } + // An exact Prepared record has never exposed a token: result durability + // precedes both wallet commit and return. Do not reserve fresh inputs merely + // to reject an already-expired accepted plan. + if matches!(&record.request, Request::Exact { .. }) { + ensure_fresh_payment_allowed(expires_at, now())?; + } journal.reserve_wallet(&binding).await?; let (send, change) = match &record.request { Request::Exact { proofs } => { + if let Some(plan) = plan { + plan.validate_proofs(proofs)?; + plan.verify_mint_keysets(&MintClient::new(mint_url)?.get_keysets().await?, false)?; + } ensure_fresh_payment_allowed(expires_at, now())?; (proofs.clone(), vec![]) } @@ -961,7 +997,11 @@ async fn send_token_recoverable_with_deadline( "This payment is still pending at the mint; do not pay again" ); } + if let Some(plan) = plan { + plan.verify_mint_keysets(&client.get_keysets().await?, true)?; + } ensure_fresh_payment_allowed(expires_at, now())?; + journal.mark_dispatched(&binding).await?; client.execute_prepared_swap(prepared).await.map_err(|_| anyhow::anyhow!( "The mint did not confirm this payment; retry this same operation to recover it"))? }; @@ -985,6 +1025,9 @@ async fn send_token_recoverable_with_deadline( }; let token = CashuToken::new(&binding.mint_url, send); let encoded = token.serialize_v4().or_else(|_| token.serialize())?; + if let Some(plan) = plan { + plan.validate_token(&encoded)?; + } journal .record_result( &binding, @@ -997,6 +1040,27 @@ async fn send_token_recoverable_with_deadline( journal.commit_wallet(&binding).await } +/// Executes only a previously pinned private wallet plan. A missing send record +/// rejects instead of silently selecting another set of inputs. +pub(crate) async fn send_token_preplanned_before( + data_dir: &Path, + contract: &crate::content_purchase::Contract, + plan: &super::purchase_fee_plan::FeePlan, +) -> Result { + send_token_recoverable_with_deadline( + data_dir, + &contract.id, + contract.network, + &contract.mint_url, + contract.gross_token_sats, + &contract.context_hash()?, + Some(contract.expires_at), + || chrono::Utc::now().timestamp(), + Some(plan), + ) + .await +} + async fn send_token_at_locked(data_dir: &Path, mint_url: &str, amount_sats: u64) -> Result { let mut wallet = load_wallet(data_dir).await?; let mint_url = mint_url.to_string(); diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 972baca2..49869114 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -83,6 +83,14 @@ impl std::fmt::Debug for PreparedSwap { } impl PreparedSwap { +// Add inside impl PreparedSwap; no mutability or proof/output secrets exposed. +pub(super) fn payment_keyset_id(&self) -> &str { &self.keyset.id } +pub(super) fn input_fee_sats(&self) -> Result { + let inputs = self.inputs.iter().try_fold(0u64, |sum, proof| sum.checked_add(proof.amount)).context("Prepared input sum overflow")?; + let outputs = self.outputs.iter().try_fold(0u64, |sum, output| sum.checked_add(output.amount)).context("Prepared output sum overflow")?; + inputs.checked_sub(outputs).context("Prepared outputs exceed input value") +} + pub(super) fn inputs(&self) -> &[Proof] { &self.inputs } diff --git a/core/archipelago/src/wallet/mod.rs b/core/archipelago/src/wallet/mod.rs index c53097aa..e81ca125 100644 --- a/core/archipelago/src/wallet/mod.rs +++ b/core/archipelago/src/wallet/mod.rs @@ -8,10 +8,12 @@ pub mod ecash; pub mod fedimint_client; pub mod minibits; pub mod mint_client; -mod mutation; +pub(crate) mod mutation; pub mod nut13; pub mod profits; mod send_journal; mod receive_journal; pub(crate) mod purchase_fee_plan; + +pub(crate) mod purchase_plan; diff --git a/core/archipelago/src/wallet/mutation.rs b/core/archipelago/src/wallet/mutation.rs index df66ec15..780d67bb 100644 --- a/core/archipelago/src/wallet/mutation.rs +++ b/core/archipelago/src/wallet/mutation.rs @@ -30,12 +30,12 @@ async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc>)> { Ok((canonical, lock)) } -pub(super) struct WalletMutation { +pub(crate) struct WalletMutation { pub(super) data_dir: PathBuf, _held: OwnedMutexGuard<()>, } -pub(super) async fn guard(data_dir: &Path) -> Result { +pub(crate) async fn guard(data_dir: &Path) -> Result { let (data_dir, lock) = canonical_lock(data_dir).await?; Ok(WalletMutation { data_dir, diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index 8971fd6c..b678c953 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -1849,6 +1849,7 @@ async fn purchase_expiring_during_mint_preflight_never_reserves_or_posts_swap() &"ab".repeat(32), Some(2000), || clock.load(Ordering::SeqCst), + None, ) .await .unwrap_err(); @@ -1933,3 +1934,785 @@ async fn stale_planned_inputs_do_not_reselect_wallet_coins_or_post_to_mint() { ); assert!(mint.requests.lock().unwrap().is_empty()); } + +// Add within wallet payment_tests, using its existing fake proof fixtures. +#[tokio::test] +async fn expired_saved_exact_plan_never_reserves_spendable_inputs() { + let root = tempfile::tempdir().unwrap(); + let mint = "https://unused-mint.invalid"; + let mut wallet = WalletState::default(); + wallet.mint_url = mint.into(); + wallet.add_proofs(mint, vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); + let id = uuid::Uuid::new_v4().to_string(); + let context = "ab".repeat(32); + { + let guard = crate::wallet::mutation::guard(root.path()).await.unwrap(); + let binding = crate::wallet::send_journal::Binding { + id: id.clone(), + network: EcashNetwork::Mainnet, + mint_url: mint.into(), + amount_sats: 8, + context_hash: context.clone(), + }; + crate::wallet::send_journal::Journal::new(&guard) + .prepare( + binding, + crate::wallet::send_journal::Request::Exact { + proofs: vec![proof(ACTIVE, 8)], + }, + ) + .await + .unwrap(); + } + assert!(send_token_recoverable_before( + root.path(), + &id, + EcashNetwork::Mainnet, + mint, + 8, + &context, + chrono::Utc::now().timestamp() - 1 + ) + .await + .is_err()); + assert_eq!( + std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), + original + ); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); +} + +// Append to wallet/payment_tests.rs after integrating dispatch/cancellation APIs. +#[tokio::test] +async fn cancelled_exact_plan_cannot_later_send_and_releases_only_its_reservation() { + use crate::wallet::{ + mutation, + send_journal::{Binding, Journal, Request}, + }; + let root = tempfile::tempdir().unwrap(); + let mint = "https://unused-mint.invalid"; + let mut wallet = WalletState::default(); + wallet.mint_url = mint.into(); + wallet.add_proofs(mint, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let binding = Binding { + id: uuid::Uuid::new_v4().to_string(), + network: EcashNetwork::Mainnet, + mint_url: mint.into(), + amount_sats: 8, + context_hash: "ab".repeat(32), + }; + { + let guard = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&guard); + journal + .prepare( + binding.clone(), + Request::Exact { + proofs: vec![proof(ACTIVE, 8)], + }, + ) + .await + .unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); + journal.cancel_unspent(&binding).await.unwrap(); + journal.cancel_unspent(&binding).await.unwrap(); + } + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12); + assert!(send_token_recoverable( + root.path(), + &binding.id, + binding.network, + mint, + 8, + &binding.context_hash + ) + .await + .is_err()); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12); + assert!(load_wallet(root.path()) + .await + .unwrap() + .transactions + .is_empty()); +} +#[tokio::test] +async fn dispatched_or_legacy_unknown_swap_cannot_cancel_despite_unspent_mint_inputs() { + use crate::wallet::{ + mutation, + send_journal::{Binding, Journal, Request}, + }; + use sha2::{Digest, Sha256}; + for legacy in [false, true] { + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let binding = Binding { + id: uuid::Uuid::new_v4().to_string(), + network: EcashNetwork::Mainnet, + mint_url: mint.url.clone(), + amount_sats: 4, + context_hash: "ab".repeat(32), + }; + let prepared = MintClient::new(&mint.url) + .unwrap() + .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) + .await + .unwrap(); + { + let guard = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&guard); + journal + .prepare(binding.clone(), Request::Swap(prepared)) + .await + .unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + if !legacy { + journal.mark_dispatched(&binding).await.unwrap(); + } + } + if legacy { + let path = root + .path() + .join("wallet/send-operations") + .join(format!("{}.json", binding.id)); + let mut envelope: serde_json::Value = + serde_json::from_slice(&std::fs::read(&path).unwrap()).unwrap(); + let mut payload: serde_json::Value = + serde_json::from_str(envelope["payload"].as_str().unwrap()).unwrap(); + payload.as_object_mut().unwrap().remove("dispatch"); + let payload = serde_json::to_string(&payload).unwrap(); + envelope["checksum"] = json!(hex::encode(Sha256::digest(payload.as_bytes()))); + envelope["payload"] = json!(payload); + std::fs::write(path, serde_json::to_vec(&envelope).unwrap()).unwrap(); + } + let guard = mutation::guard(root.path()).await.unwrap(); + assert!(Journal::new(&guard).cancel_unspent(&binding).await.is_err()); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 0); + assert!(mint.requests.lock().unwrap().is_empty()); + } +} +#[tokio::test] +async fn planner_rejects_wrong_network_before_creating_intent_or_reservation() { + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = "http://127.0.0.1:1".into(); + wallet.add_proofs("http://127.0.0.1:1", vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let original = std::fs::read(root.path().join("wallet/ecash.json")).unwrap(); + let error = crate::wallet::purchase_plan::prepare( + root.path(), + "http://127.0.0.1:1", + EcashNetwork::Testnet, + 4, + 8, + ) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("another wallet network")); + assert_eq!( + std::fs::read(root.path().join("wallet/ecash.json")).unwrap(), + original + ); + assert!(!root.path().join("content-purchases").exists()); + assert!(!root.path().join("wallet/send-operations").exists()); +} +#[tokio::test] +async fn planner_skips_unfundable_swaps_and_finds_later_exact_shape_within_budget() { + let mint = Mint::start(2000, None).await; + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8), proof(ACTIVE, 4)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let plan = + crate::wallet::purchase_plan::prepare(root.path(), &mint.url, EcashNetwork::Mainnet, 7, 12) + .await + .unwrap(); + assert_eq!(plan.fee_plan.gross_sats, 12); + assert_eq!(plan.fee_plan.fee_sats, 4); + assert_eq!(plan.fee_plan.net_sats, 8); + assert_eq!(plan.wallet_debit_sats, 12); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 12); + assert!(mint.requests.lock().unwrap().is_empty()); +} + +#[tokio::test] +async fn cancellation_seal_wins_against_an_already_waiting_fresh_swap_executor() { + use crate::wallet::{ + mutation, + send_journal::{Binding, Journal, Request}, + }; + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + let binding = Binding { + id: uuid::Uuid::new_v4().to_string(), + network: EcashNetwork::Mainnet, + mint_url: mint.url.clone(), + amount_sats: 4, + context_hash: "ab".repeat(32), + }; + let prepared = MintClient::new(&mint.url) + .unwrap() + .prepare_swap_at_least(&[proof(ACTIVE, 8)], &[4, 4], 4) + .await + .unwrap(); + let guard = mutation::guard(root.path()).await.unwrap(); + let journal = Journal::new(&guard); + journal + .prepare(binding.clone(), Request::Swap(prepared)) + .await + .unwrap(); + journal.reserve_wallet(&binding).await.unwrap(); + let waiting = send_token_recoverable( + root.path(), + &binding.id, + binding.network, + &binding.mint_url, + binding.amount_sats, + &binding.context_hash, + ); + tokio::pin!(waiting); + assert!(futures_util::poll!(&mut waiting).is_pending()); + journal.cancel_unspent(&binding).await.unwrap(); + drop(journal); + drop(guard); + assert!(waiting.await.is_err()); + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); + assert!(load_wallet(root.path()) + .await + .unwrap() + .transactions + .is_empty()); +} + +// Append under wallet/payment_tests.rs: real local journals + fake mint, no real funds. +struct PurchaseTestTransport { + seller_root: std::path::PathBuf, + template: crate::content_purchase_protocol::Offer, + lose_offer: std::sync::atomic::AtomicBool, + lose_accept: std::sync::atomic::AtomicBool, + lose_settle: std::sync::atomic::AtomicBool, + offers: std::sync::Mutex>, +} +impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport { + fn seller_did(&self) -> &str { + &self.template.seller_did + } + fn seller_onion(&self) -> &str { + "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion" + } + async fn offer( + &self, + id: &str, + content_id: &str, + ) -> anyhow::Result { + self.offers.lock().unwrap().push(id.into()); + let mut offer = self.template.clone(); + offer.id = id.into(); + offer.content_id = content_id.into(); + let saved = crate::content_purchase_protocol::save_offer( + &self.seller_root, + &offer, + &offer.buyer_did, + chrono::Utc::now().timestamp(), + ) + .await?; + anyhow::ensure!( + !self + .lose_offer + .swap(false, std::sync::atomic::Ordering::SeqCst), + "Lost offer response" + ); + Ok(saved) + } + async fn accept( + &self, + envelope: &crate::content_purchase_protocol::Envelope, + ) -> anyhow::Result { + let reply = crate::content_purchase_protocol::accept( + &self.seller_root, + envelope, + &envelope.offer.buyer_did, + || chrono::Utc::now().timestamp(), + ) + .await?; + anyhow::ensure!( + !self + .lose_accept + .swap(false, std::sync::atomic::Ordering::SeqCst), + "Lost acceptance response" + ); + Ok(reply) + } + async fn status( + &self, + envelope: &crate::content_purchase_protocol::Envelope, + ) -> anyhow::Result { + crate::content_purchase_protocol::status( + &self.seller_root, + envelope, + &envelope.offer.buyer_did, + ) + .await + } + async fn cancel( + &self, + envelope: &crate::content_purchase_protocol::Envelope, + ) -> anyhow::Result { + crate::content_purchase_protocol::cancel( + &self.seller_root, + envelope, + &envelope.offer.buyer_did, + ) + .await + } + async fn settle( + &self, + request: &crate::content_purchase_protocol::Settlement, + ) -> anyhow::Result { + let reply = crate::content_purchase_protocol::settle( + &self.seller_root, + request, + &request.envelope.offer.buyer_did, + ) + .await?; + anyhow::ensure!( + !self + .lose_settle + .swap(false, std::sync::atomic::Ordering::SeqCst), + "Invalid purchase response after settlement" + ); + Ok(reply) + } +} +#[tokio::test] +async fn full_caller_recovers_lost_acceptance_and_settlement_without_another_payment() { + use crate::content_purchase_caller::{purchase, PurchaseConsent, ReadyPurchase}; + use std::sync::atomic::{AtomicBool, Ordering}; + let mint = Mint::start(0, None).await; + let buyer = tempfile::tempdir().unwrap(); + let seller = mint.wallet().await; + let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap(); + let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(buyer.path(), &wallet).await.unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + save_wallet(seller.path(), &wallet).await.unwrap(); + let now = chrono::Utc::now().timestamp(); + let transport = PurchaseTestTransport { + seller_root: seller.path().into(), + template: crate::content_purchase_protocol::Offer { + id: uuid::Uuid::new_v4().to_string(), + buyer_did: buyer_did.clone(), + seller_did, + content_id: "paid-film".into(), + filename: "film.mp4".into(), + mime_type: "video/mp4".into(), + content_sha256: "ab".repeat(32), + content_size: 16, + viewing_seconds: None, + terms_sha256: "cd".repeat(32), + network: EcashNetwork::Mainnet, + mint_url: mint.url.clone(), + seller_net_sats: 8, + offered_at: now, + expires_at: now + 300, + }, + lose_offer: AtomicBool::new(true), + lose_accept: AtomicBool::new(true), + lose_settle: AtomicBool::new(true), + offers: Default::default(), + }; + assert!(purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport + ) + .await + .is_err()); + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); + let quote = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport, + ) + .await + .unwrap(); + let consent = match quote { + ReadyPurchase::AwaitingConfirmation { + operation_id, + envelope_sha256, + wallet_debit_sats, + .. + } => PurchaseConsent { + operation_id, + envelope_sha256, + wallet_debit_sats, + }, + _ => panic!("Fresh purchase spent before confirmation"), + }; + let reopened = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 9_007_199_254_740_991, + None, + &transport, + ) + .await + .unwrap(); + match reopened { + ReadyPurchase::AwaitingConfirmation { + operation_id, + wallet_debit_sats, + .. + } => { + assert_eq!(operation_id, consent.operation_id); + assert_eq!(wallet_debit_sats, consent.wallet_debit_sats); + } + _ => panic!("A broad quote budget initiated spending without consent"), + } + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); + // A quote alone does not pin seller policy. Removing acceptance must stop + // the buyer before any token is exposed; the same quote can resume later. + save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] }) + .await + .unwrap(); + let rejected = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + Some(&consent), + &transport, + ) + .await + .err() + .unwrap(); + assert!(rejected + .to_string() + .contains("does not accept the quoted mint")); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); + assert!(mint.requests.lock().unwrap().is_empty()); + save_accepted_mints( + seller.path(), + &AcceptedMints { + mints: vec![mint.url.clone()], + }, + ) + .await + .unwrap(); + let error = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + Some(&consent), + &transport, + ) + .await + .err() + .expect("The simulated lost response must surface"); + assert!( + error.to_string().contains("Lost acceptance response"), + "unexpected purchase failure: {error:#}" + ); + assert!(mint.requests.lock().unwrap().is_empty()); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); + // Durable acceptance now pins redemption policy until cancellation or + // settlement, including when the acceptance reply was lost. + assert!( + save_accepted_mints(seller.path(), &AcceptedMints { mints: vec![] }) + .await + .is_err() + ); + assert!(save_network(seller.path(), EcashNetwork::Testnet) + .await + .is_err()); + assert_eq!( + load_network(seller.path()).await.unwrap(), + EcashNetwork::Mainnet + ); + let error = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + Some(&consent), + &transport, + ) + .await + .err() + .expect("The simulated lost response must surface"); + assert!( + error + .to_string() + .contains("Invalid purchase response after settlement"), + "unexpected purchase failure: {error:#}" + ); + assert_eq!(mint.requests.lock().unwrap().len(), 1); + let recovered = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport, + ) + .await + .unwrap(); + let original = match recovered { + ReadyPurchase::Entitlement { contract, receipt } => { + assert_eq!(contract.id, consent.operation_id); + receipt + } + _ => panic!("Original entitlement was not recovered"), + }; + let again = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport, + ) + .await + .unwrap(); + match again { + ReadyPurchase::Entitlement { receipt, .. } => assert!(receipt == original), + _ => panic!("Receipt replay changed"), + } + assert_eq!(transport.offers.lock().unwrap().len(), 2); + assert_ne!(transport.offers.lock().unwrap()[0], consent.operation_id); + assert_eq!(transport.offers.lock().unwrap()[1], consent.operation_id); + assert_eq!(mint.requests.lock().unwrap().len(), 1); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 0); + assert_eq!(load_wallet(seller.path()).await.unwrap().balance(), 8); + assert_eq!( + load_wallet(buyer.path()).await.unwrap().transactions.len(), + 1 + ); + assert_eq!( + load_wallet(seller.path()).await.unwrap().transactions.len(), + 1 + ); + assert!(!transport.lose_accept.load(Ordering::SeqCst)); +} + +#[tokio::test] +async fn rental_catalog_term_mismatch_never_plans_or_creates_buyer_intent() { + use crate::content_purchase_caller::{purchase_bound, ExpectedRental}; + use std::sync::atomic::AtomicBool; + let buyer = tempfile::tempdir().unwrap(); + let seller = tempfile::tempdir().unwrap(); + save_accepted_mints( + seller.path(), + &AcceptedMints { + mints: vec!["http://127.0.0.1:1".into()], + }, + ) + .await + .unwrap(); + let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap(); + let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap(); + let now = chrono::Utc::now().timestamp(); + let transport = PurchaseTestTransport { + seller_root: seller.path().into(), + template: crate::content_purchase_protocol::Offer { + id: uuid::Uuid::new_v4().to_string(), + buyer_did: buyer_did.clone(), + seller_did: seller_did.clone(), + content_id: "registered_film".into(), + filename: "film.mp4".into(), + mime_type: "video/mp4".into(), + content_sha256: "ab".repeat(32), + content_size: 16, + viewing_seconds: Some(60), + terms_sha256: "cd".repeat(32), + network: EcashNetwork::Mainnet, + mint_url: "http://127.0.0.1:1".into(), + seller_net_sats: 8, + offered_at: now, + expires_at: now + 300, + }, + lose_offer: AtomicBool::new(false), + lose_accept: AtomicBool::new(false), + lose_settle: AtomicBool::new(false), + offers: Default::default(), + }; + let expected = ExpectedRental { + seller_did, + content_id: "registered_film".into(), + sha256: "ab".repeat(32), + price_sats: 8, + viewing_seconds: 60, + }; + let mut changed_hash = expected.clone(); + changed_hash.sha256 = "ef".repeat(32); + let mut changed_price = expected.clone(); + changed_price.price_sats = 9; + let mut changed_duration = expected.clone(); + changed_duration.viewing_seconds = 120; + for wrong in [changed_hash, changed_price, changed_duration] { + let error = purchase_bound( + buyer.path(), + &buyer_did, + "registered_film", + None, + 20, + None, + &transport, + Some(&wrong), + ) + .await + .err() + .unwrap(); + assert!(error.to_string().contains("Published rental"), "{error:#}"); + assert!(!buyer.path().join("wallet/ecash.json").exists()); + assert!(!buyer.path().join("wallet/send-operations").exists()); + assert!(crate::content_purchase::Journal::open(buyer.path()) + .await + .unwrap() + .find_buyers(&buyer_did, &expected.seller_did, "registered_film") + .await + .unwrap() + .is_empty()); + } +} + +#[tokio::test] +async fn unconfirmed_quote_can_cancel_and_requote_without_exposing_wallet_funds() { + use crate::content_purchase_caller::{purchase, ReadyPurchase}; + use std::sync::atomic::{AtomicBool, Ordering}; + let mint = Mint::start(0, None).await; + let buyer = tempfile::tempdir().unwrap(); + let seller = mint.wallet().await; + let buyer_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([1u8; 32])).unwrap(); + let seller_did = crate::identity::did_key_from_pubkey_hex(&hex::encode([2u8; 32])).unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(buyer.path(), &wallet).await.unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + save_wallet(seller.path(), &wallet).await.unwrap(); + let now = chrono::Utc::now().timestamp(); + let transport = PurchaseTestTransport { + seller_root: seller.path().into(), + template: crate::content_purchase_protocol::Offer { + id: uuid::Uuid::new_v4().to_string(), + buyer_did: buyer_did.clone(), + seller_did, + content_id: "paid-film".into(), + filename: "film.mp4".into(), + mime_type: "video/mp4".into(), + content_sha256: "ab".repeat(32), + content_size: 16, + viewing_seconds: None, + terms_sha256: "cd".repeat(32), + network: EcashNetwork::Mainnet, + mint_url: mint.url.clone(), + seller_net_sats: 8, + offered_at: now, + expires_at: now + 300, + }, + lose_offer: AtomicBool::new(false), + lose_accept: AtomicBool::new(false), + lose_settle: AtomicBool::new(false), + offers: Default::default(), + }; + let quote = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport, + ) + .await + .unwrap(); + let id = match quote { + ReadyPurchase::AwaitingConfirmation { operation_id, .. } => operation_id, + _ => panic!("Quote spent funds"), + }; + assert!(!buyer + .path() + .join("wallet/send-operations") + .join(format!("{id}.json")) + .exists()); + let (envelope, plan) = { + let journal = crate::content_purchase::Journal::open(buyer.path()) + .await + .unwrap(); + ( + journal + .protocol_envelope("buyer", &id) + .await + .unwrap() + .unwrap(), + journal.buyer_plan(&id).await.unwrap().unwrap(), + ) + }; + crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport) + .await + .unwrap(); + crate::content_purchase_caller::cancel_purchase(buyer.path(), &envelope, &plan, &transport) + .await + .unwrap(); + assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8); + assert!(load_wallet(buyer.path()) + .await + .unwrap() + .transactions + .is_empty()); + assert!(mint.requests.lock().unwrap().is_empty()); + let next = purchase( + buyer.path(), + &buyer_did, + "paid-film", + Some("film.mp4"), + 8, + None, + &transport, + ) + .await + .unwrap(); + match next { + ReadyPurchase::AwaitingConfirmation { operation_id, .. } => assert_ne!(operation_id, id), + _ => panic!("Replacement quote spent funds"), + } + assert!(mint.requests.lock().unwrap().is_empty()); +} diff --git a/core/archipelago/src/wallet/purchase_plan.rs b/core/archipelago/src/wallet/purchase_plan.rs new file mode 100644 index 00000000..0cc2b280 --- /dev/null +++ b/core/archipelago/src/wallet/purchase_plan.rs @@ -0,0 +1,264 @@ +//! Local-only deterministic payment planning. No swap POST or reservation here. +//! Serialize this object only into the private buyer journal, never onto the wire. +use super::{ + cashu::{KeysetInfo, Proof}, + ecash, + mint_client::MintClient, + mutation, + purchase_fee_plan::{FeePlan, KeysetPlan}, + send_journal::{Binding, Journal, Request}, +}; +use anyhow::{Context, Result}; +use serde::{Deserialize, Serialize}; +use std::{collections::BTreeMap, path::Path}; + +#[derive(Clone, Serialize, Deserialize)] +pub(crate) struct PreparedPayment { + pub fee_plan: FeePlan, + /// Includes any buyer funding-swap input fee, distinct from seller redemption. + pub wallet_debit_sats: u64, + request: Request, +} +fn exact_shape(proofs: &[Proof], keysets: &[KeysetInfo]) -> Result> { + let mut groups: BTreeMap = BTreeMap::new(); + for proof in proofs { + let matches: Vec<_> = keysets + .iter() + .filter(|keyset| super::cashu::matches_stored_keyset_id(&proof.id, &keyset.id)) + .collect(); + anyhow::ensure!(matches.len() == 1, "Missing or ambiguous payment keyset"); + let keyset = matches[0]; + anyhow::ensure!(keyset.unit == "sat", "Payment keyset has another unit"); + groups + .entry(keyset.id.to_ascii_lowercase()) + .or_insert_with(|| KeysetPlan { + keyset_id: keyset.id.to_ascii_lowercase(), + denominations: vec![], + input_fee_ppk: keyset.input_fee_ppk, + }) + .denominations + .push(proof.amount); + } + Ok(groups.into_values().collect()) +} +/// Quote at most 1024 gross amounts. Failure is explicit; never silently increase +/// the user-approved debit limit or substitute another mint/network. +pub(crate) async fn prepare( + data_dir: &Path, + mint: &str, + expected_network: ecash::EcashNetwork, + seller_net_sats: u64, + max_wallet_debit: u64, +) -> Result { + anyhow::ensure!( + seller_net_sats > 0 && max_wallet_debit >= seller_net_sats, + "Invalid payment budget" + ); + let _held = mutation::guard(data_dir).await?; + anyhow::ensure!( + ecash::load_network(data_dir).await? == expected_network, + "Offer uses another wallet network; no intent was created" + ); + let wallet = ecash::load_wallet(data_dir).await?; + anyhow::ensure!( + wallet.select_proofs(mint, seller_net_sats).is_some(), + "No spendable balance at the seller's mint; no intent was created" + ); + let client = + MintClient::new(mint)?.with_recovery(super::nut13::RecoverySource::load(data_dir).await?); + let keysets = client.get_keysets().await?; + let active = client.get_active_sat_keyset().await?; + let active_fee: Vec<_> = keysets + .iter() + .filter(|keyset| keyset.id == active.id && keyset.active && keyset.unit == "sat") + .collect(); + anyhow::ensure!( + active_fee.len() == 1, + "Active payment keyset is not uniquely bound" + ); + for additional in 0..1024u64 { + let gross = seller_net_sats + .checked_add(additional) + .context("Payment amount overflow")?; + if gross > max_wallet_debit { + break; + } + let Some((indices, excess)) = wallet.select_proofs(mint, gross) else { + break; + }; + let proofs: Vec<_> = indices + .iter() + .map(|&index| wallet.proofs[index].proof.clone()) + .collect(); + let input_shape = exact_shape(&proofs, &keysets)?; + let input_ppk = input_shape + .iter() + .try_fold(0u64, |total, group| { + total.checked_add( + group + .input_fee_ppk + .checked_mul(group.denominations.len() as u64)?, + ) + }) + .context("Funding fee overflow")?; + let quoted_funding_fee = input_ppk.checked_add(999).context("Funding fee overflow")? / 1000; + if excess > 0 + && (quoted_funding_fee > excess + || gross + .checked_add(quoted_funding_fee) + .is_none_or(|debit| debit > max_wallet_debit)) + { + continue; + } + let shape = if excess == 0 { + input_shape + } else { + vec![KeysetPlan { + keyset_id: active.id.to_ascii_lowercase(), + denominations: super::cashu::amount_to_denominations(gross), + input_fee_ppk: active_fee[0].input_fee_ppk, + }] + }; + let ppk = shape + .iter() + .try_fold(0u64, |total, group| { + total.checked_add( + group + .input_fee_ppk + .checked_mul(group.denominations.len() as u64)?, + ) + }) + .context("Quoted fee overflow")?; + let fee = ppk.checked_add(999).context("Quoted fee overflow")? / 1000; + if gross <= fee || gross - fee < seller_net_sats { + continue; + } + let fee_plan = FeePlan::from_shape(mint, shape)?; + if fee_plan.net_sats < seller_net_sats { + continue; + } + let (request, funding_fee) = if excess == 0 { + (Request::Exact { proofs }, 0) + } else { + let mut amounts = super::cashu::amount_to_denominations(gross); + amounts.extend(super::cashu::amount_to_denominations(excess)); + let prepared = client + .prepare_swap_at_least(&proofs, &amounts, gross) + .await?; + anyhow::ensure!( + prepared.payment_keyset_id() == active.id, + "Mint rotated while planning; refresh the offer" + ); + let fee = prepared.input_fee_sats()?; + anyhow::ensure!( + fee == quoted_funding_fee, + "Mint funding fee changed while planning; refresh before acceptance" + ); + anyhow::ensure!( + client.restore_prepared_swap(&prepared).await?.is_none(), + "Planned outputs already exist; recover the previous operation" + ); + (Request::Swap(prepared), fee) + }; + let debit = gross + .checked_add(funding_fee) + .context("Payment debit overflow")?; + anyhow::ensure!( + debit <= max_wallet_debit, + "Funding fee exceeds the approved debit limit" + ); + return Ok(PreparedPayment { + fee_plan, + wallet_debit_sats: debit, + request, + }); + } + anyhow::bail!("No bounded payment plan covers the seller price within the approved debit limit") +} +/// Must precede authenticated seller acceptance. This durably pins the exact +/// inputs/outputs without reserving or spending; stale inputs later reject. +pub(crate) async fn persist( + data_dir: &Path, + contract: &crate::content_purchase::Contract, + plan: &PreparedPayment, +) -> Result<()> { + contract.validate()?; + anyhow::ensure!( + plan.fee_plan.mint_url == contract.mint_url + && plan.fee_plan.gross_sats == contract.gross_token_sats + && plan.fee_plan.net_sats >= contract.minimum_net_sats, + "Wallet plan changed purchase amounts" + ); + let held = mutation::guard(data_dir).await?; + anyhow::ensure!( + ecash::load_network(data_dir).await? == contract.network, + "Purchase wallet network changed" + ); + let binding = Binding { + id: contract.id.clone(), + network: contract.network, + mint_url: contract.mint_url.clone(), + amount_sats: contract.gross_token_sats, + context_hash: contract.context_hash()?, + }; + let journal = Journal::new(&held); + if let Some(existing) = journal.load(&contract.id).await? { + anyhow::ensure!( + existing.binding == binding + && serde_json::to_value(&existing.request)? == serde_json::to_value(&plan.request)?, + "Original wallet preparation changed" + ); + } + if journal.load(&contract.id).await?.is_none() { + let buyer = crate::content_purchase::Journal::open(data_dir) + .await? + .buyer(&contract.id) + .await? + .context("Buyer intent is missing")?; + anyhow::ensure!(buyer.phase == crate::content_purchase::BuyerPhase::Intent, + "Accepted operation lost its wallet journal; no new preparation or cancellation is allowed"); + } + journal.prepare(binding, plan.request.clone()).await?; + Ok(()) +} + +/// Seal before contacting seller. Repeating after a lost seller reply is safe. +pub(crate) async fn cancel_unspent( + data_dir: &Path, + contract: &crate::content_purchase::Contract, + plan: &PreparedPayment, +) -> Result<()> { + persist(data_dir, contract, plan).await?; + let held = mutation::guard(data_dir).await?; + let binding = Binding { + id: contract.id.clone(), + network: contract.network, + mint_url: contract.mint_url.clone(), + amount_sats: contract.gross_token_sats, + context_hash: contract.context_hash()?, + }; + Journal::new(&held).cancel_unspent(&binding).await +} + +/// Publish the buyer intent while holding the wallet network mutation guard, so +/// a concurrent network switch cannot strand a newly published wrong-network row. +pub(crate) async fn persist_intent( + data_dir: &Path, + envelope: &crate::content_purchase_protocol::Envelope, + plan: &PreparedPayment, +) -> Result<()> { + let contract = envelope.contract()?; + anyhow::ensure!(plan.fee_plan == envelope.fee_plan, "Buyer fee plan changed"); + let _held = mutation::guard(data_dir).await?; + anyhow::ensure!( + ecash::load_network(data_dir).await? == contract.network, + "Offer uses another wallet network; no intent was created" + ); + let journal = crate::content_purchase::Journal::open(data_dir).await?; + journal.save_buyer_plan(&contract.id, plan).await?; + journal.save_protocol_envelope("buyer", envelope).await?; + journal + .prepare_buyer(&contract, chrono::Utc::now().timestamp()) + .await?; + Ok(()) +} diff --git a/core/archipelago/src/wallet/send_journal.rs b/core/archipelago/src/wallet/send_journal.rs index 4934d291..d6ea6208 100644 --- a/core/archipelago/src/wallet/send_journal.rs +++ b/core/archipelago/src/wallet/send_journal.rs @@ -450,16 +450,26 @@ pub(super) struct Outcome { #[derive(Clone, Serialize, Deserialize)] pub(super) enum Phase { Prepared, + Cancelled, Result(Outcome), Committed(Outcome), } +#[derive(Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)] +pub(super) enum DispatchState { + #[default] + Unknown, + NotDispatched, + Started, +} #[derive(Clone, Serialize, Deserialize)] #[serde(deny_unknown_fields)] pub(super) struct Record { pub binding: Binding, pub request: Request, pub phase: Phase, + #[serde(default)] + pub dispatch: DispatchState, } impl std::fmt::Debug for Record { @@ -528,6 +538,9 @@ impl<'a> Journal<'a> { { continue; } + if matches!(record.phase, Phase::Cancelled) { + continue; + } let Phase::Committed(outcome) = record.phase else { anyhow::bail!( "Recover pending payments before restoring this mint from the backup phrase" @@ -634,6 +647,7 @@ impl<'a> Journal<'a> { use super::ecash::TransactionType; let record = self.bound_record(binding).await?; let (outcome, committed) = match &record.phase { + Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"), Phase::Prepared => anyhow::bail!("Payment result is not durable yet"), Phase::Result(outcome) => (outcome, false), Phase::Committed(outcome) => (outcome, true), @@ -867,7 +881,7 @@ impl<'a> Journal<'a> { Self::validate_binding(&record.binding)?; Self::validate_request(&record.binding, &record.request)?; match &record.phase { - Phase::Prepared => (), + Phase::Prepared | Phase::Cancelled => (), Phase::Result(outcome) | Phase::Committed(outcome) => { Self::validate_outcome(&record, outcome)? } @@ -895,6 +909,7 @@ impl<'a> Journal<'a> { binding, request, phase: Phase::Prepared, + dispatch: DispatchState::NotDispatched, }; self.write(&record).await?; Ok(record) @@ -911,6 +926,7 @@ impl<'a> Journal<'a> { ); Self::validate_outcome(&record, &outcome)?; match &record.phase { + Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"), Phase::Prepared => record.phase = Phase::Result(outcome), Phase::Result(previous) | Phase::Committed(previous) => { anyhow::ensure!( @@ -935,6 +951,7 @@ impl<'a> Journal<'a> { "Payment operation terms changed" ); record.phase = match record.phase { + Phase::Cancelled => anyhow::bail!("Payment operation was cancelled"), Phase::Prepared => anyhow::bail!("Payment result is not durable yet"), Phase::Result(outcome) | Phase::Committed(outcome) => Phase::Committed(outcome), }; @@ -942,6 +959,60 @@ impl<'a> Journal<'a> { Ok(record) } + /// Must finish durably immediately before every fresh mint POST, under the + /// same wallet mutation guard as cancellation and input reservation. + pub async fn mark_dispatched(&self, binding: &Binding) -> Result<()> { + let mut record = self.bound_record(binding).await?; + anyhow::ensure!( + matches!(record.phase, Phase::Prepared), + "Payment cannot be dispatched in this phase" + ); + record.dispatch = DispatchState::Started; + self.write(&record).await + } + /// A terminal local seal precedes release. No mint state query can prove an + /// ambiguous old POST will not finish later, so such swaps are never released. + pub async fn cancel_unspent(&self, binding: &Binding) -> Result<()> { + let mut record = self.bound_record(binding).await?; + if !matches!(record.phase, Phase::Cancelled) { + anyhow::ensure!( + matches!(record.phase, Phase::Prepared), + "A prepared token/result cannot be cancelled as unspent" + ); + anyhow::ensure!( + matches!(record.request, Request::Exact { .. }) + || record.dispatch == DispatchState::NotDispatched, + "Mint dispatch is possible; recover original results instead of cancelling" + ); + record.phase = Phase::Cancelled; + self.write(&record).await?; + } + let mut wallet = super::ecash::load_wallet(&self.guard.data_dir).await?; + let mut changed = false; + for stored in &mut wallet.proofs { + if stored.reserved_by.as_deref() != Some(binding.id.as_str()) { + continue; + } + anyhow::ensure!( + Self::inputs(&record.request) + .iter() + .any(|input| input.secret == stored.proof.secret + && input.amount == stored.proof.amount + && input.id == stored.proof.id + && input.c == stored.proof.c), + "Cancellation reservation differs from original inputs" + ); + anyhow::ensure!(!stored.spent, "Cancellation cannot restore spent inputs"); + stored.reserved = false; + stored.reserved_by = None; + changed = true; + } + if changed { + super::ecash::save_wallet(&self.guard.data_dir, &wallet).await?; + } + Ok(()) + } + async fn write(&self, record: &Record) -> Result<()> { let payload = serde_json::to_string(record)?; let checksum = hex::encode(Sha256::digest(payload.as_bytes())); diff --git a/docs/indeehub-node-registration-followup.md b/docs/indeehub-node-registration-followup.md index 5ee6b5e0..b9b41471 100644 --- a/docs/indeehub-node-registration-followup.md +++ b/docs/indeehub-node-registration-followup.md @@ -180,10 +180,12 @@ Seven registered-media tests and three route/stream tests passed in `/tmp/archy-registration-rental-batch-tests.log`; its overall result was 1,834 passed, one failed legacy missing-file expectation, and five existing skips. All 383 captured source/build/fixture hashes remained unchanged. The expectation -was corrected separately and awaits the next full run. Do not describe that batch +was corrected separately; the later full run passed 1,848 tests, zero failures +and five existing skips with all 385 captured hashes unchanged. Do not describe that earlier batch as a clean combined suite or live playback acceptance. -The subsequent, not-yet-tested performance refinement persists the already +The subsequent performance refinement, qualified in the later clean 1,848-test +backend run, persists the already verified snapshot's signed hash and inode/device/ctime/size attestation during registration. Ordinary first-open/range requests reuse it. A missing cache streams the original signed hash under a per-registration lock; buyer leases use separate @@ -197,3 +199,52 @@ picker/consent bridge, app receipt consumption, and player reconnect/expiry UI a being connected next. Their source is not yet deployed; app registration and publication flags remain disabled pending complete qualification. No real payment, announcement, media publication or node deployment was performed by this work. + + +## Applied native caller and terminal recovery — 7 October UTC + +The next source batch now connects the dashboard Cloud picker, exact producer +signature, owner-session/CSRF RPC, shared snapshot reservation budget and Backstage +receipt consumption. It is local and uncommitted; the deployed b52214f7 backend +candidate does not contain these caller changes. Registration/publication flags +remain disabled. + +An interrupted operation can now be resolved under its original operation lock: +return and verify its completed receipt, or durably retire an expired incomplete +request. Retirement is node-signed against the entire original intent. It prevents +late preparation and cannot replace a completed receipt. App pending lookup and +retirement consumption authenticate the current producer/project owner and pinned +installation; an expired unknown intent cannot silently become a fresh one. +Backstage retains signatures/receipts across lost replies and offers explicit +resume/resolve actions. Completed media remains available without the Cloud source. + +Focused qualification: PostgreSQL registration/retirement and migrations plus +HTTP identity routes passed 54 tests in two suites; app caller passed seven tests; +dashboard native bridge passed six tests. App frontend typecheck passed. Logs: +`/tmp/indeehub-terminal-registration-focused.log`, +`/tmp/indeehub-terminal-registration-client-rerun.log`, +`/tmp/archy-native-registration-bridge-tests.log`, and +`/tmp/indeehub-terminal-registration-typecheck.log`. +The first client test command found zero tests because the new file was outside +the repository include pattern; it was moved to `tests/backstage-registration.test.ts` +and the rerun passed. No zero-test run is counted as qualification. + +The backend all-source noEmit check reports two unchanged legacy test-mock errors: +missing Subscription.flashId and User.libraryItems. Its production-config noEmit +check passed; the all-source failure remains recorded separately. Combined new Rust primitive/RPC/caller tests and +real native registration/rental acceptance remain pending. No new payment, +announcement, media publication or deployment was performed by this batch. + + +The connected app rental player passed eight mounted-Vue lifecycle/status tests +and frontend typecheck. Opening the dialog creates no purchase or media request; +video uses preload=none and does not autoplay. Native response generations reject +late close/reopen or changed-offer results. The actual playing event starts a +read-only status(handle) request and non-overlapping five-second checks; pause, +ended, error and close stop polling. Only the server expires_at is displayed; +null never starts a local rental clock. A status error retains the original +purchase handle and offers recovery without another payment. Logs: +`/tmp/indeehub-rental-player-status-tests.log` and +`/tmp/indeehub-rental-player-status-typecheck.log`. +Host broker and Rust proxy/caller qualification are tracked separately; these app +tests do not claim a live paid-stream acceptance. diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index f7fd1cbb..70fd13cd 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -646,3 +646,92 @@ on-chain amount matches the backend. These checks do not establish complete durable recovery for every payment method. Full purchase integration and live acceptance remain open; Framework dashboard verification still needs normal TOTP. No new real payment was made. + +## Next integrated caller batch — qualification in progress + +The owner Cloud purchase RPC, registered video rental RPC and FIPS seller routes +are now connected to the durable purchase journal. Fresh spending requires the +original operation UUID, envelope hash and exact wallet debit returned for owner +confirmation. Reopening a title checks its existing operation first. Cancellation +must obtain the seller's unspent acknowledgement before a replacement quote. +External Lightning QR invoices expose authoritative lifecycle state; a local timer +alone cannot authorize another payment method. + +The native IndeeHub rental broker and player are now implemented in source. The +browser receives a session-bound local playback handle, not the seller receipt +capability. Handle creation/status do not start the viewing period; the actual +video GET does. The app uses `preload="none"` and no autoplay. Closing or changing +content invalidates asynchronous UI results, and status polling cannot initiate +a purchase. Native origin, app lifecycle and HTTP/HTTPS route review are ongoing. + +This is **not deployed or accepted**. The combined Rust source is undergoing its +isolated compile/test run. Focused registration tests passed 54 backend, seven +app caller and six host bridge tests; the app rental player passed eight mounted +lifecycle/status cases and frontend typecheck. Host rental tests initially passed +five cases before cancellation/status additions and further independent review; +final host qualification remains pending. Two preexisting backend test/mock type +omissions remain recorded separately from the passing production typecheck. + +The original Framework paid-file recovery, real Yaya↔Framework/dev method-switch +acceptance, app image deployment, complete published-title playback and physical +companion checks remain open. No new real payment or public announcement was made. + +### Integrated purchase qualification and seller policy correction + +The integrated caller, immutable offers, durable acceptance/cancellation, fee-plan +execution, retained Cloud delivery and registered rental plumbing are now in the +candidate source. These are not yet accepted as a live payment flow. The first +combined compile failed before tests; subsequent isolated runs reported 1,884 +passed/3 failed/5 ignored, then 1,886 passed/1 failed/5 ignored. The latter run +verified all 406 captured source inputs unchanged. Logs are retained at +`/tmp/archy-purchase-integrated-backend-rerun.log` and +`/tmp/archy-purchase-integrated-backend-final.log`. + +The remaining caller test exposed missing explicit mint acceptance in its seller +fixture. Production review also found that a configured default mint could be +quoted without checking the seller's redemption allowlist. The candidate now +checks network and mint policy before new offers/acceptance and before reporting +an unresolved accepted operation ready for fresh buyer spending. Wallet policy +changes cannot remove a mint or switch networks while an accepted seller +liability remains unresolved. Acceptance and policy updates use the same wallet +then purchase lock order. Settlement and cancellation release that policy pin; +already-settled receipts remain recoverable after policy changes. No generic +redemption allowlist bypass was added. New tests cover these transitions and the +original lost acceptance/settlement sequence; this correction awaits the next +isolated run. + +Confirmation responses additionally carry the original saved offer's Cashu +network and mint for display. They are not derived from later wallet settings. +No new real payments were made for these checks. + +Remaining protocol work is explicit: authenticated server-owned creation and +cancellation of external Lightning invoices across browser-state loss; durable +on-chain address/dispatch/transaction recovery; and explicit rental renewal only +after seller-authoritative expiry evidence. Existing paid receipts and ambiguous +legacy attempts must retain recovery access. A local timeout, missing browser +record or clock expiry must never authorize a second payment. + +Qualification update: the corrected combined isolated backend run passed **1,889 +tests, zero failures, five existing skips**. Compilation took 4m22s; isolated +execution took 14.60s. All 406 captured inputs remained unchanged. Evidence: +`/tmp/archy-purchase-policy-backend-tests.log` and +`/tmp/archy-purchase-policy-green-provenance.json`. This includes the complete +caller lost-acceptance/lost-settlement regression, seller policy transitions, +immutable-content first-use integrity, and explicit application license metadata. +Production build, deployment and live payment acceptance are separate gates. + +### Integrated native purchase dashboard qualification — 7 October + +The matching dashboard passed 1,375 tests across 170 files, the actual project +TypeScript check, and its production build with 500 source/build inputs unchanged. +Twenty-one local browser cases passed at 320, 390 and 1440px, including long mint +URLs, Cashu network labels, busy/error states and reachable mobile actions. These +are local fixtures, not live payment acceptance. The earlier full-suite loading +notice timeout is retained in its log; its unchanged-timeout focused rerun and +the subsequent complete suite passed. + +The deployable UI and evidence are preserved under +`~/.local/state/archipelago/release-qualification/ui-purchase-6fd81faf0d05/`. +Its index SHA256 is `6fd81faf0d057b1c689610ebfbd04193071e282d85e27ec07b34f927525be1f5`. +It requires the matching purchase backend and is not yet deployed. The prior +qualified backend and dashboard remain live on dev, Yaya and Framework. diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 906a804a..8925f589 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -395,6 +395,23 @@ server { error_page 504 = @backend_timeout; } + + # Session-bound rental playback: never cache opaque handles or capabilities. + location /api/rental-playback/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header Origin $http_origin; + proxy_set_header Range $http_range; + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 10s; + proxy_read_timeout 40s; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + location /lnd-connect-info { proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1; @@ -1302,6 +1319,23 @@ server { error_page 504 = @backend_timeout; } + + # Session-bound rental playback: never cache opaque handles or capabilities. + location /api/rental-playback/ { + proxy_pass http://127.0.0.1:5678; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header Cookie $http_cookie; + proxy_set_header Origin $http_origin; + proxy_set_header Range $http_range; + proxy_buffering off; + proxy_cache off; + proxy_connect_timeout 10s; + proxy_read_timeout 40s; + error_page 502 503 = @backend_unavailable; + error_page 504 = @backend_timeout; + } + location /lnd-connect-info { proxy_pass http://127.0.0.1:5678/lnd-connect-info; proxy_http_version 1.1; diff --git a/neode-ui/public/nostr-provider.js b/neode-ui/public/nostr-provider.js index c27fe2a3..9f6deea4 100644 --- a/neode-ui/public/nostr-provider.js +++ b/neode-ui/public/nostr-provider.js @@ -14,7 +14,7 @@ var providerScript = document.currentScript; var autoNip98 = !(providerScript && providerScript.hasAttribute('data-no-nip98')); var embedded = window !== window.top; - var pending = {}, nextId = 1, queuedMessages = []; + var pending = {}, rentalPending = {}, mediaPending = {}, nextId = 1, queuedMessages = []; var identitySelection = null; var selectedIdentity = null, identitySubscribers = []; var selectedPublicKey = null, selectedPublicKeyTimer = null; @@ -288,6 +288,21 @@ cancelIdentitySelection(); return; } + if (e.data.type === 'archipelago-rental-response') { + var rental = rentalPending[e.data.id]; + if (!rental) return; + delete rentalPending[e.data.id]; clearTimeout(rental.timer); + e.data.error ? rental.reject(new Error(e.data.error)) : rental.resolve(e.data.result); + return; + } + if (e.data.type === 'archipelago-media-registration-response') { + var media = mediaPending[e.data.id]; + if (!media) return; + delete mediaPending[e.data.id]; + clearTimeout(media.timer); + e.data.error ? media.reject(new Error(e.data.error)) : media.resolve(e.data.result); + return; + } if (e.data.type !== 'nostr-response') return; var handler = pending[e.data.id]; if (!handler) return; @@ -310,6 +325,58 @@ }, }; + // Exact owner-approved Cloud registration. The dashboard checks the installed + // app origin/audience and displays the native picker before any preparation. + function nativeRequestId() { + if (typeof crypto.randomUUID === 'function') return crypto.randomUUID(); + // Secure randomness remains available on ordinary HTTP node/LAN origins. + var bytes = new Uint8Array(16); crypto.getRandomValues(bytes); + bytes[6] = (bytes[6] & 15) | 64; bytes[8] = (bytes[8] & 63) | 128; + var hex = Array.from(bytes, function (value) { return value.toString(16).padStart(2, '0'); }).join(''); + return hex.slice(0,8)+'-'+hex.slice(8,12)+'-'+hex.slice(12,16)+'-'+hex.slice(16,20)+'-'+hex.slice(20); + } + + window.archipelagoRental = { + status: function (handle) { + return new Promise(function (resolve, reject) { + var id = nativeRequestId(); + rentalPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () { + delete rentalPending[id]; reject(new Error('Playback status unavailable.')); + }, 15000) }; + postToSigner({ type: 'archipelago-rental-request', id: id, action: 'status', handle: handle }); + }); + }, + request: function (offer) { + return new Promise(function (resolve, reject) { + var id = nativeRequestId(); + rentalPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () { + delete rentalPending[id]; reject(new Error('Rental response unavailable. Reopen this title to recover the same purchase.')); + }, 600000) }; + postToSigner({ type: 'archipelago-rental-request', id: id, offer: offer }); + }); + } + }; + + window.archipelagoMediaRegistration = { + request: function (action, payload) { + if (['select', 'resume', 'submit', 'complete', 'resolve', 'resolve-submit'].indexOf(action) === -1 || !payload || typeof payload !== 'object') { + return Promise.reject(new Error('Invalid native media registration request')); + } + return new Promise(function (resolve, reject) { + var id = nativeRequestId(); + mediaPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () { + var item = mediaPending[id]; + if (!item) return; + delete mediaPending[id]; + item.reject(new Error('Registration was not confirmed. Resume the same saved operation.')); + }, 600000) }; + postToSigner({ type: 'archipelago-media-registration-request', id: id, action: action, + intent: payload.intent, selection: payload.selection, approvalId: payload.approvalId, + producerEvent: payload.producerEvent }); + }); + }, + }; + window.archipelagoNostr = { selectIdentity: selectIdentity, onIdentitySelected: onIdentitySelected, diff --git a/neode-ui/src/components/AppLauncherOverlay.vue b/neode-ui/src/components/AppLauncherOverlay.vue index 03ced061..dfd1e15c 100644 --- a/neode-ui/src/components/AppLauncherOverlay.vue +++ b/neode-ui/src/components/AppLauncherOverlay.vue @@ -181,6 +181,10 @@ + + { // Nostr identity picker state const showIdentityPicker = ref(false) +watch(showIdentityPicker, value => store.setNativeIdentityBusy(value), { flush: 'sync' }) const IDENTITY_STORAGE_KEY = 'archipelago_app_identity_' interface SelectedIdentity { diff --git a/neode-ui/src/components/MediaRegistrationConsent.vue b/neode-ui/src/components/MediaRegistrationConsent.vue new file mode 100644 index 00000000..4f634c16 --- /dev/null +++ b/neode-ui/src/components/MediaRegistrationConsent.vue @@ -0,0 +1,71 @@ + + diff --git a/neode-ui/src/components/NostrSignConsent.vue b/neode-ui/src/components/NostrSignConsent.vue index f604efcd..23168fde 100644 --- a/neode-ui/src/components/NostrSignConsent.vue +++ b/neode-ui/src/components/NostrSignConsent.vue @@ -28,7 +28,12 @@

Request

{{ methodLabel }}

Identity

{{ identityLabel }}

-

Content

{{ contentPreview }}

+
+

{{ mediaApproval.resolving ? 'Recover the completed registration or retire its expired incomplete request.' : 'Register this video for the selected IndeeHub project.' }}

+
Project
{{ mediaApproval.project }}
Cloud video
{{ mediaApproval.file }}
Rental terms
{{ mediaApproval.price }} sats · {{ mediaApproval.seconds }} seconds after first play
+
Full signed terms
{{ content }}
+
+

Content

{{ contentPreview }}

Event kind

{{ eventKind }} ({{ eventKindLabel }})

@@ -55,7 +60,7 @@ const EVENT_KIND_LABELS: Record = { 0: 'Metadata', 1: 'Short text note', 2: 'Recommend relay', 3: 'Contacts', 4: 'Encrypted DM', 5: 'Event deletion', 6: 'Repost', 7: 'Reaction', 1618: 'Git pull request', 1619: 'Git pull request update', 1621: 'Git issue', 9734: 'Zap request', 9735: 'Zap receipt', 10002: 'Relay list', - 30023: 'Long-form content', 30617: 'Git repository announcement', + 27236: 'Local Cloud video registration', 27237: 'Recover or retire video registration', 30023: 'Long-form content', 30617: 'Git repository announcement', } const METHOD_LABELS: Record = { getPublicKey: 'Share public identity', signEvent: 'Sign Nostr event', @@ -78,6 +83,17 @@ const methodLabel = computed(() => METHOD_LABELS[props.method] ?? props.method) const requestTitle = computed(() => props.method === 'getPublicKey' ? 'Share this identity?' : 'Approve this request?') const progressTitle = computed(() => props.method === 'getPublicKey' ? 'Sharing identity…' : 'Signing locally…') const successTitle = computed(() => props.method === 'getPublicKey' ? 'Identity shared' : 'Request signed') +const mediaApproval = computed(() => { + if (![27236, 27237].includes(props.eventKind ?? 0) || !props.content) return null + try { + const value = JSON.parse(props.content) + if (!['archipelago.media-registration.approval.v1', 'archipelago.media-registration.resolution.v1'].includes(value.scope) + || typeof value.intent?.projectId !== 'string' || (props.eventKind === 27236 && typeof value.selection?.cloudFile !== 'string') + || !Number.isSafeInteger(value.intent.priceSats) || !Number.isSafeInteger(value.intent.viewingSeconds)) return null + return { project: value.intent.projectId, file: value.selection?.cloudFile ?? '', resolving: props.eventKind === 27237, + price: value.intent.priceSats, seconds: value.intent.viewingSeconds } + } catch { return null } +}) const contentPreview = computed(() => !props.content ? '' : props.content.length > 200 ? `${props.content.slice(0, 200)}…` : props.content) const eventKindLabel = computed(() => props.eventKind === undefined ? '' : EVENT_KIND_LABELS[props.eventKind] ?? 'Unknown') function approve() { emit('approve', rememberChoice.value) } diff --git a/neode-ui/src/components/RentalPurchaseConsent.vue b/neode-ui/src/components/RentalPurchaseConsent.vue new file mode 100644 index 00000000..06a1feeb --- /dev/null +++ b/neode-ui/src/components/RentalPurchaseConsent.vue @@ -0,0 +1,25 @@ + + + diff --git a/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts b/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts index 3e898574..e38b49f1 100644 --- a/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts +++ b/neode-ui/src/components/__tests__/AppLauncherSlowLoad.test.ts @@ -5,7 +5,7 @@ import AppLauncherOverlay from '../AppLauncherOverlay.vue' import { useAppLauncherStore } from '@/stores/appLauncher' vi.mock('@/stores/appLauncher', async () => { const { reactive } = await import('vue') - const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review' }) + const state = reactive({ isOpen: false, url: '', title: 'Custom app', showConsent: false, setNostrFrame: vi.fn(), close: vi.fn(), consentPhase: 'review', setNativeIdentityBusy: vi.fn(), registrationRequest: null, registrationPhase: 'select', registrationError: '', rentalRequest: null, rentalQuote: null, rentalPhase: 'review', rentalError: '' }) return { useAppLauncherStore: () => state } }) vi.mock('@/composables/useLightningRequired', () => ({ useLightningRequired: () => ({}) })) diff --git a/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts b/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts new file mode 100644 index 00000000..c00d2ab6 --- /dev/null +++ b/neode-ui/src/composables/__tests__/nativeProviderRequests.test.ts @@ -0,0 +1,23 @@ +import { readFileSync } from 'node:fs' +import { runInNewContext } from 'node:vm' +import { describe, expect, it, vi } from 'vitest' +const source=readFileSync('public/nostr-provider.js','utf8') +describe('native provider on ordinary HTTP node origins',()=>{ + it('uses secure randomness without randomUUID for both rental and registration requests',async()=>{ + const listeners:((event:unknown)=>void)[]=[] + const parent={postMessage:vi.fn()} + const window:any={top:{},parent,location:{href:'http://node.local:7778/browse',pathname:'/browse',port:'7778',origin:'http://node.local:7778'},addEventListener:(_name:string,handler:(event:unknown)=>void)=>listeners.push(handler)} + const timers=new Set();let count=0 + runInNewContext(source,{window,document:{currentScript:{hasAttribute:()=>true},readyState:'complete'},crypto:{getRandomValues:(bytes:Uint8Array)=>{bytes.fill(++count);return bytes}},Uint8Array,URL,console, + setTimeout:(fn:unknown)=>{timers.add(fn);return fn},clearTimeout:(fn:unknown)=>timers.delete(fn)}) + const rental=window.archipelagoRental.request({title:'Film'}) + const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}}) + const requests=parent.postMessage.mock.calls.map(([message])=>message) + expect(requests).toHaveLength(2) + expect(requests[0].id).toMatch(/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/) + expect(requests[1].id).not.toBe(requests[0].id) + for(const message of requests) for(const receive of listeners) receive({source:parent,origin:'http://node.local',data:{type:message.type.replace('-request','-response'),id:message.id,result:{ok:true}}}) + await expect(rental).resolves.toEqual({ok:true});await expect(registration).resolves.toEqual({ok:true}) + expect(timers.size).toBe(0) + }) +}) diff --git a/neode-ui/src/composables/__tests__/peerCashuPurchase.test.ts b/neode-ui/src/composables/__tests__/peerCashuPurchase.test.ts new file mode 100644 index 00000000..aaf9bec6 --- /dev/null +++ b/neode-ui/src/composables/__tests__/peerCashuPurchase.test.ts @@ -0,0 +1,16 @@ +import { beforeEach, describe, expect, it } from 'vitest' +import { keepCashuAttempt, parseCashuQuote, readCashuAttempt } from '../peerCashuPurchase' +const quote = { state: 'confirmation_required' as const, network: 'testnet' as const, mint_url: 'https://original.example.test/mint', operation_id: '12345678-1234-4234-8234-123456789abc', envelope_sha256: 'b'.repeat(64), gross_token_sats: 6, seller_net_sats: 5, wallet_debit_sats: 7, expires_at: 2_000_000_000 } +beforeEach(() => localStorage.clear()) +describe('saved Cashu quote identity', () => { + it('retains original network and mint across browser recovery and rejects substitution', () => { + keepCashuAttempt('peer','file',quote,false) + expect(readCashuAttempt('peer','file')?.quote).toEqual(quote) + expect(() => keepCashuAttempt('peer','file',{...quote,network:'mainnet'},false)).toThrow('original purchase') + expect(() => keepCashuAttempt('peer','file',{...quote,mint_url:'https://replacement.test'},false)).toThrow('original purchase') + expect(readCashuAttempt('peer','file')?.quote).toEqual(quote) + }) + it.each([{network:undefined},{network:'unknown'},{mint_url:undefined},{mint_url:'javascript:bad'},{mint_url:'https://user:secret@mint.test'}])('refuses an incomplete or unsafe identity %j', invalid => { + expect(() => parseCashuQuote({...quote,...invalid})).toThrow('invalid payment quote') + }) +}) diff --git a/neode-ui/src/composables/__tests__/useMediaRegistrationBridge.test.ts b/neode-ui/src/composables/__tests__/useMediaRegistrationBridge.test.ts new file mode 100644 index 00000000..85ae15f3 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useMediaRegistrationBridge.test.ts @@ -0,0 +1,123 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const rpc = vi.hoisted(() => ({ call: vi.fn() })) +vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc })) +import { installedOriginMatches, useMediaRegistrationBridge } from '../useMediaRegistrationBridge' + +const intent = { version: 1 as const, requestId: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', nonce: 'a'.repeat(64), + appAudience: 'fixture-installed-app', nodeDid: 'did:key:fixture', producer: 'b'.repeat(64), projectId: 'project', + priceSats: 15, viewingSeconds: 3600, createdAt: 1000, expiresAt: 1600 } +const selection = { relative_path: 'Movies/film.mp4', payment_methods: ['cashu'] } +const installed = { appId: 'indeedhub', appAudience: intent.appAudience, nodeDid: intent.nodeDid, appOrigins: ['https://node.test:7778'] } +let sequence = 1 +const id = () => `bbbbbbbb-bbbb-4bbb-8bbb-${String(sequence++).padStart(12, '0')}` +function fixture() { + const child = { postMessage: vi.fn() } + const bridge = useMediaRegistrationBridge({ appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window }) + const send = (action: string, extra: Record = {}, origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ + data: { type: 'archipelago-media-registration-request', id: id(), action, intent, ...extra }, origin, source, + } as MessageEvent) + return { child, bridge, send } +} +beforeEach(() => { + localStorage.clear(); vi.clearAllMocks(); sequence = 1 + vi.spyOn(Date, 'now').mockReturnValue(1100_000) + vi.stubGlobal('crypto', { randomUUID: id }) + rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : { requestId: intent.requestId, contentId: 'registered_fixture' }) +}) +afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals() }) + +describe('native Cloud registration ownership and interrupted operation boundary', () => { + it('ignores other windows/origins and checks installer scope before showing Cloud selection', async () => { + const f = fixture() + await f.send('select', {}, 'https://evil.test'); await f.send('select', {}, undefined, {}) + expect(rpc.call).not.toHaveBeenCalled(); expect(f.bridge.request.value).toBeNull() + rpc.call.mockResolvedValue({ ...installed, appAudience: 'other-install' }) + await f.send('select') + expect(f.bridge.request.value).toBeNull() + expect(f.child.postMessage.mock.lastCall?.[0].error).toContain('installed IndeeHub') + }) + it('saves exact owner approval before signature and never prepares changed file/terms', async () => { + const f = fixture(); await f.send('select') + expect(localStorage.length).toBe(0) + f.bridge.approve(selection) + const approved = f.child.postMessage.mock.lastCall![0].result + expect(localStorage.length).toBe(1) + expect(f.bridge.phase.value).toBe('signing') + await f.send('submit', { selection: { ...selection, relative_path: 'private.mp4' }, approvalId: approved.approvalId }) + expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0) + await f.send('submit', { selection, approvalId: approved.approvalId, producerEvent: { ...approved.event, pubkey: intent.producer, content: '{}' } }) + expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0) + }) + it('resumes the exact saved event after reload and expiry without a new selection or timestamp', async () => { + const first = fixture(); await first.send('select'); first.bridge.approve(selection) + const original = first.child.postMessage.mock.lastCall![0].result + first.bridge.dispose() + vi.mocked(Date.now).mockReturnValue(1700_000) + const resumed = fixture(); await resumed.send('resume') + expect(resumed.child.postMessage.mock.lastCall![0].result).toEqual(original) + const signed = { ...original.event, pubkey: intent.producer, id: 'c'.repeat(64), sig: 'd'.repeat(128) } + await resumed.send('submit', { selection, approvalId: original.approvalId, producerEvent: signed }) + expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(1) + expect(resumed.bridge.request.value).toBeNull() + await resumed.send('complete') + expect(localStorage.length).toBe(0) + await resumed.send('complete') + expect(resumed.child.postMessage.mock.lastCall![0].result.completed).toBe(true) + }) + it('allows same-operation signer cancellation retry but rejects replacement pending terms', async () => { + const f = fixture(); await f.send('select'); f.bridge.approve(selection) + const original = f.child.postMessage.mock.lastCall![0].result + await f.send('resume') + expect(f.child.postMessage.mock.lastCall![0].result).toEqual(original) + await f.send('resume', { intent: { ...intent, priceSats: 99 } }) + expect(f.child.postMessage.mock.lastCall![0].error).toBeTruthy() + expect(f.bridge.request.value!.intent.priceSats).toBe(15) + }) + it('does not authorize preparation when owner approval cannot be persisted', async () => { + const f = fixture(); await f.send('select') + vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('storage full') }) + f.bridge.approve(selection) + expect(f.bridge.phase.value).toBe('select') + expect(f.bridge.error.value).toBe('storage full') + expect(f.child.postMessage).not.toHaveBeenCalled() + }) + it('reserves validation and cannot restore a cancelled selection after its response arrives', async () => { + const f=fixture(); let release!:(value:unknown)=>void + const implementation=rpc.call.getMockImplementation()! + rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve})) + const work=f.send('select') + expect(f.bridge.isBusy()).toBe(true) + f.bridge.cancel() + release(await implementation({method:'media.registration.context'})); await work + expect(f.bridge.request.value).toBeNull();expect(f.bridge.isBusy()).toBe(false) + }) + it('recovers resolution approval across reload and cannot use it to prepare a file', async () => { + vi.mocked(Date.now).mockReturnValue(1700_000) + const first = fixture(); await first.send('resolve') + expect(first.bridge.phase.value).toBe('resolve') + first.bridge.approveResolution() + const original = first.child.postMessage.mock.lastCall![0].result + expect(original.event.kind).toBe(27237) + first.bridge.dispose() + vi.mocked(Date.now).mockReturnValue(1900_000) + const next = fixture(); await next.send('resolve') + expect(next.child.postMessage.mock.lastCall![0].result).toEqual(original) + const signed = { ...original.event, pubkey: intent.producer, id: 'c'.repeat(64), sig: 'd'.repeat(128) } + await next.send('submit', { selection, approvalId: original.approvalId, producerEvent: signed }) + expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.prepare')).toHaveLength(0) + await next.send('resolve-submit', { approvalId: original.approvalId, producerEvent: signed }) + expect(rpc.call.mock.calls.filter(([v]) => v.method === 'media.registration.resolve')).toHaveLength(1) + await next.send('complete') + expect(localStorage.length).toBe(0) + }) + +}) + +describe('installed registration origin mapping',()=>{ + it('keeps mapped loopback origins on the actual dashboard hostname and configured port',()=>{ + const actual=new URL(window.location.href);actual.port='7778' + expect(installedOriginMatches(actual.origin,`${actual.protocol}//127.0.0.1:7778`)).toBe(true) + expect(installedOriginMatches('http://foreign.test:7778','http://127.0.0.1:7778')).toBe(false) + expect(installedOriginMatches(actual.origin,`${actual.protocol}//127.0.0.1:7779`)).toBe(false) + }) +}) diff --git a/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts b/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts new file mode 100644 index 00000000..e6bbbef8 --- /dev/null +++ b/neode-ui/src/composables/__tests__/useRentalPurchaseBridge.test.ts @@ -0,0 +1,113 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +const rpc = vi.hoisted(() => ({ call: vi.fn() })) +vi.mock('@/api/rpc-client', () => ({ rpcClient: rpc })) +import { supportsRentalPlaybackOrigin, useRentalPurchaseBridge } from '../useRentalPurchaseBridge' +const offer = { title: 'Film', terms: { nodeDid: 'did:key:fixture', contentId: 'registered_fixture', sha256: 'a'.repeat(64), priceSats: 8, viewingSeconds: 3600 } } +const installed = { appId: 'indeedhub', appOrigins: ['https://node.test:7778'] } +const quote = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', operation_id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa', envelope_sha256: 'b'.repeat(64), wallet_debit_sats: 10, gross_token_sats: 9, seller_net_sats: 8, expires_at: 2000, seller_onion: 'fixture.onion' } +function fixture(consentBusy: () => boolean = () => false) { + const child = { postMessage: vi.fn() } + const bridge = useRentalPurchaseBridge({ consentBusy, appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window }) + const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', offer }, origin, source } as MessageEvent) + return { bridge, child, send } +} +afterEach(() => vi.unstubAllGlobals()) +beforeEach(() => { vi.stubGlobal('location', new URL('https://node.test')); vi.clearAllMocks(); rpc.call.mockImplementation(async ({ method }) => method === 'media.registration.context' ? installed : method === 'content.rental-purchase' ? quote : { playback_url: '/api/rental-playback/' + 'd'.repeat(64), expires_at: null }) }) +describe('native rental confirmation', () => { + it('cannot approve a quote without its saved network and mint', async()=>{ + const f=fixture();await f.send() + rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{...quote,mint_url:undefined}) + await f.bridge.review();expect(f.bridge.quote.value).toBeNull();expect(f.bridge.error.value).toContain('Invalid payment confirmation') + const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls) + }) + + it('ignores foreign frames and origins before RPC', async () => { const f=fixture(); await f.send('https://foreign.test'); await f.send(undefined, {}); expect(rpc.call).not.toHaveBeenCalled() }) + it('requires review then confirmation of the exact debit', async () => { + const f=fixture(); await f.send(); await f.bridge.approve(); expect(rpc.call).toHaveBeenCalledTimes(1) + await f.bridge.review(); expect(f.bridge.phase.value).toBe('confirm') + expect(rpc.call.mock.calls.find(([v]) => v.method==='content.rental-purchase')![0].params.consent).toBeUndefined() + rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{playback_url:'/api/rental-playback/'+'d'.repeat(64),expires_at:null}) + await f.bridge.approve() + const calls=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase') + expect(calls[1]![0].params.consent).toEqual({operation_id:quote.operation_id,envelope_sha256:quote.envelope_sha256,wallet_debit_sats:10}) + expect(calls[1]![0].params.max_wallet_debit).toBe(10); expect(f.bridge.request.value).toBeNull() + expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toContain('/api/rental-playback/') + }) + it('does not dispatch after closing during installation validation', async()=>{ + const f=fixture(); await f.send(); let release!:(value:unknown)=>void + rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve})) + const work=f.bridge.review();f.bridge.cancel();release(installed);await work + expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false) + }) + it('does not assign a delayed payment result to a replacement request', async()=>{ + const f=fixture();await f.send();await f.bridge.review();let release!:(value:unknown)=>void + rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve})) + const work=f.bridge.approve();await vi.waitFor(()=>expect(release).toBeTypeOf('function')) + f.bridge.cancel();await f.send();release({state:'entitled',operation_id:quote.operation_id});await work + expect(f.bridge.phase.value).toBe('review');expect(f.bridge.request.value).toEqual(offer) + expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-handle')).toBe(false) + }) + it('retries recovery without reusing UI approval after an ambiguous response', async()=>{ + const f=fixture();await f.send();await f.bridge.review() + rpc.call.mockImplementation(async({method})=>{if(method==='media.registration.context')return installed;throw Error('Response lost')}) + await f.bridge.approve();expect(f.bridge.phase.value).toBe('review');await f.bridge.review() + expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase').slice(-1)[0]![0].params.consent).toBeUndefined() + }) + it('clears an unpaid quote only after acknowledged cancellation', async()=>{ + const f=fixture();await f.send();await f.bridge.review() + rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'unknown'}) + await f.bridge.cancelUnpaid();expect(f.bridge.quote.value?.operation_id).toBe(quote.operation_id) + rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'cancelled_unspent'}) + await f.bridge.cancelUnpaid();expect(f.bridge.quote.value).toBeNull() + expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.cancel-purchase').slice(-1)[0]![0].params).toEqual({onion:'fixture.onion',operation_id:quote.operation_id}) + }) + it('lease status does not open a purchase or confirm spending', async()=>{ + const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{expires_at:null}) + await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent) + expect(rpc.call.mock.calls.map(([v])=>v.method)).toEqual(['media.registration.context','content.playback-status']) + expect(f.child.postMessage.mock.lastCall![0].result).toEqual({expires_at:null}) + expect(f.bridge.request.value).toBeNull() + }) + + it('rejects a rental during another native confirmation without contacting the wallet',async()=>{ + const f=fixture(()=>true);await f.send();expect(rpc.call).not.toHaveBeenCalled() + expect(f.child.postMessage.mock.lastCall![0].error).toContain('other native confirmation') + expect(f.bridge.request.value).toBeNull() + }) + it('does not approve a reviewed quote while a signer confirmation owns the surface',async()=>{ + let busy=false;const f=fixture(()=>busy);await f.send();await f.bridge.review() + const calls=rpc.call.mock.calls.length;busy=true;await f.bridge.approve() + expect(rpc.call).toHaveBeenCalledTimes(calls);expect(f.bridge.phase.value).toBe('confirm') + expect(f.bridge.error.value).toContain('other native confirmation') + }) + it('drops a status response after the surface closes even if its WindowProxy is reused',async()=>{ + const f=fixture();let release!:(value:unknown)=>void + rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve})) + const work=f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'status',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent) + await vi.waitFor(()=>expect(release).toBeTypeOf('function')) + f.bridge.cancel();release({expires_at:100});await work + expect(f.child.postMessage).not.toHaveBeenCalled() + }) + it('does not dispatch when the active frame disappears during installation verification',async()=>{ + const child={postMessage:vi.fn()};let active=true + const bridge=useRentalPurchaseBridge({appId:()=> 'indeedhub',appUrl:()=> 'https://node.test:7778/browse',frameWindow:()=>active?child as unknown as Window:null}) + await bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:child} as unknown as MessageEvent) + let release!:(value:unknown)=>void;rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve})) + const work=bridge.review();active=false;bridge.cancel();release(installed);await work + expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false) + }) + + it('rejects cross-site rental before preparation or spending',async()=>{ + vi.stubGlobal('location',new URL('https://dashboard.onion')) + const f=fixture();await f.send() + expect(rpc.call).not.toHaveBeenCalled();expect(f.bridge.request.value).toBeNull() + expect(f.child.postMessage.mock.lastCall![0].error).toContain('same LAN hostname') + }) + it('permits same-host ports but rejects separate onions and mixed schemes',()=>{ + expect(supportsRentalPlaybackOrigin('https://node.test:7778','https://node.test')).toBe(true) + expect(supportsRentalPlaybackOrigin('http://node.test:7778','http://node.test')).toBe(true) + expect(supportsRentalPlaybackOrigin('http://app.onion','http://dashboard.onion')).toBe(false) + expect(supportsRentalPlaybackOrigin('http://node.test:7778','https://node.test')).toBe(false) + }) + +}) diff --git a/neode-ui/src/composables/peerCashuPurchase.ts b/neode-ui/src/composables/peerCashuPurchase.ts new file mode 100644 index 00000000..aa4dc059 --- /dev/null +++ b/neode-ui/src/composables/peerCashuPurchase.ts @@ -0,0 +1,51 @@ +/** Supplemental UI recovery marker; immutable terms and settlement live on the node. */ +export type CashuQuote = { network: 'mainnet' | 'testnet'; mint_url: string; state: 'confirmation_required'; operation_id: string; envelope_sha256: string; gross_token_sats: number; seller_net_sats: number; wallet_debit_sats: number; expires_at: number } +export type CashuAttempt = { version: 1; peer: string; contentId: string; quote: CashuQuote; dispatched: boolean } +export function validCashuIdentity(value: { network?: unknown; mint_url?: unknown }): boolean { + if (value.network !== 'mainnet' && value.network !== 'testnet') return false + if (typeof value.mint_url !== 'string' || value.mint_url.length > 2048) return false + try { const url = new URL(value.mint_url); return ['http:', 'https:'].includes(url.protocol) && Boolean(url.hostname) && !url.username && !url.password && !url.hash } catch { return false } +} +export function parseCashuQuote(value: unknown): CashuQuote { + const v = value as Partial | null + if (!v || !validCashuIdentity(v) || v.state !== 'confirmation_required' || !/^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(v.operation_id || '') + || !/^[0-9a-f]{64}$/.test(v.envelope_sha256 || '') + || ![v.gross_token_sats, v.seller_net_sats, v.wallet_debit_sats, v.expires_at].every(n => Number.isSafeInteger(n) && Number(n) > 0) + || v.wallet_debit_sats! < v.gross_token_sats! || v.gross_token_sats! < v.seller_net_sats!) throw new Error('The node returned an invalid payment quote. No new payment was confirmed.') + return v as CashuQuote +} +export function cashuAttemptKey(peer: string, id: string) { return `peer-file-cashu:${encodeURIComponent(peer)}:${encodeURIComponent(id)}` } +export function readCashuAttempt(peer: string, id: string): CashuAttempt | null { + const raw = localStorage.getItem(cashuAttemptKey(peer, id)); if (!raw) return null + const v = JSON.parse(raw) as CashuAttempt + if (v.version !== 1 || v.peer !== peer || v.contentId !== id || typeof v.dispatched !== 'boolean') throw new Error('Saved Cashu purchase needs recovery; do not pay again.') + parseCashuQuote(v.quote); return v +} +export function keepCashuAttempt(peer: string, id: string, quote: CashuQuote, dispatched: boolean) { + parseCashuQuote(quote) + const old = readCashuAttempt(peer, id) + if (old && (old.quote.operation_id !== quote.operation_id || old.quote.envelope_sha256 !== quote.envelope_sha256 || old.quote.wallet_debit_sats !== quote.wallet_debit_sats || old.quote.network !== quote.network || old.quote.mint_url !== quote.mint_url)) throw new Error('Recover or cancel the original purchase before replacing it.') + localStorage.setItem(cashuAttemptKey(peer, id), JSON.stringify({ version: 1, peer, contentId: id, quote, dispatched })) +} +export function clearCashuAttempt(peer: string, id: string) { localStorage.removeItem(cashuAttemptKey(peer, id)) } + +/** Keep one bounded private browser copy before replacing an unreadable marker. + * The caller invokes this only after a valid node recovery response, never on + * network failure or to authorize fresh spending. */ +export function archiveMalformedCashuAttempt(peer: string, id: string) { + try { readCashuAttempt(peer, id); return } catch { /* preserve before replacement */ } + const key = cashuAttemptKey(peer, id) + const raw = localStorage.getItem(key) + if (raw === null) return + if (new TextEncoder().encode(raw).byteLength > 65536) throw new Error('The saved recovery marker is too large to archive safely. It remains intact; no new payment was confirmed.') + const archiveKey = `${key}:unreadable` + const previous = localStorage.getItem(archiveKey) + if (previous !== null && previous !== raw) throw new Error('An earlier recovery marker is already archived. Original records remain intact; no new payment was confirmed.') + localStorage.setItem(archiveKey, raw) + localStorage.removeItem(key) +} +export function keepAuthoritativeCashuQuote(peer: string, id: string, quote: CashuQuote) { + parseCashuQuote(quote) + archiveMalformedCashuAttempt(peer, id) + keepCashuAttempt(peer, id, quote, false) +} diff --git a/neode-ui/src/composables/useMediaRegistrationBridge.ts b/neode-ui/src/composables/useMediaRegistrationBridge.ts new file mode 100644 index 00000000..57ff64be --- /dev/null +++ b/neode-ui/src/composables/useMediaRegistrationBridge.ts @@ -0,0 +1,225 @@ +import { ref, shallowRef } from 'vue' +import { rpcClient } from '@/api/rpc-client' +import { appPortIsGateFronted } from '@/views/appSession/appSessionConfig' + +export const REGISTRATION_SCOPE = 'archipelago.media-registration.approval.v1' +export interface RegistrationIntent { + version: 1; requestId: string; nonce: string; appAudience: string; nodeDid: string + producer: string; projectId: string; priceSats: number; viewingSeconds: number + createdAt: number; expiresAt: number +} +export interface CloudSelection { relative_path: string; payment_methods: string[] } +export interface RegistrationRequest { intent: RegistrationIntent; selection?: CloudSelection; resolution?: boolean } +interface SavedApproval { version: 1; intent: RegistrationIntent; selection: CloudSelection; approvalId: string; event: Record } +const approvalKey = (intent: RegistrationIntent) => `archipelago:media-approval:${intent.requestId}` +function savedApproval(intent: RegistrationIntent): SavedApproval | null { + const raw = localStorage.getItem(approvalKey(intent)) + if (raw === null) return null + if (raw.length > 32768) throw new Error('Saved Cloud approval is damaged. Preserve this operation for recovery.') + const saved = JSON.parse(raw) as SavedApproval + if (saved.version !== 1 || !exact(saved.intent, intent) || !saved.selection || !saved.approvalId || !saved.event) { + throw new Error('Saved Cloud approval differs from this operation. It has not been replaced.') + } + return saved +} +interface InstallationContext { appId: string; appAudience: string; nodeDid: string; appOrigins: string[] } +interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean } +interface Pending { generation: number; mode?: 'resolve'; source: Window; origin: string; requestId: string; intent: RegistrationIntent; selection?: CloudSelection; approvalId?: string; approvedEvent?: Record } +export function approvalContent(intent: RegistrationIntent, selection: CloudSelection) { + return { action: 'Register this Cloud video for an IndeeHub project', scope: REGISTRATION_SCOPE, + intent, selection: { cloudFile: selection.relative_path, paymentMethods: selection.payment_methods } } +} +function exact(left: unknown, right: unknown): boolean { + if (left === right) return true + if (!left || !right || typeof left !== 'object' || typeof right !== 'object') return false + if (Array.isArray(left) || Array.isArray(right)) return Array.isArray(left) && Array.isArray(right) + && left.length === right.length && left.every((v, i) => exact(v, right[i])) + const a = left as Record, b = right as Record + return Object.keys(a).length === Object.keys(b).length && Object.keys(a).every(k => Object.prototype.hasOwnProperty.call(b, k) && exact(a[k], b[k])) +} +function validatedIntent(value: unknown): RegistrationIntent { + const intent = value as RegistrationIntent + if (!intent || intent.version !== 1 || !/^[0-9a-f-]{36}$/.test(intent.requestId) + || !/^[0-9a-f]{64}$/.test(intent.producer) || !/^[0-9a-f]{64}$/.test(intent.nonce) + || typeof intent.nodeDid !== 'string' || !intent.nodeDid.startsWith('did:key:') + || typeof intent.appAudience !== 'string' || !intent.appAudience || intent.appAudience.length > 128 + || typeof intent.projectId !== 'string' || !intent.projectId || intent.projectId.length > 128 + || !Number.isSafeInteger(intent.priceSats) || intent.priceSats < 0 + || !Number.isSafeInteger(intent.viewingSeconds) || intent.viewingSeconds < 1 + || !Number.isSafeInteger(intent.createdAt) || !Number.isSafeInteger(intent.expiresAt) + || intent.expiresAt <= intent.createdAt || intent.expiresAt - intent.createdAt > 600) throw new Error('Invalid node registration intent.') + return structuredClone(intent) +} +export function installedOriginMatches(actual: string, expected: string): boolean { + try { + const a = new URL(actual), e = new URL(expected) + if (a.origin === e.origin) return true + // Runtime interface scans may report loopback. Only map that exact configured + // scheme/port to the dashboard host, never to an arbitrary app-supplied host. + const sameNode = a.hostname === window.location.hostname + const host = ['localhost', '127.0.0.1', '[::1]'].includes(e.hostname) || a.hostname === e.hostname + const scheme = a.protocol === e.protocol || (a.protocol === 'https:' && e.protocol === 'http:' + && window.location.protocol === 'https:' && appPortIsGateFronted('indeedhub', a.port)) + return host && sameNode && scheme && a.port === e.port + } catch { return false } +} +export function useMediaRegistrationBridge(context: FrameContext) { + const request = shallowRef(null) + const phase = ref<'select' | 'resolve' | 'signing' | 'preparing'>('select') + const error = ref('') + let pending: Pending | null = null, disposed = false, generation = 0, validating = 0 + function current(item: Pending): boolean { + if (disposed || item.generation !== generation || item.source !== context.frameWindow() || context.appId() !== 'indeedhub') return false + try { return new URL(context.appUrl(), window.location.origin).origin === item.origin } catch { return false } + } + async function validateInstallation(item: Pending) { + const installed = await rpcClient.call({ method: 'media.registration.context', params: {} }) + if (!current(item)) throw new Error('The app frame changed. Resume from the current app.') + if (installed.appId !== 'indeedhub' || installed.appAudience !== item.intent.appAudience + || installed.nodeDid !== item.intent.nodeDid || !Array.isArray(installed.appOrigins) + || !installed.appOrigins.some(expected => installedOriginMatches(item.origin, expected))) { + throw new Error('This request does not match the installed IndeeHub identity and browser origin.') + } + } + function reply(item: Pending, result?: unknown, failure?: string) { + if (!current(item)) return + item.source.postMessage({ type: 'archipelago-media-registration-response', id: item.requestId, + ...(failure ? { error: failure } : { result }) }, item.origin) + } + function cancel() { + if (pending) reply(pending, undefined, phase.value === 'preparing' + ? 'Preparation may still be running. Resume this same registration.' : 'Cloud selection cancelled.') + generation++ + pending = null; request.value = null; error.value = ''; phase.value = 'select' + } + function approve(selection: CloudSelection) { + if (!pending || phase.value !== 'select' || !current(pending)) return + if (!selection.relative_path || selection.relative_path.startsWith('/') + || selection.relative_path.split('/').some(p => !p || p === '.' || p === '..') + || !/\.(mp4|m4v|webm|mov)$/i.test(selection.relative_path) + || !exact(selection.payment_methods, ['cashu'])) { error.value = 'Choose a supported Cloud video.'; return } + try { + const old = savedApproval(pending.intent) + if (old && !exact(old.selection, selection)) throw new Error('This operation already approved another file. Resume its original selection.') + const saved: SavedApproval = old ?? { version: 1, intent: pending.intent, selection: structuredClone(selection), + approvalId: crypto.randomUUID(), event: { kind: 27236, created_at: Math.floor(Date.now() / 1000), + tags: [['d', REGISTRATION_SCOPE]], content: JSON.stringify(approvalContent(pending.intent, selection), null, 2) } } + // Persist owner approval before replying. Storage failure cannot silently + // turn a later retry into a newly approved file or a replacement operation. + localStorage.setItem(approvalKey(pending.intent), JSON.stringify(saved)) + pending.selection = saved.selection; pending.approvalId = saved.approvalId; pending.approvedEvent = saved.event + request.value = { intent: pending.intent, selection: saved.selection }; phase.value = 'signing' + reply(pending, { selection: saved.selection, approvalId: saved.approvalId, event: saved.event }) + } catch (cause) { error.value = cause instanceof Error ? cause.message : 'Cloud approval could not be saved.' } + } + function approveResolution() { + if (!pending || pending.mode !== 'resolve' || phase.value !== 'resolve' || !current(pending)) return + const approved = { intent: pending.intent, approvalId: crypto.randomUUID(), event: { + kind: 27237, created_at: Math.floor(Date.now() / 1000), tags: [['d', 'archipelago.media-registration.resolution.v1']], + content: JSON.stringify({ action: 'Recover prepared video or retire this expired incomplete registration', + scope: 'archipelago.media-registration.resolution.v1', intent: pending.intent }, null, 2), + } } + try { + localStorage.setItem(approvalKey(pending.intent) + ':resolution', JSON.stringify(approved)) + pending.approvalId = approved.approvalId; pending.approvedEvent = approved.event; phase.value = 'signing' + reply(pending, { approvalId: approved.approvalId, event: approved.event }) + } catch (cause) { error.value = cause instanceof Error ? cause.message : 'Resolution approval could not be saved.' } + } + async function handle(event: MessageEvent) { + if (disposed || context.appId() !== 'indeedhub' || event.source !== context.frameWindow()) return + let origin: string + try { origin = new URL(context.appUrl(), window.location.origin).origin } catch { return } + if (event.origin !== origin || !event.data || event.data.type !== 'archipelago-media-registration-request') return + const source = event.source as Window + const id = event.data.id + if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return + if (context.consentBusy?.()) { source.postMessage({ type: 'archipelago-media-registration-response', id, error: 'Finish the other native confirmation first.' }, origin); return } + let size = Infinity + try { size = JSON.stringify(event.data).length } catch { return } + if (size > 32768) return + const item: Pending = { generation, source, origin, requestId: id, intent: event.data.intent } + validating++ + try { + if (event.data.action === 'complete') { + item.intent = validatedIntent(event.data.intent) + if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) { + throw new Error('Wait for this registration to finish before clearing its saved approval.') + } + // App sends this only after its authenticated backend confirms receipt + // consumption. Exact-scope removal is idempotent if its reply is lost. + savedApproval(item.intent) + localStorage.removeItem(approvalKey(item.intent)) + localStorage.removeItem(approvalKey(item.intent) + ':resolution') + if (pending && exact(pending.intent, item.intent)) { pending = null; request.value = null; phase.value = 'select' } + reply(item, { completed: true, requestId: item.intent.requestId }); return + } + if (event.data.action === 'resolve') { + item.intent = validatedIntent(event.data.intent); item.mode = 'resolve' + if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) throw new Error('Wait for the current registration operation.') + await validateInstallation(item) + if (pending && (phase.value === 'preparing' || !exact(pending.intent, item.intent))) throw new Error('Wait for the current registration operation.') + const raw = localStorage.getItem(approvalKey(item.intent) + ':resolution') + let saved: { intent: RegistrationIntent; approvalId: string; event: Record } | null = null + if (raw !== null) { + if (raw.length > 32768) throw new Error('Saved resolution is damaged; preserve the operation.') + saved = JSON.parse(raw) + if (!saved || !exact(saved.intent, item.intent) || !saved.approvalId || !saved.event) throw new Error('Saved resolution changed the original intent.') + } + pending = item; request.value = { intent: item.intent, resolution: true }; error.value = '' + if (saved) { + item.approvalId = saved.approvalId; item.approvedEvent = saved.event; phase.value = 'signing' + reply(item, { approvalId: saved.approvalId, event: saved.event }) + } else { phase.value = 'resolve' } + return + } + if (event.data.action === 'select' || event.data.action === 'resume') { + if (pending && (phase.value !== 'signing' || !exact(pending.intent, event.data.intent))) { + throw new Error('Finish or cancel the current Cloud registration first.') + } + item.intent = validatedIntent(event.data.intent) + const saved = savedApproval(item.intent) + if (!saved && event.data.action === 'resume') throw new Error('The original owner approval is unavailable. Do not replace this pending operation.') + if (!saved && item.intent.expiresAt <= Math.floor(Date.now() / 1000)) throw new Error('This registration intent expired. Refresh its terms before selecting a new video.') + // Verify the installer-owned scope before displaying any Cloud data. + // This matters for the standalone broker, whose app name is caller-supplied. + await validateInstallation(item) + if (pending && (phase.value !== 'signing' || !exact(pending.intent, event.data.intent))) { + throw new Error('Finish or cancel the current Cloud registration first.') + } + pending = item; error.value = '' + if (saved) { + item.selection = saved.selection; item.approvalId = saved.approvalId; item.approvedEvent = saved.event + request.value = { intent: item.intent, selection: saved.selection }; phase.value = 'signing' + reply(item, { selection: saved.selection, approvalId: saved.approvalId, event: saved.event }) + } else { request.value = { intent: item.intent }; phase.value = 'select' } + return + } + const resolving = event.data.action === 'resolve-submit' + if ((!resolving && event.data.action !== 'submit') || !pending || phase.value !== 'signing' + || resolving !== (pending.mode === 'resolve') || !current(pending) || event.data.approvalId !== pending.approvalId + || !exact(event.data.intent, pending.intent) || (!resolving && !exact(event.data.selection, pending.selection))) { + throw new Error('Approve this exact Cloud file and project before registering it.') + } + const approved = pending + const signed = event.data.producerEvent + if (!signed || signed.pubkey !== approved.intent.producer + || !exact({ kind: signed.kind, created_at: signed.created_at, tags: signed.tags, content: signed.content }, approved.approvedEvent)) { + throw new Error('The producer signature does not match the original owner-approved event.') + } + // The producer event is verified by the node. The host never claims that + // request-body identity alone is an authenticated producer. + phase.value = 'preparing'; error.value = ''; approved.requestId = id + const result = await rpcClient.call({ method: resolving ? 'media.registration.resolve' : 'media.registration.prepare', params: { + intent: approved.intent, ...(!resolving ? { selection: approved.selection } : {}), producerEvent: event.data.producerEvent, + }, timeout: 600_000 }) + if (pending !== approved) return + reply(approved, result); pending = null; request.value = null; phase.value = 'select' + } catch (cause) { + const message = cause instanceof Error ? cause.message : 'Registration was not confirmed. Resume the same operation.' + reply(item, undefined, message) + if (pending?.requestId === id) { error.value = message; phase.value = 'signing' } + } finally { validating-- } + } + function dispose() { cancel(); disposed = true } + return { isBusy: () => pending !== null || validating > 0, request, phase, error, handle, approve, approveResolution, cancel, dispose } +} diff --git a/neode-ui/src/composables/useRentalPurchaseBridge.ts b/neode-ui/src/composables/useRentalPurchaseBridge.ts new file mode 100644 index 00000000..c147d7ff --- /dev/null +++ b/neode-ui/src/composables/useRentalPurchaseBridge.ts @@ -0,0 +1,126 @@ +import { ref, shallowRef } from 'vue' +import { validCashuIdentity } from './peerCashuPurchase' +import { rpcClient } from '@/api/rpc-client' +import { installedOriginMatches } from './useMediaRegistrationBridge' +interface FrameContext { appId: () => string; appUrl: () => string; frameWindow: () => Window | null; consentBusy?: () => boolean } +export interface RentalOffer { title: string; terms: { nodeDid: string; contentId: string; sha256: string; priceSats: number; viewingSeconds: number } } +interface Quote { network: 'mainnet' | 'testnet'; mint_url: string; state: string; operation_id: string; envelope_sha256: string; wallet_debit_sats: number; gross_token_sats: number; seller_net_sats: number; expires_at: number; seller_onion: string } +interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; quote?: Quote } +export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin: string): boolean { + try { + const app = new URL(appOrigin), dashboard = new URL(dashboardOrigin) + // Host-only SameSite=Lax owner cookies support same-host app ports, but + // not an app on a separate onion/domain or a mixed-scheme frame. + return ['http:', 'https:'].includes(app.protocol) && app.protocol === dashboard.protocol + && app.hostname === dashboard.hostname + } catch { return false } +} +export function useRentalPurchaseBridge(context: FrameContext) { + const request = shallowRef(null), quote = shallowRef(null) + const phase = ref<'review' | 'loading' | 'confirm' | 'paying'>('review'), error = ref('') + let pending: Pending | null = null, disposed = false, generation = 0 + const frameOrigin = () => { try { return new URL(context.appUrl(), window.location.origin).origin } catch { return '' } } + const current = (item: Pending) => !disposed && pending === item && context.appId() === 'indeedhub' + && context.frameWindow() === item.source && frameOrigin() === item.origin + function reply(item: Pending, result?: unknown, failure?: string) { + if (current(item)) item.source.postMessage({ type: 'archipelago-rental-response', id: item.id, + ...(failure ? { error: failure } : { result }) }, item.origin) + } + function cancel() { + if (pending) reply(pending, undefined, phase.value === 'paying' + ? 'Payment may be processing. Reopen this title to recover the same purchase.' : 'Rental confirmation closed. No new payment was approved.') + generation++ + pending = null; request.value = null; quote.value = null; error.value = ''; phase.value = 'review' + } + async function installed(item: Pending) { + const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} }) + if (!current(item) || value.appId !== 'indeedhub' || !value.appOrigins.some(origin => installedOriginMatches(item.origin, origin))) throw new Error('The installed app or its frame changed.') + } + async function run(confirm: boolean) { + const item = pending + if (!item || !current(item) || (confirm ? phase.value !== 'confirm' : phase.value !== 'review')) return + if (!supportsRentalPlaybackOrigin(item.origin, window.location.origin)) { error.value = 'Open the app from the same node dashboard address before paying.'; return } + if (context.consentBusy?.()) { error.value = 'Finish the other native confirmation first.'; return } + phase.value = confirm ? 'paying' : 'loading'; error.value = '' + try { + await installed(item) + if (!current(item)) return + if (context.consentBusy?.()) throw new Error('Finish the other native confirmation first.') + const terms = item.offer.terms + const result = await rpcClient.call({ method: 'content.rental-purchase', params: { + seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256, + expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds, + max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER, + ...(confirm ? { consent: { operation_id: item.quote!.operation_id, + envelope_sha256: item.quote!.envelope_sha256, wallet_debit_sats: item.quote!.wallet_debit_sats } } : {}), + }, timeout: 120000 }) + if (!current(item)) return + if (result.state === 'confirmation_required') { + if (!validCashuIdentity(result) || !Number.isSafeInteger(result.wallet_debit_sats) || result.wallet_debit_sats < terms.priceSats + || !/^[0-9a-f]{64}$/.test(result.envelope_sha256) || !result.operation_id) throw new Error('Invalid payment confirmation. No payment approved.') + item.quote = result; quote.value = result; phase.value = 'confirm'; return + } + if (result.state !== 'entitled') throw new Error(result.state === 'cancelled_unspent' ? 'This purchase was cancelled without spending.' : 'Purchase has not completed. Reopen this title to recover it.') + const playback = await rpcClient.call<{ playback_url: string; expires_at: number | null }>({ method: 'content.playback-handle', params: { purchase_id: result.operation_id } }) + if (!current(item)) return + if (!/^\/api\/rental-playback\/[0-9a-f]{64}$/.test(playback.playback_url)) throw new Error('Invalid playback address.') + reply(item, { ...playback, playback_url: new URL(playback.playback_url, window.location.origin).href, operation_id: result.operation_id }) + pending = null; request.value = null; quote.value = null + } catch (cause) { + if (current(item)) { error.value = cause instanceof Error ? cause.message : 'Could not complete this purchase. Retry to recover its original result.'; phase.value = 'review' } + } + } + async function cancelUnpaid() { + const item = pending + if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading') return + phase.value = 'loading'; error.value = '' + try { + await installed(item) + if (!current(item)) return + const result = await rpcClient.call<{ state: string }>({ method: 'content.cancel-purchase', + params: { onion: item.quote.seller_onion, operation_id: item.quote.operation_id }, timeout: 120000 }) + if (!current(item)) return + if (result.state !== 'cancelled_unspent') throw new Error('Cancellation has not been confirmed. Recover this original purchase before trying another payment.') + item.quote = undefined; quote.value = null; phase.value = 'review' + error.value = 'The unpaid quote was cancelled. Check purchase to request fresh terms.' + } catch (cause) { if (current(item)) { error.value = String(cause); phase.value = 'review' } } + } + async function handle(event: MessageEvent) { + if (disposed || context.appId() !== 'indeedhub' || event.source !== context.frameWindow() + || event.origin !== frameOrigin() || event.data?.type !== 'archipelago-rental-request') return + const { id, offer } = event.data + if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return + if (event.data.action === 'status') { + const source = event.source as Window, origin = event.origin, handle = event.data.handle, scope = generation + const selected = () => !disposed && generation === scope && context.appId() === 'indeedhub' && source === context.frameWindow() && frameOrigin() === origin + if (typeof handle !== 'string' || !/^[0-9a-f]{64}$/.test(handle)) return + try { + const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} }) + if (!selected()) return + if (value.appId !== 'indeedhub' || !value.appOrigins.some(expected => installedOriginMatches(origin, expected))) throw new Error('Installed app changed.') + const result = await rpcClient.call<{ expires_at: number | null }>({ method: 'content.playback-status', params: { handle } }) + if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, result }, origin) + } catch (cause) { + if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, error: String(cause) }, origin) + } + return + } + if (!supportsRentalPlaybackOrigin(event.origin, window.location.origin)) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Open IndeeHub from this node’s dashboard using the same LAN hostname and HTTP/HTTPS scheme before renting. This app address cannot receive the playback session cookie; no payment was requested.' }, event.origin); return } + if (context.consentBusy?.()) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Finish the other native confirmation first.' }, event.origin); return } + if (pending) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Finish or close the current rental confirmation first.' }, event.origin); return } + const terms = offer?.terms + if (!terms || typeof offer.title !== 'string' || offer.title.length > 240 + || typeof terms.nodeDid !== 'string' || !terms.nodeDid.startsWith('did:key:') + || typeof terms.contentId !== 'string' || !/^registered_[a-zA-Z0-9_-]+$/.test(terms.contentId) + || !/^[0-9a-f]{64}$/.test(terms.sha256) || !Number.isSafeInteger(terms.priceSats) || terms.priceSats < 0 + || !Number.isSafeInteger(terms.viewingSeconds) || terms.viewingSeconds < 1) { + (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Invalid rental terms.' }, event.origin); return + } + const item: Pending = { source: event.source as Window, origin: event.origin, id, offer: structuredClone(offer) } + pending = item + try { await installed(item); if (current(item)) { request.value = item.offer; phase.value = 'review' } } + catch (cause) { reply(item, undefined, String(cause)); if (pending === item) pending = null } + } + return { isBusy: () => pending !== null, request, quote, phase, error, handle, cancelUnpaid, review: () => run(false), approve: () => run(true), cancel, + dispose: () => { cancel(); disposed = true } } +} diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 4a7cb722..958b38e6 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -14,6 +14,8 @@ import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/us import type { AppCredential, AppCredentialsResponse } from '@/types/api' import { resolveAppCredentials } from '@/views/apps/appCredentials' import { useNostrBridge } from '@/views/appSession/useNostrBridge' +import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge' +import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge' import type { SelectedIdentity } from '@/views/appSession/useAppIdentity' /** @@ -507,6 +509,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { function close() { bridge.cancelPending() + registrationBridge.cancel(); rentalBridge.cancel() const toRestore = previousActiveElement previousActiveElement = null isOpen.value = false @@ -514,7 +517,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { title.value = '' // Explicitly remove NIP-07 listener as safety net — if user navigates away // without close() triggering the isOpen watcher, the listener would leak - window.removeEventListener('message', handleNostrRequest) + window.removeEventListener('message', handleNativeRequest) if (toRestore && typeof toRestore.focus === 'function') { requestAnimationFrame(() => { toRestore.focus() @@ -533,6 +536,17 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { return { ...stored, name: typeof stored.name === 'string' ? stored.name : stored.id } } catch { return null } } + const nativeIdentityBusy = ref(false) + const registrationBridge = useMediaRegistrationBridge({ + consentBusy: (): boolean => rentalBridge.isBusy(), + appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app', + appUrl: () => url.value, frameWindow: () => isOpen.value ? nostrFrame : null, + }) + const rentalBridge = useRentalPurchaseBridge({ + consentBusy: (): boolean => bridge.showConsent.value || nativeIdentityBusy.value || registrationBridge.isBusy(), + appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app', + appUrl: () => url.value, frameWindow: () => isOpen.value ? nostrFrame : null, + }) const bridge = useNostrBridge(overlayIdentity, { appId: () => resolveAppIdFromUrl(url.value) || inferAppIdFromTitle(title.value) || 'unknown-app', appName: () => title.value || 'App', @@ -542,25 +556,33 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { const { handleNostrRequest, showConsent, consentRequest, consentPhase, consentError, approveConsent, denyConsent } = bridge + function handleNativeRequest(event: MessageEvent) { + handleNostrRequest(event) + void registrationBridge.handle(event); void rentalBridge.handle(event) + } + function setNostrFrame(frame: Window | null) { if (frame === nostrFrame) return bridge.cancelPending() + registrationBridge.cancel(); rentalBridge.cancel() nostrFrame = frame } - watch(url, () => bridge.cancelPending(), { flush: 'sync' }) + watch(url, () => { bridge.cancelPending(); registrationBridge.cancel(); rentalBridge.cancel() }, { flush: 'sync' }) onScopeDispose(() => { - window.removeEventListener('message', handleNostrRequest) + window.removeEventListener('message', handleNativeRequest) bridge.dispose() + registrationBridge.dispose(); rentalBridge.dispose() nostrFrame = null }) // Listen for NIP-07 requests only while an app is open watch(isOpen, (open) => { if (open) { - window.addEventListener('message', handleNostrRequest) + window.addEventListener('message', handleNativeRequest) } else { - window.removeEventListener('message', handleNostrRequest) + window.removeEventListener('message', handleNativeRequest) bridge.cancelPending() + registrationBridge.cancel(); rentalBridge.cancel() } }, { flush: 'sync' }) @@ -585,6 +607,15 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { consentError, approveConsent, denyConsent, + setNativeIdentityBusy: (busy: boolean) => { nativeIdentityBusy.value = busy }, + rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase, + cancelUnpaidRental: rentalBridge.cancelUnpaid, rentalError: rentalBridge.error, reviewRental: rentalBridge.review, approveRental: rentalBridge.approve, cancelRental: rentalBridge.cancel, + registrationRequest: registrationBridge.request, + registrationPhase: registrationBridge.phase, + registrationError: registrationBridge.error, + approveRegistration: registrationBridge.approve, + approveRegistrationResolution: registrationBridge.approveResolution, + cancelRegistration: registrationBridge.cancel, setNostrFrame, } }) diff --git a/neode-ui/src/views/AppSession.vue b/neode-ui/src/views/AppSession.vue index 80a3cc41..b624a029 100644 --- a/neode-ui/src/views/AppSession.vue +++ b/neode-ui/src/views/AppSession.vue @@ -91,6 +91,10 @@ + + frameRef.value?.iframeRef ?? null) const mediaBridge = useAppMediaBridge(appId, appUrl, iframeRef, computed(() => !props.suspended)) +const registrationBridge = useMediaRegistrationBridge({ + consentBusy: (): boolean => rentalBridge.isBusy(), + appId: () => appId.value, appUrl: () => appUrl.value, + frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null, +}) +const rentalBridge = useRentalPurchaseBridge({ + consentBusy: (): boolean => nostrBridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(), + appId: () => appId.value, appUrl: () => appUrl.value, + frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null, +}) const identity = useAppIdentity(appId, iframeRef, showIdentityPicker) const nostrBridge = useNostrBridge(identity.getStoredIdentity, { appId: () => appId.value, appName: () => appTitle.value, appUrl: () => appUrl.value, - frameWindow: () => iframeRef.value?.contentWindow ?? null, + frameWindow: () => props.suspended ? null : iframeRef.value?.contentWindow ?? null, }) // An actual destination change invalidates consent queued for the previous app page. +watch(() => props.suspended, suspended => { if (suspended) { nostrBridge.cancelPending(); registrationBridge.cancel(); rentalBridge.cancel() } }, { flush: 'sync' }) + watch(appUrl, () => { loadedAppUrl.value = '' slowLoad.value = false nostrBridge.cancelPending() -}) + registrationBridge.cancel(); rentalBridge.cancel() +}, { flush: 'sync' }) // --- Display mode --- @@ -514,6 +535,7 @@ function handleBackdropClick() { function closeSession() { nostrBridge.cancelPending() + registrationBridge.cancel(); rentalBridge.cancel() if (document.fullscreenElement) document.exitFullscreen().catch(() => {}) if (isInlinePanel.value) emit('close') else closeRouteSession() @@ -543,6 +565,8 @@ function onFullscreenChange() { function onMessage(e: MessageEvent) { if (e.source !== iframeRef.value?.contentWindow) return mediaBridge.handle(e) + if (props.suspended) return + void registrationBridge.handle(e); void rentalBridge.handle(e) if (e.data?.type === 'nostr-request') nostrBridge.handleNostrRequest(e) if (e.data?.type === 'archipelago:identity:request') identity.handleIdentityRequest(e.data?.force === true) if (e.data?.type === 'archipelago:media:playing') screensaverStore.suppress(screensaverReason.value) @@ -605,6 +629,7 @@ onMounted(() => { onBeforeUnmount(() => { nostrBridge.dispose() + registrationBridge.dispose(); rentalBridge.dispose() if (loadTimeoutId) clearTimeout(loadTimeoutId) if (autoRetryId) clearTimeout(autoRetryId) if (iframeCheckId) clearTimeout(iframeCheckId) diff --git a/neode-ui/src/views/NostrTabSigner.vue b/neode-ui/src/views/NostrTabSigner.vue index 4f1d5f42..67e62fc6 100644 --- a/neode-ui/src/views/NostrTabSigner.vue +++ b/neode-ui/src/views/NostrTabSigner.vue @@ -6,6 +6,10 @@ @select="onIdentitySelected" @cancel="cancelIdentitySelection" /> + + { hideTimer = null - if (!showIdentityPicker.value && !bridge.showConsent.value) { + if (!showIdentityPicker.value && !bridge.showConsent.value && !registrationBridge.request.value && !rentalBridge.request.value) { parentPost({ type: 'archipelago:signer-hide' }) } } @@ -89,6 +97,17 @@ function sendIdentity(identity: SelectedIdentity) { parentPost({ type: 'archipelago:signer-identity', identity: publicIdentity }) } +const registrationBridge = useMediaRegistrationBridge({ + consentBusy: (): boolean => rentalBridge.isBusy(), + appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent, +}) +const rentalBridge = useRentalPurchaseBridge({ + consentBusy: (): boolean => bridge.showConsent.value || showIdentityPicker.value || registrationBridge.isBusy(), + appId: () => appId.value, appUrl: () => appOrigin.value, frameWindow: () => window.parent, +}) +watch(rentalBridge.request, request => { if (request) showSigner(); else hideSigner() }) +watch(registrationBridge.request, request => { if (request) showSigner(); else hideSigner() }) +watch([appId, appOrigin], () => { registrationBridge.cancel(); rentalBridge.cancel() }, { flush: 'sync' }) const bridge = useNostrBridge(getStoredIdentity, { appId: () => appId.value, appName: () => appName.value, @@ -185,6 +204,13 @@ function onMessage(event: MessageEvent) { return } + if (data.type === 'archipelago-rental-request' && appId.value && event.origin === appOrigin.value) { + void rentalBridge.handle(event); return + } + if (data.type === 'archipelago-media-registration-request' && appId.value && event.origin === appOrigin.value) { + void registrationBridge.handle(event) + return + } if (data.type !== 'nostr-request' || !appId.value || event.origin !== appOrigin.value) return if (!getStoredIdentity()) { queuedRequests.push(event) @@ -208,6 +234,7 @@ onMounted(() => { window.parent.postMessage({ type: 'archipelago:signer-ready' }, '*') }) onBeforeUnmount(() => { + registrationBridge.dispose(); rentalBridge.dispose() if (hideTimer !== null) clearTimeout(hideTimer) window.removeEventListener('message', onMessage) document.documentElement.classList.remove('nostr-signer-route') diff --git a/neode-ui/src/views/PeerFiles.vue b/neode-ui/src/views/PeerFiles.vue index 7c6bc2f7..184f7065 100644 --- a/neode-ui/src/views/PeerFiles.vue +++ b/neode-ui/src/views/PeerFiles.vue @@ -377,9 +377,10 @@ class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4" @click.self="closePayModal" > -
+
+ >{{ paymentActionBusy ? 'Working…' : ecashPlan.chosen === 'cashu' && !cashuQuote ? 'Check original purchase' : 'Pay' }}
@@ -595,6 +603,7 @@ '})); + const page=await context.newPage();page.on('pageerror',e=>{errors.push(e.message);console.error('FIXTURE PAGE ERROR',e.message)});page.on('response',r=>{if(r.status()>=400)console.error('FIXTURE RESPONSE',r.status(),new URL(r.url()).pathname)});await page.goto(origin+'/native-payment-fixture',{waitUntil:'domcontentloaded',timeout:60000});await page.waitForFunction(()=>window.fixtureReady,undefined,{timeout:30000}); + for(const [kind,phase,error] of [['rental','confirm',''],['rental','paying',''],['rental','review','The response was lost. Recover the original purchase without paying again.'],['rental','signer',''],['peer','confirm',''],['peer','paying',''],['peer','confirm','Payment status could not be confirmed. Keep the original receipt and retry recovery.']]){ + await page.evaluate(([k,p,e])=>window.showFixture(k,p,e),[kind,phase,error]); + await page.evaluate(async()=>{await new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve)));await Promise.all(document.getAnimations().filter(a=>a.effect?.getComputedTiming().iterations!==Infinity).map(a=>a.finished.catch(()=>{})))}); + if(kind==='rental'&&phase==='signer'){await expect(page.getByRole('dialog',{name:'Confirm video rental'})).toHaveCount(0);await expect(page.getByRole('dialog')).toHaveCount(1)} + if(phase!=='signer'){await expect(page.getByText('Cashu · Testnet',{exact:false}).locator('visible=true').first()).toBeVisible();await expect(page.getByText('Mint: https://original-mint-with-long-name.example.test/cashu',{exact:true}).locator('visible=true').first()).toBeVisible()} + if(phase!=='signer')await page.getByRole('button',{name:kind==='peer'?(phase==='paying'?'Working…':'Pay'):(phase==='confirm'?'Pay 10 sats':'Check purchase'),exact:true}).scrollIntoViewIfNeeded().catch(()=>{}); + const result=await page.evaluate(()=>({height:innerHeight,overflow:document.documentElement.scrollWidth>innerWidth,buttons:[...document.querySelectorAll('button')].filter(b=>b.getBoundingClientRect().width>0&&getComputedStyle(b).visibility!=='hidden').map(b=>({text:b.textContent.trim(),disabled:b.disabled,x:b.getBoundingClientRect().x,y:b.getBoundingClientRect().y,w:b.getBoundingClientRect().width,h:b.getBoundingClientRect().height}))})); + if(result.overflow)throw Error('horizontal overflow '+width+' '+kind+' '+phase); + const actions=result.buttons.filter(b=>/^(Close|Pay|Pay 10 sats|Working…|Cancel unpaid quote|Back|Check purchase)$/.test(b.text)); + for(const b of actions)if(b.x<0||b.x+b.w>width+1||b.y<24||b.y+b.h>result.height)throw Error('clipped footer '+JSON.stringify({width,kind,phase,b})); + if(phase==='paying'&&actions.some(b=>b.text!=='Close'&&!b.disabled))throw Error('Busy action enabled'); + const screenshot='/tmp/archy-payment-layout-'+width+'-'+kind+'-'+phase+(error?'-error':'')+'.png';await page.screenshot({path:screenshot});fs.chmodSync(screenshot,0o600); + console.log(JSON.stringify({scope:'LOCAL COMPONENT FIXTURE ONLY',width,kind,phase,error:!!error,footerFits:true,buttons:actions})); + } + if(errors.length)throw Error(errors.join('\n'));console.log(JSON.stringify({width,blockedNetworkRequests:blocked,result:'PASS fixture layouts only'}));await context.close(); +}}finally{await browser.close();fs.rmSync(fixturePath,{force:true})}})().catch(e=>{console.error(e.stack);process.exit(1)}); diff --git a/tests/regression/indeehub-provider-hook.py b/tests/regression/indeehub-provider-hook.py new file mode 100644 index 00000000..0f8f85b0 --- /dev/null +++ b/tests/regression/indeehub-provider-hook.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +"""Run real provider hooks only against disposable nginx/index fixtures.""" +from pathlib import Path +import re +import subprocess +import tempfile +import yaml + +root = Path(__file__).resolve().parents[2] +hooks = yaml.safe_load((root / 'apps/indeedhub/manifest.yml').read_text())['app']['hooks']['post_install'] +for mode in ('fresh', 'literal', 'legacy-filter'): + with tempfile.TemporaryDirectory(prefix='indeehub-provider-hook-') as directory: + fixture = Path(directory) + nginx, index = fixture / 'default.conf', fixture / 'index.html' + old_script = '' + index.write_text('' + (old_script if mode == 'literal' else '') + '') + nginx.write_text('server {\n location = /sw.js {\n }\n' + ( + " sub_filter '' '" + old_script + "';\n" if mode == 'legacy-filter' else '') + '}\n') + first = None + for repeat in range(2): + for hook in hooks: + command = hook.get('exec') + if not command or command[0] == 'nginx': + continue + command = [argument.replace('/etc/nginx/conf.d/default.conf', str(nginx)).replace('/usr/share/nginx/html/index.html', str(index)) for argument in command] + subprocess.run(command, check=True, capture_output=True, text=True) + rendered = index.read_text() + nginx.read_text() + assert len(re.findall(r']*nostr-provider', rendered)) == 1, mode + assert rendered.count('location = /nostr-provider.js {') == 1, mode + assert 'tab-signer-v2' not in rendered and 'tab-signer-v4' in rendered, mode + assert 'no-cache, no-store, must-revalidate' in rendered, mode + if first is not None: + assert rendered == first, mode + first = rendered +print('PASS: fresh, literal and legacy sub_filter provider hooks are idempotent; fixtures only')