Integrate recoverable native purchases, registered rentals and explicit payment consent

This commit is contained in:
archipelago
2026-10-06 22:44:06 -04:00
parent e4eae71314
commit 49703d7e88
63 changed files with 8028 additions and 134 deletions
+4 -4
View File
@@ -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#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /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 '<script[^>]*nostr-provider' /usr/share/nginx/html/index.html && ! grep -qE '(sub_filter|<script).*nostr-provider' /etc/nginx/conf.d/default.conf; then sed -i 's#</head>#<script src=\"/nostr-provider.js\"></script></head>#' /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
@@ -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<Response<Body>> {
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))?)
}
}
+6 -6
View File
@@ -416,7 +416,7 @@ impl ApiHandler {
path: &str,
) -> Result<Response<hyper::Body>> {
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<F, Fut>(path: &str, settle: F) -> Response<hyper::Body>
where
F: FnOnce(String, String) -> Fut,
Fut: std::future::Future<Output = Result<bool>>,
Fut: std::future::Future<Output = Result<serde_json::Value>>,
{
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);
+18
View File
@@ -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
}
@@ -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<Body>,
) -> Result<Response<Body>> {
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)?),
))
}
}
@@ -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<Option<(u64, u64)>> {
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::<u64>().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::<u64>()?;
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<u64> {
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<Response<Body>> {
// 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<Response<Body>> {
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::<Instant>),
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());
}
}
@@ -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,
+125
View File
@@ -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<serde_json::Value> {
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<serde_json::Value> {
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);
}
}
@@ -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<AtomicBool>);
impl Drop for RequestCancellation {
fn drop(&mut self) {
self.0.store(true, Ordering::Relaxed);
}
}
fn request_cancellation() -> (RequestCancellation, Arc<AtomicBool>) {
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<ResolveParams> {
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 = &params.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<serde_json::Value> {
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<String> {
let event = &params.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(&params.intent, &params.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(&params, 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<serde_json::Value> {
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<serde_json::Value> {
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,
&params.intent,
&params.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<serde_json::Value> {
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: &params.intent,
selection: &params.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());
}
}
+54 -6
View File
@@ -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<crate::appgate::AppGate>,
endpoint_rate_limiter: EndpointRateLimiter,
response_cache: ResponseCache,
playback_handles: crate::playback_handles::PlaybackHandles,
mesh_service: Arc<tokio::sync::RwLock<Option<crate::mesh::MeshService>>>,
/// 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<StateManager>,
@@ -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));
+74
View File
@@ -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<String>,
) -> 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<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
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,
&params.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<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
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(&params.handle, &session, &context)?;
Ok(serde_json::json!({"expires_at":expires}))
}
}
+216
View File
@@ -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<String>,
max_wallet_debit: u64,
consent: Option<PurchaseConsent>,
}
#[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<serde_json::Value>,
) -> Result<serde_json::Value> {
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,
&params.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,
&params.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<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params.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", &params.operation_id)
.await?
.context("Original payment shape missing")?,
journal
.buyer_plan(&params.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<PurchaseConsent>,
}
impl RpcHandler {
pub(super) async fn handle_content_rental_purchase(
&self,
input: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
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(&params.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,
&params.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,
&params.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")
}
}
}
}
+53 -2
View File
@@ -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<String> {
}
/// 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<bool> {
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<bool> {
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<bool> {
#[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; } }";
@@ -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;
}
@@ -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<String>,
}
/// 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<InstalledAppContext> {
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());
}
}
+137
View File
@@ -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<Offer> {
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<AtomicBool>);
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
}
+369 -9
View File
@@ -39,7 +39,7 @@ fn validate_id(id: &str) -> Result<()> {
);
Ok(())
}
fn canonical_mint(value: &str) -> Result<String> {
pub(crate) fn canonical_mint(value: &str) -> Result<String> {
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<Acceptance> {
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<PathBuf> {
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<Option<crate::content_purchase_protocol::Offer>> {
let value: Option<crate::content_purchase_protocol::Offer> =
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<RetiredOffer> = 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<RetiredOffer> = 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<bool> {
let value: Option<RetiredOffer> = 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<Option<crate::content_purchase_protocol::Envelope>> {
let role = match role {
"buyer" => "envelope-buyer",
"seller" => "envelope-seller",
_ => anyhow::bail!("Invalid envelope role"),
};
let value: Option<crate::content_purchase_protocol::Envelope> = 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<Option<crate::wallet::purchase_plan::PreparedPayment>> {
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<Option<BuyerRecord>> {
let result: Option<BuyerRecord> = 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<SellerRecord> {
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<Receipt> {
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()),
@@ -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<Output = Result<Offer>> + Send;
fn accept(&self, envelope: &Envelope) -> impl Future<Output = Result<Accepted>> + Send;
fn status(&self, envelope: &Envelope) -> impl Future<Output = Result<SellerStatus>> + Send;
fn cancel(&self, envelope: &Envelope) -> impl Future<Output = Result<Cancelled>> + Send;
fn settle(&self, request: &Settlement) -> impl Future<Output = Result<Receipt>> + 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<ReadyPurchase> {
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<ReadyPurchase> {
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
}
@@ -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<crate::content_owned::OwnedItem> {
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<S, E>(
stream: S,
expected_hash: String,
expected_size: u64,
) -> impl futures_util::Stream<Item = std::io::Result<bytes::Bytes>>
where
S: futures_util::Stream<Item = Result<bytes::Bytes, E>>,
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
);
}
}
@@ -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<String> {
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())
}
@@ -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<u64>,
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::<hyper::header::HeaderValue>().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<Contract> {
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<Contract> {
self.offer.contract(&self.fee_plan)
}
pub fn commitment(&self) -> Result<String> {
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<Offer> {
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<Accepted> {
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<Receipt> {
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<SellerStatus> {
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<Cancelled> {
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()
);
}
}
@@ -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<Self> {
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<B: Serialize + Sync, R: DeserializeOwned>(
&self,
route: &str,
body: &B,
) -> Result<R> {
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<Offer> {
#[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<Accepted> {
self.post(protocol::ACCEPT_ROUTE, envelope).await
}
async fn status(&self, envelope: &Envelope) -> Result<SellerStatus> {
self.post(protocol::STATUS_ROUTE, envelope).await
}
async fn cancel(&self, envelope: &Envelope) -> Result<Cancelled> {
self.post(protocol::CANCEL_ROUTE, envelope).await
}
async fn settle(&self, request: &Settlement) -> Result<Receipt> {
self.post(protocol::SETTLE_ROUTE, request).await
}
}
pub(crate) async fn seller_onion_for_did(data_dir: &std::path::Path, did: &str) -> Result<String> {
Ok(
crate::federation::load_unique_payment_peer_by_did(data_dir, did)
.await?
.onion,
)
}
+26
View File
@@ -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<crate::content_purchase_protocol::Offer> {
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
}
+12 -47
View File
@@ -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::<libc::statvfs>::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<u64> {
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,
+1 -1
View File
@@ -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,
@@ -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<FederatedNode> {
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
+18 -5
View File
@@ -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;
+456 -3
View File
@@ -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<Vec<u8>> {
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<Resolution> {
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::<Retirement>(&dir, "retirement.json")? {
retired.verify(intent, identity)?;
dir.sync_all()?;
if let Some(operation) = read_record::<Operation>(&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<Operation> = 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::<Receipt>(&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::<SnapshotRecord>(&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::<std::io::Error>()
.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::<Retirement>(&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>(&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());
}
}
}
}
@@ -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"
}
}
+308
View File
@@ -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<u64>,
}
#[derive(Default)]
pub(crate) struct PlaybackHandles {
entries: Mutex<HashMap<String, Entry>>,
}
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<String> {
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<String> {
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<Binding> {
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<Option<u64>> {
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());
}
}
+143 -6
View File
@@ -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: &registration_pin::RegistrationPin,
prepared: media_registration::PreparedRegistration,
mime_type: String,
) -> Result<Receipt> {
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<serde_json::Value> {
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>(&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()
);
}
}
+419
View File
@@ -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<u64> {
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<u64> {
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<Reservation> {
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<Reservation> {
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<Record> = 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::<std::io::Error>()
.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::<libc::statvfs>::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::<std::io::Error>()
.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::<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);
}
}
+64
View File
@@ -290,6 +290,10 @@ pub async fn load_network(data_dir: &Path) -> Result<EcashNetwork> {
/// 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<AcceptedMints> {
/// 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<i64>,
now: impl Fn() -> i64 + Send + Sync,
plan: Option<&super::purchase_fee_plan::FeePlan>,
) -> Result<String> {
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<String> {
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<String> {
let mut wallet = load_wallet(data_dir).await?;
let mint_url = mint_url.to_string();
@@ -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<u64> {
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
}
+3 -1
View File
@@ -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;
+2 -2
View File
@@ -30,12 +30,12 @@ async fn canonical_lock(data_dir: &Path) -> Result<(PathBuf, Arc<Mutex<()>>)> {
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<WalletMutation> {
pub(crate) async fn guard(data_dir: &Path) -> Result<WalletMutation> {
let (data_dir, lock) = canonical_lock(data_dir).await?;
Ok(WalletMutation {
data_dir,
@@ -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<Vec<String>>,
}
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<crate::content_purchase_protocol::Offer> {
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<crate::content_purchase_protocol::Accepted> {
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::SellerStatus> {
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::Cancelled> {
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<crate::content_purchase::Receipt> {
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());
}
@@ -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<Vec<KeysetPlan>> {
let mut groups: BTreeMap<String, KeysetPlan> = 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<PreparedPayment> {
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(())
}
+72 -1
View File
@@ -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()));
+53 -2
View File
@@ -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.
+89
View File
@@ -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.
@@ -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;
+68 -1
View File
@@ -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,
@@ -181,6 +181,10 @@
</div>
</Transition>
<RentalPurchaseConsent v-if="!store.showConsent && !showIdentityPicker && !store.registrationRequest" :request="store.rentalRequest" :quote="store.rentalQuote" :phase="store.rentalPhase" :error="store.rentalError" @cancel-unpaid="store.cancelUnpaidRental" @review="store.reviewRental" @approve="store.approveRental" @cancel="store.cancelRental" />
<MediaRegistrationConsent v-if="!store.showConsent"
:request="store.registrationRequest" :phase="store.registrationPhase" :error="store.registrationError"
@approve="store.approveRegistration" @resolve="store.approveRegistrationResolution" @cancel="store.cancelRegistration" />
<NostrSignConsent
:show="store.showConsent"
:app-name="store.consentRequest?.appName ?? ''"
@@ -211,6 +215,8 @@
import { ref, computed, watch, onMounted, onBeforeUnmount } from 'vue'
import { useAppLauncherStore } from '@/stores/appLauncher'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import AppLoadingScreen from '@/components/AppLoadingScreen.vue'
import AppSlowLoadNotice from '@/components/AppSlowLoadNotice.vue'
@@ -275,6 +281,7 @@ watch(iframeLoading, (loading) => {
// 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 {
@@ -0,0 +1,71 @@
<template>
<div v-if="request" class="absolute inset-0 z-40 flex items-center justify-center bg-black/70 p-3 backdrop-blur-md sm:p-6">
<section ref="modal" role="dialog" aria-modal="true" :aria-labelledby="titleId"
:aria-busy="loading || phase === 'preparing'" class="glass-card flex max-h-[90%] w-full max-w-xl flex-col overflow-hidden rounded-2xl p-4 sm:p-6">
<header class="flex items-start gap-3">
<div class="min-w-0 flex-1"><p class="text-xs text-white/50">IndeeHub · Cloud video</p>
<h2 :id="titleId" class="mt-1 text-xl font-semibold text-white">{{ phase === 'resolve' ? 'Recover your video registration' : phase === 'select' ? 'Choose your project video' : phase === 'signing' ? 'Approve your producer signature' : 'Preparing your video' }}</h2></div>
<button type="button" aria-label="Close video registration" class="min-h-11 min-w-11 rounded-lg text-white/70 hover:bg-white/10" @click="emit('cancel')">✕</button>
</header>
<dl class="my-4 grid grid-cols-2 gap-3 rounded-xl border border-white/10 p-3 text-sm">
<div class="col-span-2 min-w-0"><dt class="text-white/45">Project</dt><dd class="break-words text-white">{{ request.intent.projectId }}</dd></div>
<div><dt class="text-white/45">Price</dt><dd class="text-white">{{ request.intent.priceSats }} sats</dd></div>
<div><dt class="text-white/45">Access after first play</dt><dd class="text-white">{{ duration }}</dd></div>
</dl>
<p v-if="error || localError" role="alert" class="mb-3 rounded-lg border border-red-400/30 p-3 text-sm text-red-200">{{ error || localError }}</p>
<div v-if="phase === 'resolve'" class="space-y-4 py-3 text-sm text-white/70">
<p>The node will recover this request's completed video and receipt. If it expired before completion, the node will retire the request so you can start another.</p>
<button type="button" class="glass-button min-h-11 w-full rounded-lg border-orange-400/30 px-4 py-3 text-orange-200" @click="emit('resolve')">Recover or retire expired request</button>
</div>
<template v-else-if="phase === 'select'">
<div class="mb-2 flex items-center gap-2"><button type="button" :disabled="directory === '/' || loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75 disabled:opacity-40" @click="up">Up</button><p class="min-w-0 flex-1 break-all text-xs text-white/50">Cloud{{ directory }}</p><button type="button" :disabled="loading" class="min-h-11 rounded-lg px-3 text-sm text-white/75" @click="load(directory)">Refresh</button></div>
<div class="min-h-24 overflow-y-auto rounded-xl border border-white/10">
<p v-if="loading" role="status" class="p-4 text-sm text-white/55">Loading Cloud files…</p>
<p v-else-if="!visible.length" class="p-4 text-sm text-white/55">No supported videos here. Choose an MP4, WebM or MOV file.</p>
<button v-for="file in visible" :key="file.path" type="button" :disabled="loading"
:aria-pressed="!file.isDir && selected?.path === file.path" class="flex min-h-12 w-full items-center gap-3 border-b border-white/5 px-3 py-2 text-left text-sm hover:bg-white/10"
:class="selected?.path === file.path ? 'bg-orange-400/15 text-orange-200' : 'text-white/80'" @click="choose(file)">
<span aria-hidden="true">{{ file.isDir ? '▸' : '▷' }}</span><span class="min-w-0 flex-1 break-words">{{ file.name }}</span>
<span v-if="!file.isDir" class="shrink-0 text-xs text-white/40">{{ formatSize(file.size) }}</span>
</button>
</div>
<p class="my-3 text-xs leading-relaxed text-white/50">The node keeps a fixed copy for this project's rental terms. This step prepares the video; publishing remains a separate action.</p>
<footer class="mt-auto flex flex-col-reverse gap-2 pt-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-4" @click="emit('cancel')">Cancel</button><button type="button" :disabled="!selected || loading" class="glass-button min-h-11 flex-1 rounded-lg border-orange-400/30 px-4 text-orange-200 disabled:opacity-40" @click="approve">Use this video</button></footer>
</template>
<div v-else role="status" class="py-5 text-sm leading-relaxed text-white/65">
<p class="break-all">{{ request.selection?.relative_path }}</p>
<p class="mt-3">{{ request.resolution ? (phase === 'signing' ? 'Sign the request to recover its original result or retire it if it expired before completion.' : 'Checking the original operation. Keep this request until its result is confirmed.') : (phase === 'signing' ? 'Sign the exact project, video and terms with your active producer identity. Your signing key stays in its signer.' : 'Creating the fixed video copy and durable registration. You can reopen the same operation if the connection is interrupted.') }}</p>
</div>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { fileBrowserClient, type FileBrowserItem } from '@/api/filebrowser-client'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RegistrationRequest, CloudSelection } from '@/composables/useMediaRegistrationBridge'
const props = defineProps<{ request: RegistrationRequest | null; phase: 'select' | 'resolve' | 'signing' | 'preparing'; error: string }>()
const emit = defineEmits<{ approve: [selection: CloudSelection]; cancel: []; resolve: [] }>()
const modal = ref<HTMLElement | null>(null), directory = ref('/'), files = ref<FileBrowserItem[]>([])
const selected = ref<FileBrowserItem | null>(null), loading = ref(false), localError = ref('')
const titleId = `media-registration-${crypto.randomUUID()}`
let generation = 0
const visible = computed(() => files.value.filter(item => item.isDir || /\.(mp4|m4v|webm|mov)$/i.test(item.name)))
const duration = computed(() => { const seconds = props.request?.intent.viewingSeconds ?? 0; return seconds % 3600 === 0 ? `${seconds / 3600} hours` : `${Math.ceil(seconds / 60)} minutes` })
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
async function load(path: string) {
const run = ++generation; loading.value = true; localError.value = ''; selected.value = null
try {
if (!await fileBrowserClient.login()) throw new Error('Cloud files could not be opened. Check your node connection and try again.')
const result = await fileBrowserClient.listDirectory(path)
if (run !== generation) return
files.value = result; directory.value = path
} catch (error) { if (run === generation) localError.value = error instanceof Error ? error.message : 'Cloud files could not be loaded.' }
finally { if (run === generation) loading.value = false }
}
function up() { const pieces = directory.value.split('/').filter(Boolean); pieces.pop(); void load('/' + pieces.join('/')) }
function choose(file: FileBrowserItem) { if (file.isDir) void load(file.path); else selected.value = file }
function approve() { if (!selected.value) return; emit('approve', { relative_path: selected.value.path.replace(/^\/+/, ''), payment_methods: ['cashu'] }) }
function formatSize(bytes: number) { return bytes >= 1024 ** 3 ? `${(bytes / 1024 ** 3).toFixed(1)} GB` : `${(bytes / 1024 ** 2).toFixed(1)} MB` }
watch(() => props.request?.intent.requestId, id => { ++generation; files.value = []; selected.value = null; localError.value = ''; if (id && props.phase === 'select') void load('/'); else loading.value = false }, { immediate: true })
</script>
+18 -2
View File
@@ -28,7 +28,12 @@
<div v-else class="mb-5 space-y-2">
<div class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Request</p><p class="text-sm font-medium text-white">{{ methodLabel }}</p></div>
<div v-if="identityLabel" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Identity</p><p class="text-sm font-medium text-white">{{ identityLabel }}</p></div>
<div v-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="mediaApproval" class="space-y-2 rounded-xl border border-white/10 bg-black/20 p-3 text-sm">
<p class="text-white/75">{{ mediaApproval.resolving ? 'Recover the completed registration or retire its expired incomplete request.' : 'Register this video for the selected IndeeHub project.' }}</p>
<dl class="space-y-2"><div><dt class="text-xs text-white/45">Project</dt><dd class="break-all text-white">{{ mediaApproval.project }}</dd></div><div v-if="mediaApproval.file"><dt class="text-xs text-white/45">Cloud video</dt><dd class="break-all text-white">{{ mediaApproval.file }}</dd></div><div><dt class="text-xs text-white/45">Rental terms</dt><dd class="text-white">{{ mediaApproval.price }} sats · {{ mediaApproval.seconds }} seconds after first play</dd></div></dl>
<details><summary class="min-h-11 cursor-pointer py-3 text-white/65">Full signed terms</summary><pre class="max-h-48 overflow-auto whitespace-pre-wrap break-all text-xs text-white/60">{{ content }}</pre></details>
</div>
<div v-else-if="contentPreview" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Content</p><p class="break-all font-mono text-sm text-white/75">{{ contentPreview }}</p></div>
<div v-if="eventKind !== undefined" class="rounded-xl border border-white/10 bg-black/20 p-3"><p class="mb-1 text-xs uppercase tracking-wider text-white/45">Event kind</p><p class="text-sm font-medium text-white">{{ eventKind }} <span class="text-white/45">({{ eventKindLabel }})</span></p></div>
</div>
@@ -55,7 +60,7 @@ const EVENT_KIND_LABELS: Record<number, string> = {
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<string, string> = {
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) }
@@ -0,0 +1,25 @@
<template>
<div v-if="request" class="absolute inset-0 z-[81] flex items-center justify-center bg-black/70 p-3">
<section ref="modal" role="dialog" aria-modal="true" aria-label="Confirm video rental" :aria-busy="busy" class="glass-card max-h-[90%] w-full max-w-md overflow-y-auto rounded-2xl p-5 text-white">
<p class="text-xs text-white/50">IndeeHub · Your node wallet</p>
<h2 class="mt-2 break-words text-xl font-semibold">{{ request.title }}</h2>
<dl class="my-4 space-y-2 text-sm"><div>Rental: {{ request.terms.priceSats }} sats</div><div>Viewing period: {{ Math.ceil(request.terms.viewingSeconds / 60) }} minutes after first play</div><template v-if="quote"><div>Payment: Cashu · {{ quote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</div><div class="break-all">Mint: {{ quote.mint_url }}</div></template><div v-if="quote" class="font-semibold">Total wallet debit: {{ quote.wallet_debit_sats }} sats</div></dl>
<p class="text-sm text-white/60">Review the exact wallet debit before paying. If a response is lost, reopen this title to recover the same purchase.</p>
<p v-if="error" role="alert" class="mt-3 break-words text-sm text-red-200">{{ error }}</p>
<p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'paying' ? 'Recovering or completing your payment…' : 'Checking the original purchase and current fees…' }}</p>
<button type="button" v-if="quote" :disabled="busy" class="mt-3 min-h-11 w-full rounded-lg border border-white/20 text-sm disabled:opacity-40" @click="$emit('cancelUnpaid')">Cancel unpaid quote</button>
<footer class="mt-5 flex flex-col gap-2 sm:flex-row"><button type="button" class="glass-button min-h-11 flex-1 rounded-lg px-3" @click="$emit('cancel')">Close</button><button type="button" :disabled="busy" class="glass-button min-h-11 flex-1 rounded-lg px-3 text-orange-200 disabled:opacity-40" @click="phase === 'confirm' ? $emit('approve') : $emit('review')">{{ phase === 'confirm' ? `Pay ${quote?.wallet_debit_sats} sats` : 'Check purchase' }}</button></footer>
</section>
</div>
</template>
<script setup lang="ts">
import { computed, ref } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RentalOffer } from '@/composables/useRentalPurchaseBridge'
const props = defineProps<{ request: RentalOffer | null; quote: { wallet_debit_sats: number; network: 'mainnet' | 'testnet'; mint_url: string } | null; phase: string; error: string }>()
const emit = defineEmits<{ review: []; approve: []; cancel: []; cancelUnpaid: [] }>()
const modal = ref<HTMLElement | null>(null)
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
const busy = computed(() => props.phase === 'loading' || props.phase === 'paying')
</script>
@@ -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: () => ({}) }))
@@ -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<unknown>();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)
})
})
@@ -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')
})
})
@@ -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<string, unknown> = {}, 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)
})
})
@@ -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)
})
})
@@ -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<CashuQuote> | 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)
}
@@ -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<string, unknown> }
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<string, unknown> }
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<string, unknown>, b = right as Record<string, unknown>
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<RegistrationRequest | null>(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<InstallationContext>({ 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<string, unknown> } | 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 }
}
@@ -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<RentalOffer | null>(null), quote = shallowRef<Quote | null>(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<Quote>({ 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 } }
}
+36 -5
View File
@@ -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,
}
})
+27 -2
View File
@@ -91,6 +91,10 @@
<!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. -->
<RentalPurchaseConsent v-if="!nostrBridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
<MediaRegistrationConsent v-if="!nostrBridge.showConsent.value"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
<NostrSignConsent
:show="nostrBridge.showConsent.value"
:app-name="nostrBridge.consentRequest.value?.appName ?? appTitle"
@@ -123,6 +127,10 @@ import { useAppStore } from '@/stores/app'
import { useScreensaverStore } from '@/stores/screensaver'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
import { isAutoTabApp, rememberAutoTabApp, forgetAutoTabApp } from '@/utils/autoTabApps'
import AppSessionHeader from './appSession/AppSessionHeader.vue'
import AppSessionFrame from './appSession/AppSessionFrame.vue'
@@ -293,20 +301,33 @@ function closeRouteSession() {
const iframeRef = computed(() => 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)
+28 -1
View File
@@ -6,6 +6,10 @@
@select="onIdentitySelected"
@cancel="cancelIdentitySelection"
/>
<RentalPurchaseConsent v-if="!bridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
<MediaRegistrationConsent v-if="!bridge.showConsent.value && !showIdentityPicker"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
<NostrSignConsent
:show="bridge.showConsent.value"
:app-name="bridge.consentRequest.value?.appName ?? appName"
@@ -25,6 +29,10 @@
import { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import NostrIdentityPicker from '@/components/NostrIdentityPicker.vue'
import NostrSignConsent from '@/components/NostrSignConsent.vue'
import MediaRegistrationConsent from '@/components/MediaRegistrationConsent.vue'
import RentalPurchaseConsent from '@/components/RentalPurchaseConsent.vue'
import { useMediaRegistrationBridge } from '@/composables/useMediaRegistrationBridge'
import { useRentalPurchaseBridge } from '@/composables/useRentalPurchaseBridge'
import { useNostrBridge } from '@/views/appSession/useNostrBridge'
import type { SelectedIdentity } from '@/views/appSession/useAppIdentity'
@@ -65,7 +73,7 @@ function hideSigner(delay = 0) {
if (hideTimer !== null) clearTimeout(hideTimer)
const hide = () => {
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')
+157 -8
View File
@@ -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"
>
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
<div class="glass-card w-full max-w-md max-h-[calc(100dvh-2rem)] overflow-y-auto p-5 rounded-2xl relative">
<button
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
class="absolute top-1 right-1 min-h-11 min-w-11 flex items-center justify-center text-white/50 hover:text-white transition-colors"
aria-label="Close payment"
@click="closePayModal"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -461,18 +462,24 @@
<!-- Step 1b: ecash confirmation — show which wallet will be spent -->
<div v-else-if="payMode === 'ecash-confirm' && ecashPlan" class="space-y-4">
<div class="text-center py-2">
<div class="text-3xl font-bold text-white">{{ getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
<div class="text-3xl font-bold text-white">{{ ecashPlan.chosen === 'cashu' && cashuQuote ? cashuQuote.wallet_debit_sats : getItemPrice(payItem.access) }} <span class="text-lg text-white/50">sats</span></div>
<div class="text-xs text-white/50 mt-1">from your node’s ecash wallet</div>
</div>
<p v-if="ecashPlan.chosen === 'cashu' && cashuQuote" class="text-sm text-white/70 text-center">
<span class="block">Cashu · {{ cashuQuote.network === 'testnet' ? 'Testnet' : 'Mainnet' }}</span>
<span class="block break-all">Mint: {{ cashuQuote.mint_url }}</span>
File: {{ cashuQuote.seller_net_sats }} sats · fees/rounding: {{ cashuQuote.wallet_debit_sats - cashuQuote.seller_net_sats }} sats
</p>
<p v-if="hasBlockingCashuPurchase" class="text-xs text-white/60">The original purchase is saved on your node. Retry recovers it without starting another payment.</p>
<!-- Backend selector: the chosen one is highlighted; the user can
switch to the other if it has enough balance. -->
<div class="space-y-2">
<button
v-for="b in (['cashu', 'fedimint', 'ark'] as const)"
:key="b"
@click="ecashPlan.chosen = b"
:disabled="ecashBalanceOf(b) < getItemPrice(payItem.access)"
@click="selectEcashBackend(b)"
:disabled="paymentActionBusy || (b !== 'cashu' && hasBlockingCashuPurchase) || (b !== 'cashu' && ecashBalanceOf(b) < getItemPrice(payItem.access))"
class="w-full px-4 py-3 rounded-xl flex items-center gap-3 text-left border transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
:class="ecashPlan.chosen === b ? 'border-green-400/70 bg-green-400/10' : 'border-white/10 bg-white/5 hover:bg-white/10'"
>
@@ -489,6 +496,7 @@
<p v-if="purchaseError" class="text-sm text-red-400">{{ purchaseError }}</p>
<button v-if="cashuQuote" class="w-full glass-button px-4 py-2.5 rounded-xl text-sm text-white/70" :disabled="paymentActionBusy" @click="cancelCashuPurchase">Cancel unpaid quote</button>
<div class="flex gap-2 pt-1">
<button
class="flex-1 glass-button px-4 py-2.5 rounded-xl text-sm text-white/70"
@@ -499,7 +507,7 @@
class="flex-1 px-4 py-2.5 rounded-xl text-sm font-semibold text-black bg-green-400 hover:bg-green-300 transition-colors disabled:opacity-50"
:disabled="!ecashPlan.chosen || paymentActionBusy"
@click="confirmEcashPay"
>{{ paymentActionBusy ? 'Paying…' : 'Pay' }}</button>
>{{ paymentActionBusy ? 'Working…' : ecashPlan.chosen === 'cashu' && !cashuQuote ? 'Check original purchase' : 'Pay' }}</button>
</div>
</div>
@@ -595,6 +603,7 @@
</template>
<script setup lang="ts">
import { parseCashuQuote, readCashuAttempt, keepCashuAttempt, keepAuthoritativeCashuQuote, archiveMalformedCashuAttempt, clearCashuAttempt, type CashuQuote } from '@/composables/peerCashuPurchase'
import { usePeerPaymentOperations } from '@/composables/peerPaymentOperations'
import { ref, computed, reactive, watch, onMounted, onUnmounted } from 'vue'
import { useRouter } from 'vue-router'
@@ -835,6 +844,33 @@ const ecashPlan = ref<{
chosen: EcashBackend | null
} | null>(null)
const ecashPreparing = ref(false)
const cashuQuote = ref<CashuQuote | null>(null)
const cashuRecoveryRequired = ref(false)
const cashuRecoveryError = ref(false)
const hasBlockingCashuPurchase = computed(() => cashuRecoveryRequired.value || cashuRecoveryError.value)
let cashuLookup: Promise<void> = Promise.resolve()
async function lookupCashuPurchase(onion: string, item: CatalogItem, generation: number) {
const selected = () => paymentGeneration.value === generation && activePaymentMatches(onion, item.id)
try {
const state = await rpcClient.call<{attempts?: Array<{operation_id?: string;state?: string}>}>({method:'content.payment-status',params:{onion,content_id:item.id},timeout:15000})
if (!Array.isArray(state?.attempts)) throw new Error('Could not verify saved purchases; recover the original payment before choosing another method.')
if (selected() && state.attempts.some(attempt => attempt.state !== 'cancelled_unspent')) {
cashuRecoveryRequired.value=true
lnError.value='A Cashu purchase is saved on this node. Choose ecash to recover or cancel that operation.'
}
} catch (error) {
if (selected()) { cashuRecoveryError.value=true;lnError.value=error instanceof Error?error.message:'Could not verify saved purchases' }
}
}
async function permitFreshOtherRail(item: CatalogItem, onion: string) {
const generation=paymentGeneration.value
await cashuLookup
if (paymentGeneration.value!==generation || !activePaymentMatches(onion,item.id)) return false
if (hasBlockingCashuPurchase.value) { lnError.value='Recover or cancel the saved Cashu purchase before choosing another method.'; return false }
return true
}
// Pay-from-another-wallet QR view: tabbed like the wallet's Send/Receive modal,
// on-chain first (the default).
const qrTab = ref<'onchain' | 'lightning'>('onchain')
@@ -877,6 +913,13 @@ function keepFailedLightningAttempt(onion: string, id: string, receipt: Lightnin
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: reason }, selected)
if (selected()) lnError.value = `Lightning attempt failed: ${reason}. No sats were sent by this attempt. You can choose another payment method.`
}
type InvoiceLifecycle = { paid?: boolean; state?: string; can_switch_method?: boolean }
function keepCanceledInvoice(onion: string, id: string, receipt: LightningReceipt, result: InvoiceLifecycle | undefined, selected: () => boolean) {
if (receipt.state === 'succeeded' || result?.paid !== false || result.state !== 'canceled' || result.can_switch_method !== true) return false
keepReceipt(onion, id, { ...receipt, state: 'failed', failure_reason: 'Seller confirmed the invoice is canceled and unpaid' }, selected)
if (selected()) lnError.value = 'The seller confirmed this invoice is canceled and unpaid. You can choose another payment method.'
return true
}
async function recoverFailedLightningAttempt(onion: string, id: string, receipt: LightningReceipt, selected = () => activePaymentMatches(onion, id)): Promise<'failed' | 'pending' | 'other'> {
// Old backends throw for terminal failures. Only LND's matching payment status
// can distinguish that from a lost reply; never infer failure from error text.
@@ -890,6 +933,14 @@ async function recoverFailedLightningAttempt(onion: string, id: string, receipt:
}
if (result?.status === 'pending' || result?.status === 'in_flight') return 'pending'
} catch { /* unavailable/unknown is still recoverable, never permission to pay again */ }
try {
const result = await rpcClient.call<InvoiceLifecycle>({
method: 'content.invoice-status', params: { onion, content_id: id, payment_hash: receipt.payment_hash }, timeout: 15000,
})
if (keepCanceledInvoice(onion, id, receipt, result, selected)) return 'failed'
if (result?.paid === true) keepReceipt(onion, id, { ...receipt, state: 'succeeded' }, selected)
else if (result?.state === 'open' || result?.state === 'accepted') return 'pending'
} catch { /* Seller outage or old boolean-only response cannot authorize another payment. */ }
return 'other'
}
const onchainPaying = ref(false)
@@ -1130,6 +1181,13 @@ async function downloadFile(item: CatalogItem) {
function openPayModal(item: CatalogItem) {
paymentGeneration.value++
cashuQuote.value = null
cashuRecoveryRequired.value = false
cashuRecoveryError.value = false
try {
const saved = readCashuAttempt(props.peerId || currentPeer.value?.onion || '', item.id)
if (saved) { cashuQuote.value = saved.quote; cashuRecoveryRequired.value = true }
} catch { cashuRecoveryError.value = true }
payItem.value = item
payMode.value = 'choose'
qrTab.value = 'onchain'
@@ -1152,6 +1210,7 @@ function openPayModal(item: CatalogItem) {
if (lnReceipt.value?.state === 'failed') lnError.value = `Previous Lightning attempt failed: ${lnReceipt.value.failure_reason || 'Payment failed'}. You can choose another method.`
} catch { lnReceiptReadError.value = true; lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false
cashuLookup = lookupCashuPurchase(props.peerId || currentPeer.value?.onion || '', item, paymentGeneration.value)
}
function closePayModal() {
@@ -1161,6 +1220,9 @@ function closePayModal() {
payItem.value = null
payMode.value = 'choose'
ecashPlan.value = null
cashuQuote.value = null
cashuRecoveryRequired.value = false
cashuRecoveryError.value = false
ecashPreparing.value = false
invoiceWaiting.value = false
onchainWaiting.value = false
@@ -1232,6 +1294,7 @@ async function loadOnchainQr() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
if (!await permitFreshOtherRail(item, onion)) return
if (getItemPrice(item.access) < 546) { onchainError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
const operation = paymentOperations.begin('onchain-qr', onion, item.id)
if (!operation) return
@@ -1280,6 +1343,7 @@ async function payOnchain() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value) return
if (!await permitFreshOtherRail(item, onion)) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
if (getItemPrice(item.access) < 546) { lnError.value = 'On-chain payment requires at least 546 sats. Choose Lightning or ecash for this file.'; return }
const operation = paymentOperations.begin('onchain-send', onion, item.id)
@@ -1348,6 +1412,9 @@ async function prepareEcashPay() {
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value) return
if (hasBlockingLightningReceipt.value) { lnError.value = 'Check the saved Lightning attempt before choosing another method.'; return }
const generation = paymentGeneration.value
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion, item.id)) return
const operation = paymentOperations.begin('prepare-ecash', onion, item.id)
if (!operation) return
const price = getItemPrice(item.access)
@@ -1372,12 +1439,15 @@ async function prepareEcashPay() {
// Prefer Cashu when it covers the price, else Fedimint, else Ark, else
// leave null (insufficient — shown in the confirm screen, Confirm disabled).
const chosen: EcashBackend | null =
cashu >= price ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
acceptsMethod(item.access, 'ecash') || hasBlockingCashuPurchase.value ? 'cashu' : fedimint >= price ? 'fedimint' : ark >= price ? 'ark' : null
ecashPlan.value = { cashu, fedimint, ark, total, chosen }
if (!chosen) {
purchaseError.value = `Not enough funds: Cashu ${cashu} + Fedimint ${fedimint} + Ark ${ark} sats, need ${price}. Fund a wallet, or pay another way.`
}
payMode.value = 'ecash-confirm'
if (chosen === 'cashu') await requestCashuPurchase(item, onion, operation, false)
} catch (error) {
if (paymentOperations.selected(operation)) purchaseError.value = error instanceof Error ? error.message : 'Could not recover the Cashu purchase'
} finally {
if (paymentOperations.finish(operation)) ecashPreparing.value = false
}
@@ -1420,12 +1490,80 @@ function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeTy
void loadOwned()
}
type CashuPurchaseReply = Partial<Omit<CashuQuote, 'state'>> & { state?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }
async function requestCashuPurchase(item: CatalogItem, onion: string, operation: NonNullable<ReturnType<typeof paymentOperations.begin>>, confirm: boolean) {
const selected = () => paymentOperations.selected(operation)
let saved: ReturnType<typeof readCashuAttempt> = null
let unreadable = false
try { saved = readCashuAttempt(onion, item.id) } catch { unreadable = true }
// A corrupt browser marker may query/recover node-owned state, but cannot
// supply consent or authorize a newly selected payment.
const quote = unreadable ? null : saved?.quote || (selected() ? cashuQuote.value : null)
if (confirm && quote) keepCashuAttempt(onion, item.id, quote, true)
const result = await rpcClient.call<CashuPurchaseReply>({
method: 'content.purchase',
params: { onion, content_id: item.id, filename: item.filename,
max_wallet_debit: confirm && quote ? quote.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
...(confirm && quote ? { consent: { operation_id: quote.operation_id, envelope_sha256: quote.envelope_sha256, wallet_debit_sats: quote.wallet_debit_sats } } : {}) },
timeout: 960000, maxRetries: 1,
})
if (result?.state === 'confirmation_required') {
const next = parseCashuQuote(result)
keepAuthoritativeCashuQuote(onion, item.id, next)
if (selected()) { cashuQuote.value = next; cashuRecoveryRequired.value = true; cashuRecoveryError.value = false }
return
}
if (result?.state === 'delivered' && result.owned === true && result.owned_content_id) {
archiveMalformedCashuAttempt(onion, item.id)
clearCashuAttempt(onion, item.id)
if (selected()) openPurchased(item, undefined, result.mime_type, onion, result.owned_content_id)
return
}
if (result?.state === 'cancelled_unspent') {
archiveMalformedCashuAttempt(onion, item.id)
clearCashuAttempt(onion, item.id)
if (selected()) { cashuQuote.value = null; cashuRecoveryRequired.value = false; cashuRecoveryError.value = false; payMode.value = 'choose' }
return
}
throw new Error(result?.error || 'The original Cashu purchase is not confirmed yet. Retry recovers it; do not pay using another method.')
}
async function selectEcashBackend(backend: EcashBackend) {
if (!ecashPlan.value || paymentActionBusy.value) return
if (backend !== 'cashu' && hasBlockingCashuPurchase.value) { purchaseError.value = 'Cancel the original unpaid Cashu quote before selecting another wallet.'; return }
ecashPlan.value.chosen = backend
if (backend === 'cashu') await prepareEcashPay()
}
async function cancelCashuPurchase() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const quote = cashuQuote.value
if (!item || !onion || !quote || paymentActionBusy.value) return
const generation = paymentGeneration.value
await cashuLookup
if (paymentGeneration.value !== generation || !activePaymentMatches(onion,item.id)) return
const operation = paymentOperations.begin('cashu-cancel', onion, item.id)
if (!operation) return
try {
const result = await rpcClient.call<{state?: string;operation_id?: string}>({ method:'content.cancel-purchase',
params:{onion,operation_id:quote.operation_id}, timeout:60000,maxRetries:1 })
if (result?.state !== 'cancelled_unspent' || result.operation_id !== quote.operation_id) throw new Error('Cancellation is not confirmed. Recover the original purchase before paying another way.')
clearCashuAttempt(onion,item.id)
if (paymentOperations.selected(operation)) {
cashuQuote.value=null;cashuRecoveryRequired.value=false;cashuRecoveryError.value=false
purchaseError.value=null;payMode.value='choose'
}
} catch (error) {
if (paymentOperations.selected(operation)) purchaseError.value=error instanceof Error?error.message:'Could not confirm cancellation'
} finally { paymentOperations.finish(operation) }
}
/** Confirm the ecash payment with the backend the user selected. */
async function confirmEcashPay() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
const method = ecashPlan.value?.chosen
if (!item || !onion || !method || paymentActionBusy.value || hasBlockingLightningReceipt.value) return
if (method !== 'cashu' && !await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('ecash-send', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
@@ -1433,6 +1571,8 @@ async function confirmEcashPay() {
purchaseError.value = null
try {
if (method === 'cashu') { await requestCashuPurchase(item, onion, operation, true); return }
if (hasBlockingCashuPurchase.value) throw new Error('Recover or cancel the saved Cashu purchase first.')
const result = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; error?: string; ecash_backend?: string; mime_type?: string }>({
method: 'content.download-peer-paid',
params: { onion, content_id: item.id, price_sats: price, method, filename: item.filename, cache_only: true },
@@ -1457,6 +1597,7 @@ async function payWithInvoice() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion) return
if (!await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('invoice', onion, item.id)
if (!operation) return
payMode.value = 'qr'
@@ -1495,6 +1636,7 @@ async function payWithLightning() {
const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || paymentActionBusy.value || lnReceiptReadError.value) return
if (!await permitFreshOtherRail(item, onion)) return
const operation = paymentOperations.begin('lightning', onion, item.id)
if (!operation) return
const selected = () => paymentOperations.selected(operation)
@@ -1568,11 +1710,18 @@ async function pollInvoice(scope: InvoicePollScope) {
if (!invoiceScopeSelected(scope)) return
const { item, onion, invoice: inv } = scope
try {
const res = await rpcClient.call<{ paid?: boolean }>({
const res = await rpcClient.call<InvoiceLifecycle>({
method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true }, timeout: 30000,
})
if (!invoiceScopeSelected(scope)) return
if (keepCanceledInvoice(onion, item.id, inv, res, () => invoiceScopeSelected(scope))) {
invoiceWaiting.value = false
invoiceData.value = null
invoiceQr.value = ''
payMode.value = 'choose'
return
}
if (res?.paid === true) {
localStorage.setItem(receiptKey(onion, item.id), JSON.stringify({ ...inv, state: 'succeeded' }))
const dl = await rpcClient.call<{ data?: string; owned?: boolean; owned_content_id?: string; mime_type?: string; error?: string }>({
@@ -7,8 +7,9 @@ vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64)
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning', 'ecash'] } } }
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const cashuQuoteFixture = { state: 'confirmation_required', network: 'mainnet', mint_url: 'https://original-mint.example.test', 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 }
const download = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
@@ -23,9 +24,10 @@ beforeEach(() => {
localStorage.clear(); vi.clearAllMocks()
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.purchase') return cashuQuoteFixture
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
if (method === 'content.download-peer-invoice') return download()
return { items: [] }
return { items: [], attempts: [] }
})
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
})
@@ -34,7 +36,7 @@ describe('Lightning file delivery recovery', () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.onchain-status') return { paid: true }
if (method === 'content.download-peer-onchain') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.pollOnchain('bc1test')
@@ -47,29 +49,29 @@ describe('Lightning file delivery recovery', () => {
})
it('opens a cached ecash purchase without transferring base64 into the UI', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return { owned: true, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [] }
if (method === 'content.purchase') return { state: 'delivered', owned: true, owned_content_id: item.id, mime_type: 'video/mp4', size_bytes: 200000000 }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
await vm.confirmEcashPay()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].params).toMatchObject({ cache_only: true, method: 'cashu' })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-paid')![0].maxRetries).toBe(1)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].params).toMatchObject({ content_id: item.id, max_wallet_debit: Number.MAX_SAFE_INTEGER })
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.purchase')![0].maxRetries).toBe(1)
wrapper.unmount()
})
it('does not issue a duplicate ecash purchase while delivery is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.download-peer-paid') return await new Promise(resolve => { finish = resolve })
return { items: [] }
if (method === 'content.purchase') return await new Promise(resolve => { finish = resolve })
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.ecashPlan = { cashu: 10, fedimint: 0, ark: 0, total: 10, chosen: 'cashu' }
const first = vm.confirmEcashPay()
await vm.confirmEcashPay()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.download-peer-paid')).toHaveLength(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.purchase')).toHaveLength(1)
finish({ error: 'Delivery needs recovery; do not pay again' })
await first
expect(vm.purchaseError).toContain('do not pay again')
@@ -214,9 +216,11 @@ describe('Lightning file delivery recovery', () => {
it('does not pay when an invoice arrives after closing and reopening the same file', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
expect(typeof reply).toBe('function')
vm.closePayModal(); vm.openPayModal(item)
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
@@ -229,9 +233,11 @@ it('does not pay when an invoice arrives after closing and reopening the same fi
it('retains a late invoice for its original file without changing another file modal', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithInvoice()
await flushPromises()
expect(typeof reply).toBe('function')
await vm.payWithInvoice()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
@@ -247,11 +253,15 @@ it('retains a late invoice for its original file without changing another file m
it('keeps a new file balance preparation busy when an older preparation finishes', async () => {
const replies: ((value: unknown) => void)[] = []
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'wallet.ecash-balance'
? await new Promise(resolve => { replies.push(resolve) }) : { items: [] })
? await new Promise(resolve => { replies.push(resolve) }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const first = vm.prepareEcashPay()
await flushPromises()
expect(replies).toHaveLength(1)
vm.closePayModal(); vm.openPayModal({ ...item, id: 'second-file' })
const second = vm.prepareEcashPay()
await flushPromises()
expect(replies).toHaveLength(2)
replies[0]!({ cashu_sats: 100 })
await first
expect(vm.ecashPreparing).toBe(true)
@@ -267,7 +277,7 @@ it('cannot deliver a late paid invoice into another file modal', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.invoice-status') return { paid: true }
if (method === 'content.download-peer-invoice') return await new Promise(resolve => { reply = resolve })
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
const invoice = { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
@@ -304,9 +314,11 @@ it('persists a dispatched Lightning result after closing without changing anothe
it('does not dispatch Lightning when its invoice arrives after component unmount', async () => {
let reply!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'content.request-invoice'
? await new Promise(resolve => { reply = resolve }) : { items: [] })
? await new Promise(resolve => { reply = resolve }) : { items: [], attempts: [] })
const { wrapper, vm } = await open()
const pending = vm.payWithLightning()
await flushPromises()
expect(typeof reply).toBe('function')
wrapper.unmount()
reply({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 })
await pending
@@ -341,7 +353,7 @@ it('retains already dispatched Lightning evidence after unmount without opening
it('allows the exact 546-sat boundary and never dispatches a changed seller amount', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-onchain') return { address: 'bc1test', amount_sats: 547 }
return { items: [] }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
vm.openPayModal({ ...item, access: { paid: { price_sats: 546, accepted: ['onchain', 'lightning', 'ecash'] } } })
@@ -404,3 +416,151 @@ it('retains already dispatched Lightning evidence after unmount without opening
})
})
describe('Seller-authoritative external invoice lifecycle', () => {
it('unlocks alternate methods only after the seller confirms the saved external invoice canceled and unpaid', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return { paid: false, state: 'canceled', can_switch_method: true }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(false)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'failed' })
expect(vm.lnError).toContain('seller confirmed')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => ['content.request-invoice', 'content.download-peer-invoice'].includes(call.method))).toBe(false)
wrapper.unmount()
})
it.each([
{ paid: false },
{ paid: false, state: 'open', expires_at: 1, can_switch_method: false },
{ paid: false, state: 'unknown', can_switch_method: false },
{ paid: false, state: 'canceled' },
{ paid: false, state: 'accepted', can_switch_method: true },
])('retains the saved attempt when seller status does not prove terminal cancellation: %j', async response => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5, state: 'pending' }))
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'lnd.paymentstatus') throw new Error('Unknown external payment')
if (method === 'content.invoice-status') return response
if (method === 'content.download-peer-invoice') return { error: 'Payment is still pending' }
return { items: [], attempts: [] }
})
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.hasBlockingLightningReceipt).toBe(true)
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash, state: 'pending' })
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call]) => call.method === 'content.request-invoice')).toBe(false)
wrapper.unmount()
})
})
describe('Durable node Cashu purchases', () => {
it('shows the exact quoted debit and confirms its immutable terms without the legacy send RPC', async () => {
let purchaseCalls = 0
vi.mocked(rpcClient.call).mockImplementation(async ({method}) => {
if (method === 'content.purchase') return ++purchaseCalls === 1 ? cashuQuoteFixture : {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open()
await vm.prepareEcashPay()
expect(vm.cashuQuote.wallet_debit_sats).toBe(7)
expect(wrapper.text()).toContain('fees/rounding: 2 sats')
expect(purchaseCalls).toBe(1)
await vm.confirmEcashPay()
const calls=vi.mocked(rpcClient.call).mock.calls.map(([call])=>call)
const requests=calls.filter(call=>call.method==='content.purchase')
expect(requests[0]?.params).not.toHaveProperty('consent')
expect(requests[1]?.params).toMatchObject({max_wallet_debit:7,consent:{operation_id:cashuQuoteFixture.operation_id,envelope_sha256:cashuQuoteFixture.envelope_sha256,wallet_debit_sats:7}})
expect(calls.some(call=>call.method==='content.download-peer-paid')).toBe(false)
expect(vm.viewerUrl).toContain('/paid-file')
wrapper.unmount()
})
it('recovers after a lost submit reply and remount without confirming another payment', async () => {
let count=0
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if (method==='content.purchase') {
count++
if(count===1)return cashuQuoteFixture
if(count===2)throw new Error('Connection lost; original purchase saved')
return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
}
return {items:[],attempts:[]}
})
const first=await open();await first.vm.prepareEcashPay();await first.vm.confirmEcashPay();first.wrapper.unmount()
const next=await open();expect(next.vm.hasBlockingCashuPurchase).toBe(true)
await next.vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
await next.vm.prepareEcashPay()
const requests=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(requests).toHaveLength(3)
expect(requests[2]?.[0].params).not.toHaveProperty('consent')
expect(next.vm.viewerUrl).toContain('/paid-file')
next.wrapper.unmount()
})
it('keeps the original operation until seller cancellation acknowledgement, then permits a new quote', async () => {
let canceled=false, cancelCalls=0
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.purchase')return canceled?{...cashuQuoteFixture,operation_id:'87654321-1234-4234-8234-123456789abc'}:cashuQuoteFixture
if(method==='content.cancel-purchase'){
if(++cancelCalls===1)throw new Error('Cancellation reply lost')
canceled=true;return {state:'cancelled_unspent',operation_id:cashuQuoteFixture.operation_id}
}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.cancelCashuPurchase()
expect(vm.hasBlockingCashuPurchase).toBe(true)
await vm.payWithLightning();expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
await vm.cancelCashuPurchase();expect(vm.hasBlockingCashuPurchase).toBe(false)
await vm.prepareEcashPay();expect(vm.cashuQuote.operation_id).not.toBe(cashuQuoteFixture.operation_id)
wrapper.unmount()
})
it('finds a node-owned pending purchase after browser state loss before another rail can dispatch', async () => {
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.payment-status')return {attempts:[{operation_id:cashuQuoteFixture.operation_id,state:'token_prepared_settlement_unconfirmed'}]}
if(method==='content.purchase')return {state:'delivered',owned:true,owned_content_id:item.id,mime_type:'text/plain'}
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.some(([call])=>call.method==='content.request-invoice')).toBe(false)
await vm.prepareEcashPay();expect(vm.viewerUrl).toContain('/paid-file')
wrapper.unmount()
})
})
describe('Malformed browser Cashu marker recovery', () => {
const marker='peer-file-cashu:peer.onion:paid-file'
it('queries node-owned state without consent, archives the malformed marker and restores the authoritative quote', async () => {
localStorage.setItem(marker,'{original broken marker')
const {wrapper,vm}=await open()
expect(vm.cashuRecoveryError).toBe(true)
await vm.prepareEcashPay()
expect(localStorage.getItem(`${marker}:unreadable`)).toBe('{original broken marker')
expect(JSON.parse(localStorage.getItem(marker)!)).toMatchObject({quote:cashuQuoteFixture,dispatched:false})
expect(vm.cashuRecoveryError).toBe(false)
expect(vm.hasBlockingCashuPurchase).toBe(true)
const calls=vi.mocked(rpcClient.call).mock.calls.filter(([call])=>call.method==='content.purchase')
expect(calls).toHaveLength(1)
expect(calls[0]?.[0].params).not.toHaveProperty('consent')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
wrapper.unmount()
})
it('keeps original malformed data and all replacement methods blocked when node recovery is unavailable', async () => {
localStorage.setItem(marker,'{original broken marker')
vi.mocked(rpcClient.call).mockImplementation(async ({method})=>{
if(method==='content.purchase')throw new Error('Node purchase journal is unavailable')
return {items:[],attempts:[]}
})
const {wrapper,vm}=await open();await vm.prepareEcashPay();await vm.payWithLightning()
expect(localStorage.getItem(marker)).toBe('{original broken marker')
expect(localStorage.getItem(`${marker}:unreadable`)).toBeNull()
expect(vm.hasBlockingCashuPurchase).toBe(true)
expect(vm.purchaseError).toContain('journal is unavailable')
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
wrapper.unmount()
})
})
@@ -106,7 +106,7 @@ describe('PeerFiles', () => {
}
vi.mocked(rpcClient.call).mockImplementation((async (req: { method: string }) => {
if (req.method === 'content.browse-peer') return { items: [freeImage] }
if (req.method === 'content.owned-list') return { items: [] }
if (req.method === 'content.owned-list') return { items: [], attempts: [] }
return {}
}) as never)
+62
View File
@@ -0,0 +1,62 @@
// Local component fixtures only. Start loopback Vite on32915; never use a live node URL.
const fs=require('node:fs');const path=require('node:path');
const repo=path.resolve(__dirname,'../..');
const fixtureDir=path.join(repo,'neode-ui/.qualification');const fixturePath=path.join(fixtureDir,'native-payment-fixture.js');
const {chromium,expect}=require(path.join(repo,'neode-ui/node_modules/@playwright/test'));
const origin='http://127.0.0.1:32915';
const boot=String.raw`
import {createApp,h,reactive,nextTick} from 'vue';
import {createPinia} from 'pinia';
import {createRouter,createMemoryHistory} from 'vue-router';
import Rental from '/src/components/RentalPurchaseConsent.vue';
import Signer from '/src/components/NostrSignConsent.vue';
import PeerFiles from '/src/views/PeerFiles.vue';
import {rpcClient} from '/src/api/rpc-client.ts';
import '/src/style.css';
window.fixtureCalls=[];
rpcClient.call=async ({method})=>{window.fixtureCalls.push(method);if(method==='content.purchase-pending')return {attempts:[]};if(method==='wallet.ecash-balance')return {cashu_sats:100,fedimint_sats:0,ark_sats:0};return {items:[]}};
rpcClient.federationListNodes=async()=>({nodes:[]});
rpcClient.payLightningInvoice=async()=>{throw Error('No fixture payment is permitted')};
const quote={network:'testnet',mint_url:'https://original-mint-with-long-name.example.test/cashu',wallet_debit_sats:10,gross_token_sats:9,seller_net_sats:8,operation_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',envelope_sha256:'b'.repeat(64),expires_at:2000000000};
window.layout=reactive({kind:'rental',phase:'confirm',error:'',signing:false});
const offer={title:'A long film title with a story about independent creators',terms:{nodeDid:'did:key:fixture',contentId:'registered_fixture',sha256:'a'.repeat(64),priceSats:8,viewingSeconds:3600}};
const rental=createApp({render(){return h('div',{style:'position:fixed;inset:24px 0 0'},[
!window.layout.signing&&h(Rental,{request:offer,quote,phase:window.layout.phase,error:window.layout.error,onCancel:()=>window.layout.cancelled=true}),
h(Signer,{show:window.layout.signing,appName:'Fixture IndeeHub',method:'signEvent',phase:'review',content:'Fixture signature; no RPC',onDeny:()=>window.layout.signing=false})])}}).mount('#rental');
const router=createRouter({history:createMemoryHistory(),routes:[]});
let peerApp=null,vm=null;
window.showFixture=async(kind,phase,error='')=>{
document.querySelector('#rental').style.display=kind==='rental'?'block':'none';document.querySelector('#peer').style.display=kind==='peer'?'block':'none';
window.layout.phase=phase;window.layout.error=error;window.layout.signing=phase==='signer';
if(vm)vm.closePayModal();
if(kind!=='peer'&&peerApp){peerApp.unmount();peerApp=null;vm=null}
if(kind==='peer'){
if(!peerApp){peerApp=createApp(PeerFiles,{peerId:'fixture.onion'}).use(createPinia()).use(router);const peer=peerApp.mount('#peer');vm=peer.$.setupState;window.peerVm=vm;}
vm.openPayModal({id:'fixture-file',filename:'A meaningful independent documentary.mp4',mime_type:'video/mp4',size_bytes:1024,access:{paid:{price_sats:8,accepted:['ecash']}}});
vm.payMode='ecash-confirm';vm.ecashPlan={cashu:100,fedimint:0,ark:0,total:100,chosen:'cashu'};vm.cashuQuote=quote;vm.cashuRecoveryRequired=true;vm.purchaseError=error;vm.ecashPreparing=phase==='paying';
}
await nextTick();
};
await window.showFixture('rental','confirm');window.fixtureReady=true;
`;
fs.mkdirSync(fixtureDir,{recursive:true});fs.writeFileSync(fixturePath,boot);
(async()=>{const browser=await chromium.launch({headless:true});try{for(const width of [320,390,1440]){const context=await browser.newContext({viewport:{width,height:width===320?568:844},reducedMotion:'reduce'});let blocked=0;const errors=[];await context.routeWebSocket('**/*',socket=>socket.close());
await context.route('**/*',r=>{const u=new URL(r.request().url());if(u.origin!==origin||u.pathname==='/rpc/v1'){blocked++;return r.abort()}return r.continue()});
await context.route(origin+'/native-payment-fixture',r=>r.fulfill({contentType:'text/html',body:'<html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"></head><body style="background:#11151a"><div style="position:fixed;top:0;z-index:999;color:white;background:#553;font:12px sans-serif;width:100%;height:24px">LOCAL LAYOUT FIXTURE · no payment or live wallet</div><div id="rental"></div><div id="peer"></div><script type="module" src="/.qualification/native-payment-fixture.js"></script></body></html>'}));
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)});
@@ -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 = '<script src="/nostr-provider.js?v=tab-signer-v2"></script>'
index.write_text('<html><head>' + (old_script if mode == 'literal' else '') + '</head><body></body></html>')
nginx.write_text('server {\n location = /sw.js {\n }\n' + (
" sub_filter '</head>' '" + old_script + "</head>';\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'<script[^>]*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')