Integrate recoverable native purchases, registered rentals and explicit payment consent
This commit is contained in:
@@ -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))?)
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 = ¶ms.producer_event;
|
||||
event
|
||||
.verify()
|
||||
.context("Resolution producer signature failed")?;
|
||||
anyhow::ensure!(
|
||||
event.kind == Kind::Custom(27237) && event.pubkey.to_hex() == params.intent.producer,
|
||||
"Resolution signature purpose or producer changed"
|
||||
);
|
||||
let encoded = serde_json::to_value(event)?;
|
||||
anyhow::ensure!(
|
||||
encoded["tags"] == serde_json::json!([["d", RESOLUTION_DOMAIN]])
|
||||
&& event.created_at.as_u64() >= params.intent.created_at.saturating_sub(30)
|
||||
&& event.created_at.as_u64() <= now.saturating_add(30),
|
||||
"Invalid resolution signature time or scope"
|
||||
);
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)?;
|
||||
anyhow::ensure!(
|
||||
content
|
||||
== serde_json::json!({
|
||||
"action":"Recover prepared video or retire this expired incomplete registration",
|
||||
"scope":RESOLUTION_DOMAIN, "intent":params.intent,
|
||||
}),
|
||||
"Resolution signature changed the original intent"
|
||||
);
|
||||
Ok(params)
|
||||
}
|
||||
|
||||
fn approval_content(intent: &Intent, selection: &AuthorizedSelection) -> Result<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 = ¶ms.producer_event;
|
||||
anyhow::ensure!(
|
||||
event.kind == Kind::Custom(KIND),
|
||||
"This signature is not a media registration approval"
|
||||
);
|
||||
event
|
||||
.verify()
|
||||
.context("Producer approval signature failed")?;
|
||||
let producer = event.pubkey.to_hex();
|
||||
anyhow::ensure!(
|
||||
producer == params.intent.producer,
|
||||
"The signing identity differs from the project producer"
|
||||
);
|
||||
let created = event.created_at.as_u64();
|
||||
anyhow::ensure!(
|
||||
created >= params.intent.created_at.saturating_sub(30)
|
||||
&& created < params.intent.expires_at
|
||||
&& created <= now.saturating_add(30),
|
||||
"Producer approval was not signed within this registration intent"
|
||||
);
|
||||
let encoded = serde_json::to_value(event)?;
|
||||
anyhow::ensure!(
|
||||
encoded["tags"] == serde_json::json!([["d", DOMAIN]]),
|
||||
"Media approval signature scope changed"
|
||||
);
|
||||
let content: serde_json::Value = serde_json::from_str(&event.content)
|
||||
.context("Producer approval is not readable registration terms")?;
|
||||
anyhow::ensure!(
|
||||
content == approval_content(¶ms.intent, ¶ms.selection)?,
|
||||
"Approved project, Cloud selection or rental terms changed"
|
||||
);
|
||||
Ok(producer)
|
||||
}
|
||||
fn parse(input: serde_json::Value, now: u64) -> Result<(Params, String)> {
|
||||
anyhow::ensure!(
|
||||
serde_json::to_vec(&input)?.len() <= MAX_APPROVAL,
|
||||
"Registration approval is too large"
|
||||
);
|
||||
let params: Params = serde_json::from_value(input).context("Invalid registration approval")?;
|
||||
let producer = verified_producer(¶ms, now)?;
|
||||
Ok((params, producer))
|
||||
}
|
||||
fn registration_limit(_data_dir: &Path) -> u64 {
|
||||
// Explicit per-file staging bound; shared immutable snapshot storage handles
|
||||
// disk reservations separately before this route is enabled for live apps.
|
||||
16 * 1024 * 1024 * 1024
|
||||
}
|
||||
impl RpcHandler {
|
||||
/// Read-only public installation bindings for the native consent bridge.
|
||||
/// A hidden standalone signer must compare its actual app origin with these
|
||||
/// installed addresses; a caller-supplied app name is never sufficient.
|
||||
pub(super) async fn handle_media_registration_context(
|
||||
&self,
|
||||
_input: serde_json::Value,
|
||||
) -> Result<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,
|
||||
¶ms.intent,
|
||||
¶ms.producer_event.pubkey.to_hex(),
|
||||
now,
|
||||
&Limits {
|
||||
max_bytes: registration_limit(&data_dir),
|
||||
cancelled: &cancelled,
|
||||
},
|
||||
)
|
||||
})
|
||||
.await?
|
||||
}
|
||||
|
||||
/// Standard RPC front door already requires owner session, permitted origin
|
||||
/// and CSRF. Producer signature is additional exact-scope consent, not a
|
||||
/// replacement for those owner checks. A replay repeats the original UUID.
|
||||
pub(super) async fn handle_media_registration_prepare(
|
||||
&self,
|
||||
input: serde_json::Value,
|
||||
) -> Result<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: ¶ms.intent,
|
||||
selection: ¶ms.selection,
|
||||
},
|
||||
now,
|
||||
&limits,
|
||||
|_| Ok(()),
|
||||
)
|
||||
})
|
||||
.await??;
|
||||
Ok(serde_json::to_value(receipt)?)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use nostr_sdk::prelude::{EventBuilder, Keys, Tag, Timestamp};
|
||||
fn fixture() -> Params {
|
||||
let keys = Keys::parse(&"07".repeat(32)).unwrap();
|
||||
let intent = Intent {
|
||||
version: 1,
|
||||
request_id: uuid::Uuid::new_v4().to_string(),
|
||||
nonce: "ab".repeat(32),
|
||||
app_audience: uuid::Uuid::new_v4().to_string(),
|
||||
node_did: crate::identity::did_key_from_pubkey_hex(&hex::encode([7; 32])).unwrap(),
|
||||
producer: keys.public_key().to_hex(),
|
||||
project_id: "fixture-project".into(),
|
||||
price_sats: 8,
|
||||
viewing_seconds: 3600,
|
||||
created_at: 1000,
|
||||
expires_at: 1600,
|
||||
};
|
||||
let selection = AuthorizedSelection {
|
||||
relative_path: "Movies/Film.mp4".into(),
|
||||
payment_methods: vec!["cashu".into()],
|
||||
};
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(KIND),
|
||||
serde_json::to_string_pretty(&approval_content(&intent, &selection).unwrap()).unwrap(),
|
||||
)
|
||||
.tag(Tag::identifier(DOMAIN))
|
||||
.custom_created_at(Timestamp::from(1200))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
Params {
|
||||
intent,
|
||||
selection,
|
||||
producer_event: event,
|
||||
}
|
||||
}
|
||||
#[test]
|
||||
fn producer_signature_binds_human_readable_exact_selection_terms_node_and_installation() {
|
||||
let original = fixture();
|
||||
assert_eq!(
|
||||
verified_producer(&original, 1300).unwrap(),
|
||||
original.intent.producer
|
||||
);
|
||||
// Original consent can recover an already-completed operation after
|
||||
// expiry; underlying snapshot journal refuses creating a fresh one.
|
||||
assert!(verified_producer(&original, 2000).is_ok());
|
||||
let mut changed = fixture();
|
||||
changed.intent.price_sats += 1;
|
||||
assert!(verified_producer(&changed, 1300).is_err());
|
||||
let mut changed = fixture();
|
||||
changed.selection.relative_path = "Other.mp4".into();
|
||||
assert!(verified_producer(&changed, 1300).is_err());
|
||||
let mut changed = fixture();
|
||||
changed.intent.app_audience = uuid::Uuid::new_v4().to_string();
|
||||
assert!(verified_producer(&changed, 1300).is_err());
|
||||
let mut changed = fixture();
|
||||
changed.intent.producer = "cd".repeat(32);
|
||||
assert!(verified_producer(&changed, 1300).is_err());
|
||||
assert!(verified_producer(&fixture(), 1000).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn request_parser_rejects_unsigned_claims_unknown_fields_and_changed_signed_content() {
|
||||
let original = fixture();
|
||||
let mut encoded = serde_json::json!({"intent":original.intent,"selection":original.selection,"producerEvent":original.producer_event});
|
||||
assert!(parse(encoded.clone(), 1300).is_ok());
|
||||
encoded["producerEvent"]["content"] = serde_json::json!("approve everything");
|
||||
assert!(parse(encoded, 1300).is_err());
|
||||
assert!(parse(serde_json::json!({"producer":"cd".repeat(32)}), 1300).is_err());
|
||||
}
|
||||
#[test]
|
||||
fn dropped_request_signals_blocking_copy_cancellation() {
|
||||
let (guard, signal) = request_cancellation();
|
||||
assert!(!signal.load(Ordering::Relaxed));
|
||||
drop(guard);
|
||||
assert!(signal.load(Ordering::Relaxed));
|
||||
}
|
||||
#[test]
|
||||
fn resolution_signature_recovers_only_exact_intent_after_expiry_without_file_authority() {
|
||||
let original = fixture();
|
||||
let keys = Keys::parse(&"07".repeat(32)).unwrap();
|
||||
let event = EventBuilder::new(
|
||||
Kind::Custom(27237),
|
||||
serde_json::json!({
|
||||
"action":"Recover prepared video or retire this expired incomplete registration",
|
||||
"scope":RESOLUTION_DOMAIN,"intent":original.intent,
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
.tag(Tag::identifier(RESOLUTION_DOMAIN))
|
||||
.custom_created_at(Timestamp::from(1700))
|
||||
.sign_with_keys(&keys)
|
||||
.unwrap();
|
||||
let encoded = serde_json::json!({"intent":original.intent,"producerEvent":event});
|
||||
assert!(verified_resolution(encoded.clone(), 1800).is_ok());
|
||||
assert!(verified_resolution(encoded.clone(), 9999).is_ok());
|
||||
assert!(parse(encoded.clone(), 1800).is_err());
|
||||
let mut changed = encoded.clone();
|
||||
changed["intent"]["priceSats"] = serde_json::json!(999);
|
||||
assert!(verified_resolution(changed, 1800).is_err());
|
||||
let mut changed = encoded;
|
||||
changed["selection"] =
|
||||
serde_json::json!({"relative_path":"film.mp4","payment_methods":["cashu"]});
|
||||
assert!(verified_resolution(changed, 1800).is_err());
|
||||
assert!(verified_resolution(
|
||||
serde_json::json!({"intent":original.intent,"producerEvent":original.producer_event}),
|
||||
1800
|
||||
)
|
||||
.is_err());
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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,
|
||||
¶ms.purchase_id,
|
||||
)
|
||||
.await?;
|
||||
let expires = self
|
||||
.playback_handles()
|
||||
.expiry(&handle, &session, &context)?;
|
||||
Ok(
|
||||
serde_json::json!({"playback_url":format!("/api/rental-playback/{handle}"), "expires_at":expires}),
|
||||
)
|
||||
}
|
||||
pub(super) async fn handle_playback_status(
|
||||
&self,
|
||||
params: Option<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(¶ms.handle, &session, &context)?;
|
||||
Ok(serde_json::json!({"expires_at":expires}))
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
¶ms.content_id,
|
||||
params.filename.as_deref(),
|
||||
params.max_wallet_debit,
|
||||
params.consent.as_ref(),
|
||||
&transport,
|
||||
)
|
||||
.await?;
|
||||
match result {
|
||||
ReadyPurchase::AwaitingConfirmation {
|
||||
operation_id,
|
||||
envelope_sha256,
|
||||
gross_token_sats,
|
||||
seller_net_sats,
|
||||
wallet_debit_sats,
|
||||
expires_at,
|
||||
network,
|
||||
mint_url,
|
||||
} => Ok(
|
||||
serde_json::json!({"state":"confirmation_required","operation_id":operation_id,
|
||||
"envelope_sha256":envelope_sha256,"gross_token_sats":gross_token_sats,
|
||||
"seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats,"expires_at":expires_at,"network":network,"mint_url":mint_url}),
|
||||
),
|
||||
ReadyPurchase::Cancelled { operation_id } => {
|
||||
Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id}))
|
||||
}
|
||||
ReadyPurchase::Cached { content_id, .. } => Ok(
|
||||
serde_json::json!({"state":"delivered","owned":true,"owned_content_id":content_id}),
|
||||
),
|
||||
ReadyPurchase::Entitlement { contract, receipt } => {
|
||||
let item = crate::content_purchase_download::cache(
|
||||
&self.config.data_dir,
|
||||
¶ms.onion,
|
||||
&contract,
|
||||
&receipt,
|
||||
)
|
||||
.await?;
|
||||
Ok(
|
||||
serde_json::json!({"state":"delivered","owned":true,"operation_id":contract.id,
|
||||
"owned_content_id":item.content_id,"mime_type":item.mime_type,"size_bytes":item.size_bytes}),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(super) async fn handle_content_cancel_purchase(
|
||||
&self,
|
||||
params: Option<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(¶ms.operation_id)
|
||||
.await?
|
||||
.context("Original purchase not found")?;
|
||||
anyhow::ensure!(
|
||||
record.contract.buyer_did == identity.did_key()?
|
||||
&& record.contract.seller_did == transport.seller_did(),
|
||||
"Cancellation purchase binding changed"
|
||||
);
|
||||
(
|
||||
journal
|
||||
.protocol_envelope("buyer", ¶ms.operation_id)
|
||||
.await?
|
||||
.context("Original payment shape missing")?,
|
||||
journal
|
||||
.buyer_plan(¶ms.operation_id)
|
||||
.await?
|
||||
.context("Original wallet plan missing")?,
|
||||
)
|
||||
};
|
||||
caller::cancel_purchase(&self.config.data_dir, &envelope, &plan, &transport).await?;
|
||||
Ok(serde_json::json!({"state":"cancelled_unspent","operation_id":params.operation_id}))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct RentalParams {
|
||||
seller_did: String,
|
||||
content_id: String,
|
||||
expected_sha256: String,
|
||||
expected_price_sats: u64,
|
||||
expected_viewing_seconds: u64,
|
||||
max_wallet_debit: u64,
|
||||
consent: Option<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(¶ms.expected_viewing_seconds)
|
||||
&& params.expected_sha256.len() == 64
|
||||
&& params
|
||||
.expected_sha256
|
||||
.bytes()
|
||||
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
|
||||
"Invalid published rental terms"
|
||||
);
|
||||
let identity =
|
||||
crate::identity::NodeIdentity::load_existing(&self.config.data_dir.join("identity"))
|
||||
.await?;
|
||||
let buyer = identity.did_key()?;
|
||||
let (state, _) = self.state_manager.get_snapshot().await;
|
||||
let data = self.config.data_dir.clone();
|
||||
tokio::task::spawn_blocking(move || {
|
||||
crate::container::registration_pin::installed_context(&data, &identity, &state)
|
||||
})
|
||||
.await??;
|
||||
let onion = crate::content_purchase_transport::seller_onion_for_did(
|
||||
&self.config.data_dir,
|
||||
¶ms.seller_did,
|
||||
)
|
||||
.await?;
|
||||
let transport =
|
||||
FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone()).await?;
|
||||
let expected = caller::ExpectedRental {
|
||||
seller_did: params.seller_did,
|
||||
content_id: params.content_id.clone(),
|
||||
sha256: params.expected_sha256,
|
||||
price_sats: params.expected_price_sats,
|
||||
viewing_seconds: params.expected_viewing_seconds,
|
||||
};
|
||||
match caller::purchase_bound(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
¶ms.content_id,
|
||||
None,
|
||||
params.max_wallet_debit,
|
||||
params.consent.as_ref(),
|
||||
&transport,
|
||||
Some(&expected),
|
||||
)
|
||||
.await?
|
||||
{
|
||||
ReadyPurchase::AwaitingConfirmation {
|
||||
operation_id,
|
||||
envelope_sha256,
|
||||
gross_token_sats,
|
||||
seller_net_sats,
|
||||
wallet_debit_sats,
|
||||
expires_at,
|
||||
network,
|
||||
mint_url,
|
||||
} => Ok(serde_json::json!({
|
||||
"state":"confirmation_required","operation_id":operation_id,"envelope_sha256":envelope_sha256,
|
||||
"gross_token_sats":gross_token_sats,"seller_net_sats":seller_net_sats,"wallet_debit_sats":wallet_debit_sats,
|
||||
"expires_at":expires_at,"seller_onion":onion,"network":network,"mint_url":mint_url})),
|
||||
ReadyPurchase::Entitlement { contract, .. } => Ok(
|
||||
serde_json::json!({"state":"entitled","operation_id":contract.id,"seller_onion":onion}),
|
||||
),
|
||||
ReadyPurchase::Cancelled { operation_id } => Ok(
|
||||
serde_json::json!({"state":"cancelled_unspent","operation_id":operation_id,"seller_onion":onion}),
|
||||
),
|
||||
ReadyPurchase::Cached { .. } => {
|
||||
anyhow::bail!("A timed rental cannot use a permanent owned copy")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -317,9 +317,18 @@ fn persist_verified(held: &Held, record: &Registered) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Result<()> {
|
||||
ensure_verified_for_use(data_dir, record, file, false)
|
||||
}
|
||||
fn ensure_verified_for_use(
|
||||
data_dir: &Path,
|
||||
record: &Registered,
|
||||
file: &mut File,
|
||||
first_use: bool,
|
||||
) -> Result<()> {
|
||||
// This per-registration lock does not hold the mapping/global directory or
|
||||
// any buyer lease lock while hashing. Normally registration already saved
|
||||
// the verified stamp, so first-open needs no second read of a large movie.
|
||||
// any buyer lease lock while hashing. Range opens can reuse the saved
|
||||
// verification, but a new lease always checks bytes before starting its clock:
|
||||
// same-size writes within a filesystem timestamp tick can share a stamp.
|
||||
let held = keyed(data_dir, "verify", &record.receipt.request_id)?;
|
||||
let path = held
|
||||
.path
|
||||
@@ -330,10 +339,13 @@ fn ensure_verified(data_dir: &Path, record: &Registered, file: &mut File) -> Res
|
||||
saved == expected && Stamp::from_file(file)? == record.stamp,
|
||||
"Immutable snapshot verification binding changed"
|
||||
);
|
||||
return Ok(());
|
||||
if !first_use {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
// Recover a missing cache by streaming the original signed hash. Never
|
||||
// A new lease or missing cache requires the original signed byte hash. Never
|
||||
// manufacture a positive cache entry from metadata alone after restart.
|
||||
file.seek(SeekFrom::Start(0))?;
|
||||
let mut digest = Sha256::new();
|
||||
let mut buffer = [0u8; 64 * 1024];
|
||||
loop {
|
||||
@@ -462,6 +474,16 @@ pub(crate) fn register_approved_selection(
|
||||
limits,
|
||||
progress,
|
||||
)?;
|
||||
commit_prepared(data_dir, identity, &pin, prepared, mime_type)
|
||||
}
|
||||
|
||||
fn commit_prepared(
|
||||
data_dir: &Path,
|
||||
identity: &NodeIdentity,
|
||||
pin: ®istration_pin::RegistrationPin,
|
||||
prepared: media_registration::PreparedRegistration,
|
||||
mime_type: String,
|
||||
) -> Result<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()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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()));
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
@@ -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 } }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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')
|
||||
Reference in New Issue
Block a user