fix: allow explicit renewal of expired rentals
This commit is contained in:
@@ -144,6 +144,8 @@ impl RpcHandler {
|
||||
struct RentalParams {
|
||||
#[serde(default)]
|
||||
retry_preparation: bool,
|
||||
#[serde(default)]
|
||||
renew_expired: bool,
|
||||
seller_did: String,
|
||||
content_id: String,
|
||||
expected_sha256: String,
|
||||
@@ -209,6 +211,46 @@ impl RpcHandler {
|
||||
price_sats: params.expected_price_sats,
|
||||
viewing_seconds: params.expected_viewing_seconds,
|
||||
};
|
||||
if params.renew_expired {
|
||||
let journal = Journal::open(&self.config.data_dir).await?;
|
||||
let matching = journal
|
||||
.find_buyers(&buyer, transport.seller_did(), ¶ms.content_id)
|
||||
.await?;
|
||||
let settled: Vec<_> = matching
|
||||
.into_iter()
|
||||
.filter(|record| record.phase == crate::content_purchase::BuyerPhase::ReceiptSaved)
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
settled.len() <= 1,
|
||||
"Multiple original rentals require recovery"
|
||||
);
|
||||
if let Some(record) = settled.into_iter().next() {
|
||||
let capability = record
|
||||
.receipt()
|
||||
.context("Original rental receipt is missing")?
|
||||
.capability
|
||||
.clone();
|
||||
let original_duration = journal
|
||||
.protocol_envelope("buyer", &record.contract.id)
|
||||
.await?
|
||||
.context("Original rental terms are missing")?
|
||||
.offer
|
||||
.viewing_seconds
|
||||
.context("Original purchase is not a timed rental")?;
|
||||
let (started, expires) = transport
|
||||
.expired_rental_window(&record.contract, &capability, original_duration)
|
||||
.await?;
|
||||
journal
|
||||
.record_rental_expiry(
|
||||
&record.contract,
|
||||
started,
|
||||
expires,
|
||||
original_duration,
|
||||
u64::try_from(chrono::Utc::now().timestamp()).unwrap_or(0),
|
||||
)
|
||||
.await?;
|
||||
}
|
||||
}
|
||||
match caller::purchase_bound(
|
||||
&self.config.data_dir,
|
||||
&buyer,
|
||||
|
||||
@@ -1167,6 +1167,40 @@ impl Journal {
|
||||
}
|
||||
Ok(record)
|
||||
}
|
||||
|
||||
/// Retire a settled timed-rental receipt only after the authenticated
|
||||
/// seller has reported its immutable viewing window as expired. This is a
|
||||
/// lifecycle transition, not evidence that a permanent copy was cached:
|
||||
/// it merely permits a later, explicitly approved rental operation for the
|
||||
/// same registered title. Incomplete and still-active purchases must remain
|
||||
/// recoverable under their original UUID.
|
||||
pub async fn record_rental_expiry(
|
||||
&self,
|
||||
contract: &Contract,
|
||||
started_at: u64,
|
||||
expires_at: u64,
|
||||
viewing_seconds: u64,
|
||||
now: u64,
|
||||
) -> Result<BuyerRecord> {
|
||||
let mut record = self.bound_buyer(contract).await?;
|
||||
anyhow::ensure!(
|
||||
contract.content_id.starts_with("registered_")
|
||||
&& record.phase == BuyerPhase::ReceiptSaved
|
||||
&& record.receipt.is_some(),
|
||||
"Only a settled timed rental can be retired"
|
||||
);
|
||||
anyhow::ensure!(
|
||||
viewing_seconds > 0
|
||||
&& started_at > 0
|
||||
&& started_at.checked_add(viewing_seconds) == Some(expires_at)
|
||||
&& now >= expires_at,
|
||||
"Original rental viewing period has not ended"
|
||||
);
|
||||
record.phase = BuyerPhase::Delivered;
|
||||
record.validate()?;
|
||||
self.write("buyer", &contract.id, &record).await?;
|
||||
Ok(record)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -1365,6 +1399,49 @@ mod tests {
|
||||
assert!(journal.buyer(&expired.id).await.unwrap().is_none());
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn only_an_expired_settled_rental_can_be_retired_for_another_period() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let mut rental = contract();
|
||||
rental.content_id = "registered_film-1".into();
|
||||
let journal = Journal::open(root.path()).await.unwrap();
|
||||
journal.prepare_buyer(&rental, 1100).await.unwrap();
|
||||
let seller = journal.prepare_seller(&rental, 1100).await.unwrap();
|
||||
journal
|
||||
.record_acceptance(&rental, &seller.acceptance().unwrap(), &rental.seller_did)
|
||||
.await
|
||||
.unwrap();
|
||||
let encoded = token(&rental, "rental-expiry");
|
||||
journal.record_token(&rental, &encoded).await.unwrap();
|
||||
journal
|
||||
.record_incoming_token(&rental, &encoded)
|
||||
.await
|
||||
.unwrap();
|
||||
journal
|
||||
.record_settlement(&rental, rental.minimum_net_sats)
|
||||
.await
|
||||
.unwrap();
|
||||
let receipt = journal.issue_receipt(&rental).await.unwrap();
|
||||
journal.record_receipt(&rental, &receipt).await.unwrap();
|
||||
|
||||
assert!(journal
|
||||
.record_rental_expiry(&rental, 2000, 5600, 3600, 5599)
|
||||
.await
|
||||
.is_err());
|
||||
assert_eq!(
|
||||
journal.buyer(&rental.id).await.unwrap().unwrap().phase,
|
||||
BuyerPhase::ReceiptSaved
|
||||
);
|
||||
let retired = journal
|
||||
.record_rental_expiry(&rental, 2000, 5600, 3600, 5600)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(retired.phase, BuyerPhase::Delivered);
|
||||
|
||||
let mut next = rental.clone();
|
||||
next.id = uuid::Uuid::new_v4().to_string();
|
||||
journal.prepare_buyer(&next, 1500).await.unwrap();
|
||||
}
|
||||
#[tokio::test]
|
||||
async fn damaged_records_and_nonregular_targets_are_preserved() {
|
||||
let root = tempfile::tempdir().unwrap();
|
||||
let contract = contract();
|
||||
|
||||
@@ -206,7 +206,10 @@ pub(crate) async fn purchase_bound(
|
||||
.await?;
|
||||
let unresolved: Vec<_> = matching
|
||||
.into_iter()
|
||||
.filter(|record| record.phase != BuyerPhase::Cancelled)
|
||||
.filter(|record| {
|
||||
record.phase != BuyerPhase::Cancelled
|
||||
&& !(expected.is_some() && record.phase == BuyerPhase::Delivered)
|
||||
})
|
||||
.collect();
|
||||
anyhow::ensure!(
|
||||
unresolved.len() <= 1,
|
||||
|
||||
@@ -39,6 +39,60 @@ impl FipsPurchaseTransport {
|
||||
self.retry_preparation = retry;
|
||||
self
|
||||
}
|
||||
|
||||
/// Ask the authenticated seller for the original rental window without
|
||||
/// starting or extending it. The capability never crosses into the app.
|
||||
pub async fn expired_rental_window(
|
||||
&self,
|
||||
contract: &crate::content_purchase::Contract,
|
||||
capability: &str,
|
||||
viewing_seconds: u64,
|
||||
) -> Result<(u64, u64)> {
|
||||
let route = format!(
|
||||
"/content/{}/rental/{}/prepare",
|
||||
contract.content_id, contract.id
|
||||
);
|
||||
let body = serde_json::json!({"capability":capability,"ready_id":null,"retry":false});
|
||||
let (mut response, _) =
|
||||
crate::fips::dial::PeerRequest::new(Some(&self.fips_npub), &self.onion, &route)
|
||||
.require_fips()
|
||||
.single_delivery()
|
||||
.timeout(Duration::from_secs(20))
|
||||
.send_rental_control_json(&self.data_dir, &self.seller_did, &body)
|
||||
.await?;
|
||||
anyhow::ensure!(
|
||||
response.status().is_success(),
|
||||
"Original rental status is unavailable"
|
||||
);
|
||||
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 status response is too large"
|
||||
);
|
||||
bytes.extend_from_slice(&chunk);
|
||||
}
|
||||
let value: serde_json::Value = serde_json::from_slice(&bytes)?;
|
||||
anyhow::ensure!(
|
||||
value["state"] == "expired",
|
||||
"Original rental viewing period is still available"
|
||||
);
|
||||
let started = value["started_at"]
|
||||
.as_u64()
|
||||
.context("Missing rental start")?;
|
||||
let expires = value["expires_at"]
|
||||
.as_u64()
|
||||
.context("Missing rental expiry")?;
|
||||
anyhow::ensure!(
|
||||
value["viewing_seconds"].as_u64() == Some(viewing_seconds)
|
||||
&& started.checked_add(viewing_seconds) == Some(expires),
|
||||
"Seller changed the original rental window"
|
||||
);
|
||||
Ok((started, expires))
|
||||
}
|
||||
async fn post<B: Serialize + Sync, R: DeserializeOwned>(
|
||||
&self,
|
||||
route: &str,
|
||||
|
||||
Reference in New Issue
Block a user