Draft explicit rental readiness and start with verified chunk delivery

This commit is contained in:
archipelago
2026-10-07 00:23:43 -04:00
parent 697aabeec3
commit ed96df0ac3
25 changed files with 1914 additions and 129 deletions
+42 -15
View File
@@ -1,16 +1,16 @@
mod purchase;
mod cloud_purchase;
mod blob; mod blob;
mod cdp; mod cdp;
mod cloud_purchase;
mod content; mod content;
mod registered_media;
mod rental_playback;
mod dwn; mod dwn;
mod model_proxy; mod model_proxy;
mod node_message; mod node_message;
mod proxy; mod proxy;
mod purchase;
mod registered_media;
mod remote_input; mod remote_input;
mod remote_relay; mod remote_relay;
mod rental_playback;
mod routstr_proxy; mod routstr_proxy;
mod websocket; mod websocket;
@@ -389,7 +389,9 @@ impl ApiHandler {
let method = req.method().clone(); let method = req.method().clone();
if path.starts_with("/api/rental-playback/") { if path.starts_with("/api/rental-playback/") {
return self.handle_local_rental_request(&method, &path, req.headers()).await; return self
.handle_local_rental_request(&method, &path, req.headers())
.await;
} }
// Handle CORS preflight for all routes // Handle CORS preflight for all routes
@@ -453,13 +455,28 @@ impl ApiHandler {
} }
// Purchase routes bound the original body before the generic buffer. // Purchase routes bound the original body before the generic buffer.
if method == Method::POST && matches!(path.as_str(), if method == Method::POST
crate::content_purchase_protocol::OFFER_ROUTE | crate::content_purchase_protocol::ACCEPT_ROUTE && matches!(
| crate::content_purchase_protocol::SETTLE_ROUTE | crate::content_purchase_protocol::STATUS_ROUTE path.as_str(),
| crate::content_purchase_protocol::CANCEL_ROUTE) { crate::content_purchase_protocol::PREPARE_OFFER_ROUTE
| 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; return self.handle_purchase_request(req).await;
} }
if method == Method::POST
&& path.starts_with("/content/registered_")
&& path.contains("/rental/")
&& (path.ends_with("/prepare") || path.ends_with("/start"))
{
return self.handle_rental_control(req).await;
}
// Convert body to bytes for non-WS routes // Convert body to bytes for non-WS routes
let headers = req.headers().clone(); let headers = req.headers().clone();
let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default(); let query_string = req.uri().query().map(|s| s.to_string()).unwrap_or_default();
@@ -649,21 +666,31 @@ impl ApiHandler {
// falls outside `connect-src`). Session-authenticated so only // falls outside `connect-src`). Session-authenticated so only
// the logged-in node owner can spin up fetches. // the logged-in node owner can spin up fetches.
(Method::GET, "/api/node-app-catalog") => { (Method::GET, "/api/node-app-catalog") => {
if !self.is_authenticated(&headers).await { return Ok(Self::unauthorized()); } if !self.is_authenticated(&headers).await {
return Ok(Self::unauthorized());
}
let data_dir = self.config.data_dir.clone(); let data_dir = self.config.data_dir.clone();
let result = tokio::task::spawn_blocking(move || { let result = tokio::task::spawn_blocking(move || {
crate::container::node_catalog::verified_body(&data_dir) crate::container::node_catalog::verified_body(&data_dir)
}).await.unwrap_or_else(|error| Err(anyhow::anyhow!(error))); })
.await
.unwrap_or_else(|error| Err(anyhow::anyhow!(error)));
let (status, body) = match result { let (status, body) = match result {
Ok(Some(body)) => (StatusCode::OK, body), Ok(Some(body)) => (StatusCode::OK, body),
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()), Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
Err(error) => { Err(error) => {
tracing::warn!("Node demo catalog rejected: {error}"); tracing::warn!("Node demo catalog rejected: {error}");
(StatusCode::CONFLICT, "{\"error\":\"Node demo catalog is unavailable\"}".to_owned()) (
}, StatusCode::CONFLICT,
"{\"error\":\"Node demo catalog is unavailable\"}".to_owned(),
)
}
}; };
Ok(Response::builder().status(status).header("Content-Type", "application/json") Ok(Response::builder()
.header("Cache-Control", "private, no-store").body(hyper::Body::from(body))?) .status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "private, no-store")
.body(hyper::Body::from(body))?)
} }
(Method::GET, "/api/app-catalog") => { (Method::GET, "/api/app-catalog") => {
+28 -1
View File
@@ -23,7 +23,8 @@ impl ApiHandler {
request.method() == Method::POST request.method() == Method::POST
&& matches!( && matches!(
path.as_str(), path.as_str(),
protocol::OFFER_ROUTE protocol::PREPARE_OFFER_ROUTE
| protocol::OFFER_ROUTE
| protocol::ACCEPT_ROUTE | protocol::ACCEPT_ROUTE
| protocol::SETTLE_ROUTE | protocol::SETTLE_ROUTE
| protocol::STATUS_ROUTE | protocol::STATUS_ROUTE
@@ -59,6 +60,32 @@ impl ApiHandler {
)?; )?;
let data_dir = &self.config.data_dir; let data_dir = &self.config.data_dir;
let result = match path.as_str() { let result = match path.as_str() {
protocol::PREPARE_OFFER_ROUTE => {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Prepare {
content_id: String,
#[serde(default)]
retry: bool,
}
let input: Prepare = serde_json::from_slice(&bytes)?;
let identity = std::sync::Arc::new(
NodeIdentity::load_existing(&data_dir.join("identity")).await?,
);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let root = data_dir.clone();
serde_json::to_value(
tokio::task::spawn_blocking(move || {
crate::registered_media::prepare_registered(
root,
identity,
&input.content_id,
input.retry,
)
})
.await??,
)?
}
protocol::OFFER_ROUTE => { protocol::OFFER_ROUTE => {
let body: OfferRequest = serde_json::from_slice(&bytes)?; let body: OfferRequest = serde_json::from_slice(&bytes)?;
anyhow::ensure!( anyhow::ensure!(
@@ -4,7 +4,6 @@ use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media:
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode}; use hyper::{Body, HeaderMap, Response, StatusCode};
use std::sync::Arc; use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
fn route(path: &str) -> Result<(&str, &str)> { fn route(path: &str) -> Result<(&str, &str)> {
let (content, purchase) = path let (content, purchase) = path
@@ -46,6 +45,125 @@ fn denied(message: &'static str) -> Response<Body> {
} }
impl ApiHandler { impl ApiHandler {
pub(super) async fn handle_rental_control(
&self,
mut request: hyper::Request<Body>,
) -> Result<Response<Body>> {
use hyper::body::HttpBody;
#[derive(serde::Deserialize)]
#[serde(deny_unknown_fields)]
struct Control {
capability: String,
ready_id: Option<String>,
#[serde(default)]
retry: bool,
}
let path = request.uri().path().to_owned();
let (base, action) = path.rsplit_once('/').context("Invalid rental action")?;
anyhow::ensure!(
matches!(action, "prepare" | "start") && request.method() == hyper::Method::POST,
"Invalid rental action"
);
let (content, purchase) = route(base)?;
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 <= 16 * 1024),
"Rental request too large"
);
bytes.extend_from_slice(&chunk);
}
Ok::<_, anyhow::Error>(bytes)
})
.await
.context("Rental request timed out")??;
let audience = crate::identity::did_key_from_pubkey_hex(&self.self_pubkey_hex)?;
let buyer = crate::content_auth::authenticate_request(
request.headers(),
&audience,
&hyper::Method::POST,
&path,
&bytes,
chrono::Utc::now().timestamp(),
)?;
let input: Control = serde_json::from_slice(&bytes)?;
let identity =
Arc::new(NodeIdentity::load_existing(&self.config.data_dir.join("identity")).await?);
anyhow::ensure!(identity.did_key()? == audience, "Node identity changed");
let result = if action == "prepare" {
anyhow::ensure!(input.ready_id.is_none(), "Prepare does not start a rental");
let prior = crate::registered_media::paid_window(
&self.config.data_dir,
&identity,
content,
purchase,
&buyer,
&input.capability,
)
.await?;
let metadata = crate::registered_media::registered_metadata(
&self.config.data_dir,
&identity,
content,
)?;
anyhow::ensure!(
prior
.as_ref()
.is_none_or(|window| clock() >= window.started_at),
"Rental clock moved backwards"
);
if let Some(window) = prior.as_ref().filter(|window| clock() >= window.expires_at) {
serde_json::json!({"state":"expired", "viewing_seconds":metadata.0.viewing_seconds,"started_at":window.started_at,"expires_at":window.expires_at})
} else {
let state = crate::registered_media::prepare_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
input.capability,
input.retry,
)
.await?;
let mut result = serde_json::to_value(state)?;
result["viewing_seconds"] = serde_json::json!(metadata.0.viewing_seconds);
result["started_at"] =
serde_json::json!(prior.as_ref().map(|window| window.started_at));
result["expires_at"] =
serde_json::json!(prior.as_ref().map(|window| window.expires_at));
result
}
} else {
anyhow::ensure!(!input.retry, "Start cannot retry verification");
let ready_id = input
.ready_id
.context("Media must be ready before explicit Start")?;
let window = crate::registered_media::start_paid(
self.config.data_dir.clone(),
identity,
content.into(),
purchase.into(),
buyer,
input.capability,
ready_id,
)
.await?;
anyhow::ensure!(clock() >= window.started_at, "Rental clock moved backwards");
serde_json::json!({"state": if clock() >= window.expires_at {"expired"} else {"started"},
"started_at":window.started_at,"expires_at":window.expires_at})
};
Ok(build_response(
StatusCode::OK,
"application/json",
Body::from(serde_json::to_vec(&result)?),
))
}
pub(super) async fn handle_registered_rental( pub(super) async fn handle_registered_rental(
&self, &self,
path: &str, path: &str,
@@ -102,7 +220,7 @@ impl ApiHandler {
let selected = content.to_owned(); let selected = content.to_owned();
let key = identity.clone(); let key = identity.clone();
let metadata = tokio::task::spawn_blocking(move || { let metadata = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_terms(&data, &key, &selected) crate::registered_media::registered_metadata(&data, &key, &selected)
}) })
.await?; .await?;
let (receipt, _) = match metadata { let (receipt, _) = match metadata {
@@ -160,11 +278,9 @@ async fn rental_response(
let started = opened.started_at; let started = opened.started_at;
let expires = opened.expires_at; let expires = opened.expires_at;
let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1)); let (start, length) = range.map_or((0, total), |(start, end)| (start, end - start + 1));
let mut file = tokio::fs::File::from_std(opened.file);
file.seek(std::io::SeekFrom::Start(start)).await?;
let chunks = futures_util::stream::try_unfold( let chunks = futures_util::stream::try_unfold(
(file, length, now), (opened.file, opened.verification, start, length, now),
move |(mut file, left, now)| async move { move |(mut file, verification, position, left, now)| async move {
if left == 0 { if left == 0 {
return Ok::<_, std::io::Error>(None); return Ok::<_, std::io::Error>(None);
} }
@@ -175,15 +291,20 @@ async fn rental_response(
"Rental window ended", "Rental window ended",
)); ));
} }
let mut bytes = vec![0; left.min(64 * 1024) as usize]; let read = tokio::task::spawn_blocking(move || {
let count = tokio::time::timeout( let bytes = verification
std::time::Duration::from_secs(expires - instant), .index
file.read(&mut bytes), .read_slice(&mut file, position, left.min(64 * 1024) as usize)
) .map_err(std::io::Error::other)?;
.await Ok::<_, std::io::Error>((file, verification, bytes))
.map_err(|_| { });
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended") let (file, verification, bytes) =
})??; tokio::time::timeout(std::time::Duration::from_secs(expires - instant), read)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
})?
.map_err(std::io::Error::other)??;
let instant = now(); let instant = now();
if instant < started || instant >= expires { if instant < started || instant >= expires {
return Err(std::io::Error::new( return Err(std::io::Error::new(
@@ -191,14 +312,11 @@ async fn rental_response(
"Rental window ended", "Rental window ended",
)); ));
} }
if count == 0 { let count = bytes.len() as u64;
return Err(std::io::Error::new( Ok(Some((
std::io::ErrorKind::UnexpectedEof, bytes,
"Registered snapshot ended early", (file, verification, position + count, left - count, now),
)); )))
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64, now))))
}, },
); );
let mut response = Response::builder() let mut response = Response::builder()
@@ -257,10 +375,19 @@ mod tests {
); );
} }
fn opened(size: u64) -> OpenedMedia { fn opened(size: u64) -> OpenedMedia {
let file = tempfile::tempfile().unwrap(); use sha2::{Digest, Sha256};
let mut file = tempfile::tempfile().unwrap();
file.set_len(size).unwrap(); file.set_len(size).unwrap();
let binding = crate::rental_chunk_index::Binding {
content_id: format!("registered_{}", uuid::Uuid::new_v4()),
receipt_sha256: "ab".repeat(32),
full_sha256: hex::encode(Sha256::digest(vec![0; size as usize])),
size,
};
let index = crate::rental_chunk_index::Index::scan(&mut file, binding, |_| Ok(())).unwrap();
OpenedMedia { OpenedMedia {
file, file,
verification: crate::rental_readiness::Ready::fixture(index),
size_bytes: size, size_bytes: size,
mime_type: "video/mp4".into(), mime_type: "video/mp4".into(),
started_at: 1000, started_at: 1000,
@@ -333,6 +333,8 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await, "content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().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-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-prepare" => self.handle_playback_prepare(params, session_token).await,
"content.playback-start" => self.handle_playback_start(params, session_token).await,
"content.playback-status" => self.handle_playback_status(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.rental-purchase" => self.handle_content_rental_purchase(params).await,
"content.purchase" => self.handle_content_purchase(params).await, "content.purchase" => self.handle_content_purchase(params).await,
+5 -1
View File
@@ -20,8 +20,8 @@ mod interfaces;
pub(crate) mod lnd; pub(crate) mod lnd;
mod marketplace; mod marketplace;
mod media_registration; mod media_registration;
mod purchase;
mod playback; mod playback;
mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses // pub(crate): 13-10's `assistant::backends::select_backend` reuses
// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing — // `mesh::assistant::detect_ollama()` (D-04) rather than re-probing —
// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod // matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod
@@ -113,6 +113,8 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "content.cancel-purchase" | "content.cancel-purchase"
| "content.playback-handle" | "content.playback-handle"
| "content.playback-status" | "content.playback-status"
| "content.playback-prepare"
| "content.playback-start"
) || nostr_signing_origin_allowed(headers, dev_mode) ) || nostr_signing_origin_allowed(headers, dev_mode)
} }
@@ -831,6 +833,8 @@ mod nostr_signing_origin_tests {
"content.cancel-purchase", "content.cancel-purchase",
"content.playback-handle", "content.playback-handle",
"content.playback-status", "content.playback-status",
"content.playback-prepare",
"content.playback-start",
] { ] {
assert!(!native_consent_origin_allowed( assert!(!native_consent_origin_allowed(
method, method,
+177 -3
View File
@@ -55,9 +55,7 @@ impl RpcHandler {
let expires = self let expires = self
.playback_handles() .playback_handles()
.expiry(&handle, &session, &context)?; .expiry(&handle, &session, &context)?;
Ok( Ok(serde_json::json!({"handle":handle,"expires_at":expires}))
serde_json::json!({"playback_url":format!("/api/rental-playback/{handle}"), "expires_at":expires}),
)
} }
pub(super) async fn handle_playback_status( pub(super) async fn handle_playback_status(
&self, &self,
@@ -72,3 +70,179 @@ impl RpcHandler {
Ok(serde_json::json!({"expires_at":expires})) Ok(serde_json::json!({"expires_at":expires}))
} }
} }
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Prepare {
handle: String,
#[serde(default)]
retry: bool,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct Start {
handle: String,
ready_id: String,
}
impl RpcHandler {
async fn playback_control(
&self,
handle: &str,
ready_id: Option<&str>,
retry: bool,
session: &Option<String>,
) -> Result<serde_json::Value> {
let (session, context) = self.playback_context(session).await?;
let binding = self.playback_handles().lookup(handle, &session, &context)?;
let (capability, duration) = {
let journal = crate::content_purchase::Journal::open(&self.config.data_dir).await?;
let record = journal
.buyer(&binding.contract.id)
.await?
.context("Original purchase missing")?;
anyhow::ensure!(
record.contract == binding.contract,
"Original purchase changed"
);
let capability = record
.receipt()
.context("Original payment is not settled")?
.capability
.clone();
let envelope = journal
.protocol_envelope("buyer", &binding.contract.id)
.await?
.context("Original rental terms missing")?;
(
capability,
envelope
.offer
.viewing_seconds
.context("Purchase is not a timed rental")?,
)
};
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 unavailable")?;
let action = if ready_id.is_some() {
"start"
} else {
"prepare"
};
let path = format!(
"/content/{}/rental/{}/{action}",
binding.contract.content_id, binding.contract.id
);
let body = serde_json::json!({"capability":capability,"ready_id":ready_id,"retry":retry});
let (mut response, _) =
crate::fips::dial::PeerRequest::new(Some(&mesh), &binding.seller_onion, &path)
.require_fips()
.single_delivery()
.timeout(std::time::Duration::from_secs(20))
.send_content_json(&self.config.data_dir, &binding.contract.seller_did, &body)
.await?;
anyhow::ensure!(
response.status().is_success(),
"Original rental is unavailable; retry this purchase without paying again"
);
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 <= 16 * 1024),
"Rental control response too large"
);
bytes.extend_from_slice(&chunk);
}
let remote: serde_json::Value = serde_json::from_slice(&bytes)?;
let state = remote["state"].as_str().context("Missing rental state")?;
let window = match (remote["started_at"].as_u64(), remote["expires_at"].as_u64()) {
(None, None) if remote["started_at"].is_null() && remote["expires_at"].is_null() => {
None
}
(Some(started), Some(expires))
if started > 0 && started.checked_add(duration) == Some(expires) =>
{
Some((started, expires))
}
_ => anyhow::bail!("Seller changed the original rental window"),
};
let (started_at, expires_at) =
window.map_or((None, None), |(start, end)| (Some(start), Some(end)));
let result = match state {
"preparing" if ready_id.is_none() => {
let completed = remote["completed_bytes"]
.as_u64()
.context("Invalid verification progress")?;
anyhow::ensure!(
remote["total_bytes"].as_u64() == Some(binding.contract.content_size)
&& completed <= binding.contract.content_size
&& remote["viewing_seconds"].as_u64() == Some(duration),
"Rental preparation terms changed"
);
serde_json::json!({"state":"preparing","completed_bytes":completed,"total_bytes":binding.contract.content_size,
"viewing_seconds":duration,"started_at":started_at,"expires_at":expires_at})
}
"ready" if ready_id.is_none() => {
let id = remote["ready_id"]
.as_str()
.context("Missing readiness identifier")?;
let parsed = uuid::Uuid::parse_str(id)?;
anyhow::ensure!(
parsed.to_string() == id
&& parsed.get_version_num() == 4
&& remote["total_bytes"].as_u64() == Some(binding.contract.content_size)
&& remote["viewing_seconds"].as_u64() == Some(duration),
"Rental readiness changed"
);
serde_json::json!({"state":"ready","ready_id":id,"handle":handle,"viewing_seconds":duration,
"started_at":started_at,"expires_at":expires_at})
}
"unavailable" if ready_id.is_none() => {
serde_json::json!({"state":"unavailable","expires_at":expires_at})
}
"expired" if window.is_some() => {
serde_json::json!({"state":"expired","started_at":started_at,"expires_at":expires_at})
}
"started" if ready_id.is_some() && window.is_some() => {
serde_json::json!({"state":"started","started_at":started_at,
"expires_at":expires_at,"playback_url":format!("/api/rental-playback/{handle}")})
}
_ => {
anyhow::bail!("Unexpected rental state; recover this purchase without paying again")
}
};
if let Some((_, expires)) = window {
self.playback_handles()
.note_expiry(handle, &binding, expires)?;
}
Ok(result)
}
pub(super) async fn handle_playback_prepare(
&self,
params: Option<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
let input: Prepare = serde_json::from_value(params.context("Missing playback handle")?)?;
self.playback_control(&input.handle, None, input.retry, session)
.await
}
pub(super) async fn handle_playback_start(
&self,
params: Option<serde_json::Value>,
session: &Option<String>,
) -> Result<serde_json::Value> {
let input: Start = serde_json::from_value(params.context("Missing readiness identifier")?)?;
self.playback_control(&input.handle, Some(&input.ready_id), false, session)
.await
}
}
+13 -2
View File
@@ -51,6 +51,9 @@ impl RpcHandler {
) )
.await?; .await?;
match result { match result {
ReadyPurchase::Preparing { .. } => {
anyhow::bail!("Ordinary purchase cannot prepare a timed rental")
}
ReadyPurchase::AwaitingConfirmation { ReadyPurchase::AwaitingConfirmation {
operation_id, operation_id,
envelope_sha256, envelope_sha256,
@@ -127,6 +130,8 @@ impl RpcHandler {
#[derive(Deserialize)] #[derive(Deserialize)]
#[serde(deny_unknown_fields)] #[serde(deny_unknown_fields)]
struct RentalParams { struct RentalParams {
#[serde(default)]
retry_preparation: bool,
seller_did: String, seller_did: String,
content_id: String, content_id: String,
expected_sha256: String, expected_sha256: String,
@@ -168,8 +173,9 @@ impl RpcHandler {
&params.seller_did, &params.seller_did,
) )
.await?; .await?;
let transport = let transport = FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone())
FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone()).await?; .await?
.retry_preparation(params.retry_preparation);
let expected = caller::ExpectedRental { let expected = caller::ExpectedRental {
seller_did: params.seller_did, seller_did: params.seller_did,
content_id: params.content_id.clone(), content_id: params.content_id.clone(),
@@ -189,6 +195,11 @@ impl RpcHandler {
) )
.await? .await?
{ {
ReadyPurchase::Preparing {
completed_bytes,
total_bytes,
} => Ok(serde_json::json!({
"state":"preparing", "completed_bytes":completed_bytes,"total_bytes":total_bytes})),
ReadyPurchase::AwaitingConfirmation { ReadyPurchase::AwaitingConfirmation {
operation_id, operation_id,
envelope_sha256, envelope_sha256,
@@ -12,6 +12,12 @@ pub(crate) trait PurchaseTransport: Send + Sync {
/// This identity is the independently verified peer binding, not response JSON. /// This identity is the independently verified peer binding, not response JSON.
fn seller_did(&self) -> &str; fn seller_did(&self) -> &str;
fn seller_onion(&self) -> &str; fn seller_onion(&self) -> &str;
fn prepare_offer(
&self,
_content_id: &str,
) -> impl Future<Output = Result<Option<(u64, u64)>>> + Send {
async { Ok(None) }
}
fn offer(&self, id: &str, content_id: &str) -> impl Future<Output = Result<Offer>> + Send; 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 accept(&self, envelope: &Envelope) -> impl Future<Output = Result<Accepted>> + Send;
fn status(&self, envelope: &Envelope) -> impl Future<Output = Result<SellerStatus>> + Send; fn status(&self, envelope: &Envelope) -> impl Future<Output = Result<SellerStatus>> + Send;
@@ -20,6 +26,10 @@ pub(crate) trait PurchaseTransport: Send + Sync {
} }
#[derive(Clone)] #[derive(Clone)]
pub(crate) enum ReadyPurchase { pub(crate) enum ReadyPurchase {
Preparing {
completed_bytes: u64,
total_bytes: u64,
},
AwaitingConfirmation { AwaitingConfirmation {
operation_id: String, operation_id: String,
envelope_sha256: String, envelope_sha256: String,
@@ -210,6 +220,16 @@ pub(crate) async fn purchase_bound(
!unresolved_owned, !unresolved_owned,
"Prior purchase delivery needs recovery; no new payment operation was created" "Prior purchase delivery needs recovery; no new payment operation was created"
); );
if content_id.starts_with("registered_") {
if let Some((completed_bytes, total_bytes)) =
transport.prepare_offer(content_id).await?
{
return Ok(ReadyPurchase::Preparing {
completed_bytes,
total_bytes,
});
}
}
let id = uuid::Uuid::new_v4().to_string(); let id = uuid::Uuid::new_v4().to_string();
let offer = transport.offer(&id, content_id).await?; let offer = transport.offer(&id, content_id).await?;
offer.validate()?; offer.validate()?;
@@ -8,6 +8,7 @@ use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::Path; use std::path::Path;
pub(crate) const PREPARE_OFFER_ROUTE: &str = "/content/purchase/v1/prepare-offer";
pub(crate) const OFFER_ROUTE: &str = "/content/purchase/v1/offer"; pub(crate) const OFFER_ROUTE: &str = "/content/purchase/v1/offer";
pub(crate) const ACCEPT_ROUTE: &str = "/content/purchase/v1/accept"; pub(crate) const ACCEPT_ROUTE: &str = "/content/purchase/v1/accept";
pub(crate) const SETTLE_ROUTE: &str = "/content/purchase/v1/settle"; pub(crate) const SETTLE_ROUTE: &str = "/content/purchase/v1/settle";
@@ -15,6 +15,7 @@ pub(crate) struct FipsPurchaseTransport {
onion: String, onion: String,
seller_did: String, seller_did: String,
fips_npub: String, fips_npub: String,
retry_preparation: bool,
} }
impl FipsPurchaseTransport { impl FipsPurchaseTransport {
pub async fn load(data_dir: PathBuf, onion: String) -> Result<Self> { pub async fn load(data_dir: PathBuf, onion: String) -> Result<Self> {
@@ -31,8 +32,13 @@ impl FipsPurchaseTransport {
onion, onion,
seller_did: peer.did, seller_did: peer.did,
fips_npub, fips_npub,
retry_preparation: false,
}) })
} }
pub fn retry_preparation(mut self, retry: bool) -> Self {
self.retry_preparation = retry;
self
}
async fn post<B: Serialize + Sync, R: DeserializeOwned>( async fn post<B: Serialize + Sync, R: DeserializeOwned>(
&self, &self,
route: &str, route: &str,
@@ -73,6 +79,30 @@ impl PurchaseTransport for FipsPurchaseTransport {
fn seller_did(&self) -> &str { fn seller_did(&self) -> &str {
&self.seller_did &self.seller_did
} }
async fn prepare_offer(&self, content_id: &str) -> Result<Option<(u64, u64)>> {
let state: crate::rental_readiness::Status = self
.post(
protocol::PREPARE_OFFER_ROUTE,
&serde_json::json!({"content_id":content_id,"retry":self.retry_preparation}),
)
.await?;
match state {
crate::rental_readiness::Status::Ready { .. } => Ok(None),
crate::rental_readiness::Status::Preparing {
completed_bytes,
total_bytes,
} => {
anyhow::ensure!(
completed_bytes <= total_bytes && total_bytes > 0,
"Invalid media preparation progress"
);
Ok(Some((completed_bytes, total_bytes)))
}
crate::rental_readiness::Status::Unavailable => anyhow::bail!(
"Media verification failed; no payment started. Retry media preparation."
),
}
}
async fn offer(&self, id: &str, content_id: &str) -> Result<Offer> { async fn offer(&self, id: &str, content_id: &str) -> Result<Offer> {
#[derive(Serialize)] #[derive(Serialize)]
struct Request<'a> { struct Request<'a> {
+2
View File
@@ -83,6 +83,8 @@ mod port_allocator;
mod prepared_media; mod prepared_media;
mod rate_limit; mod rate_limit;
mod registered_media; mod registered_media;
mod rental_chunk_index;
mod rental_readiness;
pub mod seed; pub mod seed;
mod server; mod server;
mod session; mod session;
+498 -35
View File
@@ -86,6 +86,7 @@ struct Lease {
/// stream at expiry, and must not call open_paid for HTTP HEAD/preflight. /// stream at expiry, and must not call open_paid for HTTP HEAD/preflight.
pub(crate) struct OpenedMedia { pub(crate) struct OpenedMedia {
pub file: File, pub file: File,
pub verification: Arc<crate::rental_readiness::Ready>,
pub size_bytes: u64, pub size_bytes: u64,
pub mime_type: String, pub mime_type: String,
pub started_at: u64, pub started_at: u64,
@@ -395,6 +396,13 @@ fn verify(
Ok(()) Ok(())
} }
fn open_snapshot(data_dir: &Path, record: &Registered) -> Result<File> { fn open_snapshot(data_dir: &Path, record: &Registered) -> Result<File> {
open_snapshot_with_stamp(data_dir, record, true)
}
fn open_snapshot_with_stamp(
data_dir: &Path,
record: &Registered,
require_original_stamp: bool,
) -> Result<File> {
use std::ffi::CString; use std::ffi::CString;
use std::os::fd::FromRawFd; use std::os::fd::FromRawFd;
// Resolve from the configured data directory in one kernel operation. No // Resolve from the configured data directory in one kernel operation. No
@@ -435,7 +443,8 @@ fn open_snapshot(data_dir: &Path, record: &Registered) -> Result<File> {
); );
let file = unsafe { File::from_raw_fd(fd as i32) }; let file = unsafe { File::from_raw_fd(fd as i32) };
anyhow::ensure!( anyhow::ensure!(
Stamp::from_file(&file)? == record.stamp, Stamp::from_file(&file)?.size == record.stamp.size
&& (!require_original_stamp || Stamp::from_file(&file)? == record.stamp),
"Registered immutable snapshot changed" "Registered immutable snapshot changed"
); );
Ok(file) Ok(file)
@@ -561,11 +570,11 @@ pub(crate) fn resolve_registration(
/// No request chooses app scope or storage path. This is an offer prerequisite, /// No request chooses app scope or storage path. This is an offer prerequisite,
/// not advertisement: the future offer creator must authenticate the peer and /// not advertisement: the future offer creator must authenticate the peer and
/// bind all returned terms into the purchase contract before seller acceptance. /// bind all returned terms into the purchase contract before seller acceptance.
pub(crate) fn registered_terms( fn metadata_record(
data_dir: &Path, data_dir: &Path,
identity: &NodeIdentity, identity: &NodeIdentity,
content_id: &str, content_id: &str,
) -> Result<(Receipt, String)> { ) -> Result<Registered> {
let id = registration_id(content_id)?; let id = registration_id(content_id)?;
let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?; let pin = registration_pin::load_existing(data_dir, APP_ID, identity)?;
let held = held(data_dir, false)?; let held = held(data_dir, false)?;
@@ -573,32 +582,198 @@ pub(crate) fn registered_terms(
.context("Registered content is unavailable")?; .context("Registered content is unavailable")?;
drop(held); drop(held);
verify(&record, &pin, identity)?; verify(&record, &pin, identity)?;
let mut file = open_snapshot(data_dir, &record)?; Ok(record)
// A quote must not invite payment for altered bytes, including a same-tick }
// metadata collision. This scan completes before any offer is accepted. fn chunk_binding(record: &Registered) -> Result<crate::rental_chunk_index::Binding> {
ensure_verified_for_use(data_dir, &record, &mut file, true)?; Ok(crate::rental_chunk_index::Binding {
content_id: record.receipt.content_id.clone(),
receipt_sha256: hash(&serde_json::to_vec(&record.receipt)?),
full_sha256: record.receipt.sha256.clone(),
size: record.stamp.size,
})
}
fn readiness_key(data_dir: &Path, record: &Registered) -> Result<String> {
Ok(format!(
"{}:{}",
data_dir.canonicalize()?.display(),
hash(&serde_json::to_vec(&record.receipt)?)
))
}
/// Signed metadata only: no whole-file scan or lease is permitted here.
pub(crate) fn registered_metadata(
data_dir: &Path,
identity: &NodeIdentity,
content_id: &str,
) -> Result<(Receipt, String)> {
let record = metadata_record(data_dir, identity, content_id)?;
Ok((record.receipt, record.terms_sha256))
}
/// Starts/reuses bounded background verification, before quoting or playing.
/// The signed index survives restart; range requests never invoke this scan.
pub(crate) fn prepare_registered(
data_dir: PathBuf,
identity: Arc<NodeIdentity>,
content_id: &str,
retry: bool,
) -> Result<crate::rental_readiness::Status> {
let record = metadata_record(&data_dir, &identity, content_id)?;
let binding = chunk_binding(&record)?;
let key = readiness_key(&data_dir, &record)?;
let manager = crate::rental_readiness::shared();
if retry {
manager.retry_failed(&key, &binding)?;
}
manager.prepare(key, binding.clone(), move |progress| {
let held = keyed(&data_dir, "verify", &record.receipt.request_id)?;
let path = held
.path
.join(format!("chunks-{}.bin", record.receipt.request_id));
let mut file = open_snapshot_with_stamp(&data_dir, &record, false)?;
if let Ok(Some(index)) = crate::rental_chunk_index::Index::load(&path, &binding, &identity)
{
return Ok(index);
}
// A damaged derived index is not evidence about the original bytes.
// Rebuild only after a complete scan matches the signed producer SHA;
// this avoids making an editable cache a permanent recovery dead end.
let index = crate::rental_chunk_index::Index::scan(&mut file, binding, |n| {
progress.store(n, std::sync::atomic::Ordering::SeqCst);
Ok(())
})?;
index.save(&path, &identity)?;
Ok(index)
})
}
#[derive(Clone, Serialize)]
pub(crate) struct RentalWindow {
pub started_at: u64,
pub expires_at: u64,
}
fn verify_lease(
lease: &Lease,
contract: &Contract,
capability: &str,
duration: u64,
) -> Result<RentalWindow> {
anyhow::ensure!(
lease.version == 1
&& lease.purchase_id == contract.id
&& lease.buyer_did == contract.buyer_did
&& lease.content_id == contract.content_id
&& lease.contract_hash == contract.context_hash()?
&& lease.capability_hash == hash(capability.as_bytes())
&& lease.started_at.checked_add(duration) == Some(lease.expires_at),
"Persisted rental terms changed"
);
Ok(RentalWindow {
started_at: lease.started_at,
expires_at: lease.expires_at,
})
}
fn verify_settled_terms(record: &Registered, contract: &Contract) -> Result<()> {
anyhow::ensure!(
contract.content_sha256 == record.receipt.sha256
&& contract.content_size == record.stamp.size
&& contract.terms_sha256 == record.terms_sha256
&& record.receipt.price_sats > 0
&& record
.receipt
.payment_methods
.iter()
.any(|method| method == "cashu")
&& contract.minimum_net_sats == record.receipt.price_sats,
"Settled purchase does not match registered immutable terms"
);
Ok(())
}
/// Only explicit authenticated Start invokes this commit. Losing its response
/// cannot reset the original clock; repeated Start returns the same window.
fn start_ready(
data_dir: &Path,
identity: &NodeIdentity,
contract: &Contract,
capability: &str,
ready_id: &str,
now: impl Fn() -> Result<u64>,
) -> Result<RentalWindow> {
let record = metadata_record(data_dir, identity, &contract.content_id)?;
verify_settled_terms(&record, contract)?;
{
let held = keyed(data_dir, "lease", &contract.id)?;
if let Some(lease) = read::<Lease>(&held.path.join(format!("lease-{}.json", contract.id)))?
{
return verify_lease(&lease, contract, capability, record.receipt.viewing_seconds);
}
}
let ready = crate::rental_readiness::shared().ready(
&readiness_key(data_dir, &record)?,
&chunk_binding(&record)?,
Some(ready_id),
)?;
let mut file = open_snapshot_with_stamp(data_dir, &record, false)?;
// A bounded actual byte read before the first clock. Later chunks are
// verified immediately before delivery, including seek/range requests.
ready.index.read_slice(&mut file, 0, 1)?;
let held = keyed(data_dir, "lease", &contract.id)?;
let path = held.path.join(format!("lease-{}.json", contract.id));
if let Some(lease) = read::<Lease>(&path)? {
return verify_lease(&lease, contract, capability, record.receipt.viewing_seconds);
}
let started_at = now()?;
let expires_at = started_at
.checked_add(record.receipt.viewing_seconds)
.context("Rental expiry overflow")?;
let lease = Lease {
version: 1,
purchase_id: contract.id.clone(),
buyer_did: contract.buyer_did.clone(),
content_id: contract.content_id.clone(),
contract_hash: contract.context_hash()?,
capability_hash: hash(capability.as_bytes()),
started_at,
expires_at,
};
persist(&held, &path, &lease)?;
Ok(RentalWindow {
started_at,
expires_at,
})
}
pub(crate) fn registered_terms(
data_dir: &Path,
identity: &NodeIdentity,
content_id: &str,
) -> Result<(Receipt, String)> {
let record = metadata_record(data_dir, identity, content_id)?;
let _ready = crate::rental_readiness::shared().ready(
&readiness_key(data_dir, &record)?,
&chunk_binding(&record)?,
None,
)?;
let _file = open_snapshot_with_stamp(data_dir, &record, false)?;
Ok((record.receipt, record.terms_sha256)) Ok((record.receipt, record.terms_sha256))
} }
/// Only authenticated peer GET/range routes may call this. The capability is /// Only authenticated peer GET/range routes may call this. The capability is
/// checked against this node's durable seller journal; client receipts do not /// checked against this node's durable seller journal; client receipts do not
/// establish payment. A lease is persisted before any bytes can be returned. /// establish payment. A lease is persisted before any bytes can be returned.
pub(crate) async fn open_paid( async fn paid_contract(
data_dir: PathBuf, data_dir: &Path,
identity: Arc<NodeIdentity>, identity: &NodeIdentity,
content_id: String, content_id: &str,
purchase_id: String, purchase_id: &str,
authenticated_buyer: String, authenticated_buyer: &str,
capability: String, capability: &str,
) -> Result<OpenedMedia> { ) -> Result<Contract> {
uuid(&purchase_id)?; uuid(&purchase_id)?;
registration_id(&content_id)?; registration_id(&content_id)?;
anyhow::ensure!( anyhow::ensure!(
capability.len() == 64 && capability.bytes().all(|c| c.is_ascii_hexdigit()), capability.len() == 64 && capability.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid delivery capability" "Invalid delivery capability"
); );
let (contract, saved_capability) = { let contract = {
let journal = Journal::open(&data_dir).await?; let journal = Journal::open(data_dir).await?;
let record = journal let record = journal
.seller(&purchase_id) .seller(&purchase_id)
.await? .await?
@@ -614,18 +789,157 @@ pub(crate) async fn open_paid(
_ => anyhow::bail!("Seller receipt is not durable"), _ => anyhow::bail!("Seller receipt is not durable"),
}; };
anyhow::ensure!( anyhow::ensure!(
constant_equal(&capability, &receipt.capability), constant_equal(capability, &receipt.capability),
"Delivery capability does not match this purchase" "Delivery capability does not match this purchase"
); );
(record.contract, receipt.capability) record.contract
}; };
Ok(contract)
}
pub(crate) async fn prepare_paid(
data_dir: PathBuf,
identity: Arc<NodeIdentity>,
content_id: String,
purchase_id: String,
buyer: String,
capability: String,
retry: bool,
) -> Result<crate::rental_readiness::Status> {
paid_contract(
&data_dir,
&identity,
&content_id,
&purchase_id,
&buyer,
&capability,
)
.await?;
tokio::task::spawn_blocking(move || prepare_registered(data_dir, identity, &content_id, retry))
.await?
}
pub(crate) async fn paid_window(
data_dir: &Path,
identity: &NodeIdentity,
content_id: &str,
purchase_id: &str,
buyer: &str,
capability: &str,
) -> Result<Option<RentalWindow>> {
let contract = paid_contract(
data_dir,
identity,
content_id,
purchase_id,
buyer,
capability,
)
.await?;
let record = metadata_record(data_dir, identity, content_id)?;
verify_settled_terms(&record, &contract)?;
let held = keyed(data_dir, "lease", purchase_id)?;
read::<Lease>(&held.path.join(format!("lease-{purchase_id}.json")))?
.map(|lease| {
verify_lease(
&lease,
&contract,
capability,
record.receipt.viewing_seconds,
)
})
.transpose()
}
pub(crate) async fn start_paid(
data_dir: PathBuf,
identity: Arc<NodeIdentity>,
content_id: String,
purchase_id: String,
buyer: String,
capability: String,
ready_id: String,
) -> Result<RentalWindow> {
let contract = paid_contract(
&data_dir,
&identity,
&content_id,
&purchase_id,
&buyer,
&capability,
)
.await?;
tokio::task::spawn_blocking(move || { tokio::task::spawn_blocking(move || {
open_settled(&data_dir, &identity, &contract, &saved_capability, || { start_ready(
&data_dir,
&identity,
&contract,
&capability,
&ready_id,
|| u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock"),
)
})
.await?
}
pub(crate) async fn open_paid(
data_dir: PathBuf,
identity: Arc<NodeIdentity>,
content_id: String,
purchase_id: String,
authenticated_buyer: String,
capability: String,
) -> Result<OpenedMedia> {
let contract = paid_contract(
&data_dir,
&identity,
&content_id,
&purchase_id,
&authenticated_buyer,
&capability,
)
.await?;
tokio::task::spawn_blocking(move || {
open_started(&data_dir, &identity, &contract, &capability, || {
u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock") u64::try_from(chrono::Utc::now().timestamp()).context("Invalid node clock")
}) })
}) })
.await? .await?
} }
fn open_started(
data_dir: &Path,
identity: &NodeIdentity,
contract: &Contract,
capability: &str,
now: impl Fn() -> Result<u64>,
) -> Result<OpenedMedia> {
let record = metadata_record(data_dir, identity, &contract.content_id)?;
verify_settled_terms(&record, contract)?;
let lease = {
let held = keyed(data_dir, "lease", &contract.id)?;
read::<Lease>(&held.path.join(format!("lease-{}.json", contract.id)))?
.context("Explicit Start is required; no rental was started")?
};
let window = verify_lease(&lease, contract, capability, record.receipt.viewing_seconds)?;
let instant = now()?;
anyhow::ensure!(
instant >= window.started_at && instant < window.expires_at,
"Rental expired or node clock moved backwards"
);
let verification = crate::rental_readiness::shared().ready(
&readiness_key(data_dir, &record)?,
&chunk_binding(&record)?,
None,
)?;
let file = open_snapshot_with_stamp(data_dir, &record, false)?;
Ok(OpenedMedia {
file,
verification,
size_bytes: record.stamp.size,
mime_type: record.mime_type,
started_at: window.started_at,
expires_at: window.expires_at,
})
}
// Existing clock/receipt invariant fixtures exercise an explicit local start;
// public prepare/start/open separation is tested independently below.
#[cfg(test)]
fn open_settled( fn open_settled(
data_dir: &Path, data_dir: &Path,
identity: &NodeIdentity, identity: &NodeIdentity,
@@ -701,8 +1015,12 @@ fn open_settled(
now >= lease.started_at && now < lease.expires_at, now >= lease.started_at && now < lease.expires_at,
"Rental expired or node clock moved backwards" "Rental expired or node clock moved backwards"
); );
let index =
crate::rental_chunk_index::Index::scan(&mut file, chunk_binding(&record)?, |_| Ok(()))?;
let verification = crate::rental_readiness::Ready::fixture(index);
Ok(OpenedMedia { Ok(OpenedMedia {
file, file,
verification,
size_bytes: record.stamp.size, size_bytes: record.stamp.size,
mime_type: record.mime_type, mime_type: record.mime_type,
started_at: lease.started_at, started_at: lease.started_at,
@@ -786,6 +1104,30 @@ mod tests {
|_| Ok(()), |_| Ok(()),
) )
} }
async fn ready(&self, receipt: &Receipt) -> String {
tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
match prepare_registered(
self.root.path().into(),
self.identity.clone(),
&receipt.content_id,
false,
)
.unwrap()
{
crate::rental_readiness::Status::Ready { ready_id, .. } => return ready_id,
crate::rental_readiness::Status::Preparing { .. } => {
tokio::time::sleep(std::time::Duration::from_millis(5)).await
}
crate::rental_readiness::Status::Unavailable => {
panic!("fixture verification failed")
}
}
}
})
.await
.expect("bounded fixture preparation")
}
fn contract(&self, receipt: &Receipt) -> Contract { fn contract(&self, receipt: &Receipt) -> Contract {
Contract { Contract {
version: 1, version: 1,
@@ -872,6 +1214,7 @@ mod tests {
std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap(); std::fs::remove_file(fixture.root.path().join("cloud/film.mp4")).unwrap();
assert_eq!(fixture.register(2000).unwrap(), receipt); assert_eq!(fixture.register(2000).unwrap(), receipt);
assert_eq!(std::fs::read(&mapping).unwrap(), original); assert_eq!(std::fs::read(&mapping).unwrap(), original);
fixture.ready(&receipt).await;
let found = let found =
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap(); registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap();
assert_eq!(found, (receipt.clone(), terms(&receipt).unwrap())); assert_eq!(found, (receipt.clone(), terms(&receipt).unwrap()));
@@ -1029,6 +1372,57 @@ mod tests {
.join(STORE) .join(STORE)
.join(format!("lease-{}.json", contract.id)) .join(format!("lease-{}.json", contract.id))
.exists()); .exists());
let ready_id = fixture.ready(&receipt).await;
// Preparation, settlement and a GET are not permission to start the clock.
assert!(open_paid(
fixture.root.path().into(),
fixture.identity.clone(),
receipt.content_id.clone(),
contract.id.clone(),
contract.buyer_did.clone(),
settled.capability.clone()
)
.await
.is_err());
assert!(paid_window(
fixture.root.path(),
&fixture.identity,
&receipt.content_id,
&contract.id,
&contract.buyer_did,
&settled.capability
)
.await
.unwrap()
.is_none());
let window = start_paid(
fixture.root.path().into(),
fixture.identity.clone(),
receipt.content_id.clone(),
contract.id.clone(),
contract.buyer_did.clone(),
settled.capability.clone(),
ready_id,
)
.await
.unwrap();
// Lost Start response: a retry replays the durable original window even
// if its ephemeral readiness identifier no longer exists after restart.
let replay = start_paid(
fixture.root.path().into(),
fixture.identity.clone(),
receipt.content_id.clone(),
contract.id.clone(),
contract.buyer_did.clone(),
settled.capability.clone(),
uuid::Uuid::new_v4().to_string(),
)
.await
.unwrap();
assert_eq!(
(window.started_at, window.expires_at),
(replay.started_at, replay.expires_at)
);
let opened = open_paid( let opened = open_paid(
fixture.root.path().into(), fixture.root.path().into(),
fixture.identity.clone(), fixture.identity.clone(),
@@ -1041,6 +1435,33 @@ mod tests {
.unwrap(); .unwrap();
assert_eq!(opened.expires_at - opened.started_at, 60); assert_eq!(opened.expires_at - opened.started_at, 60);
assert_eq!(opened.size_bytes, 24); assert_eq!(opened.size_bytes, 24);
for offset in [0, 8, 23] {
let mut range = open_paid(
fixture.root.path().into(),
fixture.identity.clone(),
receipt.content_id.clone(),
contract.id.clone(),
contract.buyer_did.clone(),
settled.capability.clone(),
)
.await
.unwrap();
assert_eq!(
range
.verification
.index
.read_slice(&mut range.file, offset, 1)
.unwrap()
.len(),
1
);
assert_eq!(range.started_at, window.started_at);
}
assert_eq!(
crate::rental_chunk_index::scans(&receipt.content_id),
1,
"quote preparation and repeated GET/ranges share one original SHA scan"
);
} }
#[tokio::test] #[tokio::test]
async fn altered_snapshot_or_overflow_never_creates_first_rental() { async fn altered_snapshot_or_overflow_never_creates_first_rental() {
@@ -1249,21 +1670,69 @@ mod tests {
.exists()); .exists());
} }
#[tokio::test] #[tokio::test]
async fn offer_preflight_rebuilds_verified_cache_without_creating_rental_and_rejects_corruption( async fn signed_index_preparation_is_lease_free_and_unsigned_cache_cannot_change_it() {
) {
let fixture = Fixture::new().await; let fixture = Fixture::new().await;
let receipt = fixture.register(1100).unwrap(); let receipt = fixture.register(1100).unwrap();
let path = fixture assert!(
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).is_err()
);
let ready_id = fixture.ready(&receipt).await;
let record =
metadata_record(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap();
let index_path = fixture
.root
.path()
.join(STORE)
.join(format!("chunks-{}.bin", receipt.request_id));
let index = crate::rental_chunk_index::Index::load(
&index_path,
&chunk_binding(&record).unwrap(),
&fixture.identity,
)
.unwrap()
.unwrap();
assert_eq!(index.binding.full_sha256, receipt.sha256);
let cache = fixture
.root .root
.path() .path()
.join(STORE) .join(STORE)
.join(format!("verified-{}.json", receipt.request_id)); .join(format!("verified-{}.json", receipt.request_id));
let original = std::fs::read(&path).unwrap(); std::fs::write(cache, b"untrusted corrupt old metadata").unwrap();
std::fs::remove_file(&path).unwrap(); assert_eq!(
let (terms, _) = registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id)
registered_terms(fixture.root.path(), &fixture.identity, &receipt.content_id).unwrap(); .unwrap()
assert_eq!(terms, receipt); .0,
assert_eq!(std::fs::read(&path).unwrap(), original); receipt
);
assert_eq!(fixture.ready(&receipt).await, ready_id);
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 bytes = std::fs::read(&index_path).unwrap();
*bytes.last_mut().unwrap() ^= 1;
std::fs::write(&index_path, bytes).unwrap();
assert!(crate::rental_chunk_index::Index::load(
&index_path,
&chunk_binding(&record).unwrap(),
&fixture.identity
)
.is_err());
crate::rental_readiness::shared()
.forget_for_restart(&readiness_key(fixture.root.path(), &record).unwrap());
let replacement = fixture.ready(&receipt).await;
assert_ne!(replacement, ready_id);
assert_eq!(crate::rental_chunk_index::scans(&receipt.content_id), 2);
assert!(crate::rental_chunk_index::Index::load(
&index_path,
&chunk_binding(&record).unwrap(),
&fixture.identity
)
.unwrap()
.is_some());
assert!(std::fs::read_dir(fixture.root.path().join(STORE)) assert!(std::fs::read_dir(fixture.root.path().join(STORE))
.unwrap() .unwrap()
.all(|entry| !entry .all(|entry| !entry
@@ -1271,11 +1740,5 @@ mod tests {
.file_name() .file_name()
.to_string_lossy() .to_string_lossy()
.starts_with("lease-"))); .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()
);
} }
} }
+319
View File
@@ -0,0 +1,319 @@
//! Node-signed byte indexes, created only by a scan matching the producer's
//! original signed full SHA. Metadata alone never authorizes streamed bytes.
use crate::identity::NodeIdentity;
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use std::{
fs::{File, OpenOptions},
io::{Read, Seek, SeekFrom, Write},
os::unix::fs::OpenOptionsExt,
path::Path,
};
pub(crate) const CHUNK_BYTES: usize = 64 * 1024;
const MAX_BYTES: u64 = 16 * 1024 * 1024 * 1024;
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(deny_unknown_fields)]
pub(crate) struct Binding {
pub content_id: String,
pub receipt_sha256: String,
pub full_sha256: String,
pub size: u64,
}
impl Binding {
fn validate(&self) -> Result<()> {
anyhow::ensure!(
self.content_id
.strip_prefix("registered_")
.and_then(|id| uuid::Uuid::parse_str(id).ok())
.map(|id| format!("registered_{id}") == self.content_id)
.unwrap_or(false)
&& self.size > 0
&& self.size <= MAX_BYTES,
"Invalid byte index binding"
);
for value in [&self.receipt_sha256, &self.full_sha256] {
anyhow::ensure!(
value.len() == 64
&& value
.bytes()
.all(|b| b.is_ascii_digit() || (b'a'..=b'f').contains(&b)),
"Invalid byte index digest"
);
}
Ok(())
}
pub fn index_bytes(&self) -> Result<u64> {
self.validate()?;
Ok(self.size.div_ceil(CHUNK_BYTES as u64) * 32)
}
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
struct Header {
version: u32,
binding: Binding,
chunks_sha256: String,
signature: String,
}
fn preimage(binding: &Binding, chunks_sha256: &str) -> Result<Vec<u8>> {
Ok(serde_json::to_vec(&(
"archipelago-rental-chunk-index-v1",
CHUNK_BYTES,
&binding.content_id,
&binding.receipt_sha256,
&binding.full_sha256,
binding.size,
chunks_sha256,
))?)
}
pub(crate) struct Index {
pub binding: Binding,
chunks: Vec<[u8; 32]>,
pub commitment: String,
}
#[cfg(test)]
fn scan_counts() -> &'static std::sync::Mutex<std::collections::HashMap<String, usize>> {
static COUNTS: std::sync::OnceLock<std::sync::Mutex<std::collections::HashMap<String, usize>>> =
std::sync::OnceLock::new();
COUNTS.get_or_init(Default::default)
}
#[cfg(test)]
pub(crate) fn scans(content_id: &str) -> usize {
*scan_counts().lock().unwrap().get(content_id).unwrap_or(&0)
}
impl Index {
/// Exactly one whole-file scan. Caller runs it as a bounded background job;
/// progress/cancellation cannot create or alter a purchase lease.
pub fn scan(
file: &mut File,
binding: Binding,
mut progress: impl FnMut(u64) -> Result<()>,
) -> Result<Self> {
let bytes = binding.index_bytes()?;
#[cfg(test)]
{
*scan_counts()
.lock()
.unwrap()
.entry(binding.content_id.clone())
.or_default() += 1;
}
anyhow::ensure!(
file.metadata()?.is_file() && file.metadata()?.len() == binding.size,
"Snapshot size changed"
);
file.seek(SeekFrom::Start(0))?;
let mut chunks = Vec::with_capacity(bytes as usize / 32);
let mut full = Sha256::new();
let mut buffer = [0u8; CHUNK_BYTES];
let mut read = 0;
while read < binding.size {
progress(read)?;
let count = (binding.size - read).min(CHUNK_BYTES as u64) as usize;
file.read_exact(&mut buffer[..count])?;
full.update(&buffer[..count]);
chunks.push(Sha256::digest(&buffer[..count]).into());
read += count as u64;
}
anyhow::ensure!(
file.metadata()?.len() == binding.size
&& hex::encode(full.finalize()) == binding.full_sha256,
"Registered snapshot content changed"
);
progress(read)?;
let mut digest = Sha256::new();
for chunk in &chunks {
digest.update(chunk);
}
let commitment = hex::encode(digest.finalize());
file.seek(SeekFrom::Start(0))?;
Ok(Self {
binding,
chunks,
commitment,
})
}
/// Persist under the registration's existing verification flock. Sync rename
/// is the commit point; no mutation is queued after a canceled future drops.
pub fn save(&self, path: &Path, identity: &NodeIdentity) -> Result<()> {
let header = Header {
version: 1,
binding: self.binding.clone(),
chunks_sha256: self.commitment.clone(),
signature: identity.sign(&preimage(&self.binding, &self.commitment)?),
};
let encoded = serde_json::to_vec(&header)?;
anyhow::ensure!(encoded.len() <= 4096, "Byte index header too large");
let parent = path.parent().context("Byte index directory missing")?;
let temporary = parent.join(format!(".chunk-index-{}.tmp", uuid::Uuid::new_v4()));
let result = (|| {
let mut file = OpenOptions::new()
.create_new(true)
.write(true)
.mode(0o600)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC)
.open(&temporary)?;
file.write_all(&(encoded.len() as u32).to_be_bytes())?;
file.write_all(&encoded)?;
for chunk in &self.chunks {
file.write_all(chunk)?;
}
file.sync_all()?;
std::fs::rename(&temporary, path)?;
File::open(parent)?.sync_all()?;
Ok(())
})();
if result.is_err() {
let _ = std::fs::remove_file(temporary);
}
result
}
/// A signed index survives restart without treating an editable metadata
/// cache as proof. Every delivered chunk is independently checked below.
pub fn load(path: &Path, binding: &Binding, identity: &NodeIdentity) -> Result<Option<Self>> {
let expected = binding.index_bytes()?;
let mut file = match OpenOptions::new()
.read(true)
.custom_flags(libc::O_NOFOLLOW | libc::O_CLOEXEC | libc::O_NONBLOCK)
.open(path)
{
Ok(file) => file,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error.into()),
};
let metadata = file.metadata()?;
anyhow::ensure!(
metadata.is_file() && metadata.len() <= expected + 4100,
"Invalid byte index file"
);
let mut len = [0u8; 4];
file.read_exact(&mut len)?;
let len = u32::from_be_bytes(len) as usize;
anyhow::ensure!(
len <= 4096 && metadata.len() == 4 + len as u64 + expected,
"Invalid byte index length"
);
let mut header = vec![0; len];
file.read_exact(&mut header)?;
let header: Header = serde_json::from_slice(&header)?;
anyhow::ensure!(
header.version == 1
&& &header.binding == binding
&& NodeIdentity::verify(
&identity.pubkey_hex(),
&preimage(binding, &header.chunks_sha256)?,
&header.signature
)?,
"Byte index signature or immutable binding changed"
);
let mut chunks = Vec::with_capacity(expected as usize / 32);
let mut digest = Sha256::new();
for _ in 0..expected / 32 {
let mut chunk = [0; 32];
file.read_exact(&mut chunk)?;
digest.update(chunk);
chunks.push(chunk);
}
anyhow::ensure!(
hex::encode(digest.finalize()) == header.chunks_sha256,
"Byte index was altered"
);
Ok(Some(Self {
binding: binding.clone(),
chunks,
commitment: header.chunks_sha256,
}))
}
/// Return only a slice of a completely verified aligned chunk. This bounds
/// seek/range work to64KiB and detects same-inode/same-stamp byte changes.
pub fn read_slice(&self, file: &mut File, start: u64, requested: usize) -> Result<Vec<u8>> {
anyhow::ensure!(
start < self.binding.size
&& requested > 0
&& file.metadata()?.len() == self.binding.size,
"Snapshot range or size changed"
);
let chunk = start / CHUNK_BYTES as u64;
let aligned = chunk * CHUNK_BYTES as u64;
let size = (self.binding.size - aligned).min(CHUNK_BYTES as u64) as usize;
let mut bytes = vec![0; size];
file.seek(SeekFrom::Start(aligned))?;
file.read_exact(&mut bytes)?;
let actual: [u8; 32] = Sha256::digest(&bytes).into();
anyhow::ensure!(
self.chunks.get(chunk as usize) == Some(&actual),
"Registered snapshot chunk changed; recover original entitlement"
);
let offset = (start - aligned) as usize;
Ok(bytes[offset..offset + requested.min(size - offset)].to_vec())
}
}
#[cfg(test)]
mod tests {
use super::*;
fn binding(bytes: &[u8]) -> Binding {
Binding {
content_id: format!("registered_{}", uuid::Uuid::new_v4()),
receipt_sha256: "ab".repeat(32),
full_sha256: hex::encode(Sha256::digest(bytes)),
size: bytes.len() as u64,
}
}
#[tokio::test]
async fn signed_index_reopens_without_whole_scan_and_seek_slices_reject_mutation() {
let root = tempfile::tempdir().unwrap();
let identity = NodeIdentity::load_or_create(&root.path().join("identity"))
.await
.unwrap();
let bytes: Vec<u8> = (0..CHUNK_BYTES * 5 + 19).map(|n| (n % 251) as u8).collect();
let media = root.path().join("media");
std::fs::write(&media, &bytes).unwrap();
let mut file = File::open(&media).unwrap();
let mut progress = Vec::new();
let original = binding(&bytes);
let index = Index::scan(&mut file, original.clone(), |n| {
progress.push(n);
Ok(())
})
.unwrap();
assert_eq!(progress.last(), Some(&(bytes.len() as u64)));
index.save(&root.path().join("index"), &identity).unwrap();
drop(index);
let loaded = Index::load(&root.path().join("index"), &original, &identity)
.unwrap()
.unwrap();
for start in [0, CHUNK_BYTES + 7, CHUNK_BYTES * 4, bytes.len() - 1] {
let got = loaded.read_slice(&mut file, start as u64, 37).unwrap();
assert_eq!(got, bytes[start..start + got.len()]);
}
let mut corrupt = bytes.clone();
corrupt[CHUNK_BYTES + 9] ^= 1;
std::fs::write(&media, corrupt).unwrap();
assert!(loaded
.read_slice(&mut file, (CHUNK_BYTES + 7) as u64, 1)
.is_err());
assert_eq!(loaded.read_slice(&mut file, 0, 5).unwrap(), &bytes[..5]);
let mut saved = std::fs::read(root.path().join("index")).unwrap();
*saved.last_mut().unwrap() ^= 1;
std::fs::write(root.path().join("index"), saved).unwrap();
assert!(Index::load(&root.path().join("index"), &original, &identity).is_err());
}
#[test]
fn canceled_or_wrong_hash_scan_never_produces_index() {
let root = tempfile::tempdir().unwrap();
let bytes = vec![7; CHUNK_BYTES * 2];
let media = root.path().join("media");
std::fs::write(&media, &bytes).unwrap();
let mut file = File::open(media).unwrap();
assert!(Index::scan(&mut file, binding(&bytes), |n| {
anyhow::ensure!(n == 0, "canceled");
Ok(())
})
.is_err());
let mut wrong = binding(&bytes);
wrong.full_sha256 = "cd".repeat(32);
assert!(Index::scan(&mut file, wrong, |_| Ok(())).is_err());
}
}
+315
View File
@@ -0,0 +1,315 @@
//! Bounded background verification. Preparing/ready never creates a lease.
use crate::rental_chunk_index::{Binding, Index};
use anyhow::{Context, Result};
use serde::{Deserialize, Serialize};
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU64, Ordering},
Arc, Mutex, OnceLock,
},
time::Instant,
};
use tokio::sync::Semaphore;
const MAX_JOBS: usize = 8;
const MAX_INDEX_MEMORY: u64 = 64 * 1024 * 1024;
#[derive(Serialize, Deserialize, Debug)]
#[serde(tag = "state", rename_all = "snake_case")]
pub(crate) enum Status {
Preparing {
completed_bytes: u64,
total_bytes: u64,
},
Ready {
ready_id: String,
total_bytes: u64,
},
Unavailable,
}
struct Reservation {
used: Arc<AtomicU64>,
bytes: u64,
}
impl Drop for Reservation {
fn drop(&mut self) {
self.used.fetch_sub(self.bytes, Ordering::SeqCst);
}
}
pub(crate) struct Ready {
pub index: Index,
pub id: String,
_memory: Reservation,
}
#[cfg(test)]
impl Ready {
pub(crate) fn fixture(index: Index) -> Arc<Self> {
Arc::new(Self {
index,
id: uuid::Uuid::new_v4().to_string(),
_memory: Reservation {
used: Arc::new(AtomicU64::new(0)),
bytes: 0,
},
})
}
}
enum State {
Preparing,
Ready(Arc<Ready>),
Failed,
}
struct Job {
binding: Binding,
state: Mutex<State>,
progress: Arc<AtomicU64>,
touched: Instant,
}
pub(crate) struct Manager {
jobs: Mutex<HashMap<String, Arc<Job>>>,
workers: Arc<Semaphore>,
memory: Arc<AtomicU64>,
}
impl Default for Manager {
fn default() -> Self {
Self {
jobs: Mutex::new(HashMap::new()),
workers: Arc::new(Semaphore::new(2)),
memory: Arc::new(AtomicU64::new(0)),
}
}
}
pub(crate) fn shared() -> &'static Manager {
static INSTANCE: OnceLock<Manager> = OnceLock::new();
INSTANCE.get_or_init(Manager::default)
}
impl Manager {
#[cfg(test)]
pub(crate) fn forget_for_restart(&self, key: &str) {
self.jobs.lock().unwrap().remove(key);
}
fn status(job: &Job) -> Result<Status> {
Ok(
match &*job
.state
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?
{
State::Preparing => Status::Preparing {
completed_bytes: job.progress.load(Ordering::SeqCst),
total_bytes: job.binding.size,
},
State::Ready(ready) => Status::Ready {
ready_id: ready.id.clone(),
total_bytes: job.binding.size,
},
State::Failed => Status::Unavailable,
},
)
}
/// Work closure opens only the already-authorized immutable snapshot. At most
/// two closures execute, eight jobs exist, and64MiB is reserved for indexes.
/// Stream-held Arcs retain their reservation even after cache eviction.
pub fn prepare(
&self,
key: String,
binding: Binding,
work: impl FnOnce(Arc<AtomicU64>) -> Result<Index> + Send + 'static,
) -> Result<Status> {
let mut jobs = self
.jobs
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?;
if let Some(job) = jobs.get(&key) {
anyhow::ensure!(job.binding == binding, "Readiness immutable terms changed");
return Self::status(job);
}
let bytes = binding
.index_bytes()?
.checked_mul(2)
.and_then(|v| v.checked_add(128 * 1024))
.context("Index budget overflow")?;
loop {
let used = self.memory.load(Ordering::SeqCst);
if jobs.len() < MAX_JOBS
&& used
.checked_add(bytes)
.is_some_and(|n| n <= MAX_INDEX_MEMORY)
{
break;
}
let victim = jobs
.iter()
.filter(|(_, job)| {
job.state
.lock()
.map(|state| !matches!(*state, State::Preparing))
.unwrap_or(false)
})
.min_by_key(|(_, job)| job.touched)
.map(|(key, _)| key.clone());
if let Some(victim) = victim {
jobs.remove(&victim);
} else {
return Ok(Status::Preparing {
completed_bytes: 0,
total_bytes: binding.size,
});
}
}
let permit = match self.workers.clone().try_acquire_owned() {
Ok(permit) => permit,
Err(_) => {
return Ok(Status::Preparing {
completed_bytes: 0,
total_bytes: binding.size,
})
}
};
// Admissions are serialized by jobs; concurrent drops can only lower use.
self.memory.fetch_add(bytes, Ordering::SeqCst);
let reservation = Reservation {
used: self.memory.clone(),
bytes,
};
let progress = Arc::new(AtomicU64::new(0));
let job = Arc::new(Job {
binding: binding.clone(),
state: Mutex::new(State::Preparing),
progress: progress.clone(),
touched: Instant::now(),
});
jobs.insert(key, job.clone());
tokio::task::spawn_blocking(move || {
let _permit = permit;
let result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| work(progress)))
.unwrap_or_else(|_| Err(anyhow::anyhow!("Media verification worker failed")));
let mut state = match job.state.lock() {
Ok(state) => state,
Err(_) => return,
};
*state = match result {
Ok(index) if index.binding == binding => {
job.progress.store(binding.size, Ordering::SeqCst);
State::Ready(Arc::new(Ready {
index,
id: uuid::Uuid::new_v4().to_string(),
_memory: reservation,
}))
}
_ => State::Failed,
};
});
Ok(Status::Preparing {
completed_bytes: 0,
total_bytes: binding.size,
})
}
/// Explicit retry clears only a failed preparation, never a running job or
/// a lease. The caller has already reauthenticated the original purchase.
pub fn retry_failed(&self, key: &str, binding: &Binding) -> Result<()> {
let mut jobs = self
.jobs
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?;
if let Some(job) = jobs.get(key) {
anyhow::ensure!(&job.binding == binding, "Readiness immutable terms changed");
let failed = matches!(
*job.state
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?,
State::Failed
);
if failed {
jobs.remove(key);
}
}
Ok(())
}
pub fn ready(
&self,
key: &str,
binding: &Binding,
ready_id: Option<&str>,
) -> Result<Arc<Ready>> {
let jobs = self
.jobs
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?;
let job = jobs
.get(key)
.context("Media is preparing; no rental was started")?;
anyhow::ensure!(&job.binding == binding, "Readiness immutable terms changed");
let state = job
.state
.lock()
.map_err(|_| anyhow::anyhow!("Media readiness unavailable"))?;
let State::Ready(ready) = &*state else {
anyhow::bail!("Media is not ready; no rental was started");
};
anyhow::ensure!(
ready_id.is_none_or(|id| id == ready.id),
"Media readiness changed; prepare again without paying"
);
Ok(ready.clone())
}
}
#[cfg(test)]
mod tests {
use super::*;
use sha2::{Digest, Sha256};
#[tokio::test]
async fn slow_verification_is_nonblocking_deduplicated_and_has_no_start_side_effect() {
let manager = Manager::default();
let root = tempfile::tempdir().unwrap();
let media = root.path().join("media");
let bytes = vec![9; 1024 * 1024];
std::fs::write(&media, &bytes).unwrap();
let binding = Binding {
content_id: format!("registered_{}", uuid::Uuid::new_v4()),
receipt_sha256: "ab".repeat(32),
full_sha256: hex::encode(Sha256::digest(&bytes)),
size: bytes.len() as u64,
};
let (release, wait) = std::sync::mpsc::channel();
let clone = binding.clone();
assert!(matches!(
manager
.prepare("key".into(), binding.clone(), move |progress| {
wait.recv().unwrap();
let mut file = std::fs::File::open(media)?;
Index::scan(&mut file, clone, |n| {
progress.store(n, Ordering::SeqCst);
Ok(())
})
})
.unwrap(),
Status::Preparing { .. }
));
assert!(manager.ready("key", &binding, None).is_err());
assert!(matches!(
manager
.prepare("key".into(), binding.clone(), |_| panic!(
"Duplicate full scan"
))
.unwrap(),
Status::Preparing { .. }
));
release.send(()).unwrap();
let ready = tokio::time::timeout(std::time::Duration::from_secs(5), async {
loop {
if let Ok(ready) = manager.ready("key", &binding, None) {
break ready;
}
tokio::task::yield_now().await;
}
})
.await
.unwrap();
assert!(manager.ready("key", &binding, Some("other-ready")).is_err());
assert!(manager.ready("key", &binding, Some(&ready.id)).is_ok());
let restarted = Manager::default();
assert!(restarted.ready("key", &binding, Some(&ready.id)).is_err());
}
}
@@ -2214,6 +2214,10 @@ impl crate::content_purchase_caller::PurchaseTransport for PurchaseTestTransport
fn seller_onion(&self) -> &str { fn seller_onion(&self) -> &str {
"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion" "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.onion"
} }
async fn prepare_offer(&self, content_id: &str) -> anyhow::Result<Option<(u64, u64)>> {
// A registered movie still being hashed, without asking the fake mint.
Ok(content_id.starts_with("registered_").then_some((3, 16)))
}
async fn offer( async fn offer(
&self, &self,
id: &str, id: &str,
@@ -2339,6 +2343,36 @@ async fn full_caller_recovers_lost_acceptance_and_settlement_without_another_pay
lose_settle: AtomicBool::new(true), lose_settle: AtomicBool::new(true),
offers: Default::default(), offers: Default::default(),
}; };
let preparing_id = format!("registered_{}", uuid::Uuid::new_v4());
for _ in 0..3 {
assert!(matches!(
purchase(
buyer.path(),
&buyer_did,
&preparing_id,
None,
8,
None,
&transport
)
.await
.unwrap(),
ReadyPurchase::Preparing {
completed_bytes: 3,
total_bytes: 16
}
));
}
assert!(transport.offers.lock().unwrap().is_empty());
assert!(mint.requests.lock().unwrap().is_empty());
assert_eq!(load_wallet(buyer.path()).await.unwrap().balance(), 8);
assert!(crate::content_purchase::Journal::open(buyer.path())
.await
.unwrap()
.find_buyers(&buyer_did, &transport.template.seller_did, &preparing_id)
.await
.unwrap()
.is_empty());
assert!(purchase( assert!(purchase(
buyer.path(), buyer.path(),
&buyer_did, &buyer_did,
+41
View File
@@ -772,3 +772,44 @@ The separate readiness/index work must remove those scans from request paths,
verify chunks and start the original clock only after explicit ready/start. verify chunks and start the original clock only after explicit ready/start.
IndeeHub private app packaging, distributed announcement delivery and actual IndeeHub private app packaging, distributed announcement delivery and actual
registration/payment/playback acceptance remain open. registration/payment/playback acceptance remain open.
### Separate rental readiness candidate — not yet qualified or deployed
The `work/rental-readiness` checkout builds on `49703d7e`; it does not change the
qualified ordinary-purchase deployment. Large-film rental activation remains
blocked by the documented full-file hashing and first-lease timeout problem.
The draft separates bounded background verification from explicit Start. A
completed scan must match the original producer receipt's full SHA before the
node signs a 64 KiB chunk index. Signed indexes bind the receipt, full hash, size,
content identity and chunk hashes. At most two verification jobs run; index
memory and cached jobs are bounded. Preparation can finish after a disconnected
request but cannot start a lease. New registered purchase requests return
preparation progress before allocating an offer UUID or spending funds.
Authenticated Start commits the original viewing window once. A lost response
is recovered using the same purchase, even if its ephemeral readiness ID was
lost. Metadata and range reads do not initiate whole-file verification or leases.
Each returned range slice is taken from a complete chunk whose hash was checked
immediately before delivery. A changed chunk stops delivery; it does not renew
the window or authorize another payment. Existing signed indexes avoid a full
scan after restart, but do not establish that every current media byte is still
present: subsequent corruption remains a recoverable delivery failure.
The added tests cover preparation without a buyer intent or mint request,
settlement/preparation without a lease, GET refusal before Start, original-window
replay, repeated range reads with a full-scan counter, signature/index alteration
and changed chunk rejection. These changes have only passed formatting and diff
checks so far. Isolated compilation, regression tests, actual large-file timing,
installed-app consent/Start flows and recovery acceptance are still outstanding.
The matching host draft advertises read-only `archipelagoRental.playbackProtocol`
2 and requires `request(offer, {playbackProtocol: 2, signal?})`. Paid replies expose
only protocol, opaque handle, operation ID and known expiry. Separate installed-
frame `prepare`, `start` and `status` actions revalidate the native app context.
Only a successful explicit Start returns a playback URL. Preparation progress is
visible in overlay, app-session and tab-signer consent surfaces. Polling is capped;
closing, aborting or timing out cancels only its own pending prompt. A payment
already dispatched remains journaled and is recovered using its original ID.
The host rejects malformed states, changed observed windows and playback URLs in
non-started replies. The focused host/provider run passed 29 tests across two files; actual `vue-tsc -b` passed, with all 542 captured host inputs unchanged. Logs are `/tmp/archy-rental-protocol2-host-tests.log` and `/tmp/archy-rental-protocol2-host-typecheck.log`; provenance is `/tmp/archy-rental-protocol2-host-provenance.json`. The rental Rust remains uncompiled pending the combined backend candidate.
+28 -18
View File
@@ -291,7 +291,7 @@
if (e.data.type === 'archipelago-rental-response') { if (e.data.type === 'archipelago-rental-response') {
var rental = rentalPending[e.data.id]; var rental = rentalPending[e.data.id];
if (!rental) return; if (!rental) return;
delete rentalPending[e.data.id]; clearTimeout(rental.timer); delete rentalPending[e.data.id]; clearTimeout(rental.timer); rental.cleanup();
e.data.error ? rental.reject(new Error(e.data.error)) : rental.resolve(e.data.result); e.data.error ? rental.reject(new Error(e.data.error)) : rental.resolve(e.data.result);
return; return;
} }
@@ -336,26 +336,36 @@
return hex.slice(0,8)+'-'+hex.slice(8,12)+'-'+hex.slice(12,16)+'-'+hex.slice(16,20)+'-'+hex.slice(20); return hex.slice(0,8)+'-'+hex.slice(8,12)+'-'+hex.slice(12,16)+'-'+hex.slice(16,20)+'-'+hex.slice(20);
} }
function rentalRequest(action, payload, timeout, signal) {
return new Promise(function (resolve, reject) {
if (signal && (typeof signal.addEventListener !== 'function' || typeof signal.removeEventListener !== 'function' || typeof signal.aborted !== 'boolean')) { reject(new Error('Invalid rental cancellation signal.')); return; }
if (signal && signal.aborted) { reject(new Error('Rental request closed.')); return; }
var id = nativeRequestId();
function cleanup() { if (signal) signal.removeEventListener('abort', abort); }
function fail(message) {
var pending = rentalPending[id]; if (!pending) return;
delete rentalPending[id]; clearTimeout(pending.timer); cleanup();
if (action === 'request') postToSigner({type:'archipelago-rental-request',id:id,action:'cancel',playbackProtocol:2});
reject(new Error(message));
}
function abort() { fail('Rental request closed. Recover any dispatched payment using the original purchase.'); }
rentalPending[id] = { resolve: resolve, reject: reject, cleanup: cleanup, timer: setTimeout(function () {
fail('Rental response unavailable. Recover the same purchase without paying again.');
}, timeout) };
if (signal) signal.addEventListener('abort', abort, {once:true});
postToSigner(Object.assign({ type: 'archipelago-rental-request', id: id, action: action, playbackProtocol: 2 }, payload));
});
}
window.archipelagoRental = { window.archipelagoRental = {
status: function (handle) { status: function (handle) { return rentalRequest('status', { handle: handle }, 35000); },
return new Promise(function (resolve, reject) { prepare: function (handle, retry) { return rentalRequest('prepare', { handle: handle, retry: retry === true }, 35000); },
var id = nativeRequestId(); start: function (handle, readyId) { return rentalRequest('start', { handle: handle, ready_id: readyId }, 35000); },
rentalPending[id] = { resolve: resolve, reject: reject, timer: setTimeout(function () { request: function (offer, options) {
delete rentalPending[id]; reject(new Error('Playback status unavailable.')); if (!options || options.playbackProtocol !== 2) return Promise.reject(new Error('Playback protocol 2 is required before requesting a rental.'));
}, 15000) }; return rentalRequest('request', { offer: offer }, 600000, options.signal);
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 });
});
} }
}; };
Object.defineProperty(window.archipelagoRental, 'playbackProtocol', { value: 2, writable: false, configurable: false, enumerable: true });
window.archipelagoMediaRegistration = { window.archipelagoMediaRegistration = {
request: function (action, payload) { request: function (action, payload) {
@@ -181,7 +181,7 @@
</div> </div>
</Transition> </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" /> <RentalPurchaseConsent v-if="!store.showConsent && !showIdentityPicker && !store.registrationRequest" :request="store.rentalRequest" :quote="store.rentalQuote" :phase="store.rentalPhase" :progress="store.rentalProgress" :error="store.rentalError" @cancel-unpaid="store.cancelUnpaidRental" @review="store.reviewRental" @approve="store.approveRental" @cancel="store.cancelRental" />
<MediaRegistrationConsent v-if="!store.showConsent" <MediaRegistrationConsent v-if="!store.showConsent"
:request="store.registrationRequest" :phase="store.registrationPhase" :error="store.registrationError" :request="store.registrationRequest" :phase="store.registrationPhase" :error="store.registrationError"
@approve="store.approveRegistration" @resolve="store.approveRegistrationResolution" @cancel="store.cancelRegistration" /> @approve="store.approveRegistration" @resolve="store.approveRegistrationResolution" @cancel="store.cancelRegistration" />
@@ -3,10 +3,10 @@
<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"> <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> <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> <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> <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 you press Start</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 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="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> <p v-if="busy" role="status" class="mt-4 text-sm">{{ phase === 'preparing' ? progress : 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> <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> <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> </section>
@@ -16,10 +16,10 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import { useModalKeyboard } from '@/composables/useModalKeyboard' import { useModalKeyboard } from '@/composables/useModalKeyboard'
import type { RentalOffer } from '@/composables/useRentalPurchaseBridge' 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 props = defineProps<{ request: RentalOffer | null; quote: { wallet_debit_sats: number; network: 'mainnet' | 'testnet'; mint_url: string } | null; phase: string; error: string; progress?: string }>()
const emit = defineEmits<{ review: []; approve: []; cancel: []; cancelUnpaid: [] }>() const emit = defineEmits<{ review: []; approve: []; cancel: []; cancelUnpaid: [] }>()
const modal = ref<HTMLElement | null>(null) const modal = ref<HTMLElement | null>(null)
useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel')) useModalKeyboard(modal, computed(() => Boolean(props.request)), () => emit('cancel'))
const busy = computed(() => props.phase === 'loading' || props.phase === 'paying') const busy = computed(() => props.phase === 'preparing' || props.phase === 'loading' || props.phase === 'paying')
</script> </script>
@@ -10,7 +10,11 @@ describe('native provider on ordinary HTTP node origins',()=>{
const timers=new Set<unknown>();let count=0 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, 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)}) setTimeout:(fn:unknown)=>{timers.add(fn);return fn},clearTimeout:(fn:unknown)=>timers.delete(fn)})
const rental=window.archipelagoRental.request({title:'Film'}) expect(window.archipelagoRental.playbackProtocol).toBe(2)
expect(Object.getOwnPropertyDescriptor(window.archipelagoRental,'playbackProtocol')?.writable).toBe(false)
await expect(window.archipelagoRental.request({title:'Legacy'})).rejects.toThrow('protocol 2')
expect(parent.postMessage).not.toHaveBeenCalled()
const rental=window.archipelagoRental.request({title:'Film'},{playbackProtocol:2})
const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}}) const registration=window.archipelagoMediaRegistration.request('resume',{intent:{requestId:'existing'}})
const requests=parent.postMessage.mock.calls.map(([message])=>message) const requests=parent.postMessage.mock.calls.map(([message])=>message)
expect(requests).toHaveLength(2) expect(requests).toHaveLength(2)
@@ -19,5 +23,19 @@ describe('native provider on ordinary HTTP node origins',()=>{
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}}}) 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}) await expect(rental).resolves.toEqual({ok:true});await expect(registration).resolves.toEqual({ok:true})
expect(timers.size).toBe(0) expect(timers.size).toBe(0)
parent.postMessage.mockClear()
const stop = new AbortController()
const closed = window.archipelagoRental.request({title:'Closed'}, {playbackProtocol:2,signal:stop.signal})
const closedCheck = expect(closed).rejects.toThrow('Rental request closed')
const retained = window.archipelagoRental.request({title:'Other'}, {playbackProtocol:2})
const messages=parent.postMessage.mock.calls.map(([message])=>message)
stop.abort();await closedCheck
expect(parent.postMessage.mock.lastCall![0]).toMatchObject({action:'cancel',id:messages[0].id,playbackProtocol:2})
expect(timers.size).toBe(1)
for(const receive of listeners)receive({source:parent,origin:'http://node.local',data:{type:'archipelago-rental-response',id:messages[1].id,result:{playbackProtocol:2,handle:'d'.repeat(64)}}})
await expect(retained).resolves.toMatchObject({playbackProtocol:2});expect(timers.size).toBe(0)
parent.postMessage.mockClear()
await expect(window.archipelagoRental.request({title:'Already closed'},{playbackProtocol:2,signal:stop.signal})).rejects.toThrow('closed')
expect(parent.postMessage).not.toHaveBeenCalled()
}) })
}) })
@@ -8,11 +8,11 @@ const quote = { state: 'confirmation_required', network: 'mainnet', mint_url: 'h
function fixture(consentBusy: () => boolean = () => false) { function fixture(consentBusy: () => boolean = () => false) {
const child = { postMessage: vi.fn() } const child = { postMessage: vi.fn() }
const bridge = useRentalPurchaseBridge({ consentBusy, appId: () => 'indeedhub', appUrl: () => 'https://node.test:7778/browse', frameWindow: () => child as unknown as Window }) 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) const send = (origin = 'https://node.test:7778', source: unknown = child) => bridge.handle({ data: { type: 'archipelago-rental-request', playbackProtocol: 2, id: 'cccccccc-cccc-4ccc-8ccc-cccccccccccc', offer }, origin, source } as MessageEvent)
return { bridge, child, send } return { bridge, child, send }
} }
afterEach(() => vi.unstubAllGlobals()) afterEach(() => { vi.unstubAllGlobals(); vi.useRealTimers() })
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 }) }) 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 : { handle: 'd'.repeat(64), expires_at: null }) })
describe('native rental confirmation', () => { describe('native rental confirmation', () => {
it('cannot approve a quote without its saved network and mint', async()=>{ it('cannot approve a quote without its saved network and mint', async()=>{
const f=fixture();await f.send() const f=fixture();await f.send()
@@ -26,12 +26,12 @@ describe('native rental confirmation', () => {
const f=fixture(); await f.send(); await f.bridge.approve(); expect(rpc.call).toHaveBeenCalledTimes(1) 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') 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() 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}) rpc.call.mockImplementation(async ({method})=>method==='media.registration.context'?installed:method==='content.rental-purchase'?{state:'entitled',operation_id:quote.operation_id}:{handle:'d'.repeat(64),expires_at:null})
await f.bridge.approve() await f.bridge.approve()
const calls=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase') 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.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(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/') expect(f.child.postMessage.mock.lastCall![0].result).toEqual({playbackProtocol:2,handle:'d'.repeat(64),expires_at:null,operation_id:quote.operation_id}); expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
}) })
it('does not dispatch after closing during installation validation', async()=>{ it('does not dispatch after closing during installation validation', async()=>{
const f=fixture(); await f.send(); let release!:(value:unknown)=>void const f=fixture(); await f.send(); let release!:(value:unknown)=>void
@@ -63,7 +63,7 @@ describe('native rental confirmation', () => {
}) })
it('lease status does not open a purchase or confirm spending', async()=>{ 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}) 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) await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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(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.child.postMessage.mock.lastCall![0].result).toEqual({expires_at:null})
expect(f.bridge.request.value).toBeNull() expect(f.bridge.request.value).toBeNull()
@@ -83,7 +83,7 @@ describe('native rental confirmation', () => {
it('drops a status response after the surface closes even if its WindowProxy is reused',async()=>{ it('drops a status response after the surface closes even if its WindowProxy is reused',async()=>{
const f=fixture();let release!:(value:unknown)=>void const f=fixture();let release!:(value:unknown)=>void
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:new Promise(resolve=>{release=resolve})) 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) const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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')) await vi.waitFor(()=>expect(release).toBeTypeOf('function'))
f.bridge.cancel();release({expires_at:100});await work f.bridge.cancel();release({expires_at:100});await work
expect(f.child.postMessage).not.toHaveBeenCalled() expect(f.child.postMessage).not.toHaveBeenCalled()
@@ -91,7 +91,7 @@ describe('native rental confirmation', () => {
it('does not dispatch when the active frame disappears during installation verification',async()=>{ it('does not dispatch when the active frame disappears during installation verification',async()=>{
const child={postMessage:vi.fn()};let active=true 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}) 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) await bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,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})) 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 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) expect(rpc.call.mock.calls.some(([v])=>v.method==='content.rental-purchase')).toBe(false)
@@ -111,3 +111,87 @@ describe('native rental confirmation', () => {
}) })
}) })
describe('protocol 2 preparation and explicit Start',()=>{
it('rejects legacy callers before a wallet request',async()=>{
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
expect(rpc.call).not.toHaveBeenCalled();expect(f.child.postMessage.mock.lastCall![0].error).toContain('protocol 2')
})
it('closing while preparation waits cancels polling and never approves payment',async()=>{
vi.useFakeTimers(); const f=fixture();await f.send()
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:4,total_bytes:16})
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(0)
expect(f.bridge.phase.value).toBe('preparing');expect(f.bridge.progress.value).toContain('25%')
f.bridge.cancel();await work;await vi.advanceTimersByTimeAsync(10000)
const payments=rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase');expect(payments).toHaveLength(1);expect(payments[0]![0].params.consent).toBeUndefined()
})
it('preparation reaches confirmation without automatically accepting it',async()=>{
vi.useFakeTimers();const f=fixture();await f.send();let polls=0
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:++polls===1?{state:'preparing',completed_bytes:0,total_bytes:16}:quote)
const work=f.bridge.review();await vi.advanceTimersByTimeAsync(2000);await work
expect(f.bridge.phase.value).toBe('confirm');expect(rpc.call.mock.calls.filter(([v])=>v.method==='content.rental-purchase').every(([v])=>v.params.consent===undefined)).toBe(true)
})
it('only a distinct Start action returns the local playback URL',async()=>{
const f=fixture();const ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle:'d'.repeat(64),viewing_seconds:3600,started_at:null,expires_at:null}:{state:'started',started_at:100,expires_at:3700,playback_url:'/api/rental-playback/'+'d'.repeat(64)})
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle:'d'.repeat(64),ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
await f.bridge.handle(event('prepare'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBeUndefined();expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
await f.bridge.handle(event('start'));expect(f.child.postMessage.mock.lastCall![0].result.playback_url).toBe('https://node.test/api/rental-playback/'+'d'.repeat(64))
expect(rpc.call.mock.calls.filter(([v])=>v.method==='media.registration.context')).toHaveLength(2)
})
})
describe('provider request cancellation ownership',()=>{
it('cancels only the matching native request and cannot approve its old quote',async()=>{
const f=fixture();await f.send();await f.bridge.review()
const event=(id:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,action:'cancel',id},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
await f.bridge.handle(event('bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'));expect(f.bridge.phase.value).toBe('confirm')
await f.bridge.handle(event('cccccccc-cccc-4ccc-8ccc-cccccccccccc'));expect(f.bridge.request.value).toBeNull()
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
})
it('drops Start after closing during installed-frame verification',async()=>{
const f=fixture();let release!:(value:unknown)=>void
rpc.call.mockImplementationOnce(()=>new Promise(resolve=>{release=resolve}))
const work=f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
f.bridge.cancel();release(installed);await work
expect(rpc.call.mock.calls.some(([v])=>v.method==='content.playback-start')).toBe(false)
})
})
describe('strict protocol responses and approved-operation recovery',()=>{
it('rejects unknown actions rather than opening a purchase',async()=>{
const f=fixture();await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'unexpected',offer},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
expect(rpc.call).not.toHaveBeenCalled();expect(f.bridge.request.value).toBeNull()
})
it('preserves approved operation when a preparing response arrives after consent',async()=>{
const f=fixture();await f.send();await f.bridge.review()
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:{state:'preparing',completed_bytes:0,total_bytes:16})
await f.bridge.approve();expect(f.bridge.error.value).toContain('Payment was approved');expect(f.bridge.error.value).toContain('original operation');expect(f.bridge.quote.value?.operation_id).toBe(quote.operation_id)
const calls=rpc.call.mock.calls.length;await f.bridge.approve();expect(rpc.call).toHaveBeenCalledTimes(calls)
})
it.each([
{state:'ready',ready_id:'invalid',handle:'d'.repeat(64),viewing_seconds:60,started_at:null,expires_at:null},
{state:'preparing',completed_bytes:20,total_bytes:10,viewing_seconds:60,started_at:null,expires_at:null},
{state:'ready',ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa',handle:'d'.repeat(64),viewing_seconds:0,started_at:null,expires_at:null},
{state:'expired',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)},
])('rejects malformed or URL-bearing non-started states',async(result)=>{
const f=fixture();rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:result)
await f.bridge.handle({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'prepare',handle:'d'.repeat(64)},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
expect(f.child.postMessage.mock.lastCall![0].error).toContain('Playback state');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
})
it('drops a completed Start response from a closed frame generation',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',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action:'start',handle:'d'.repeat(64),ready_id:'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
await vi.waitFor(()=>expect(release).toBeTypeOf('function'));f.bridge.cancel();release({state:'started',started_at:100,expires_at:200,playback_url:'/api/rental-playback/'+'d'.repeat(64)});await work
expect(f.child.postMessage).not.toHaveBeenCalled()
})
})
it('rejects a changed original viewing window on a recovered Start',async()=>{
const f=fixture();const handle='d'.repeat(64),ready='aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'
rpc.call.mockImplementation(async({method})=>method==='media.registration.context'?installed:method==='content.playback-prepare'?{state:'ready',ready_id:ready,handle,viewing_seconds:3600,started_at:100,expires_at:3700}:{state:'started',started_at:200,expires_at:3800,playback_url:'/api/rental-playback/'+handle})
const event=(action:string)=>({data:{type:'archipelago-rental-request',playbackProtocol:2,id:'cccccccc-cccc-4ccc-8ccc-cccccccccccc',action,handle,ready_id:ready},origin:'https://node.test:7778',source:f.child} as unknown as MessageEvent)
await f.bridge.handle(event('prepare'));await f.bridge.handle(event('start'))
expect(f.child.postMessage.mock.lastCall![0].error).toContain('original viewing window');expect(f.child.postMessage.mock.lastCall![0].result).toBeUndefined()
})
@@ -6,6 +6,47 @@ interface FrameContext { appId: () => string; appUrl: () => string; frameWindow:
export interface RentalOffer { title: string; terms: { nodeDid: string; contentId: string; sha256: string; priceSats: number; viewingSeconds: number } } 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 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 } interface Pending { source: Window; origin: string; id: string; offer: RentalOffer; quote?: Quote }
type PlaybackAction = 'status' | 'prepare' | 'start'
interface PlaybackBinding { duration?: number; startedAt?: number; expiresAt?: number }
const readyPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
const positiveInteger = (value: unknown): value is number => Number.isSafeInteger(value) && (value as number) > 0
function validatePlayback(raw: unknown, action: PlaybackAction, handle: string, original: PlaybackBinding): { result: Record<string, unknown>; binding: PlaybackBinding } {
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) throw new Error('Invalid playback response.')
const data = raw as Record<string, unknown>, binding = { ...original }
const failure = () => { throw new Error('Playback state or original viewing window changed. Recover this purchase without paying again.') }
const expires = data.expires_at
if (!(expires === null || positiveInteger(expires))) return failure()
if (binding.expiresAt !== undefined && expires !== binding.expiresAt) return failure()
if (positiveInteger(expires)) binding.expiresAt = expires
if (action === 'status') {
if ('playback_url' in data) return failure()
return { result: { expires_at: expires }, binding }
}
const state = data.state
if (state !== 'started' && 'playback_url' in data) return failure()
if (state === 'unavailable' && action === 'prepare') return { result: {state, expires_at: expires}, binding }
const start = data.started_at
if (!((start === null && expires === null) || (positiveInteger(start) && positiveInteger(expires) && expires > start))) return failure()
if (binding.startedAt !== undefined && start !== binding.startedAt) return failure()
if (positiveInteger(start)) binding.startedAt = start
if (state === 'preparing' || state === 'ready') {
if (action !== 'prepare' || !positiveInteger(data.viewing_seconds)) return failure()
if (binding.duration !== undefined && data.viewing_seconds !== binding.duration) return failure()
binding.duration = data.viewing_seconds
if (positiveInteger(start) && expires !== start + binding.duration) return failure()
const base = {state, viewing_seconds: binding.duration, started_at: start, expires_at: expires}
if (state === 'preparing') {
if (!positiveInteger(data.total_bytes) || !Number.isSafeInteger(data.completed_bytes) || (data.completed_bytes as number) < 0 || (data.completed_bytes as number) > data.total_bytes) return failure()
return { result: {...base,completed_bytes:data.completed_bytes,total_bytes:data.total_bytes}, binding }
}
if (data.handle !== handle || typeof data.ready_id !== 'string' || !readyPattern.test(data.ready_id)) return failure()
return { result: {...base,handle,ready_id:data.ready_id}, binding }
}
if (!positiveInteger(start) || !positiveInteger(expires) || (binding.duration !== undefined && expires !== start + binding.duration)) return failure()
if (state === 'expired') return { result: {state,started_at:start,expires_at:expires}, binding }
if (state !== 'started' || action !== 'start' || data.playback_url !== `/api/rental-playback/${handle}`) return failure()
return { result: {state,started_at:start,expires_at:expires,playback_url:new URL(data.playback_url as string,window.location.origin).href}, binding }
}
export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin: string): boolean { export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin: string): boolean {
try { try {
const app = new URL(appOrigin), dashboard = new URL(dashboardOrigin) const app = new URL(appOrigin), dashboard = new URL(dashboardOrigin)
@@ -17,7 +58,10 @@ export function supportsRentalPlaybackOrigin(appOrigin: string, dashboardOrigin:
} }
export function useRentalPurchaseBridge(context: FrameContext) { export function useRentalPurchaseBridge(context: FrameContext) {
const request = shallowRef<RentalOffer | null>(null), quote = shallowRef<Quote | null>(null) const request = shallowRef<RentalOffer | null>(null), quote = shallowRef<Quote | null>(null)
const phase = ref<'review' | 'loading' | 'confirm' | 'paying'>('review'), error = ref('') const phase = ref<'review' | 'loading' | 'preparing' | 'confirm' | 'paying'>('review'), error = ref('')
const progress = ref('')
const playbackBindings = new Map<string, PlaybackBinding>()
let stopWait: (() => void) | undefined
let pending: Pending | null = null, disposed = false, generation = 0 let pending: Pending | null = null, disposed = false, generation = 0
const frameOrigin = () => { try { return new URL(context.appUrl(), window.location.origin).origin } catch { return '' } } const frameOrigin = () => { try { return new URL(context.appUrl(), window.location.origin).origin } catch { return '' } }
const current = (item: Pending) => !disposed && pending === item && context.appId() === 'indeedhub' const current = (item: Pending) => !disposed && pending === item && context.appId() === 'indeedhub'
@@ -29,6 +73,7 @@ export function useRentalPurchaseBridge(context: FrameContext) {
function cancel() { function cancel() {
if (pending) reply(pending, undefined, phase.value === 'paying' 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.') ? 'Payment may be processing. Reopen this title to recover the same purchase.' : 'Rental confirmation closed. No new payment was approved.')
stopWait?.(); stopWait = undefined; progress.value = ''
generation++ generation++
pending = null; request.value = null; quote.value = null; error.value = ''; phase.value = 'review' pending = null; request.value = null; quote.value = null; error.value = ''; phase.value = 'review'
} }
@@ -41,30 +86,48 @@ export function useRentalPurchaseBridge(context: FrameContext) {
if (!item || !current(item) || (confirm ? phase.value !== 'confirm' : phase.value !== 'review')) return 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 (!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 } if (context.consentBusy?.()) { error.value = 'Finish the other native confirmation first.'; return }
phase.value = confirm ? 'paying' : 'loading'; error.value = '' phase.value = confirm ? 'paying' : 'loading'; error.value = ''; progress.value = ''
try { try {
const terms = item.offer.terms
const deadline = Date.now() + 300000
let result!: Quote & { completed_bytes?: number; total_bytes?: number }
for (let attempt = 0; attempt < 150; attempt++) {
await installed(item) await installed(item)
if (!current(item)) return if (!current(item)) return
if (context.consentBusy?.()) throw new Error('Finish the other native confirmation first.') if (context.consentBusy?.()) throw new Error('Finish the other native confirmation first.')
const terms = item.offer.terms if (Date.now() >= deadline) throw new Error('Preparation is still running. Check purchase again; no new payment was approved.')
const result = await rpcClient.call<Quote>({ method: 'content.rental-purchase', params: { result = await rpcClient.call<Quote & { completed_bytes?: number; total_bytes?: number }>({ method: 'content.rental-purchase', params: {
seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256, seller_did: terms.nodeDid, content_id: terms.contentId, expected_sha256: terms.sha256,
expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds, expected_price_sats: terms.priceSats, expected_viewing_seconds: terms.viewingSeconds,
...(!confirm && attempt === 0 ? { retry_preparation: true } : {}),
max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER, max_wallet_debit: confirm ? item.quote!.wallet_debit_sats : Number.MAX_SAFE_INTEGER,
...(confirm ? { consent: { operation_id: item.quote!.operation_id, ...(confirm ? { consent: { operation_id: item.quote!.operation_id,
envelope_sha256: item.quote!.envelope_sha256, wallet_debit_sats: item.quote!.wallet_debit_sats } } : {}), envelope_sha256: item.quote!.envelope_sha256, wallet_debit_sats: item.quote!.wallet_debit_sats } } : {}),
}, timeout: 120000 }) }, timeout: Math.min(120000, deadline - Date.now()) })
if (!current(item)) return if (!current(item)) return
if (result.state !== 'preparing') break
if (confirm) throw new Error('Payment was approved for the original operation. Check the original purchase to recover its payment and preparation; do not pay again.')
if (!Number.isSafeInteger(result.completed_bytes) || !Number.isSafeInteger(result.total_bytes)
|| result.total_bytes! < 1 || result.completed_bytes! < 0 || result.completed_bytes! > result.total_bytes!) throw new Error('Invalid media preparation response. No new payment approved.')
phase.value = 'preparing'
progress.value = `Preparing video: ${Math.floor(result.completed_bytes! / result.total_bytes! * 100)}%. Your viewing time has not started.`
if (attempt === 149) throw new Error('Video preparation is still running. Check purchase again to continue; no new payment was approved.')
await new Promise<void>(resolve => { const timer = setTimeout(() => { stopWait = undefined; resolve() }, 2000); stopWait = () => { clearTimeout(timer); resolve() } })
if (!current(item)) return
}
progress.value = ''
if (result.state === 'confirmation_required') { if (result.state === 'confirmation_required') {
if (!validCashuIdentity(result) || !Number.isSafeInteger(result.wallet_debit_sats) || result.wallet_debit_sats < terms.priceSats 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.') || !/^[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 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.') 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 (!readyPattern.test(result.operation_id)) throw new Error('Invalid original purchase identifier. Recover the original payment.')
const playback = await rpcClient.call<{ handle: string; expires_at: number | null }>({ method: 'content.playback-handle', params: { purchase_id: result.operation_id } })
if (!current(item)) return if (!current(item)) return
if (!/^\/api\/rental-playback\/[0-9a-f]{64}$/.test(playback.playback_url)) throw new Error('Invalid playback address.') if (!/^[0-9a-f]{64}$/.test(playback.handle) || !(playback.expires_at === null || positiveInteger(playback.expires_at))) throw new Error('Invalid playback handle.')
reply(item, { ...playback, playback_url: new URL(playback.playback_url, window.location.origin).href, operation_id: result.operation_id }) playbackBindings.set(`${item.origin}:${playback.handle}`, {duration:terms.viewingSeconds,...(playback.expires_at === null ? {} : {expiresAt:playback.expires_at})})
reply(item, { playbackProtocol: 2, handle: playback.handle, expires_at: playback.expires_at, operation_id: result.operation_id })
pending = null; request.value = null; quote.value = null pending = null; request.value = null; quote.value = null
} catch (cause) { } 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' } if (current(item)) { error.value = cause instanceof Error ? cause.message : 'Could not complete this purchase. Retry to recover its original result.'; phase.value = 'review' }
@@ -72,7 +135,7 @@ export function useRentalPurchaseBridge(context: FrameContext) {
} }
async function cancelUnpaid() { async function cancelUnpaid() {
const item = pending const item = pending
if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading') return if (!item?.quote || !current(item) || phase.value === 'paying' || phase.value === 'loading' || phase.value === 'preparing') return
phase.value = 'loading'; error.value = '' phase.value = 'loading'; error.value = ''
try { try {
await installed(item) await installed(item)
@@ -90,21 +153,34 @@ export function useRentalPurchaseBridge(context: FrameContext) {
|| event.origin !== frameOrigin() || event.data?.type !== 'archipelago-rental-request') return || event.origin !== frameOrigin() || event.data?.type !== 'archipelago-rental-request') return
const { id, offer } = event.data const { id, offer } = event.data
if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return if (typeof id !== 'string' || !/^[0-9a-f-]{36}$/.test(id)) return
if (event.data.action === 'status') { if (event.data.action === 'cancel') { if (pending?.id === id && event.data.playbackProtocol === 2) cancel(); return }
if (['status', 'prepare', 'start'].includes(event.data.action)) {
const source = event.source as Window, origin = event.origin, handle = event.data.handle, scope = generation const source = event.source as Window, origin = event.origin, handle = event.data.handle, scope = generation
const action = event.data.action as PlaybackAction, readyId = event.data.ready_id
if (event.data.playbackProtocol !== 2) { source.postMessage({type:'archipelago-rental-response',id,error:'Playback protocol 2 is required.'},origin); return }
if (action === 'start' && !supportsRentalPlaybackOrigin(origin, window.location.origin)) { source.postMessage({type:'archipelago-rental-response',id,error:'Open the app from this node dashboard before starting playback.'},origin); return }
if (action === 'start' && (typeof readyId !== 'string' || !/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(readyId))) return
if (action === 'prepare' && event.data.retry !== undefined && typeof event.data.retry !== 'boolean') return
const selected = () => !disposed && generation === scope && context.appId() === 'indeedhub' && source === context.frameWindow() && frameOrigin() === origin const selected = () => !disposed && generation === scope && context.appId() === 'indeedhub' && source === context.frameWindow() && frameOrigin() === origin
if (typeof handle !== 'string' || !/^[0-9a-f]{64}$/.test(handle)) return if (typeof handle !== 'string' || !/^[0-9a-f]{64}$/.test(handle)) return
try { try {
const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} }) const value = await rpcClient.call<{ appId: string; appOrigins: string[] }>({ method: 'media.registration.context', params: {} })
if (!selected()) return if (!selected()) return
if (value.appId !== 'indeedhub' || !value.appOrigins.some(expected => installedOriginMatches(origin, expected))) throw new Error('Installed app changed.') 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 } }) const result = await rpcClient.call<Record<string, unknown>>({ method: `content.playback-${action}`, params: { handle,
if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, result }, origin) ...(action === 'prepare' ? { retry: event.data.retry === true } : {}), ...(action === 'start' ? {ready_id: readyId} : {}) }, timeout: 30000 })
if (!selected()) return
const key = `${origin}:${handle}`
const validated = validatePlayback(result, action, handle, playbackBindings.get(key) ?? {})
playbackBindings.set(key, validated.binding)
source.postMessage({ type: 'archipelago-rental-response', id, result: validated.result }, origin)
} catch (cause) { } catch (cause) {
if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, error: String(cause) }, origin) if (selected()) source.postMessage({ type: 'archipelago-rental-response', id, error: String(cause) }, origin)
} }
return return
} }
if (event.data.action !== undefined && event.data.action !== 'request') { (event.source as Window).postMessage({type:'archipelago-rental-response',id,error:'Unsupported rental action.'},event.origin); return }
if (event.data.playbackProtocol !== 2) { (event.source as Window).postMessage({type:'archipelago-rental-response',id,error:'Update this app to playback protocol 2 before renting. No payment was requested.'},event.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 (!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 (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 } if (pending) { (event.source as Window).postMessage({ type: 'archipelago-rental-response', id, error: 'Finish or close the current rental confirmation first.' }, event.origin); return }
@@ -121,6 +197,6 @@ export function useRentalPurchaseBridge(context: FrameContext) {
try { await installed(item); if (current(item)) { request.value = item.offer; phase.value = 'review' } } 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 } 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, return { isBusy: () => pending !== null, request, quote, phase, error, progress, handle, cancelUnpaid, review: () => run(false), approve: () => run(true), cancel,
dispose: () => { cancel(); disposed = true } } dispose: () => { cancel(); disposed = true } }
} }
+1 -1
View File
@@ -608,7 +608,7 @@ export const useAppLauncherStore = defineStore('appLauncher', () => {
approveConsent, approveConsent,
denyConsent, denyConsent,
setNativeIdentityBusy: (busy: boolean) => { nativeIdentityBusy.value = busy }, setNativeIdentityBusy: (busy: boolean) => { nativeIdentityBusy.value = busy },
rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase, rentalRequest: rentalBridge.request, rentalQuote: rentalBridge.quote, rentalPhase: rentalBridge.phase, rentalProgress: rentalBridge.progress,
cancelUnpaidRental: rentalBridge.cancelUnpaid, rentalError: rentalBridge.error, reviewRental: rentalBridge.review, approveRental: rentalBridge.approve, cancelRental: rentalBridge.cancel, cancelUnpaidRental: rentalBridge.cancelUnpaid, rentalError: rentalBridge.error, reviewRental: rentalBridge.review, approveRental: rentalBridge.approve, cancelRental: rentalBridge.cancel,
registrationRequest: registrationBridge.request, registrationRequest: registrationBridge.request,
registrationPhase: registrationBridge.phase, registrationPhase: registrationBridge.phase,
+1 -1
View File
@@ -91,7 +91,7 @@
<!-- Host-owned signer stays inside the active app surface. This keeps <!-- Host-owned signer stays inside the active app surface. This keeps
the context visible and works unchanged in the companion WebView. --> 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" /> <RentalPurchaseConsent v-if="!nostrBridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :progress="rentalBridge.progress.value" :error="rentalBridge.error.value" @cancel-unpaid="rentalBridge.cancelUnpaid" @review="rentalBridge.review" @approve="rentalBridge.approve" @cancel="rentalBridge.cancel" />
<MediaRegistrationConsent v-if="!nostrBridge.showConsent.value" <MediaRegistrationConsent v-if="!nostrBridge.showConsent.value"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value" :request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" /> :error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />
+1 -1
View File
@@ -6,7 +6,7 @@
@select="onIdentitySelected" @select="onIdentitySelected"
@cancel="cancelIdentitySelection" @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" /> <RentalPurchaseConsent v-if="!bridge.showConsent.value && !showIdentityPicker && !registrationBridge.request.value" :request="rentalBridge.request.value" :quote="rentalBridge.quote.value" :phase="rentalBridge.phase.value" :progress="rentalBridge.progress.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" <MediaRegistrationConsent v-if="!bridge.showConsent.value && !showIdentityPicker"
:request="registrationBridge.request.value" :phase="registrationBridge.phase.value" :request="registrationBridge.request.value" :phase="registrationBridge.phase.value"
:error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" /> :error="registrationBridge.error.value" @approve="registrationBridge.approve" @resolve="registrationBridge.approveResolution" @cancel="registrationBridge.cancel" />