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 cdp;
mod cloud_purchase;
mod content;
mod registered_media;
mod rental_playback;
mod dwn;
mod model_proxy;
mod node_message;
mod proxy;
mod purchase;
mod registered_media;
mod remote_input;
mod remote_relay;
mod rental_playback;
mod routstr_proxy;
mod websocket;
@@ -389,7 +389,9 @@ impl ApiHandler {
let method = req.method().clone();
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
@@ -453,13 +455,28 @@ impl ApiHandler {
}
// Purchase routes bound the original body before the generic buffer.
if method == Method::POST && matches!(path.as_str(),
crate::content_purchase_protocol::OFFER_ROUTE | crate::content_purchase_protocol::ACCEPT_ROUTE
| crate::content_purchase_protocol::SETTLE_ROUTE | crate::content_purchase_protocol::STATUS_ROUTE
| crate::content_purchase_protocol::CANCEL_ROUTE) {
if method == Method::POST
&& matches!(
path.as_str(),
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;
}
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
let headers = req.headers().clone();
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
// the logged-in node owner can spin up fetches.
(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 result = tokio::task::spawn_blocking(move || {
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 {
Ok(Some(body)) => (StatusCode::OK, body),
Ok(None) => (StatusCode::NOT_FOUND, "{}".to_owned()),
Err(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")
.header("Cache-Control", "private, no-store").body(hyper::Body::from(body))?)
Ok(Response::builder()
.status(status)
.header("Content-Type", "application/json")
.header("Cache-Control", "private, no-store")
.body(hyper::Body::from(body))?)
}
(Method::GET, "/api/app-catalog") => {
+28 -1
View File
@@ -23,7 +23,8 @@ impl ApiHandler {
request.method() == Method::POST
&& matches!(
path.as_str(),
protocol::OFFER_ROUTE
protocol::PREPARE_OFFER_ROUTE
| protocol::OFFER_ROUTE
| protocol::ACCEPT_ROUTE
| protocol::SETTLE_ROUTE
| protocol::STATUS_ROUTE
@@ -59,6 +60,32 @@ impl ApiHandler {
)?;
let data_dir = &self.config.data_dir;
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 => {
let body: OfferRequest = serde_json::from_slice(&bytes)?;
anyhow::ensure!(
@@ -4,7 +4,6 @@ use crate::{content_server::ByteRange, identity::NodeIdentity, registered_media:
use anyhow::{Context, Result};
use hyper::{Body, HeaderMap, Response, StatusCode};
use std::sync::Arc;
use tokio::io::{AsyncReadExt, AsyncSeekExt};
fn route(path: &str) -> Result<(&str, &str)> {
let (content, purchase) = path
@@ -46,6 +45,125 @@ fn denied(message: &'static str) -> Response<Body> {
}
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(
&self,
path: &str,
@@ -102,7 +220,7 @@ impl ApiHandler {
let selected = content.to_owned();
let key = identity.clone();
let metadata = tokio::task::spawn_blocking(move || {
crate::registered_media::registered_terms(&data, &key, &selected)
crate::registered_media::registered_metadata(&data, &key, &selected)
})
.await?;
let (receipt, _) = match metadata {
@@ -160,11 +278,9 @@ async fn rental_response(
let started = opened.started_at;
let expires = opened.expires_at;
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(
(file, length, now),
move |(mut file, left, now)| async move {
(opened.file, opened.verification, start, length, now),
move |(mut file, verification, position, left, now)| async move {
if left == 0 {
return Ok::<_, std::io::Error>(None);
}
@@ -175,15 +291,20 @@ async fn rental_response(
"Rental window ended",
));
}
let mut bytes = vec![0; left.min(64 * 1024) as usize];
let count = tokio::time::timeout(
std::time::Duration::from_secs(expires - instant),
file.read(&mut bytes),
)
.await
.map_err(|_| {
std::io::Error::new(std::io::ErrorKind::TimedOut, "Rental window ended")
})??;
let read = tokio::task::spawn_blocking(move || {
let bytes = verification
.index
.read_slice(&mut file, position, left.min(64 * 1024) as usize)
.map_err(std::io::Error::other)?;
Ok::<_, std::io::Error>((file, verification, bytes))
});
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();
if instant < started || instant >= expires {
return Err(std::io::Error::new(
@@ -191,14 +312,11 @@ async fn rental_response(
"Rental window ended",
));
}
if count == 0 {
return Err(std::io::Error::new(
std::io::ErrorKind::UnexpectedEof,
"Registered snapshot ended early",
));
}
bytes.truncate(count);
Ok(Some((bytes, (file, left - count as u64, now))))
let count = bytes.len() as u64;
Ok(Some((
bytes,
(file, verification, position + count, left - count, now),
)))
},
);
let mut response = Response::builder()
@@ -257,10 +375,19 @@ mod tests {
);
}
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();
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 {
file,
verification: crate::rental_readiness::Ready::fixture(index),
size_bytes: size,
mime_type: "video/mp4".into(),
started_at: 1000,
@@ -333,6 +333,8 @@ impl RpcHandler {
"content.indeehub-projects" => self.handle_content_indeehub_projects().await,
"content.browse-all-peers" => self.handle_content_browse_all_peers().await,
"content.playback-handle" => self.handle_playback_handle(params, session_token).await,
"content.playback-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.rental-purchase" => self.handle_content_rental_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;
mod marketplace;
mod media_registration;
mod purchase;
mod playback;
mod purchase;
// pub(crate): 13-10's `assistant::backends::select_backend` reuses
// `mesh::assistant::detect_ollama()` (D-04) rather than re-probing —
// matches the existing `pub(crate) mod bitcoin_relay;`/`pub(crate) mod
@@ -113,6 +113,8 @@ fn native_consent_origin_allowed(method: &str, headers: &hyper::HeaderMap, dev_m
| "content.cancel-purchase"
| "content.playback-handle"
| "content.playback-status"
| "content.playback-prepare"
| "content.playback-start"
) || nostr_signing_origin_allowed(headers, dev_mode)
}
@@ -831,6 +833,8 @@ mod nostr_signing_origin_tests {
"content.cancel-purchase",
"content.playback-handle",
"content.playback-status",
"content.playback-prepare",
"content.playback-start",
] {
assert!(!native_consent_origin_allowed(
method,
+177 -3
View File
@@ -55,9 +55,7 @@ impl RpcHandler {
let expires = self
.playback_handles()
.expiry(&handle, &session, &context)?;
Ok(
serde_json::json!({"playback_url":format!("/api/rental-playback/{handle}"), "expires_at":expires}),
)
Ok(serde_json::json!({"handle":handle,"expires_at":expires}))
}
pub(super) async fn handle_playback_status(
&self,
@@ -72,3 +70,179 @@ impl RpcHandler {
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?;
match result {
ReadyPurchase::Preparing { .. } => {
anyhow::bail!("Ordinary purchase cannot prepare a timed rental")
}
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,
@@ -127,6 +130,8 @@ impl RpcHandler {
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct RentalParams {
#[serde(default)]
retry_preparation: bool,
seller_did: String,
content_id: String,
expected_sha256: String,
@@ -168,8 +173,9 @@ impl RpcHandler {
&params.seller_did,
)
.await?;
let transport =
FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone()).await?;
let transport = FipsPurchaseTransport::load(self.config.data_dir.clone(), onion.clone())
.await?
.retry_preparation(params.retry_preparation);
let expected = caller::ExpectedRental {
seller_did: params.seller_did,
content_id: params.content_id.clone(),
@@ -189,6 +195,11 @@ impl RpcHandler {
)
.await?
{
ReadyPurchase::Preparing {
completed_bytes,
total_bytes,
} => Ok(serde_json::json!({
"state":"preparing", "completed_bytes":completed_bytes,"total_bytes":total_bytes})),
ReadyPurchase::AwaitingConfirmation {
operation_id,
envelope_sha256,