Publish content with an atomic price and visibility policy

This commit is contained in:
archipelago
2026-10-06 07:51:00 -04:00
parent 65b51b98f4
commit 19207282f9
9 changed files with 456 additions and 142 deletions
+152 -92
View File
@@ -19,6 +19,81 @@ fn is_valid_v3_onion(addr: &str) -> bool {
const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1"; const FILE_CATALOG_PROTOCOL: &str = "https://archipelago.dev/protocols/file-catalog/v1";
fn parse_content_access(params: &serde_json::Value) -> Result<AccessControl> {
let access_type = match params.get("access") {
None => "free",
Some(value) => value.as_str().context("Invalid access type")?,
};
match access_type {
"free" => Ok(AccessControl::Free),
"peers_only" => Ok(AccessControl::PeersOnly),
"paid" => {
let price = params
.get("price_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
}
// Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted = match params.get("accepted_methods") {
None => Vec::new(),
Some(value) => value
.as_array()
.context("Invalid accepted methods")?
.iter()
.map(|method| {
let method = method.as_str().context("Invalid payment method")?;
anyhow::ensure!(
KNOWN_METHODS.contains(&method),
"Unsupported payment method"
);
Ok(method.to_owned())
})
.collect::<Result<Vec<_>>>()?,
};
Ok(AccessControl::Paid {
price_sats: price,
accepted,
})
}
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
}
}
fn parse_content_availability(params: &serde_json::Value, default: &str) -> Result<Availability> {
let availability_type = match params.get("availability") {
None => default,
Some(value) => value.as_str().context("Invalid availability")?,
};
match availability_type {
"nobody" => Ok(Availability::Nobody),
"all_peers" => Ok(Availability::AllPeers),
"specific" => {
let peers = params
.get("peers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(Availability::Specific { peers })
}
_ => {
return Err(anyhow::anyhow!(
"Invalid availability: {}",
availability_type
))
}
}
}
/// Best-effort reclaim of an ecash payment token that was minted but the sale /// Best-effort reclaim of an ecash payment token that was minted but the sale
/// didn't complete (seller unreachable or couldn't redeem it), so the buyer /// didn't complete (seller unreachable or couldn't redeem it), so the buyer
/// doesn't lose the value. For Fedimint the spender can reissue its own /// doesn't lose the value. For Fedimint the spender can reissue its own
@@ -238,6 +313,20 @@ impl RpcHandler {
Ok(serde_json::json!({ "items": catalog.items })) Ok(serde_json::json!({ "items": catalog.items }))
} }
/// Explicit atomic publication endpoint. Older servers reject this method
/// instead of accepting an add request while ignoring its pricing fields.
pub(super) async fn handle_content_publish(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let policy = params.as_ref().context("Missing params")?;
anyhow::ensure!(
policy.get("access").is_some() && policy.get("availability").is_some(),
"A complete sharing policy is required"
);
self.handle_content_add(params).await
}
/// Add content to my catalog. /// Add content to my catalog.
pub(super) async fn handle_content_add( pub(super) async fn handle_content_add(
&self, &self,
@@ -282,8 +371,10 @@ impl RpcHandler {
mime_type: mime_type.to_string(), mime_type: mime_type.to_string(),
size_bytes: 0, size_bytes: 0,
description: description.to_string(), description: description.to_string(),
access: AccessControl::Free, access: parse_content_access(&params)?,
availability: Availability::default(), // Legacy multi-call clients must configure visibility explicitly;
// an interrupted setup must not publish a paid file as free.
availability: parse_content_availability(&params, "nobody")?,
added_at: chrono::Utc::now().to_rfc3339(), added_at: chrono::Utc::now().to_rfc3339(),
}; };
@@ -293,34 +384,45 @@ impl RpcHandler {
item.size_bytes = metadata.len(); item.size_bytes = metadata.len();
} }
content_server::add_item(&self.config.data_dir, item.clone()).await?; let catalog = content_server::add_item(&self.config.data_dir, item.clone()).await?;
let item = catalog
.items
.into_iter()
.find(|saved| saved.filename == item.filename)
.context("Saved content item is unavailable")?;
// Also store as DWN message for interoperable file catalog // Export only explicitly public metadata. A staged or peer-restricted
if let Ok(store) = DwnStore::new(&self.config.data_dir).await { // share must not leak its filename through the public DWN catalog.
let did = crate::identity::did_key_from_pubkey_hex( if matches!(&item.availability, Availability::AllPeers)
&self.state_manager.get_snapshot().await.0.server_info.pubkey, && !matches!(&item.access, AccessControl::PeersOnly)
) {
.unwrap_or_default(); // Also store as DWN message for interoperable file catalog
let dwn_data = serde_json::json!({ if let Ok(store) = DwnStore::new(&self.config.data_dir).await {
"id": item.id, let did = crate::identity::did_key_from_pubkey_hex(
"title": item.filename, &self.state_manager.get_snapshot().await.0.server_info.pubkey,
"description": item.description,
"content_type": item.mime_type,
"size_bytes": item.size_bytes,
"access": format!("{:?}", item.access).to_lowercase(),
"created_at": item.added_at,
});
if let Err(e) = store
.write_message(
&did,
Some(FILE_CATALOG_PROTOCOL),
Some("https://archipelago.dev/schemas/file-entry/v1"),
Some("application/json"),
Some(dwn_data),
) )
.await .unwrap_or_default();
{ let dwn_data = serde_json::json!({
debug!("DWN file catalog write (non-fatal): {}", e); "id": item.id,
"title": item.filename,
"description": item.description,
"content_type": item.mime_type,
"size_bytes": item.size_bytes,
"access": format!("{:?}", item.access).to_lowercase(),
"created_at": item.added_at,
});
if let Err(e) = store
.write_message(
&did,
Some(FILE_CATALOG_PROTOCOL),
Some("https://archipelago.dev/schemas/file-entry/v1"),
Some("application/json"),
Some(dwn_data),
)
.await
{
debug!("DWN file catalog write (non-fatal): {}", e);
}
} }
} }
@@ -342,6 +444,26 @@ impl RpcHandler {
Ok(serde_json::json!({ "removed": true })) Ok(serde_json::json!({ "removed": true }))
} }
/// Save a complete sharing policy without an intermediate public/free state.
pub(super) async fn handle_content_configure(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.context("Missing params")?;
let id = params
.get("id")
.and_then(|v| v.as_str())
.context("Missing id")?;
anyhow::ensure!(
params.get("access").is_some() && params.get("availability").is_some(),
"A complete sharing policy is required"
);
let access = parse_content_access(&params)?;
let availability = parse_content_availability(&params, "nobody")?;
content_server::configure_item(&self.config.data_dir, id, access, availability).await?;
Ok(serde_json::json!({"updated":true}))
}
/// Set pricing for a content item. /// Set pricing for a content item.
pub(super) async fn handle_content_set_pricing( pub(super) async fn handle_content_set_pricing(
&self, &self,
@@ -352,43 +474,7 @@ impl RpcHandler {
.get("id") .get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?; .ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let access_type = params let access = parse_content_access(&params)?;
.get("access")
.and_then(|v| v.as_str())
.unwrap_or("free");
let access = match access_type {
"free" => AccessControl::Free,
"peers_only" => AccessControl::PeersOnly,
"paid" => {
let price = params
.get("price_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0);
if price == 0 {
return Err(anyhow::anyhow!("Paid content requires price_sats > 0"));
}
// Optional list of payment methods the sharer accepts.
// Absent/empty = all methods (backward compatible).
const KNOWN_METHODS: [&str; 4] = ["lightning", "onchain", "ecash", "fedimint"];
let accepted: Vec<String> = params
.get("accepted_methods")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|m| m.as_str())
.filter(|m| KNOWN_METHODS.contains(m))
.map(str::to_string)
.collect()
})
.unwrap_or_default();
AccessControl::Paid {
price_sats: price,
accepted,
}
}
_ => return Err(anyhow::anyhow!("Invalid access type: {}", access_type)),
};
content_server::set_access(&self.config.data_dir, id, access).await?; content_server::set_access(&self.config.data_dir, id, access).await?;
Ok(serde_json::json!({ "updated": true })) Ok(serde_json::json!({ "updated": true }))
@@ -404,33 +490,7 @@ impl RpcHandler {
.get("id") .get("id")
.and_then(|v| v.as_str()) .and_then(|v| v.as_str())
.ok_or_else(|| anyhow::anyhow!("Missing id"))?; .ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let availability_type = params let availability = parse_content_availability(&params, "all_peers")?;
.get("availability")
.and_then(|v| v.as_str())
.unwrap_or("all_peers");
let availability = match availability_type {
"nobody" => Availability::Nobody,
"all_peers" => Availability::AllPeers,
"specific" => {
let peers = params
.get("peers")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Availability::Specific { peers }
}
_ => {
return Err(anyhow::anyhow!(
"Invalid availability: {}",
availability_type
))
}
};
content_server::set_availability(&self.config.data_dir, id, availability).await?; content_server::set_availability(&self.config.data_dir, id, availability).await?;
Ok(serde_json::json!({ "updated": true })) Ok(serde_json::json!({ "updated": true }))
@@ -267,3 +267,89 @@ async fn onchain_delivery_streams_to_owned_cache_and_preserves_incomplete_recove
.is_err() .is_err()
); );
} }
#[test]
fn share_creation_stays_hidden_without_explicit_visibility_and_rejects_invalid_policy() {
let empty = serde_json::json!({});
assert!(matches!(
parse_content_availability(&empty, "nobody").unwrap(),
Availability::Nobody
));
assert!(matches!(
parse_content_access(&empty).unwrap(),
AccessControl::Free
));
for invalid in [
serde_json::json!({"access":null}),
serde_json::json!({"access":"paid","price_sats":0}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["unsupported"]}),
serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":"ecash"}),
] {
assert!(parse_content_access(&invalid).is_err());
}
let paid = serde_json::json!({"access":"paid","price_sats":1,"accepted_methods":["ecash"],"availability":"all_peers"});
assert!(matches!(
parse_content_access(&paid).unwrap(),
AccessControl::Paid { price_sats: 1, .. }
));
assert!(matches!(
parse_content_availability(&paid, "nobody").unwrap(),
Availability::AllPeers
));
}
#[tokio::test]
async fn repeated_share_returns_stable_id_and_complete_policy() {
let dir = tempfile::tempdir().unwrap();
let mut config = crate::config::Config::default();
config.data_dir = dir.path().to_path_buf();
config.dev_mode = false;
let sessions = crate::session::SessionStore::new_for_tests(dir.path().join("sessions.json"));
let handler = RpcHandler::new(
config,
std::sync::Arc::new(crate::state::StateManager::new()),
std::sync::Arc::new(crate::monitoring::MetricsStore::new()),
sessions,
None,
None,
)
.await
.unwrap();
let first = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":1,
"accepted_methods":["ecash"], "availability":"nobody"
})))
.await
.unwrap();
let second = handler
.handle_content_publish(Some(serde_json::json!({
"filename":"film.mp4", "access":"paid", "price_sats":2,
"accepted_methods":["lightning"], "availability":"nobody"
})))
.await
.unwrap();
assert_eq!(first["item"]["id"], second["item"]["id"]);
let catalog = content_server::load_catalog(dir.path()).await.unwrap();
assert_eq!(catalog.items.len(), 1);
let item = &catalog.items[0];
assert_eq!(second["item"]["id"].as_str(), Some(item.id.as_str()));
assert!(
matches!(&item.access, AccessControl::Paid { price_sats: 2, accepted } if accepted == &["lightning"])
);
assert!(matches!(item.availability, Availability::Nobody));
assert!(handler
.handle_content_configure(Some(serde_json::json!({
"id":item.id, "access":"free"
})))
.await
.is_err());
assert!(matches!(
content_server::load_catalog(dir.path())
.await
.unwrap()
.items[0]
.access,
AccessControl::Paid { price_sats: 2, .. }
));
}
@@ -322,6 +322,8 @@ impl RpcHandler {
// Content catalog management // Content catalog management
"content.list-mine" => self.handle_content_list_mine().await, "content.list-mine" => self.handle_content_list_mine().await,
"content.add" => self.handle_content_add(params).await, "content.add" => self.handle_content_add(params).await,
"content.publish" => self.handle_content_publish(params).await,
"content.configure" => self.handle_content_configure(params).await,
"content.remove" => self.handle_content_remove(params).await, "content.remove" => self.handle_content_remove(params).await,
"content.set-pricing" => self.handle_content_set_pricing(params).await, "content.set-pricing" => self.handle_content_set_pricing(params).await,
"content.set-availability" => self.handle_content_set_availability(params).await, "content.set-availability" => self.handle_content_set_availability(params).await,
+135 -17
View File
@@ -6,11 +6,12 @@
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf}; use std::path::{Path, PathBuf};
use tokio::fs; use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
use tracing::{debug, warn}; use tracing::{debug, warn};
const CATALOG_FILE: &str = "content/catalog.json"; const CATALOG_FILE: &str = "content/catalog.json";
const CONTENT_DIR: &str = "content/files"; const CONTENT_DIR: &str = "content/files";
static CATALOG_WRITES: Mutex<()> = Mutex::const_new(());
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ContentItem { pub struct ContentItem {
@@ -78,27 +79,39 @@ pub struct ContentCatalog {
/// Load the content catalog from disk. /// Load the content catalog from disk.
pub async fn load_catalog(data_dir: &Path) -> Result<ContentCatalog> { pub async fn load_catalog(data_dir: &Path) -> Result<ContentCatalog> {
let path = data_dir.join(CATALOG_FILE); let path = data_dir.join(CATALOG_FILE);
if !path.exists() { let content = match fs::read(&path).await {
return Ok(ContentCatalog::default()); Ok(bytes) => bytes,
} Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
let content = fs::read_to_string(&path) return Ok(ContentCatalog::default())
.await }
.context("Failed to read content catalog")?; Err(error) => return Err(error).context("Failed to read content catalog"),
let catalog: ContentCatalog = serde_json::from_str(&content).unwrap_or_default(); };
Ok(catalog) serde_json::from_slice(&content)
.context("Invalid content catalog; existing shares were preserved")
} }
/// Save the content catalog to disk. /// Save the content catalog to disk.
pub async fn save_catalog(data_dir: &Path, catalog: &ContentCatalog) -> Result<()> { pub async fn save_catalog(data_dir: &Path, catalog: &ContentCatalog) -> Result<()> {
let _lock = CATALOG_WRITES.lock().await;
save_catalog_unlocked(data_dir, catalog).await
}
async fn save_catalog_unlocked(data_dir: &Path, catalog: &ContentCatalog) -> Result<()> {
let dir = data_dir.join("content"); let dir = data_dir.join("content");
fs::create_dir_all(&dir) fs::create_dir_all(&dir)
.await .await
.context("Failed to create content dir")?; .context("Failed to create content dir")?;
let path = data_dir.join(CATALOG_FILE); let path = data_dir.join(CATALOG_FILE);
let content = serde_json::to_string_pretty(catalog).context("Failed to serialize catalog")?; let content = serde_json::to_string_pretty(catalog).context("Failed to serialize catalog")?;
fs::write(&path, content) let (file, temporary) = tempfile::NamedTempFile::new_in(&dir)?.into_parts();
.await let mut file = fs::File::from_std(file);
.context("Failed to write catalog")?; file.write_all(content.as_bytes()).await?;
file.sync_all().await?;
drop(file);
// Complete the rename synchronously while holding the catalog lock: an
// aborted async caller must not leave a late rename racing the next writer.
std::fs::rename(&temporary, &path).context("Failed to commit content catalog")?;
fs::File::open(&dir).await?.sync_all().await?;
Ok(()) Ok(())
} }
@@ -106,13 +119,14 @@ pub async fn save_catalog(data_dir: &Path, catalog: &ContentCatalog) -> Result<(
/// means the entry gets pruned again next time it's requested, so errors are /// means the entry gets pruned again next time it's requested, so errors are
/// logged rather than propagated. /// logged rather than propagated.
async fn prune_missing_content_entry(data_dir: &Path, id: &str) { async fn prune_missing_content_entry(data_dir: &Path, id: &str) {
let _lock = CATALOG_WRITES.lock().await;
let Ok(mut catalog) = load_catalog(data_dir).await else { let Ok(mut catalog) = load_catalog(data_dir).await else {
return; return;
}; };
let before = catalog.items.len(); let before = catalog.items.len();
catalog.items.retain(|i| i.id != id); catalog.items.retain(|i| i.id != id);
if catalog.items.len() != before { if catalog.items.len() != before {
if let Err(e) = save_catalog(data_dir, &catalog).await { if let Err(e) = save_catalog_unlocked(data_dir, &catalog).await {
warn!(error = %e, content_id = %id, "failed to save catalog after pruning missing content entry"); warn!(error = %e, content_id = %id, "failed to save catalog after pruning missing content entry");
} }
} }
@@ -149,6 +163,7 @@ pub fn content_file_path(data_dir: &Path, item: &ContentItem) -> PathBuf {
/// (2026-07-22). Same filename → update the existing entry in place and /// (2026-07-22). Same filename → update the existing entry in place and
/// keep its id, so existing buyers' owned records stay valid. /// keep its id, so existing buyers' owned records stay valid.
pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> { pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatalog> {
let _lock = CATALOG_WRITES.lock().await;
let mut catalog = load_catalog(data_dir).await?; let mut catalog = load_catalog(data_dir).await?;
if catalog.items.iter().any(|i| i.id == item.id) { if catalog.items.iter().any(|i| i.id == item.id) {
return Err(anyhow::anyhow!("Content item '{}' already exists", item.id)); return Err(anyhow::anyhow!("Content item '{}' already exists", item.id));
@@ -165,24 +180,26 @@ pub async fn add_item(data_dir: &Path, item: ContentItem) -> Result<ContentCatal
} else { } else {
catalog.items.push(item); catalog.items.push(item);
} }
save_catalog(data_dir, &catalog).await?; save_catalog_unlocked(data_dir, &catalog).await?;
Ok(catalog) Ok(catalog)
} }
/// Remove a content item from the catalog. /// Remove a content item from the catalog.
pub async fn remove_item(data_dir: &Path, id: &str) -> Result<ContentCatalog> { pub async fn remove_item(data_dir: &Path, id: &str) -> Result<ContentCatalog> {
let _lock = CATALOG_WRITES.lock().await;
let mut catalog = load_catalog(data_dir).await?; let mut catalog = load_catalog(data_dir).await?;
catalog.items.retain(|i| i.id != id); catalog.items.retain(|i| i.id != id);
save_catalog(data_dir, &catalog).await?; save_catalog_unlocked(data_dir, &catalog).await?;
Ok(catalog) Ok(catalog)
} }
/// Update access control for a content item. /// Update access control for a content item.
pub async fn set_access(data_dir: &Path, id: &str, access: AccessControl) -> Result<()> { pub async fn set_access(data_dir: &Path, id: &str, access: AccessControl) -> Result<()> {
let _lock = CATALOG_WRITES.lock().await;
let mut catalog = load_catalog(data_dir).await?; let mut catalog = load_catalog(data_dir).await?;
if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) { if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) {
item.access = access; item.access = access;
save_catalog(data_dir, &catalog).await?; save_catalog_unlocked(data_dir, &catalog).await?;
Ok(()) Ok(())
} else { } else {
Err(anyhow::anyhow!("Content item '{}' not found", id)) Err(anyhow::anyhow!("Content item '{}' not found", id))
@@ -191,16 +208,36 @@ pub async fn set_access(data_dir: &Path, id: &str, access: AccessControl) -> Res
/// Update availability for a content item. /// Update availability for a content item.
pub async fn set_availability(data_dir: &Path, id: &str, availability: Availability) -> Result<()> { pub async fn set_availability(data_dir: &Path, id: &str, availability: Availability) -> Result<()> {
let _lock = CATALOG_WRITES.lock().await;
let mut catalog = load_catalog(data_dir).await?; let mut catalog = load_catalog(data_dir).await?;
if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) { if let Some(item) = catalog.items.iter_mut().find(|i| i.id == id) {
item.availability = availability; item.availability = availability;
save_catalog(data_dir, &catalog).await?; save_catalog_unlocked(data_dir, &catalog).await?;
Ok(()) Ok(())
} else { } else {
Err(anyhow::anyhow!("Content item '{}' not found", id)) Err(anyhow::anyhow!("Content item '{}' not found", id))
} }
} }
/// Change price and visibility in one durable catalog transaction.
pub async fn configure_item(
data_dir: &Path,
id: &str,
access: AccessControl,
availability: Availability,
) -> Result<()> {
let _lock = CATALOG_WRITES.lock().await;
let mut catalog = load_catalog(data_dir).await?;
let item = catalog
.items
.iter_mut()
.find(|item| item.id == id)
.context("Content item not found")?;
item.access = access;
item.availability = availability;
save_catalog_unlocked(data_dir, &catalog).await
}
/// A byte range request (start, optional end). /// A byte range request (start, optional end).
pub enum ByteRange { pub enum ByteRange {
From { start: u64, end: Option<u64> }, From { start: u64, end: Option<u64> },
@@ -1066,6 +1103,87 @@ mod paid_read_order_tests {
dir dir
} }
#[tokio::test]
async fn corrupt_catalog_is_not_overwritten_by_a_mutation() {
let dir = fixture(b"file").await;
let item = load_catalog(dir.path()).await.unwrap().items.remove(0);
fs::write(dir.path().join(CATALOG_FILE), b"corrupt")
.await
.unwrap();
assert!(add_item(dir.path(), item).await.is_err());
assert!(remove_item(dir.path(), "paid").await.is_err());
assert_eq!(
fs::read(dir.path().join(CATALOG_FILE)).await.unwrap(),
b"corrupt"
);
}
#[tokio::test]
async fn concurrent_catalog_updates_preserve_all_items_and_sharing_fields() {
let dir = fixture(b"file").await;
let template = load_catalog(dir.path()).await.unwrap().items.remove(0);
let mut tasks = Vec::new();
for i in 0..16 {
let root = dir.path().to_owned();
let mut item = template.clone();
item.id = format!("item-{i}");
item.filename = format!("file-{i}");
tasks.push(tokio::spawn(async move {
add_item(&root, item).await.unwrap();
}));
}
for task in tasks {
task.await.unwrap();
}
assert_eq!(load_catalog(dir.path()).await.unwrap().items.len(), 17);
let (price, visibility) = tokio::join!(
set_access(
dir.path(),
"paid",
AccessControl::Paid {
price_sats: 7,
accepted: vec!["ecash".into()]
}
),
set_availability(dir.path(), "paid", Availability::Nobody),
);
price.unwrap();
visibility.unwrap();
let item = load_catalog(dir.path()).await.unwrap().items.remove(0);
assert!(matches!(
item.access,
AccessControl::Paid { price_sats: 7, .. }
));
assert!(matches!(item.availability, Availability::Nobody));
configure_item(
dir.path(),
"paid",
AccessControl::Paid {
price_sats: 9,
accepted: vec!["ecash".into()],
},
Availability::AllPeers,
)
.await
.unwrap();
let item = load_catalog(dir.path()).await.unwrap().items.remove(0);
assert!(matches!(
item.access,
AccessControl::Paid { price_sats: 9, .. }
));
assert!(matches!(item.availability, Availability::AllPeers));
use std::os::unix::fs::PermissionsExt;
assert_eq!(
fs::metadata(dir.path().join(CATALOG_FILE))
.await
.unwrap()
.permissions()
.mode()
& 0o777,
0o600
);
}
#[tokio::test] #[tokio::test]
async fn all_read_failures_precede_redemption_even_as_root() { async fn all_read_failures_precede_redemption_even_as_root() {
for kind in [ for kind in [
+28
View File
@@ -397,3 +397,31 @@ build does not claim durable recovery at every pre-header payment failure or
resolve the previously recorded on-chain bearer-address concern. IndeeHub's resolve the previously recorded on-chain bearer-address concern. IndeeHub's
integration boundaries are mapped in [the integration map](indeehub-integration-map.md). integration boundaries are mapped in [the integration map](indeehub-integration-map.md).
Deployment coordination with the separate mining session is still pending. Deployment coordination with the separate mining session is still pending.
## Atomic share publication qualification in progress — October 6
Paid share creation previously added a free/public item, then set its price and
visibility in later RPCs. The Web5 form also selected the final catalog item to
price, which could target another concurrent share. The candidate now sends a
complete policy through `content.publish` or `content.configure`. These distinct
methods fail on an older backend instead of silently ignoring pricing fields.
Legacy `content.add` defaults new entries to hidden until explicitly configured.
A cached old Web5 form may therefore require a refresh to publish; do not restore
an unsafe public default for that compatibility case.
Catalog mutations serialize their read/change/write transaction, replace the
catalog atomically after syncing the temporary file, and reject malformed saved
JSON without overwriting it. Re-sharing a filename retains and returns its saved
ID. New hidden or peer-restricted shares do not export public DWN metadata;
retracting already-exported metadata remains a separate open requirement.
Focused UI checks passed five cases before the compatibility endpoint adjustment;
a rerun and isolated backend suite are in progress. Added regressions exercise
concurrent updates, malformed catalog preservation, stable IDs and rejection of
incomplete policy updates. These changes are not covered by the earlier 1,743
backend result or the archived dashboard build, and are not deployed. The running
`081c8215` production build also predates them.
The operator-funded wallets remain untouched in this pass. The private ledger
retains the 25-sat inclusive test cap and zero spent. Deployment coordination and
the durable payment recovery gates above remain open.
+10 -18
View File
@@ -400,34 +400,26 @@ async function save() {
(i) => matchesThisFile(i.filename) (i) => matchesThisFile(i.filename)
)?.id )?.id
// Add if not in catalog const policy: Record<string, unknown> = { access: accessType.value, availability: 'all_peers' }
if (accessType.value === 'paid') {
policy.price_sats = priceSats.value
policy.accepted_methods = [...acceptedSet.value]
}
if (!itemId) { if (!itemId) {
const ext = props.filename.split('.').pop()?.toLowerCase() || '' const ext = props.filename.split('.').pop()?.toLowerCase() || ''
const addRes = await rpcClient.call<{ item: { id: string } }>({ await rpcClient.call({
method: 'content.add', method: 'content.publish',
params: { params: {
filename: (props.filepath || props.filename).replace(/^\/+/, ''), filename: (props.filepath || props.filename).replace(/^\/+/, ''),
mime_type: SHARE_MIME_MAP[ext] || 'application/octet-stream', mime_type: SHARE_MIME_MAP[ext] || 'application/octet-stream',
description: '', description: '',
...policy,
}, },
}) })
itemId = addRes.item.id } else {
await rpcClient.call({ method: 'content.configure', params: { id: itemId, ...policy } })
} }
// Set pricing
const pricingParams: Record<string, unknown> = { id: itemId, access: accessType.value }
if (accessType.value === 'paid') {
pricingParams.price_sats = priceSats.value
pricingParams.accepted_methods = [...acceptedSet.value]
}
await rpcClient.call({ method: 'content.set-pricing', params: pricingParams })
// Set availability to all peers
await rpcClient.call({
method: 'content.set-availability',
params: { id: itemId, availability: 'all_peers' },
})
const label = const label =
accessType.value === 'paid' accessType.value === 'paid'
? `Shared for ${priceSats.value} sats` ? `Shared for ${priceSats.value} sats`
@@ -0,0 +1,25 @@
import { beforeEach, afterEach, expect, it, vi } from 'vitest'
import { flushPromises, mount } from '@vue/test-utils'
import ShareModal from '../ShareModal.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
beforeEach(() => { vi.clearAllMocks(); vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }) })
afterEach(() => { vi.clearAllTimers(); vi.useRealTimers() })
it.each([false, true])('publishes one complete policy for an existing share: %s', async existing => {
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.list-mine') return { items: existing ? [{ id: 'saved-id', filename: 'film.mp4', access: 'free' }] : [] }
return { item: { id: 'new-id' } }
})
const wrapper = mount(ShareModal, { props: { filename: 'film.mp4', filepath: '/film.mp4', isDir: false } })
await flushPromises()
const vm = (wrapper.vm as any).$.setupState
vm.shared = true; vm.accessType = 'paid'; vm.priceSats = 1; vm.acceptedSet = new Set(['ecash'])
await vm.save()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call).filter(call => ['content.publish','content.configure','content.set-pricing','content.set-availability'].includes(call.method))
expect(writes).toHaveLength(1)
expect(writes[0]?.method).toBe(existing ? 'content.configure' : 'content.publish')
expect(writes[0]?.params).toMatchObject({ access: 'paid', price_sats: 1, accepted_methods: ['ecash'], availability: 'all_peers' })
expect(writes[0]?.params).toMatchObject(existing ? { id: 'saved-id' } : { filename: 'film.mp4' })
wrapper.unmount()
})
+4 -15
View File
@@ -467,27 +467,16 @@ async function addContentItem() {
addContentError.value = '' addContentError.value = ''
try { try {
await rpcClient.call({ await rpcClient.call({
method: 'content.add', method: 'content.publish',
params: { params: {
filename: newContentFilename.value.trim(), filename: newContentFilename.value.trim(),
mime_type: newContentMimeType.value.trim() || 'application/octet-stream', mime_type: newContentMimeType.value.trim() || 'application/octet-stream',
description: newContentDescription.value.trim(), description: newContentDescription.value.trim(),
access: newContentAccess.value,
availability: 'all_peers',
...(newContentAccess.value === 'paid' ? { price_sats: newContentPrice.value || 100 } : {}),
}, },
}) })
if (newContentAccess.value !== 'free') {
const items = (await rpcClient.call<{ items: ContentItemData[] }>({ method: 'content.list-mine' })).items || []
const latest = items[items.length - 1]
if (latest) {
await rpcClient.call({
method: 'content.set-pricing',
params: {
id: latest.id,
access: newContentAccess.value,
...(newContentAccess.value === 'paid' ? { price_sats: newContentPrice.value || 100 } : {}),
},
})
}
}
showAddContentModal.value = false showAddContentModal.value = false
newContentFilename.value = '' newContentFilename.value = ''
newContentMimeType.value = 'application/octet-stream' newContentMimeType.value = 'application/octet-stream'
@@ -116,3 +116,17 @@ describe('Web5SharedContent', () => {
expect(wrapper.text()).not.toContain('Refreshing peer content...') expect(wrapper.text()).not.toContain('Refreshing peer content...')
}) })
}) })
it('creates a paid Web5 share with its price in the first write', async () => {
vi.mocked(rpcClient.call).mockReset()
vi.mocked(rpcClient.call).mockResolvedValue({ items: [] })
const wrapper = mount(Web5SharedContent, { props: { showStagger: false, peers: [] } })
await flushPromises()
const vm = (wrapper.vm as any).$.setupState
vm.newContentFilename = 'film.mp4'; vm.newContentAccess = 'paid'; vm.newContentPrice = 1
await vm.addContentItem()
const writes = vi.mocked(rpcClient.call).mock.calls.map(([call]) => call).filter(call => ['content.publish','content.set-pricing'].includes(call.method))
expect(writes).toHaveLength(1)
expect(writes[0]).toMatchObject({ method: 'content.publish', params: { filename: 'film.mp4', access: 'paid', price_sats: 1, availability: 'all_peers' } })
wrapper.unmount()
})