Compare commits

..
6 Commits
36 changed files with 2339 additions and 325 deletions
+11
View File
@@ -29,3 +29,14 @@ unrestricted `cargo test` on a node with installed apps: older mocked-runtime
tests still reached real service commands. The runner isolates wallet data, tests still reached real service commands. The runner isolates wallet data,
service buses, container storage, networking, and process IDs. Compilation with service buses, container storage, networking, and process IDs. Compilation with
`cargo test --no-run` is safe. Keep separately authorized live checks explicit. `cargo test --no-run` is safe. Keep separately authorized live checks explicit.
## Active release regression checklist
Before resuming release work, read
`docs/post-1.8.22-regressions-20261001.md` and retain its unfinished tasks.
The operator requested that every reported issue be tracked, fixed and tested
before another OTA/ISO. Keep source/unit-test results separate from actual-node
acceptance. In particular, paid-file recovery must not send another payment,
and app cleanup must preserve wallets, persistent data and uninstall decisions.
Do not mark the new paid-file incident resolved merely because the earlier
Framework LND startup incident was closed.
+63 -26
View File
@@ -74,7 +74,7 @@ impl ApiHandler {
let invoice_hash = headers let invoice_hash = headers
.get("x-invoice-hash") .get("x-invoice-hash")
.and_then(|v| v.to_str().ok()) .and_then(|v| v.to_str().ok())
.map(|s| s.to_string()) .map(|s| s.to_ascii_lowercase())
.or_else(|| { .or_else(|| {
headers headers
.get("x-onchain-address") .get("x-onchain-address")
@@ -98,6 +98,46 @@ impl ApiHandler {
None => false, None => false,
}; };
// Payment settlement is verified on the seller even when no status
// poll preceded this download (e.g. direct payment from another node).
let requires_payment = if !owner_session && headers.contains_key("x-invoice-hash") {
content_server::load_catalog(&config.data_dir)
.await?
.items
.iter()
.any(|item| {
item.id == content_id
&& matches!(item.access, content_server::AccessControl::Paid { .. })
})
} else {
false
};
if requires_payment {
if let Some(hash) = headers.get("x-invoice-hash").and_then(|v| v.to_str().ok()) {
if hash.len() != 64 || !hash.bytes().all(|c| c.is_ascii_hexdigit()) {
return Ok(build_response(
StatusCode::BAD_REQUEST,
"text/plain",
hyper::Body::from("Invalid payment hash"),
));
}
if let Err(error) = self
.rpc_handler
.settle_content_invoice(hash, content_id)
.await
{
tracing::warn!("Cannot verify peer-file invoice settlement: {error:#}");
return Ok(build_response(
StatusCode::SERVICE_UNAVAILABLE,
"application/json",
hyper::Body::from(
r#"{"error":"Payment verification is temporarily unavailable. Retry the download without paying again."}"#,
),
));
}
}
}
// Parse Range header for streaming support // Parse Range header for streaming support
let range = headers let range = headers
.get("range") .get("range")
@@ -249,7 +289,13 @@ impl ApiHandler {
.await .await
{ {
Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => { Ok((bolt11, payment_hash)) if !payment_hash.is_empty() => {
crate::content_invoice::record_pending(&payment_hash, content_id, price_sats).await; crate::content_invoice::record_pending(
&self.config.data_dir,
&payment_hash,
content_id,
price_sats,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"bolt11": bolt11, "bolt11": bolt11,
"payment_hash": payment_hash, "payment_hash": payment_hash,
@@ -309,26 +355,10 @@ impl ApiHandler {
)); ));
} }
// The hash must be one we issued for exactly this content item. let paid = self
match crate::content_invoice::lookup(payment_hash).await { .rpc_handler
Some((cid, _)) if cid == content_id => {} .settle_content_invoice(payment_hash, content_id)
_ => { .await?;
return Ok(build_response(
StatusCode::NOT_FOUND,
"application/json",
hyper::Body::from(r#"{"error":"Unknown invoice"}"#),
))
}
}
// Already paid? Otherwise ask our LND and persist the result.
let mut paid = crate::content_invoice::is_paid_for(payment_hash, content_id).await;
if !paid {
if let Ok(true) = self.rpc_handler.invoice_is_settled(payment_hash).await {
crate::content_invoice::mark_paid(payment_hash).await;
paid = true;
}
}
let body = serde_json::json!({ "paid": paid }); let body = serde_json::json!({ "paid": paid });
Ok(build_response( Ok(build_response(
@@ -389,7 +419,13 @@ impl ApiHandler {
match self.rpc_handler.new_onchain_address().await { match self.rpc_handler.new_onchain_address().await {
Ok(address) if !address.is_empty() => { Ok(address) if !address.is_empty() => {
crate::content_invoice::record_pending(&address, content_id, price_sats).await; crate::content_invoice::record_pending(
&self.config.data_dir,
&address,
content_id,
price_sats,
)
.await?;
let body = serde_json::json!({ let body = serde_json::json!({
"address": address, "address": address,
"amount_sats": price_sats, "amount_sats": price_sats,
@@ -439,7 +475,7 @@ impl ApiHandler {
)); ));
} }
// The address must be one we issued for exactly this content item. // The address must be one we issued for exactly this content item.
let price = match crate::content_invoice::lookup(address).await { let price = match crate::content_invoice::lookup(&self.config.data_dir, address).await? {
Some((cid, price)) if cid == content_id => price, Some((cid, price)) if cid == content_id => price,
_ => { _ => {
return Ok(build_response( return Ok(build_response(
@@ -450,10 +486,11 @@ impl ApiHandler {
} }
}; };
let mut paid = crate::content_invoice::is_paid_for(address, content_id).await; let mut paid =
crate::content_invoice::is_paid_for(&self.config.data_dir, address, content_id).await;
if !paid { if !paid {
if let Ok(true) = self.rpc_handler.onchain_received(address, price).await { if let Ok(true) = self.rpc_handler.onchain_received(address, price).await {
crate::content_invoice::mark_paid(address).await; crate::content_invoice::mark_paid(&self.config.data_dir, address).await?;
paid = true; paid = true;
} }
} }
+84 -9
View File
@@ -73,6 +73,17 @@ fn paid_content_response(bytes: &[u8], mime: &str, paid_sats: u64) -> serde_json
}) })
} }
// Updated clients open the persisted file through the Range-capable HTTP
// endpoint. Avoid putting two base64 copies of a large video in a JSON reply.
// Keep older clients compatible until both sides have upgraded.
fn invoice_download_response(bytes: &[u8], mime: &str, cache_only: bool) -> serde_json::Value {
if cache_only {
serde_json::json!({ "owned": true, "mime_type": mime, "size_bytes": bytes.len() })
} else {
paid_content_response(bytes, mime, 0)
}
}
/// File purchases through an atomic no-clobber write in Files' own namespace. /// File purchases through an atomic no-clobber write in Files' own namespace.
async fn file_purchase_in_files( async fn file_purchase_in_files(
data_dir: &std::path::Path, data_dir: &std::path::Path,
@@ -870,10 +881,29 @@ impl RpcHandler {
if !is_valid_v3_onion(onion) { if !is_valid_v3_onion(onion) {
return Err(anyhow::anyhow!("Invalid v3 onion address")); return Err(anyhow::anyhow!("Invalid v3 onion address"));
} }
if payment_hash.is_empty() || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) { if payment_hash.len() != 64 || !payment_hash.chars().all(|c| c.is_ascii_hexdigit()) {
return Err(anyhow::anyhow!("Invalid payment_hash")); return Err(anyhow::anyhow!("Invalid payment_hash"));
} }
let cache_only = params
.get("cache_only")
.and_then(|v| v.as_bool())
.unwrap_or(false);
if let Some((mime, bytes)) =
crate::content_owned::read_owned(&self.config.data_dir, onion, content_id).await
{
return Ok(invoice_download_response(&bytes, &mime, cache_only));
}
// Older sellers only mark settlement during status polling. Always
// perform that handshake before requesting bytes; retries never pay.
// The download gate remains authoritative: a file may have become
// free, and newer sellers verify directly if status polling fails.
let _ = self
.handle_content_invoice_status(Some(serde_json::json!({
"onion": onion, "content_id": content_id, "payment_hash": payment_hash,
})))
.await;
let (data, _) = self.state_manager.get_snapshot().await; let (data, _) = self.state_manager.get_snapshot().await;
let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?; let local_did = crate::identity::did_key_from_pubkey_hex(&data.server_info.pubkey)?;
let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await; let fips_npub = crate::federation::fips_npub_for_onion(&self.config.data_dir, onion).await;
@@ -912,7 +942,7 @@ impl RpcHandler {
if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED { if response.status() == reqwest::StatusCode::PAYMENT_REQUIRED {
return Ok(serde_json::json!({ return Ok(serde_json::json!({
"error": "Seller has not registered this payment yet — wait for settlement and retry." "error": "The seller has not confirmed access yet. Retry the download without paying again."
})); }));
} }
if !response.status().is_success() { if !response.status().is_success() {
@@ -921,16 +951,45 @@ impl RpcHandler {
})); }));
} }
let mime = response
.headers()
.get(reqwest::header::CONTENT_TYPE)
.and_then(|v| v.to_str().ok())
.unwrap_or("application/octet-stream")
.split(';')
.next()
.unwrap_or("application/octet-stream")
.to_string();
let bytes = response let bytes = response
.bytes() .bytes()
.await .await
.context("Failed to read response body")?; .context("Paid file transfer interrupted; retry the download without paying again")?;
use base64::Engine; let filename = params
let encoded = base64::engine::general_purpose::STANDARD.encode(&bytes); .get("filename")
Ok(serde_json::json!({ .and_then(|v| v.as_str())
"data": encoded, .unwrap_or(content_id);
"size": bytes.len(), crate::content_owned::record_purchase(
})) &self.config.data_dir,
onion,
content_id,
filename,
&mime,
&bytes,
params
.get("price_sats")
.and_then(|v| v.as_u64())
.unwrap_or(0),
"lightning",
&chrono::Utc::now().to_rfc3339(),
)
.await
.context("Paid file could not be saved; retry the download without paying again")?;
if let Err(error) =
file_purchase_in_files(&self.config.data_dir, filename, &mime, &bytes).await
{
tracing::warn!("Lightning purchase cached; optional Files copy failed: {error:#}");
}
Ok(invoice_download_response(&bytes, &mime, cache_only))
} }
/// Buyer side (#46): ask the seller for a fresh on-chain address to pay. /// Buyer side (#46): ask the seller for a fresh on-chain address to pay.
@@ -1405,3 +1464,19 @@ impl RpcHandler {
#[cfg(test)] #[cfg(test)]
#[path = "content_tests.rs"] #[path = "content_tests.rs"]
mod tests; mod tests;
#[cfg(test)]
mod invoice_delivery_response_tests {
use super::*;
#[test]
fn cached_delivery_avoids_base64_but_keeps_old_clients_compatible() {
let cached = invoice_download_response(b"paid bytes", "video/mp4", true);
assert_eq!(cached["owned"], true);
assert_eq!(cached["size_bytes"], 10);
assert!(cached.get("data").is_none());
assert!(cached.get("data_base64").is_none());
let legacy = invoice_download_response(b"paid bytes", "video/mp4", false);
assert_eq!(legacy["data"], "cGFpZCBieXRlcw==");
assert_eq!(legacy["data"], legacy["data_base64"]);
}
}
+101 -4
View File
@@ -473,6 +473,7 @@ impl RpcHandler {
)); ));
} }
let fee_query = close_channel_fee_query(&params)?;
let force = params let force = params
.get("force") .get("force")
.and_then(|v| v.as_bool()) .and_then(|v| v.as_bool())
@@ -498,13 +499,11 @@ impl RpcHandler {
.build() .build()
.context("Failed to create streaming HTTP client")?; .context("Failed to create streaming HTTP client")?;
let url = format!( let url = format!("{LND_REST_BASE_URL}/v1/channels/{}/{}", parts[0], parts[1]);
"{LND_REST_BASE_URL}/v1/channels/{}/{}?force={}",
parts[0], parts[1], force
);
let mut resp = client let mut resp = client
.delete(&url) .delete(&url)
.query(&fee_query)
.header("Grpc-Metadata-macaroon", &macaroon_hex) .header("Grpc-Metadata-macaroon", &macaroon_hex)
.send() .send()
.await .await
@@ -572,3 +571,101 @@ impl RpcHandler {
} }
} }
} }
/// LND's CloseChannel REST endpoint takes fee selection as query parameters.
/// With neither parameter LND uses a lax target; keep legacy clients on our
/// explicit Standard target rather than silently accepting that default.
fn close_channel_fee_query(params: &serde_json::Value) -> Result<Vec<(&'static str, String)>> {
let force = match params.get("force") {
None | Some(serde_json::Value::Null) => false,
Some(value) => value
.as_bool()
.ok_or_else(|| anyhow::anyhow!("force must be a boolean"))?,
};
let integer = |key: &str, max: u64| -> Result<Option<u64>> {
match params.get(key) {
None | Some(serde_json::Value::Null) => Ok(None),
Some(value) => {
let n = value
.as_u64()
.ok_or_else(|| anyhow::anyhow!("{key} must be a positive whole number"))?;
anyhow::ensure!((1..=max).contains(&n), "{key} must be between 1 and {max}");
Ok(Some(n))
}
}
};
let target = integer("target_conf", 1008)?;
let rate = integer("sat_per_vbyte", 5000)?;
anyhow::ensure!(
target.is_none() || rate.is_none(),
"Specify either target_conf or sat_per_vbyte, not both"
);
anyhow::ensure!(
!force || (target.is_none() && rate.is_none()),
"Closing fee selection requires a cooperative close"
);
let mut query = vec![("force", force.to_string())];
if !force {
if let Some(rate) = rate {
query.push(("sat_per_vbyte", rate.to_string()));
} else {
query.push(("target_conf", target.unwrap_or(6).to_string()));
}
}
Ok(query)
}
#[cfg(test)]
mod close_fee_tests {
use super::*;
#[test]
fn close_fee_query_forwards_presets_custom_and_legacy_default() {
for target in [1, 3, 6, 1008] {
assert_eq!(
close_channel_fee_query(&serde_json::json!({"target_conf":target})).unwrap(),
vec![
("force", "false".into()),
("target_conf", target.to_string())
]
);
}
for rate in [1, 25, 5000] {
let query =
close_channel_fee_query(&serde_json::json!({"sat_per_vbyte":rate})).unwrap();
let request = reqwest::Client::new()
.delete("http://localhost/v1/channels/test/0")
.query(&query)
.build()
.unwrap();
assert_eq!(request.method(), reqwest::Method::DELETE);
assert_eq!(
request.url().query(),
Some(format!("force=false&sat_per_vbyte={rate}").as_str())
);
}
assert_eq!(
close_channel_fee_query(&serde_json::json!({})).unwrap(),
vec![("force", "false".into()), ("target_conf", "6".into())]
);
assert_eq!(
close_channel_fee_query(&serde_json::json!({"force":true})).unwrap(),
vec![("force", "true".into())]
);
}
#[test]
fn malformed_or_conflicting_close_fees_fail_before_wallet_access() {
for params in [
serde_json::json!({"target_conf":1,"sat_per_vbyte":2}),
serde_json::json!({"force":true,"target_conf":1}),
serde_json::json!({"force":"false"}),
serde_json::json!({"target_conf":0}),
serde_json::json!({"target_conf":1009}),
serde_json::json!({"sat_per_vbyte":5001}),
serde_json::json!({"sat_per_vbyte":-1}),
serde_json::json!({"sat_per_vbyte":1.5}),
serde_json::json!({"sat_per_vbyte":"25"}),
] {
assert!(close_channel_fee_query(&params).is_err(), "{params}");
}
}
}
+128
View File
@@ -453,6 +453,56 @@ impl RpcHandler {
Ok(settled) Ok(settled)
} }
/// Verify against LND at download time, rather than relying on a browser
/// having polled first. The memo/amount also recover pre-upgrade in-memory
/// entitlements after restart; unrelated invoices never unlock a file.
pub(crate) async fn settle_content_invoice(
&self,
hash: &str,
content_id: &str,
) -> Result<bool> {
anyhow::ensure!(
hash.len() == 64 && hash.bytes().all(|c| c.is_ascii_hexdigit()),
"Invalid payment hash"
);
let hash = hash.to_ascii_lowercase();
let existing = crate::content_invoice::lookup(&self.config.data_dir, &hash).await?;
if let Some((id, _)) = &existing {
if id != content_id {
return Ok(false);
}
}
if crate::content_invoice::is_paid_for(&self.config.data_dir, &hash, content_id).await {
return Ok(true);
}
let (client, macaroon_hex) = self.lnd_client().await?;
let response = client
.get(format!("{LND_REST_BASE_URL}/v1/invoice/{hash}"))
.header("Grpc-Metadata-macaroon", &macaroon_hex)
.send()
.await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Ok(false);
}
let body: serde_json::Value = response.error_for_status()?.json().await?;
let Some(price) = content_invoice_amount(&body, content_id) else {
return Ok(false);
};
if existing
.as_ref()
.is_some_and(|(_, expected)| *expected != price)
{
return Ok(false);
}
crate::content_invoice::record_pending(&self.config.data_dir, &hash, content_id, price)
.await?;
let settled = content_invoice_fully_settled(&body, price);
if settled {
crate::content_invoice::mark_paid(&self.config.data_dir, &hash).await?;
}
Ok(settled)
}
/// Generate a fresh on-chain receive address (seller side, #46). /// Generate a fresh on-chain receive address (seller side, #46).
pub(crate) async fn new_onchain_address(&self) -> Result<String> { pub(crate) async fn new_onchain_address(&self) -> Result<String> {
let (client, macaroon_hex) = self.lnd_client().await?; let (client, macaroon_hex) = self.lnd_client().await?;
@@ -1444,3 +1494,81 @@ mod tests {
assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}"); assert!(s.contains("[LND_REST_UNREACHABLE]"), "got: {s}");
} }
} }
// LND REST uses decimal strings for int64 fields. Match the complete seller
// memo, not a substring supplied by a buyer or an arbitrary settled invoice.
fn json_u64(value: &serde_json::Value) -> Option<u64> {
value.as_u64().or_else(|| value.as_str()?.parse().ok())
}
fn content_invoice_fully_settled(body: &serde_json::Value, price: u64) -> bool {
let settled = match body.get("state").and_then(|v| v.as_str()) {
Some(state) => state == "SETTLED",
None => body.get("settled").and_then(|v| v.as_bool()) == Some(true),
};
settled
&& price > 0
&& body
.get("amt_paid_sat")
.and_then(json_u64)
.is_some_and(|paid| paid >= price)
}
fn content_invoice_amount(body: &serde_json::Value, content_id: &str) -> Option<u64> {
if body.get("memo")?.as_str()? != format!("Archipelago peer file {content_id}") {
return None;
}
body.get("value").and_then(json_u64).filter(|v| *v > 0)
}
#[cfg(test)]
mod peer_file_invoice_tests {
use super::*;
#[test]
fn settlement_requires_terminal_state_and_full_amount() {
for state in ["OPEN", "ACCEPTED", "CANCELED", "unknown"] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":state,"settled":true,"amt_paid_sat":"100"}),
7
));
}
for amount in [
serde_json::json!(6),
serde_json::json!("-1"),
serde_json::json!(null),
serde_json::json!("bad"),
] {
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
for amount in [serde_json::json!(7), serde_json::json!("8")] {
assert!(content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":amount}),
7
));
}
assert!(content_invoice_fully_settled(
&serde_json::json!({"settled":true,"amt_paid_sat":"7"}),
7
));
assert!(!content_invoice_fully_settled(
&serde_json::json!({"state":"SETTLED","amt_paid_sat":"7"}),
0
));
}
#[test]
fn legacy_recovery_requires_exact_file_memo_and_positive_amount() {
let invoice = serde_json::json!({"memo":"Archipelago peer file file-1", "value":"7"});
assert_eq!(content_invoice_amount(&invoice, "file-1"), Some(7));
assert_eq!(content_invoice_amount(&invoice, "file-2"), None);
for value in [
serde_json::json!("-1"),
serde_json::json!(0),
serde_json::json!("bad"),
] {
let mut invalid = invoice.clone();
invalid["value"] = value;
assert_eq!(content_invoice_amount(&invalid, "file-1"), None);
}
}
}
@@ -20,7 +20,9 @@ use crate::data_model::{
/// stopped-app restoration path in agreement with live-container discovery. /// stopped-app restoration path in agreement with live-container discovery.
fn canonical_package_id(name: &str) -> &str { fn canonical_package_id(name: &str) -> &str {
match name.strip_prefix("archy-").unwrap_or(name) { match name.strip_prefix("archy-").unwrap_or(name) {
"immich_server" => "immich", "immich_server" | "immich-server" => "immich",
"immich-postgres" => "immich_postgres",
"immich-redis" => "immich_redis",
"mempool-web" | "mempool-frontend" => "mempool", "mempool-web" | "mempool-frontend" => "mempool",
name => name, name => name,
} }
@@ -443,6 +445,19 @@ mod lifecycle_regression_tests {
use super::*; use super::*;
use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::io::{AsyncReadExt, AsyncWriteExt};
#[test]
fn immich_dependency_aliases_share_the_hidden_component_ids() {
for id in [
"immich-postgres",
"immich_postgres",
"archy-immich-postgres",
] {
assert_eq!(canonical_package_id(id), "immich_postgres");
}
assert_eq!(canonical_package_id("immich-redis"), "immich_redis");
assert_eq!(canonical_package_id("immich-server"), "immich");
}
#[test] #[test]
fn registry_survives_empty_runtime_and_deduplicates_aliases() { fn registry_survives_empty_runtime_and_deduplicates_aliases() {
let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"] let installed = ["archy-gitea", "gitea", "immich_server", "archy-removed"]
@@ -3153,7 +3153,12 @@ impl ProdContainerOrchestrator {
let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body); let restart_for_exec_change = quadlet::exec_changed(&old_body, &new_body);
let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body); let restart_for_health_change = quadlet::health_cmd_changed(&old_body, &new_body);
let restart_for_security_change = quadlet::security_changed(&old_body, &new_body); let restart_for_security_change = quadlet::security_changed(&old_body, &new_body);
let restart_for_managed_override =
quadlet::redundant_managed_network_override(&unit, &unit_dir)
.await?
.is_some();
let needs_restart = restart_required let needs_restart = restart_required
|| restart_for_managed_override
|| restart_for_port_change || restart_for_port_change
|| restart_for_network_alias_change || restart_for_network_alias_change
|| restart_for_exec_change || restart_for_exec_change
@@ -4881,6 +4886,26 @@ impl ContainerOrchestrator for ProdContainerOrchestrator {
/// here (production volumes live under `/var/lib/archipelago` — removal is a /// here (production volumes live under `/var/lib/archipelago` — removal is a
/// separate operation owned by the data layer, not this orchestrator). /// separate operation owned by the data layer, not this orchestrator).
async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> { async fn remove(&self, app_id: &str, _preserve_data: bool) -> Result<()> {
// A removed catalog entry must remain uninstallable. The RPC caller
// still removes legacy containers and persists uninstall intent after
// confirming they are gone; do not block it on a missing manifest.
if !self.state.read().await.manifests.contains_key(app_id) {
anyhow::ensure!(
!app_id.is_empty()
&& app_id.len() <= 128
&& app_id
.bytes()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, b'-' | b'_')),
"Invalid app id"
);
let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await;
for name in [app_id.to_string(), format!("archy-{app_id}")] {
self.remove_quadlet_unit_if_present(&name).await?;
}
self.state.write().await.disabled.insert(app_id.to_string());
return Ok(());
}
let lm = self.loaded(app_id).await?; let lm = self.loaded(app_id).await?;
let lock = self.app_lock(app_id).await; let lock = self.app_lock(app_id).await;
let _guard = lock.lock().await; let _guard = lock.lock().await;
@@ -7435,6 +7460,19 @@ app:
); );
} }
#[tokio::test]
async fn removed_catalog_entry_does_not_block_legacy_uninstall() {
let rt = Arc::new(MockRuntime::default());
let orch = orch_with(rt.clone()).await;
orch.remove("cryptpad", true).await.unwrap();
assert!(orch.state.read().await.disabled.contains("cryptpad"));
assert!(
rt.calls().is_empty(),
"legacy RPC teardown owns the actual containers"
);
assert!(orch.remove("../other", true).await.is_err());
}
#[tokio::test] #[tokio::test]
async fn remove_disables_manifest_so_reconcile_does_not_reinstall() { async fn remove_disables_manifest_so_reconcile_does_not_reinstall() {
let rt = Arc::new(MockRuntime::default()); let rt = Arc::new(MockRuntime::default());
+140 -19
View File
@@ -713,29 +713,76 @@ pub async fn unit_dir() -> Result<PathBuf> {
Ok(dir) Ok(dir)
} }
/// Atomically write `unit` into `dir/<name>.container` if the bytes /// The early same-node Portainer repair used a managed Quadlet drop-in. Once
/// differ from what's already there. Returns true if the file changed. /// the manifest supplies slirp, the two Network= entries are additive and
/// Podman rejects startup. Retire only that exact redundant managed override;
/// arbitrary operator settings must survive reconciliation.
pub async fn redundant_managed_network_override(
unit: &QuadletUnit,
dir: &Path,
) -> Result<Option<PathBuf>> {
if unit.name != "portainer" || !matches!(unit.network, NetworkMode::Slirp4netns) {
return Ok(None);
}
let path = dir.join("portainer.container.d/archy-same-node-network.conf");
let body = match fs::read_to_string(&path).await {
Ok(body) => body,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(error).context("read managed Portainer network override"),
};
let lines: Vec<&str> = body
.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with(['#', ';']))
.collect();
Ok((lines == ["[Container]", "Network=slirp4netns"]).then_some(path))
}
async fn retire_managed_network_override(path: &Path) -> Result<()> {
let backup = path.with_extension("conf.retired");
match fs::hard_link(path, &backup).await {
Ok(()) => {}
Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {
anyhow::ensure!(
fs::read(path).await? == fs::read(&backup).await?,
"Existing Portainer override backup differs; preserve both for operator review"
);
}
Err(error) => return Err(error).context("back up managed Portainer network override"),
}
fs::remove_file(path)
.await
.context("retire redundant Portainer network override")?;
tracing::info!("Retired redundant managed Portainer network override; backup retained");
Ok(())
}
/// Atomically write the manifest unit and retire known redundant managed
/// overrides. Returns true whenever systemd needs a daemon-reload.
pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> { pub async fn write_if_changed(unit: &QuadletUnit, dir: &Path) -> Result<bool> {
let path = dir.join(unit.unit_filename()); let path = dir.join(unit.unit_filename());
let new_bytes = unit.render(); let new_bytes = unit.render();
let redundant = redundant_managed_network_override(unit, dir).await?;
if let Ok(old) = fs::read_to_string(&path).await { let changed = fs::read_to_string(&path)
if old == new_bytes { .await
return Ok(false); .map(|old| old != new_bytes)
} .unwrap_or(true);
if changed {
fs::create_dir_all(dir)
.await
.with_context(|| format!("create_dir_all {}", dir.display()))?;
let tmp = path.with_extension("container.tmp");
fs::write(&tmp, new_bytes.as_bytes())
.await
.with_context(|| format!("write tmp {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
} }
if let Some(override_path) = &redundant {
fs::create_dir_all(dir) retire_managed_network_override(override_path).await?;
.await }
.with_context(|| format!("create_dir_all {}", dir.display()))?; Ok(changed || redundant.is_some())
let tmp = path.with_extension("container.tmp");
fs::write(&tmp, new_bytes.as_bytes())
.await
.with_context(|| format!("write tmp {}", tmp.display()))?;
fs::rename(&tmp, &path)
.await
.with_context(|| format!("rename {} -> {}", tmp.display(), path.display()))?;
Ok(true)
} }
/// Reload the user systemd manager. Required after any quadlet write /// Reload the user systemd manager. Required after any quadlet write
@@ -1991,6 +2038,80 @@ app:
assert!(!network_aliases_changed(new, new)); assert!(!network_aliases_changed(new, new));
} }
#[tokio::test]
async fn redundant_portainer_override_is_backed_up_and_retired_even_when_base_matches() {
let dir = tempfile::tempdir().unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
let unit = QuadletUnit::from_manifest(&manifest, "portainer");
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
let path = dir
.path()
.join("portainer.container.d/archy-same-node-network.conf");
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
let old = "[Container]\nNetwork=slirp4netns\n";
fs::write(&path, old).await.unwrap();
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_some());
assert!(write_if_changed(&unit, dir.path()).await.unwrap());
assert!(!path.exists());
assert_eq!(
fs::read_to_string(path.with_extension("conf.retired"))
.await
.unwrap(),
old
);
assert!(!write_if_changed(&unit, dir.path()).await.unwrap());
assert_eq!(
fs::read_to_string(dir.path().join("portainer.container"))
.await
.unwrap()
.matches("Network=slirp4netns")
.count(),
1
);
}
#[tokio::test]
async fn network_override_migration_preserves_operator_customizations_and_failed_backups() {
let dir = tempfile::tempdir().unwrap();
let manifest =
AppManifest::parse(include_str!("../../../../apps/portainer/manifest.yml")).unwrap();
let mut unit = QuadletUnit::from_manifest(&manifest, "portainer");
let path = dir
.path()
.join("portainer.container.d/archy-same-node-network.conf");
fs::create_dir_all(path.parent().unwrap()).await.unwrap();
for custom in [
"[Container]\nNetwork=custom-net\n",
"[Container]\nNetwork=slirp4netns\nEnvironment=OPERATOR_SETTING=1\n",
] {
fs::write(&path, custom).await.unwrap();
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_none());
write_if_changed(&unit, dir.path()).await.unwrap();
assert_eq!(fs::read_to_string(&path).await.unwrap(), custom);
}
fs::write(&path, "[Container]\nNetwork=slirp4netns\n")
.await
.unwrap();
unit.network = NetworkMode::Pasta;
assert!(redundant_managed_network_override(&unit, dir.path())
.await
.unwrap()
.is_none());
unit.network = NetworkMode::Slirp4netns;
fs::write(path.with_extension("conf.retired"), "different backup")
.await
.unwrap();
assert!(write_if_changed(&unit, dir.path()).await.is_err());
assert!(path.exists(), "failure must preserve the active override");
}
#[tokio::test] #[tokio::test]
async fn failed_runtime_change_remains_pending_when_unit_already_matches() { async fn failed_runtime_change_remains_pending_when_unit_already_matches() {
let dir = tempfile::tempdir().unwrap(); let dir = tempfile::tempdir().unwrap();
+136 -61
View File
@@ -1,80 +1,155 @@
//! Seller-side pending entitlements for Lightning-invoice peer-file sales (#46). //! Durable seller-side entitlements for peer-file invoices and on-chain sales.
//! //! Payment records must outlive browser polling, process restarts and invoice
//! When a buyer asks to pay for a paid catalog item with an external wallet (as //! expiry: an invoice can settle while the buyer is disconnected.
//! opposed to the local-ecash fast path), the *selling* node mints a Lightning
//! invoice on its own LND and records a pending entitlement here, keyed by the
//! invoice's payment hash. The buyer pays the invoice from any wallet and polls
//! for settlement; once the seller's LND confirms the invoice is settled we mark
//! the entitlement paid, and the content gate (`content_server::serve_content`)
//! then releases the file to anyone presenting that payment hash.
//!
//! State is in-memory and bounded by a TTL. If the seller restarts before the
//! buyer pays, the buyer simply requests a fresh invoice — no value is lost
//! because an unpaid invoice represents no money.
use std::collections::HashMap; use anyhow::{Context, Result};
use std::sync::LazyLock; use serde::{Deserialize, Serialize};
use std::time::{Duration, Instant}; use sha2::{Digest, Sha256};
use tokio::sync::Mutex; use std::path::{Path, PathBuf};
use tokio::{fs, io::AsyncWriteExt, sync::Mutex};
/// How long a pending/paid entitlement is retained. Generous enough for a human static WRITES: Mutex<()> = Mutex::const_new(());
/// to pay an invoice and download, short enough to keep the map small.
const ENTITLEMENT_TTL: Duration = Duration::from_secs(3600); // 1 hour
#[derive(Clone)] #[derive(Clone, Serialize, Deserialize)]
struct Entitlement { struct Entitlement {
content_id: String, content_id: String,
price_sats: u64, price_sats: u64,
paid: bool, paid: bool,
created_at: Instant,
} }
static ENTITLEMENTS: LazyLock<Mutex<HashMap<String, Entitlement>>> = fn path(data_dir: &Path, token: &str) -> PathBuf {
LazyLock::new(|| Mutex::new(HashMap::new())); data_dir.join("content-entitlements").join(format!(
"{}.json",
/// Drop expired entries. Caller must hold the lock. hex::encode(Sha256::digest(token.as_bytes()))
fn prune(map: &mut HashMap<String, Entitlement>) { ))
map.retain(|_, e| e.created_at.elapsed() < ENTITLEMENT_TTL);
} }
/// Record a freshly-minted invoice as a pending (unpaid) entitlement. async fn read(data_dir: &Path, token: &str) -> Result<Option<Entitlement>> {
pub async fn record_pending(payment_hash: &str, content_id: &str, price_sats: u64) { match fs::read(path(data_dir, token)).await {
let mut map = ENTITLEMENTS.lock().await; Ok(bytes) => Ok(Some(
prune(&mut map); serde_json::from_slice(&bytes).context("Invalid payment entitlement")?,
map.insert( )),
payment_hash.to_string(), Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
Entitlement { Err(e) => Err(e).context("Reading payment entitlement"),
content_id: content_id.to_string(),
price_sats,
paid: false,
created_at: Instant::now(),
},
);
}
/// Mark the entitlement for `payment_hash` paid. No-op if unknown/expired.
pub async fn mark_paid(payment_hash: &str) {
let mut map = ENTITLEMENTS.lock().await;
prune(&mut map);
if let Some(e) = map.get_mut(payment_hash) {
e.paid = true;
} }
} }
/// The content_id + price an entitlement was issued for, if still live. async fn write(data_dir: &Path, token: &str, entry: &Entitlement) -> Result<()> {
pub async fn lookup(payment_hash: &str) -> Option<(String, u64)> { let target = path(data_dir, token);
let mut map = ENTITLEMENTS.lock().await; let dir = target.parent().unwrap();
prune(&mut map); fs::create_dir_all(dir).await?;
map.get(payment_hash) let tmp = target.with_extension("tmp");
.map(|e| (e.content_id.clone(), e.price_sats)) let mut file = fs::OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.mode(0o600)
.open(&tmp)
.await?;
file.write_all(&serde_json::to_vec(entry)?).await?;
file.sync_all().await?;
drop(file);
fs::rename(&tmp, &target).await?;
fs::File::open(dir).await?.sync_all().await?;
Ok(())
} }
/// True if `payment_hash` is a paid entitlement for exactly `content_id`. /// Save before exposing an invoice/address to the buyer. Never overwrite an
/// This is the gate the content server consults to release a file. /// existing payment or silently rebind its token to another item or price.
pub async fn is_paid_for(payment_hash: &str, content_id: &str) -> bool { pub async fn record_pending(
let mut map = ENTITLEMENTS.lock().await; data_dir: &Path,
prune(&mut map); token: &str,
map.get(payment_hash) content_id: &str,
price_sats: u64,
) -> Result<()> {
let _lock = WRITES.lock().await;
if let Some(existing) = read(data_dir, token).await? {
anyhow::ensure!(
existing.content_id == content_id && existing.price_sats == price_sats,
"Payment entitlement mismatch"
);
return Ok(());
}
write(
data_dir,
token,
&Entitlement {
content_id: content_id.into(),
price_sats,
paid: false,
},
)
.await
}
pub async fn mark_paid(data_dir: &Path, token: &str) -> Result<()> {
let _lock = WRITES.lock().await;
let mut entry = read(data_dir, token)
.await?
.context("Unknown payment entitlement")?;
entry.paid = true;
write(data_dir, token, &entry).await
}
pub async fn lookup(data_dir: &Path, token: &str) -> Result<Option<(String, u64)>> {
Ok(read(data_dir, token)
.await?
.map(|e| (e.content_id, e.price_sats)))
}
pub async fn is_paid_for(data_dir: &Path, token: &str, content_id: &str) -> bool {
read(data_dir, token)
.await
.ok()
.flatten()
.map(|e| e.paid && e.content_id == content_id) .map(|e| e.paid && e.content_id == content_id)
.unwrap_or(false) .unwrap_or(false)
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn paid_entitlement_survives_reload_and_cannot_be_rebound() {
let dir = tempfile::tempdir().unwrap();
record_pending(dir.path(), "hash", "file", 12)
.await
.unwrap();
assert!(!is_paid_for(dir.path(), "hash", "file").await);
mark_paid(dir.path(), "hash").await.unwrap();
// All reads reopen disk; no process-local entitlement map exists.
assert!(is_paid_for(dir.path(), "hash", "file").await);
assert!(!is_paid_for(dir.path(), "hash", "other").await);
record_pending(dir.path(), "hash", "file", 12)
.await
.unwrap();
assert!(is_paid_for(dir.path(), "hash", "file").await);
assert!(record_pending(dir.path(), "hash", "other", 12)
.await
.is_err());
assert!(record_pending(dir.path(), "hash", "file", 13)
.await
.is_err());
let other = tempfile::tempdir().unwrap();
assert!(!is_paid_for(other.path(), "hash", "file").await);
assert!(mark_paid(dir.path(), "unknown").await.is_err());
}
#[tokio::test]
async fn corrupt_or_unwritable_records_fail_closed() {
let dir = tempfile::tempdir().unwrap();
record_pending(dir.path(), "../../token", "file", 1)
.await
.unwrap();
fs::write(path(dir.path(), "../../token"), b"broken")
.await
.unwrap();
assert!(lookup(dir.path(), "../../token").await.is_err());
assert!(!is_paid_for(dir.path(), "../../token", "file").await);
assert!(record_pending(dir.path(), "../../token", "file", 1)
.await
.is_err());
let file = dir.path().join("not-directory");
fs::write(&file, b"x").await.unwrap();
assert!(record_pending(&file, "hash", "file", 1).await.is_err());
}
}
+130 -12
View File
@@ -12,7 +12,9 @@
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};
static PURCHASE_WRITES: Mutex<()> = Mutex::const_new(());
const OWNED_DIR: &str = "purchased-content"; const OWNED_DIR: &str = "purchased-content";
const OWNED_INDEX: &str = "owned.json"; const OWNED_INDEX: &str = "owned.json";
@@ -66,20 +68,51 @@ fn bytes_path(data_dir: &Path, onion: &str, content_id: &str) -> PathBuf {
.join(sanitize(content_id)) .join(sanitize(content_id))
} }
async fn load_index(data_dir: &Path) -> OwnedIndex { async fn load_index_checked(data_dir: &Path) -> Result<OwnedIndex> {
match fs::read_to_string(index_path(data_dir)).await { match fs::read_to_string(index_path(data_dir)).await {
Ok(s) => serde_json::from_str(&s).unwrap_or_default(), Ok(s) => serde_json::from_str(&s)
Err(_) => OwnedIndex::default(), .context("Invalid purchase index; existing records were preserved"),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(OwnedIndex::default()),
Err(error) => Err(error).context("Reading purchase index"),
} }
} }
async fn load_index(data_dir: &Path) -> OwnedIndex {
load_index_checked(data_dir).await.unwrap_or_default()
}
async fn atomic_write(path: &Path, bytes: &[u8]) -> Result<()> {
let parent = path.parent().context("Purchase path has no parent")?;
fs::create_dir_all(parent).await?;
let temp = parent.join(format!(".purchase-{}.tmp", uuid::Uuid::new_v4()));
let result = async {
let mut file = fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&temp)
.await?;
file.write_all(bytes).await?;
file.sync_all().await?;
drop(file);
fs::rename(&temp, path).await?;
fs::File::open(parent).await?.sync_all().await?;
Ok::<_, anyhow::Error>(())
}
.await;
if result.is_err() {
let _ = fs::remove_file(&temp).await;
}
result
}
async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> { async fn save_index(data_dir: &Path, index: &OwnedIndex) -> Result<()> {
let root = owned_root(data_dir); let root = owned_root(data_dir);
fs::create_dir_all(&root) fs::create_dir_all(&root)
.await .await
.with_context(|| format!("creating {}", root.display()))?; .with_context(|| format!("creating {}", root.display()))?;
let content = serde_json::to_string_pretty(index).context("serializing owned index")?; let content = serde_json::to_string_pretty(index).context("serializing owned index")?;
fs::write(index_path(data_dir), content) atomic_write(&index_path(data_dir), content.as_bytes())
.await .await
.context("writing owned index") .context("writing owned index")
} }
@@ -98,17 +131,15 @@ pub async fn record_purchase(
ecash_backend: &str, ecash_backend: &str,
purchased_at: &str, purchased_at: &str,
) -> Result<()> { ) -> Result<()> {
// Read-modify-write must be one serialized transaction. Never replace a
// damaged index with an empty one, and never expose partially written bytes.
let _lock = PURCHASE_WRITES.lock().await;
let mut index = load_index_checked(data_dir).await?;
let path = bytes_path(data_dir, onion, content_id); let path = bytes_path(data_dir, onion, content_id);
if let Some(parent) = path.parent() { atomic_write(&path, bytes)
fs::create_dir_all(parent)
.await
.with_context(|| format!("creating {}", parent.display()))?;
}
fs::write(&path, bytes)
.await .await
.with_context(|| format!("writing purchased bytes to {}", path.display()))?; .with_context(|| format!("writing purchased bytes to {}", path.display()))?;
let mut index = load_index(data_dir).await;
let entry = OwnedItem { let entry = OwnedItem {
onion: onion.to_string(), onion: onion.to_string(),
content_id: content_id.to_string(), content_id: content_id.to_string(),
@@ -165,3 +196,90 @@ pub async fn read_owned(
.unwrap_or_else(|| "application/octet-stream".to_string()); .unwrap_or_else(|| "application/octet-stream".to_string());
Some((mime, bytes)) Some((mime, bytes))
} }
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn concurrent_purchases_preserve_every_item_and_exact_bytes() {
let dir = tempfile::tempdir().unwrap();
let mut jobs = tokio::task::JoinSet::new();
for n in 0..24 {
let root = dir.path().to_path_buf();
jobs.spawn(async move {
let id = format!("file-{n}");
record_purchase(
&root,
"seller.onion",
&id,
&id,
"text/plain",
id.as_bytes(),
5,
"lightning",
"now",
)
.await
.unwrap();
});
}
while let Some(result) = jobs.join_next().await {
result.unwrap();
}
assert_eq!(list_owned(dir.path()).await.len(), 24);
for n in 0..24 {
let id = format!("file-{n}");
assert!(is_owned(dir.path(), "seller.onion", &id).await);
let (mime, bytes) = read_owned(dir.path(), "seller.onion", &id).await.unwrap();
assert_eq!(mime, "text/plain");
assert_eq!(bytes, id.as_bytes());
}
record_purchase(
dir.path(),
"seller.onion",
"file-0",
"file-0",
"text/plain",
b"updated",
5,
"lightning",
"later",
)
.await
.unwrap();
assert_eq!(list_owned(dir.path()).await.len(), 24);
assert_eq!(
read_owned(dir.path(), "seller.onion", "file-0")
.await
.unwrap()
.1,
b"updated"
);
}
#[tokio::test]
async fn damaged_index_is_preserved_instead_of_erasing_prior_ownership() {
let dir = tempfile::tempdir().unwrap();
fs::create_dir_all(owned_root(dir.path())).await.unwrap();
fs::write(index_path(dir.path()), b"damaged but preserve me")
.await
.unwrap();
assert!(record_purchase(
dir.path(),
"seller.onion",
"new",
"new",
"text/plain",
b"bytes",
5,
"lightning",
"now"
)
.await
.is_err());
assert_eq!(
fs::read(index_path(dir.path())).await.unwrap(),
b"damaged but preserve me"
);
assert!(!bytes_path(dir.path(), "seller.onion", "new").exists());
}
}
+1 -1
View File
@@ -400,7 +400,7 @@ where
if !authorized { if !authorized {
if let Some(hash) = invoice_hash { if let Some(hash) = invoice_hash {
if method_accepted(&item.access, "lightning") if method_accepted(&item.access, "lightning")
&& crate::content_invoice::is_paid_for(hash, id).await && crate::content_invoice::is_paid_for(data_dir, hash, id).await
{ {
authorized = true; authorized = true;
} }
+45 -17
View File
@@ -664,6 +664,10 @@ pub async fn start_stopped_stack_containers(data_dir: &Path) -> RecoveryReport {
start_stopped_app_stacks(data_dir).await start_stopped_app_stacks(data_dir).await
} }
fn stack_member_needs_recovery(state: Option<&str>, user_stopped: bool) -> bool {
!user_stopped && matches!(state, Some("exited" | "stopped" | "created" | "configured"))
}
async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport { async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
let user_stopped = load_user_stopped(data_dir).await; let user_stopped = load_user_stopped(data_dir).await;
let mut report = RecoveryReport { let mut report = RecoveryReport {
@@ -677,24 +681,27 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
continue; continue;
} }
info!( // Healthy members must never acquire a restarting overlay merely
"Recovering stopped {} stack containers after boot", // because the periodic recovery scan ran. Queue existing stopped
stack.name // members only; recheck each immediately before starting below.
); let mut pending = Vec::new();
for container in stack.containers {
let state = container_state(container).await;
if stack_member_needs_recovery(state.as_deref(), user_stopped.contains(*container)) {
pending.push((*container).to_string());
}
}
if pending.is_empty() {
continue;
}
info!("Recovering stopped {} stack containers", stack.name);
repair_stack_network_aliases(stack).await; repair_stack_network_aliases(stack).await;
pending_boot_starts_add(pending.iter().cloned());
// Register the whole stack up front: the per-member dependency waits
// below can take minutes, and the UI should say "Restarting", not
// "Stopped", for members still queued behind them.
pending_boot_starts_add(
stack
.containers
.iter()
.filter(|c| !user_stopped.contains(**c))
.map(|c| (*c).to_string()),
);
for container in stack.containers { for container in stack.containers {
if !pending.iter().any(|name| name.as_str() == *container) {
continue;
}
if user_stopped.contains(*container) { if user_stopped.contains(*container) {
info!("Skipping user-stopped container: {}", container); info!("Skipping user-stopped container: {}", container);
continue; continue;
@@ -706,8 +713,8 @@ async fn start_stopped_app_stacks(data_dir: &Path) -> RecoveryReport {
pending_boot_start_done(container); pending_boot_start_done(container);
continue; continue;
} }
Some(_) => {} Some(state) if stack_member_needs_recovery(Some(&state), false) => {}
None => { _ => {
pending_boot_start_done(container); pending_boot_start_done(container);
continue; continue;
} }
@@ -1534,3 +1541,24 @@ mod installed_concurrency_tests {
assert!(!dir.path().join("installed-apps.json.tmp").exists()); assert!(!dir.path().join("installed-apps.json.tmp").exists());
} }
} }
#[cfg(test)]
mod stack_recovery_overlay_tests {
use super::stack_member_needs_recovery;
#[test]
fn only_existing_stopped_members_receive_recovery_overlay() {
for state in [
None,
Some("running"),
Some("paused"),
Some("restarting"),
Some("removing"),
] {
assert!(!stack_member_needs_recovery(state, false));
}
for state in ["exited", "stopped", "created", "configured"] {
assert!(stack_member_needs_recovery(Some(state), false));
assert!(!stack_member_needs_recovery(Some(state), true));
}
}
}
+45 -18
View File
@@ -1,11 +1,15 @@
# Next OTA and raw ISO after 1.8.21 # Next OTA and raw ISO after 1.8.21
**Status: implementation and acceptance in progress; NOT ready to release.** **Status: COMPLETE — 1.8.22-alpha OTA, compatible signed app catalog and raw ISO published on Git and ngit on 2026-10-01; artifact signatures, public downloads and fleet feed verified. Angor full-chain indexing still awaits dev Bitcoin synchronization.**
Current acceptance evidence: [1.8.22 release acceptance](release-1.8.22-acceptance.md).
The chronological notes below retain earlier failures and superseded candidates;
the final tested source is `6d5f3ffb`.
This is the consolidated execution checklist for the operator's chat requests. This is the consolidated execution checklist for the operator's chat requests.
A targeted node repair is not completion of the release. Finish the remaining Release acceptance and publication are complete, with live wallet and app data
acceptance gates, preserve live wallets and app data, and publish both artifacts preservation checks documented below. No universal absence of future failures
through git and ngit. No universal absence of future failures is claimed. is claimed.
## Changes already shipped in 1.8.21 or earlier ## Changes already shipped in 1.8.21 or earlier
@@ -31,12 +35,12 @@ See the Framework incident and 1.8.21 execution records for evidence/limits.
| Task | Implemented/verified | Remaining before release | | Task | Implemented/verified | Remaining before release |
| --- | --- | --- | | --- | --- | --- |
| X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Include in final UI/build checks | | X250 Bitcoin picker | Inline choices; actual Chromium kiosk selection, readability and pruning layout passed | Final UI/build checks passed |
| App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final lifecycle/reboot gate on candidate | | App disappearance/readiness | Durable inventory and safe lifecycle repair; delayed HTTP and desktop/mobile hard-refresh checks passed | Final candidate lifecycle, hard-refresh and stability checks passed |
| X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | Verify both artifact payloads contain all build contexts | | X250 GitWorkshop/Nginx | Missing build contexts restored, dependency/build checks and live UI passed; Nginx slow pull diagnosed; truthful progress label | OTA and ISO build-context/content checks passed |
| PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; include in signed artifacts | | PRs 161/162 | Reviewed, repaired, merged/closed normally; combined regression suite passed | Funded Tor-only candidate purchase, retained change, refund, Files bytes and cached repeat passed; included in signed artifacts |
| Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and release checks remain | | Gitea/Portainer | Root cause confirmed; source network/backup/retry/catalog changes; real X250 routing repair and restart verified; private Git, SSH, LFS, registry and browser fixture checks passed | Automatic migration, scratch restore, failed-start recovery and reverse installation order passed. Operator confirms production site works through Portainer; production host reboot also preserved network/Git/Compose access; final candidate delivery and integration checks passed |
| Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/install acceptance, management restart/reboot checks and signed catalog delivery; real indexing on dev waits for Bitcoin sync | | Angor headless store service | Implemented standard Mempool adapter and separate optional relay, official logo, headless store entries and declarative dependency guard. API security/outage/DNS tests and five relay lifecycle cycles passed | Final candidate prerequisite/API, lifecycle and catalog checks passed; real indexing on dev waits for Bitcoin sync |
Durable payment receipts after a lost seller response remain a separately Durable payment receipts after a lost seller response remain a separately
recorded design follow-up. Preserve the truthful unconfirmed-refund warning and recorded design follow-up. Preserve the truthful unconfirmed-refund warning and
@@ -45,21 +49,23 @@ completed. See PR review for the accepted scope and coverage limits.
## Final release checklist ## Final release checklist
- [ ] Finish all new-scope implementation and specific acceptance above. - [x] Finish new-scope implementation and release acceptance; full-chain Angor
indexing still depends on the dev node finishing initial sync.
- [x] Remove disposable fixtures and temporary test overrides; verify native - [x] Remove disposable fixtures and temporary test overrides; verify native
Bitcoin/LND identity and start-state baselines remain protected. Bitcoin/LND identity and start-state baselines remain protected.
- [x] Commit and push completed source changes to git and ngit. - [x] Commit and push completed source changes to git and ngit.
- [ ] Run final backend/UI/regression/release gates on the final source; inspect - [x] Run final backend/UI/regression/release gates on the final source; inspect
skipped tests and report actual hardware/runtime coverage. skipped tests and report actual hardware/runtime coverage.
- [ ] Prepare compatible signed app catalog; old runtimes must not apply a - [x] Prepare compatible signed app catalog; old runtimes must not apply a
migration before they have backup/recovery support. migration before they have backup/recovery support.
- [ ] Version/changelog and OTA payload prepared, validated and signed by user. - [x] Version/changelog and OTA payload prepared, validated and signed by user.
- [ ] Raw ISO built; payload hashes/content verified; installer boot tested. - [x] Raw ISO built; payload hashes/content verified; full installation and
- [ ] User signs ISO checksums; publish OTA and ISO plus verification files on installed-system boot tested in QEMU/KVM without network.
- [x] User signs ISO checksums; publish OTA and ISO plus verification files on
git and ngit; independently read back hashes and update discovery. git and ngit; independently read back hashes and update discovery.
- [ ] Provide LAN scp command for the new raw ISO. - [x] Provide LAN scp command for the new raw ISO.
Latest backend source verification: 1,609 passed, zero failed, four existing Latest backend source verification: 1,617 passed, zero failed, four existing
ignored tests. This is one layer of evidence, not a substitute for live gates. ignored tests. This is one layer of evidence, not a substitute for live gates.
## Angor verification — 2026-09-30 ## Angor verification — 2026-09-30
@@ -430,3 +436,24 @@ ignored, through the isolated runner. This includes all new port-selection cases
and the existing companion security/configuration and lifecycle regressions. and the existing companion security/configuration and lifecycle regressions.
Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both Rebuild the release binary and UI metadata, deploy those exact OTA bytes to both
boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly. boxes, and require actual kiosk hard-refresh/Launch acceptance before ISO assembly.
## Final accepted artifacts — 1.8.22-alpha
Source `6d5f3ffb` passed 1,617 backend tests (four explicit opt-in exclusions),
1,133 frontend tests and final release gates. Exact OTA bytes were deployed to
both boxes. Actual X250 kiosk NPM Launch, version/pruning, desktop/mobile
readiness/AIUI, production Portainer Git/Compose and 12-minute stability checks
on both boxes passed. No installed app was restarted by the safe diagnostics,
and the Cuprate orphan stayed absent. Native Bitcoin/LND and the production site
were preserved during final management deployment.
The raw ISO passed mounted payload checks and matches all 653 OTA frontend/runtime
files plus the backend. Full offline installation and installed UEFI boot to the
visible setup screen passed in a disposable QEMU/KVM VM. Both installed doctor
paths and the installed backend have the expected hashes. No VM wallet was set up.
See `release-1.8.22-acceptance.md` for exact artifact hashes, hardware/runtime
coverage and limits. Draft upload verification, offline signatures, publication
and public readback remain; the fleet still advertises 1.8.21 until those gates
finish. Do not confuse a draft asset or source push with completed publication.
+258
View File
@@ -0,0 +1,258 @@
# Post-1.8.22 regressions and retained release checklist
Status: OPEN. New regressions reported after publication on 2026-10-01.
Do not mark complete from source changes alone. Preserve wallets, app state and
operator uninstall decisions. Never send a second payment to recover delivery.
The earlier Framework startup incident remains separately closed with operator
acceptance; this is a new paid-file incident.
## Current tasks
- [ ] Recover the Framework's Lightning paid-file purchase without another payment;
inspect buyer/seller evidence and verify delivered bytes.
- [ ] Correct seller settlement verification when local-node payment skips polling.
- [ ] Durable seller entitlements and safe buyer retry after navigation/restart;
do not issue another payment on an uncertain or successful attempt.
- [ ] Cache Lightning purchases, preserve ownership, optional Files copy, free repeat.
- [ ] Diagnose mobile companion uploads on the affected route/device.
- [ ] Real progress in the existing compact upload bar; no increased height.
- [ ] Preserve uploads/progress across screens and original batch destination.
- [ ] Cancel active transfer and queued files; truthful partial/error/server-save status.
- [ ] Transparent transaction-filter container; single horizontal scrolling mobile row.
- [ ] Immich displayed as one app, internal components hidden; diagnose restarting services.
- [ ] Diagnose unwanted CryptPad after upgrade, failed uninstall, and persistent removal.
- [ ] Identify the other removed unexpected service from affected-node records.
- [ ] Upgrade regression matrix: installed/stopped/restarting/removed/legacy apps,
aliases, dependencies, inventory, desired-state reconciliation and data preservation.
- [ ] Portainer duplicate-network migration: retire the redundant managed repair
override, preserve operator settings/state, verify generated command,
actual request namespace, dashboard readiness and repeated reconciliation.
- [ ] Lightning cooperative-close fees: Standard/Medium/Fast/Custom selection,
explicit default target, strict backend validation and forwarding, error
handling, mobile layout and no real channel closure during tests.
- [x] Apps search clear control: My Apps, Services and App Store, desktop/mobile,
existing design tokens, right-aligned icon, no size change, keyboard focus.
## Retained release work (previous acceptance is not new-regression acceptance)
- Mempool patched image/catalog version agreement, update-button clearing, one card.
- Minibits PR160, Lightning address availability, concise single-column backup copy.
- Framework LND startup/Receive and unknown-vs-zero balance behavior.
- Friendly Bitcoin warmup; LND waiting for install/sync; Bitcoin UI during IBD;
headless Phoenixd without self-waiting or bogus launch action.
- Cashu same-mint paid files, exact amounts/change/refund, errors, stored bytes,
Files copy and repeat access without re-payment.
- mempool.space public explorer fallback, preserving local/custom configuration.
- Optional install pruning and consistent automatic-pruning policy.
- X250 kiosk version picker layering/contrast and pruning layout.
- AIUI single desktop/mobile background, transparent embedded layers,
preserved standalone wallpaper.
- PR review/fixes/tests and normal merge/closure (160 previously shipped;
161/162 merged and included in 1.8.22).
- Installed inventory retained during app restart/hard-refresh.
- Correct iframe/browser launch readiness, useful errors and delayed startup.
- GitWorkshop payload/build contexts, progress and persistence after refresh.
- Gitea/Portainer same-server Git from actual request namespace; URLs, auth,
fresh installation in either order, migration/rollback, restart/reboot,
Git/SSH/LFS/registry/browser compatibility and data/stack preservation.
- NPM correct admin port/URL, malformed URL behavior, bind-aware readiness,
persistent backed-up tunnel/LND port-conflict repair on OTA and ISO.
- Angor headless indexer on DEV BOX only, full unpruned Bitcoin/Mempool/ElectrumX
prerequisites, optional separate relay, official icon with green white areas.
- Compact named readiness messages and bottom-aligned app-card actions.
- Remove unused integration/build fixtures from Apps/Services, preserve app data.
- Safe network doctor, no all-app stop/reset on failed egress probe.
- No orphan companion resurrection; retain existing companion security repairs.
- Current companion image registry, build contexts, runtime asset promotion order,
generated-service argument quoting and graceful Bitcoin/LND shutdown.
- OTA + RAW ISO, root signatures/catalog compatibility/checksums, independently
verified public files, Git/ngit source/releases and fleet discovery.
- Correct LAN SCP command for the new ISO.
## Explicit boundaries/follow-ups
- Full-chain Angor indexing awaits development Bitcoin IBD.
- Primal automatic comment exceeding Minibits metadata limit: previously accepted
upstream limitation, no unsupported local identity/metadata rewrite.
- Lost-response ecash seller receipt redesign is a separately accepted follow-up;
do not claim an uncertain refund completed or automatically pay twice.
- Optional external-provider/hardware tests must be labelled if not exercised.
## Initial source evidence
`PeerFiles.vue::payWithLightning` immediately downloaded after buyer payment,
while only seller `handle_content_invoice_status` marked a pending invoice paid.
Seller download checked only that cached flag. This matches the reported error
and is supported by source inspection. An earlier diagnostic's HTTP 404 is not
valid confirmation: it incorrectly base64-decoded lncli's already-hex payment
hash. The corrected live diagnostic recognizes the settled invoice on the
candidate; do not cite the earlier 404 as proof of the original failure sequence.
`content_invoice.rs` stored all entitlements only in process memory with a
one-hour TTL, losing both pending and paid access on restart/expiry.
Lightning download returned transient base64 without the Cashu ownership cache.
CloudFolder's view-local spinner had no byte progress/cancel; batch upload read
`currentPath` independently for each file, allowing navigation to move destinations.
Immich's underscore dependencies are scanner-excluded; hyphen manifest IDs are
not. Live inventory confirmed both hyphenated synthetic entries while the
actual underscore-named containers had remained running for nine days.
## Access / acceptance
Operator provided updated Framework SSH authentication privately in chat.
Do not put credentials or deployment addresses in this public document.
Framework was reached over SSH. Native Bitcoin, LND and all three Immich
container identities/start times were recorded before candidate deployment.
The kiosk is at its login page. Dashboard password authentication succeeds but
requires the operator's second factor; normal uninstall acceptance remains pending.
Confirmed live evidence:
- A 10,000-sat peer-file invoice settled at 12:19:29 UTC. The original status
diagnostic used an incorrectly decoded hash; see the correction above. Buyer
identity and confirmation that this is the reported sale remain pending.
- The matching item currently allows free access; preserve that operator setting.
- CryptPad has no container but remains in installed-apps metadata. Uninstall
repeatedly aborts because the removed catalog ID has no manifest.
- Immich server/database/cache are running; synthetic hyphenated dependencies
appear stopped and the recovery overlay briefly advertises restarting.
- The other removed service was Core Lightning; uninstall tombstones exist.
- No Android resource-upload POST appears in the inspected recent nginx log.
This does not establish why the affected companion failed.
## Candidate implementation and validation
Source changes persist seller entitlements with atomic writes, verify settlement
at delivery, recover older Lightning entitlements from the seller's LND invoice,
perform the status handshake for older sellers, and cache delivered Lightning
files. Buyer purchase bytes and the shared ownership index now use atomic,
synced writes and a serialized read/modify/write transaction; a corrupt index
fails the write instead of silently replacing existing ownership. The browser saves the invoice before payment and retries delivery without
another payment. Browser receipts are not yet a node-wide recovery store.
The upload queue now belongs to the shared Cloud store, captures its original
folder, reports actual sent bytes and server completion, and cancels its active
XHR and remaining queue. The fixed-height bar remains available across routes.
Transaction filters use a transparent container and one scrollable row. Immich
aliases normalize to their real component names and internal cards are hidden.
Unknown catalog entries no longer prevent the regular uninstall flow.
Validation so far (additional acceptance still pending):
- Final isolated backend suite: **1,631 passed**, zero failed, four optional
tests ignored. This includes invoice settlement/amount boundaries, durable
seller records, concurrent buyer ownership, damaged-index preservation,
Portainer override retirement/idempotence/customization/backup failures,
recovery overlays and channel-close fee forwarding/validation.
- Final frontend suite: **1,157 passed** across 142 files. Production build
passed. Six payment-recovery and twelve channel-close tests are included.
- Real FileBrowser uploads at 1440px and 390px: exact bytes and original folder
verified after navigation, 44px bar, cancellation and queue stop passed.
- Mobile viewport acceptance is not physical Android companion acceptance.
- Final release backend build passed. Candidate backend and dashboard are now
deployed on dev and Framework. Another OTA/ISO remains pending; published
1.8.22 artifacts remain unchanged.
Release gates still include actual-node payment recovery/delivery, durable
CryptPad removal through normal controls, Immich inventory after refresh/restart,
physical companion diagnosis, and remaining upgrade regression acceptance.
No new payments, native-service restarts or wallet changes were used in testing.
## Additional live Portainer regression
The X250 user service exited 125 because the generated command supplied
`--network slirp4netns` twice. The manifest already supplies the network, while
an older Archipelago-created `archy-same-node-network.conf` drop-in adds it
again. Quadlet's Network directives accumulate; they do not override each other.
This repair artifact should have been retired when the declarative fix shipped.
The live repair backed up the override and Portainer state, removed only the
exact redundant override, reloaded user systemd and restarted Portainer. API
status returned HTTP 200 with version 2.45.0; the actual kiosk's package state
reported running and UI-ready. Bitcoin/LND and the production site's container
identities/start times remained unchanged. The source migration now detects
this exact managed override before preparing the persistent restart obligation,
backs up app state, retires the redundant file with a retained copy, and reloads
and restarts through normal reconciliation. Custom overrides are preserved.
Automated migration coverage passed; candidate is now deployed on dev and
Framework. The X250 retains its verified live repair pending the next OTA.
## Channel-close fee selection
The existing close UI sent only the channel point, and the backend forwarded
only `force=false`. LND therefore used its lax default confirmation target.
The candidate reuses the channel-opening fee choices (six/three/one block target,
or custom target/rate), explicitly sends six blocks for legacy clients that omit
fees, and validates query parameters before accessing the wallet. Cooperative
fees are never silently applied to force closes. Close RPC retries are disabled
so a timeout cannot silently repeat this mutation.
Protocol reference: [LND CloseChannel](https://lightning.engineering/api-docs/api/lnd/lightning/close-channel/).
Fee targets are estimates, not guaranteed confirmation times. Tests use mocked
requests; no production channel is closed to verify the feature.
Additional browser acceptance:
- Transaction filters at 390px: computed transparent background, one row and
horizontal overflow verified.
- Close-channel selector at 1440px and 390px: preset/custom controls visible,
no overflow, custom 25 sat/vB forwarded. The close request was intercepted;
no real channel closure or wallet mutation occurred.
- All three Apps search screens at both widths: clear icon stays inside the
field; click/Escape clear; input retains focus; desktop 40px/mobile 52px heights
stay unchanged. Shared design-system search-field classes are retained.
- Portainer remained active with zero service restarts and no pending marker.
Its real network namespace read smart HTTP Git refs and the Compose file.
Original persistent mounts were unchanged. The old integration test containers
are absent from dev, Framework and X250. One leftover upload-test folder was
removed after checking it contained only this task's test files.
## Build resource observation
The final optimized compile coincided with heavy memory/disk pressure and local
Bitcoin/LND RPC timeouts on the development node. After pausing the compiler,
both authenticated RPCs responded again; Bitcoin reported height 506400 and
19.6% verification progress, with LND waiting for chain sync. No native service
was restarted. Compilation resumed in a separate user scope limited to one CPU,
with nice 19 and idle I/O priority. This is evidence of resource contention,
not proof of a new wallet or startup defect. Verify native RPC health again
before candidate deployment.
## Candidate deployment and live acceptance — 2026-10-01
Source: `f4d34554` (later commits update this checklist only).
Backend SHA-256:
`8fb6249d1869bb8c9aea26d0f846de5b3eec328113a5c7f573628306e26e652e`.
- Backed up backend, dashboard and app metadata on both nodes under the root-only
support directory `post1822-regressions-20261001`. Deployed assets before
promoting the dashboard entry point; manager health passed first.
- Only the Archipelago manager restarted. Bitcoin/LND IDs and start times stayed
unchanged; Framework's three Immich containers also stayed unchanged.
- Dev authenticated Bitcoin/LND RPCs responded after deployment. Bitcoin IBD
continued above height 513000; LND correctly reported not yet chain-synced.
- Both nodes serve dashboard entry bytes identical to the production build.
All six search-clear cases passed against the live dev dashboard (three
screens, desktop/mobile), including focus, Escape and unchanged field size.
- Framework's stale CryptPad installed claim was removed while the manager was
stopped; both legacy IDs were recorded as user-uninstalled. Data was preserved.
Removal remained after another management restart. Dashboard uninstall-flow
acceptance still awaits authentication; this repair was performed over SSH.
- Corrected invoice diagnostic recovered the settled seller entitlement, returned
HTTP 200 with `paid: true`, and saved a mode-0600 record. A different item was
rejected. Paid status survived another manager restart without native restarts
or a second payment.
- Delivery acceptance remains OPEN: the catalog's file is absent from both its
dedicated content path and FileBrowser path; a privileged filename search of
those trees found no copy. Its free-access setting was preserved. Buyer and
reported-purchase identity still need confirmation; do not claim the actual
buyer received the file.
- The malformed-hash diagnostic also exposed that invoice-status currently
propagates validation errors as a closed connection. Before release, return a
structured 400 for malformed hashes and an explicit retryable response for
settlement-service errors, with endpoint coverage.
Physical companion upload diagnosis and remaining release acceptance stay OPEN.
This is a candidate deployment, not a newly signed OTA or ISO.
+101
View File
@@ -0,0 +1,101 @@
# Archipelago 1.8.22-alpha acceptance
Source: `6d5f3ffb850bfd3dcd396bac986ba770935d1daa`.
## Verified application and runtime changes
- Full isolated backend suite: 1,617 passed, zero failed, four explicit opt-in exclusions.
- Frontend suite: 1,133 passed. Final frontend and AIUI production builds succeeded.
- Container suite: 79 passed. Catalog compatibility/trust, release manifest, build contexts, pruning, Lightning readiness, NPM migration, safe doctor, companion recovery and ISO doctor-overlay regressions passed.
- Six companion dashboard images built using the current registry.
- Final OTA backend SHA-256: `e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b`.
- Final OTA frontend SHA-256: `2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41`.
## Live acceptance
The exact OTA bytes were deployed to the development box and ThinkPad X250.
Native Bitcoin/LND and the X250 production site retained their container identity
and start time during these final management deployments. Both boxes completed
12-minute observations including scheduled diagnostics with running containers
and persistent mounts unchanged. The orphaned Cuprate dashboard stayed absent.
Actual X250 Chromium kiosk: hard refresh, NPM Launch to the correct admin URL,
visible login/admin page, readable inline Bitcoin version choices and pruning
checkbox passed. No Bitcoin installation was triggered by this test.
Final desktop/mobile checks passed for Bitcoin's IBD dashboard, one Mempool card,
headless Phoenixd, LND waiting/unknown-balance behavior and all five transparent
AIUI embedding layers. Standalone AIUI retains its wallpaper.
Portainer's actual production network namespace fetched Git refs and the Compose
file after final deployment. Original mounts were preserved. Earlier disposable
fresh/reverse-install and migration/rollback tests, and the production host's
operator-initiated reboot check, passed.
Live Tor-only Cashu paid-file acceptance verified a one-satoshi net purchase,
change, rejected-payment refund, exact file bytes, Files access and free repeat
delivery. No native Bitcoin/LND funds were moved. PRs 161/162 are merged and
closed; the open pull-request list is empty.
## Boundaries
- Angor's real dev API, fees, block tip, CORS and rootless/headless configuration
passed. Full-chain indexing remains dependent on initial Bitcoin sync finishing.
- Optional live AI providers, physical RNode hardware, the opt-in Reticulum TCP
subprocess test and creation of a production Minibits profile were not run.
- The previously recorded unsafe-doctor incident changed X250 container start
times before the final fix. Persistent databases were present after recovery,
but no pre-incident cryptographic wallet-identity baseline was available.
Do not describe recovery evidence as an exact pre-incident balance comparison.
- No claim of perfect behavior on every device, network or future failure is made.
## Raw ISO acceptance
The raw ISO is 2,755,072,000 bytes. SHA-256:
`cf7be6378dcd52f6f62774523341fa75dd453fa73a9cadff5390846f483e0140`.
Mounted-artifact smoke checks passed, including BIOS/UEFI boot files, live-boot
hooks, build contexts, current doctor overlay, crash-capture configuration,
version and frontend payload. The ISO backend and all 653 OTA frontend/runtime
files match exactly. AIUI metadata names the tested source commit.
A disposable QEMU/KVM x86_64 VM with UEFI firmware, 3 GiB RAM, two vCPUs, a fresh
64 GiB NVMe virtual disk and no network completed the full installation. This
covered partitioning, LUKS2 data encryption, swap, system configuration, UEFI
bootloader and initramfs generation. Cold boot with the ISO detached reached the
visible Welcome to Archipelago setup screen. The installed backend and both
historical/current doctor paths matched source hashes. Backend/nginx were active;
health reported RPC/sessions ready, crash recovery complete and version 1.8.22.
No wallet was initialized in this disposable VM.
The first automatic VM reboot selected the still-attached installer ISO. That
was corrected in the test configuration by detaching the ISO and explicitly
booting NVMe. It was not accepted as an installed-system boot. The subsequent
cold boot above is the successful acceptance run.
The dev native Bitcoin/LND identity/start-time baseline also remained unchanged
after the ISO build and VM acceptance.
## Publication verification
All three operator signatures verify against the pinned release root. The five
Gitea assets match independent server-side SHA-256 checks. Both OTA components
also passed complete public HTTPS downloads with exact hashes and sizes; the
raw ISO passed public size/range checks and both checksum sidecars read back
exactly. Only after these checks were the signed OTA manifest and compatible
app catalog promoted. The release tag identifies the tested source above.
Git and ngit publication completed on 2026-10-01. Both repository relays
acknowledged the ngit release; independent `release view` resolved all five
assets with exact hashes and sizes and no unresolved asset IDs. Main and the
release tag were pushed to both remotes.
Public main-branch OTA manifests and the app catalog read back byte-for-byte
and verified cryptographically. Live `update.check` on the accepted dev node
reported 1.8.22-alpha with no further update, as expected for the installed
release. Discovery on an older production node was not repeated during this
publication step.
- [Release and verification files](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.8.22-alpha)
- [Raw ISO](https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-installer-1.8.22-alpha-unbundled-x86_64_RC1.iso)
+3
View File
@@ -19,6 +19,8 @@
<AppLauncherOverlay /> <AppLauncherOverlay />
<AppCredentialInterstitial /> <AppCredentialInterstitial />
<UploadProgress v-if="route.name !== 'cloud-folder'" floating />
<!-- Global toast notifications --> <!-- Global toast notifications -->
<ToastStack /> <ToastStack />
@@ -96,6 +98,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue' import { computed, ref, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute } from 'vue-router' import { useRouter, useRoute } from 'vue-router'
import SplashScreen from './components/SplashScreen.vue' import SplashScreen from './components/SplashScreen.vue'
@@ -336,3 +336,45 @@ describe('sanitizePath', () => {
expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg') expect(sanitizePath('/photos//image.jpg')).toBe('/photos/image.jpg')
}) })
}) })
describe('upload transport progress and cancellation', () => {
class UploadXHR {
static instances: UploadXHR[] = []
upload = { onprogress: null as ((e: { loaded: number }) => void) | null }
onload: (() => void) | null = null
onabort: (() => void) | null = null
onerror: (() => void) | null = null
status = 200
withCredentials = false
contentType = 'application/json'
open = vi.fn(); setRequestHeader = vi.fn(); send = vi.fn()
abort = vi.fn(() => this.onabort?.())
getResponseHeader() { return this.contentType }
constructor() { UploadXHR.instances.push(this) }
}
beforeEach(() => { setAuthenticated(); UploadXHR.instances = []; vi.stubGlobal('XMLHttpRequest', UploadXHR) })
it('sends the file bytes, escapes folder names, and waits for server acceptance after 100%', async () => {
const controller = new AbortController(); const onProgress = vi.fn()
const file = new File(['data'], 'a #.txt')
let complete = false
const job = fileBrowserClient.upload('/folder #1', file, { signal: controller.signal, onProgress }).then(() => { complete = true })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[0]!
expect(xhr.open).toHaveBeenCalledWith('POST', expect.stringMatching(/\/app\/filebrowser\/api\/resources\/folder%20%231\/a%20%23.txt\?override=true$/))
expect(xhr.send).toHaveBeenCalledWith(file)
xhr.upload.onprogress?.({ loaded: 4 }); expect(onProgress).toHaveBeenCalledWith(4)
expect(complete).toBe(false)
xhr.onload?.(); await job; expect(complete).toBe(true)
})
it('cancels the actual request and refuses HTML masquerading as upload success', async () => {
const controller = new AbortController()
const job = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: controller.signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
controller.abort(); await expect(job).rejects.toMatchObject({ name: 'AbortError' })
expect(UploadXHR.instances[0]!.abort).toHaveBeenCalled()
const next = fileBrowserClient.upload('/', new File(['x'], 'f'), { signal: new AbortController().signal, onProgress: vi.fn() })
await Promise.resolve(); await Promise.resolve()
const xhr = UploadXHR.instances[1]!; xhr.contentType = 'text/html'; xhr.onload?.()
await expect(next).rejects.toThrow('login page')
})
})
+37 -1
View File
@@ -201,7 +201,43 @@ class FileBrowserClient {
URL.revokeObjectURL(blobUrl) URL.revokeObjectURL(blobUrl)
} }
async upload(dirPath: string, file: File): Promise<void> { async upload(dirPath: string, file: File, options?: { signal: AbortSignal; onProgress: (sent: number) => void }): Promise<void> {
if (options) {
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
const folder = sanitizePath(dirPath).split('/').map(encodeURIComponent).join('/').replace(/\/$/, '')
const url = `${this.baseUrl}/api/resources${folder}/${encodeURIComponent(file.name)}?override=true`
const send = () => new Promise<number>((resolve, reject) => {
const xhr = new XMLHttpRequest()
const cleanup = () => options.signal.removeEventListener('abort', abort)
const abort = () => { xhr.abort(); cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.open('POST', url)
xhr.withCredentials = true
for (const [key, value] of Object.entries(this.headers())) xhr.setRequestHeader(key, value)
xhr.upload.onprogress = (event) => options.onProgress(Math.min(file.size, event.loaded))
xhr.onerror = () => { cleanup(); reject(new Error('Upload connection lost. Keep the companion open and check the server connection.')) }
xhr.onabort = () => { cleanup(); reject(new DOMException('Upload cancelled', 'AbortError')) }
xhr.onload = () => {
cleanup()
if (xhr.status === 401) { resolve(401); return }
if (xhr.status < 200 || xhr.status >= 300) { reject(new Error(`Upload failed (HTTP ${xhr.status})`)); return }
if ((xhr.getResponseHeader('Content-Type') || '').includes('text/html')) {
reject(new Error('File Browser returned a login page instead of accepting the upload.')); return
}
resolve(xhr.status)
}
options.signal.addEventListener('abort', abort, { once: true })
if (options.signal.aborted) { abort(); return }
xhr.send(file)
})
if (await send() === 401) {
this._authenticated = false
await this.ensureAuth()
if (options.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
if (await send() === 401) throw new Error('Upload authentication expired. Sign in again.')
}
return
}
const sanitized = sanitizePath(dirPath) const sanitized = sanitizePath(dirPath)
const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/` const safePath = sanitized.endsWith('/') ? sanitized : `${sanitized}/`
const encodedName = encodeURIComponent(file.name) const encodedName = encodeURIComponent(file.name)
@@ -0,0 +1,37 @@
<template>
<div class="relative min-w-0 flex-1">
<input
ref="input"
v-model="query"
type="text"
:placeholder="placeholder"
:aria-label="label"
data-controller-no-submit
class="app-header-search w-full pr-10 text-white placeholder-white/50 focus:outline-none transition-colors"
@keydown.esc.prevent="clear"
/>
<button
v-if="query.length"
type="button"
aria-label="Clear search"
title="Clear search"
class="absolute right-1 top-1/2 -translate-y-1/2 w-8 h-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-white/10 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-orange-400/60 transition-colors"
@click="clear"
>
<svg class="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" aria-hidden="true">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" />
</svg>
</button>
</div>
</template>
<script setup lang="ts">
import { nextTick, ref } from 'vue'
defineProps<{ placeholder: string; label: string }>()
const query = defineModel<string>({ default: '' })
const input = ref<HTMLInputElement | null>(null)
async function clear() {
query.value = ''
await nextTick()
input.value?.focus()
}
</script>
@@ -353,15 +353,57 @@
<!-- Close Confirmation Modal --> <!-- Close Confirmation Modal -->
<Teleport to="body"> <Teleport to="body">
<div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="closeTarget = null"> <div v-if="closeTarget" class="fixed inset-0 z-[3100] flex items-center justify-center bg-black/60 backdrop-blur-md" @click.self="!closingChannel && (closeTarget = null)">
<div class="glass-card p-6 w-full max-w-sm mx-4"> <div class="glass-card p-6 w-full max-w-sm mx-4">
<h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2> <h2 class="text-lg font-bold text-white mb-2">Close Channel?</h2>
<p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p> <p class="text-white/60 text-sm mb-4">This will cooperatively close the channel with peer {{ closeTarget.remote_pubkey.slice(0, 16) }}...</p>
<!-- Fee selection -->
<div class="mb-4">
<label class="text-white/60 text-sm block mb-1">Fee</label>
<div class="flex gap-1 p-1 bg-white/5 rounded-lg">
<button
v-for="preset in feePresets"
:key="preset.key"
@click="closeForm.feePreset = preset.key"
class="flex-1 px-2 py-1.5 rounded text-xs font-medium transition-colors"
:class="closeForm.feePreset === preset.key ? 'bg-white/15 text-white' : 'text-white/50 hover:text-white/80'"
>{{ preset.label }}</button>
</div>
<p v-if="closeForm.feePreset !== 'custom'" class="text-white/40 text-xs mt-1">
{{ feePresets.find(p => p.key === closeForm.feePreset)?.hint }}
</p>
<div v-else class="grid grid-cols-2 gap-3 mt-2">
<div>
<label class="text-white/60 text-xs block mb-1">Target confirmations</label>
<input
v-model.number="closeForm.customConfTarget"
type="number"
min="1"
max="1008"
placeholder="6"
class="w-full input-glass"
/>
</div>
<div>
<label class="text-white/60 text-xs block mb-1">Sats per vByte</label>
<input
v-model.number="closeForm.customSatPerVbyte"
type="number"
min="1"
max="5000"
placeholder="—"
class="w-full input-glass"
/>
</div>
<p class="text-white/40 text-xs col-span-2">Set one — sats per vByte takes precedence when both are set</p>
</div>
</div>
<div v-if="closeError" class="mb-3 alert-error"> <div v-if="closeError" class="mb-3 alert-error">
<p class="text-xs">{{ closeError }}</p> <p class="text-xs">{{ closeError }}</p>
</div> </div>
<div class="flex gap-3"> <div class="flex gap-3">
<button @click="closeTarget = null" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button> <button @click="closeTarget = null" :disabled="closingChannel" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button <button
@click="closeChannel" @click="closeChannel"
:disabled="closingChannel" :disabled="closingChannel"
@@ -457,8 +499,8 @@ function closeTypeLabel(ch: ClosedChannel): string {
type FeePreset = 'standard' | 'medium' | 'fast' | 'custom' type FeePreset = 'standard' | 'medium' | 'fast' | 'custom'
const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [ const feePresets: { key: FeePreset; label: string; hint?: string; confTarget?: number }[] = [
{ key: 'standard', label: 'Standard', hint: 'Confirms within ~6 blocks (about an hour)', confTarget: 6 }, { key: 'standard', label: 'Standard', hint: 'Targets ~6 blocks (about an hour)', confTarget: 6 },
{ key: 'medium', label: 'Medium', hint: 'Confirms within ~3 blocks (about 30 minutes)', confTarget: 3 }, { key: 'medium', label: 'Medium', hint: 'Targets ~3 blocks (about 30 minutes)', confTarget: 3 },
{ key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 }, { key: 'fast', label: 'Fast', hint: 'Targets the next block', confTarget: 1 },
{ key: 'custom', label: 'Custom' }, { key: 'custom', label: 'Custom' },
] ]
@@ -577,22 +619,24 @@ function loadChannels(): Promise<void> {
return main return main
} }
function feeParams(): { target_conf?: number; sat_per_vbyte?: number } | null { function feeParams(
const form = openForm.value form: { feePreset: FeePreset; customSatPerVbyte: number | null; customConfTarget: number | null } = openForm.value,
setError: (message: string) => void = message => { openError.value = message },
): { target_conf?: number; sat_per_vbyte?: number } | null {
if (form.feePreset !== 'custom') { if (form.feePreset !== 'custom') {
return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 } return { target_conf: feePresets.find(p => p.key === form.feePreset)?.confTarget ?? 6 }
} }
const rate = form.customSatPerVbyte const rate = form.customSatPerVbyte
const conf = form.customConfTarget const conf = form.customConfTarget
if (rate != null && rate !== 0) { if (rate != null && rate !== 0) {
if (rate < 1 || rate > 5000) { openError.value = 'Sats per vByte must be between 1 and 5000'; return null } if (!Number.isInteger(rate) || rate < 1 || rate > 5000) { setError('Sats per vByte must be a whole number between 1 and 5000'); return null }
return { sat_per_vbyte: Math.floor(rate) } return { sat_per_vbyte: Math.floor(rate) }
} }
if (conf != null && conf !== 0) { if (conf != null && conf !== 0) {
if (conf < 1 || conf > 1008) { openError.value = 'Target confirmations must be between 1 and 1008'; return null } if (!Number.isInteger(conf) || conf < 1 || conf > 1008) { setError('Target confirmations must be a whole number between 1 and 1008'); return null }
return { target_conf: Math.floor(conf) } return { target_conf: Math.floor(conf) }
} }
openError.value = 'Custom fee requires target confirmations or sats per vByte' setError('Custom fee requires target confirmations or sats per vByte')
return null return null
} }
@@ -629,7 +673,12 @@ async function openChannel() {
} }
} }
const defaultCloseForm = () => ({ feePreset: 'standard' as FeePreset, customConfTarget: null as number | null, customSatPerVbyte: null as number | null })
const closeForm = ref(defaultCloseForm())
function confirmClose(ch: Channel) { function confirmClose(ch: Channel) {
if (closingChannel.value) return
closeForm.value = defaultCloseForm()
closeTarget.value = ch closeTarget.value = ch
closeError.value = null closeError.value = null
} }
@@ -637,12 +686,15 @@ function confirmClose(ch: Channel) {
async function closeChannel() { async function closeChannel() {
if (closingChannel.value || !closeTarget.value) return if (closingChannel.value || !closeTarget.value) return
closeError.value = null closeError.value = null
const fee = feeParams(closeForm.value, message => { closeError.value = message })
if (!fee) return
closingChannel.value = true closingChannel.value = true
try { try {
await rpcClient.call({ await rpcClient.call({
method: 'lnd.closechannel', method: 'lnd.closechannel',
params: { channel_point: closeTarget.value.channel_point }, params: { channel_point: closeTarget.value.channel_point, ...fee },
timeout: 30000, timeout: 45000,
maxRetries: 1,
}) })
closeTarget.value = null closeTarget.value = null
await loadChannels() await loadChannels()
@@ -9,11 +9,11 @@
<!-- Transparent glass, not a black slab (operator, 2026-08-09): the <!-- Transparent glass, not a black slab (operator, 2026-08-09): the
backdrop blur alone keeps the pinned tabs legible over scrolling backdrop blur alone keeps the pinned tabs legible over scrolling
rows without painting an opaque container onto the modal. --> rows without painting an opaque container onto the modal. -->
<div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-wrap bg-white/5 backdrop-blur-md"> <div v-if="transactions.length > 0" class="sticky top-0 z-10 -mx-2 px-2 pb-2 mb-1 flex gap-1.5 flex-nowrap overflow-x-auto bg-transparent backdrop-blur-md">
<button <button
v-for="f in filters" v-for="f in filters"
:key="f.key" :key="f.key"
class="px-2.5 py-1 rounded-full text-xs transition-colors" class="shrink-0 whitespace-nowrap px-2.5 py-1 rounded-full text-xs transition-colors"
:class="activeFilter === f.key :class="activeFilter === f.key
? 'bg-orange-500/25 text-orange-200 border border-orange-400/40' ? 'bg-orange-500/25 text-orange-200 border border-orange-400/40'
: 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'" : 'bg-white/5 text-white/50 border border-white/10 hover:text-white/80'"
@@ -0,0 +1,53 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import LightningChannelsPanel from '../LightningChannelsPanel.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
vi.mock('@/composables/useTxExplorer', () => ({ useTxExplorer: () => ({ openTx: vi.fn() }) }))
vi.mock('@/composables/useCachedResource', async () => {
const { ref } = await import('vue')
return { useCachedResource: () => ({ data: ref(null), loadState: ref('ready'), error: ref(null), refresh: vi.fn().mockResolvedValue(undefined) }) }
})
const channel = { chan_id: 'test', remote_pubkey: '02' + 'a'.repeat(64), channel_point: 'b'.repeat(64) + ':0', capacity: 100000, local_balance: 50000, remote_balance: 50000, active: true }
function open() {
const wrapper = mount(LightningChannelsPanel, { global: { stubs: { Teleport: true } } })
const vm = (wrapper.vm as any).$.setupState
vm.confirmClose(channel)
return { wrapper, vm }
}
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockResolvedValue({ success: true } as never) })
describe('channel closing fee choice', () => {
it.each([['standard', 6], ['medium', 3], ['fast', 1]])('forwards %s target and never automatically retries the mutation', async (preset, target) => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = preset
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ method: 'lnd.closechannel', params: { channel_point: channel.channel_point, target_conf: target }, maxRetries: 1 }))
expect(vm.closeTarget).toBeNull(); wrapper.unmount()
})
it('sends the custom rate instead of a confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = 25; vm.closeForm.customConfTarget = 3
await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, sat_per_vbyte: 25 } }))
wrapper.unmount()
})
it.each([0.5, -1, 5001, NaN, Infinity])('rejects invalid custom rate %s before RPC', async rate => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'; vm.closeForm.customSatPerVbyte = rate
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled(); expect(vm.closeError).toBeTruthy(); wrapper.unmount()
})
it('requires a custom value and accepts a custom confirmation target', async () => {
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'custom'
await vm.closeChannel(); expect(rpcClient.call).not.toHaveBeenCalled()
vm.closeForm.customConfTarget = 2; await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledWith(expect.objectContaining({ params: { channel_point: channel.channel_point, target_conf: 2 } })); wrapper.unmount()
})
it('keeps the chosen fee and error visible when LND rejects a close', async () => {
vi.mocked(rpcClient.call).mockRejectedValue(new Error('Peer is offline'))
const { wrapper, vm } = open(); vm.closeForm.feePreset = 'fast'
await vm.closeChannel(); expect(vm.closeTarget).not.toBeNull(); expect(vm.closeError).toBe('Peer is offline'); expect(vm.closeForm.feePreset).toBe('fast'); wrapper.unmount()
})
it('prevents duplicate submits while a close is pending', async () => {
let finish!: (value: unknown) => void
vi.mocked(rpcClient.call).mockImplementation(() => new Promise(resolve => { finish = resolve }) as never)
const { wrapper, vm } = open(); const pending = vm.closeChannel(); await vm.closeChannel()
expect(rpcClient.call).toHaveBeenCalledTimes(1); finish({ success: true }); await pending; await flushPromises(); wrapper.unmount()
})
})
@@ -45,7 +45,7 @@
</button> </button>
</div> </div>
<button class="glass-button cloud-toolbar-btn" title="Upload file" @click="triggerUpload"> <button class="glass-button cloud-toolbar-btn" title="Upload file" :disabled="uploading" @click="triggerUpload">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
</svg> </svg>
@@ -61,6 +61,7 @@
<input <input
ref="fileInput" ref="fileInput"
type="file" type="file"
:disabled="uploading"
class="hidden" class="hidden"
multiple multiple
@change="handleFileSelect" @change="handleFileSelect"
@@ -74,6 +75,7 @@ import { ref } from 'vue'
defineProps<{ defineProps<{
breadcrumbs: { name: string; path: string }[] breadcrumbs: { name: string; path: string }[]
viewMode: 'list' | 'grid' viewMode: 'list' | 'grid'
uploading?: boolean
}>() }>()
const emit = defineEmits<{ const emit = defineEmits<{
@@ -0,0 +1,29 @@
<template>
<div v-if="task" :class="floating ? 'fixed z-50 top-20 left-4 right-4 md:left-auto md:w-96' : 'mb-3 shrink-0'">
<div class="glass-card relative overflow-hidden h-11 px-3 flex items-center gap-2" role="status" aria-live="polite">
<div v-if="task.active" class="absolute inset-y-0 left-0 bg-emerald-400/10 transition-[width] duration-200 pointer-events-none" :style="{ width: `${percent}%` }" />
<div v-if="task.active" class="absolute bottom-0 left-0 h-0.5 bg-emerald-400 transition-[width] duration-200" :style="{ width: `${percent}%` }" role="progressbar" :aria-valuenow="percent" aria-valuemin="0" aria-valuemax="100" :aria-label="`Uploading ${task.filename}`" />
<span class="relative min-w-0 flex-1 text-sm truncate" :class="task.error ? 'text-red-300' : 'text-white/80'" :title="label">{{ label }}</span>
<span v-if="task.active" class="relative shrink-0 text-xs tabular-nums text-white/60">{{ percent }}%</span>
<button class="relative shrink-0 w-8 h-8 flex items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/10" :aria-label="task.active ? 'Cancel upload' : 'Dismiss upload'" @click="task.active ? store.cancelUpload() : store.dismissUpload()">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true"><path stroke-linecap="round" stroke-width="2" d="m6 6 12 12M6 18 18 6" /></svg>
</button>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useCloudStore } from '@/stores/cloud'
defineProps<{ floating?: boolean }>()
const store = useCloudStore()
const task = computed(() => store.upload)
const percent = computed(() => !task.value ? 0 : task.value.total ? Math.min(100, Math.floor(task.value.sent * 100 / task.value.total)) : task.value.active ? 0 : 100)
const label = computed(() => {
const t = task.value
if (!t) return ''
if (t.error) return t.error
if (t.cancelled) return `Upload stopped · ${t.completed}/${t.count} saved`
if (!t.active) return `${t.count === 1 ? t.filename : `${t.count} files`} uploaded`
return `${percent.value === 100 ? 'Saving' : 'Uploading'} ${t.filename}${t.count > 1 ? ` · ${t.completed + 1}/${t.count}` : ''}`
})
</script>
@@ -231,3 +231,50 @@ describe('useCloudStore', () => {
expect(store.error).toBeNull() expect(store.error).toBeNull()
}) })
}) })
describe('persistent upload batch', () => {
beforeEach(() => { setActivePinia(createPinia()); vi.clearAllMocks() })
it('retains the destination and byte progress across folder navigation', async () => {
const store = useCloudStore(); store.authenticated = true; store.currentPath = '/original'
let release!: () => void
mockedClient.upload.mockImplementationOnce(async (_path, _file, options) => {
options!.onProgress(2)
await new Promise<void>(resolve => { release = resolve })
}).mockResolvedValueOnce(undefined)
mockedClient.listDirectory.mockResolvedValue([])
const job = store.uploadFiles([new File(['abcd'], 'one'), new File(['ef'], 'two')])
expect(store.upload?.sent).toBe(2)
expect(store.upload?.total).toBe(6)
await store.navigate('/elsewhere')
expect(useCloudStore().upload?.active).toBe(true)
release(); await job
expect(mockedClient.upload.mock.calls.map(call => call[0])).toEqual(['/original', '/original'])
expect(store.currentPath).toBe('/elsewhere')
expect(store.upload).toMatchObject({ active: false, sent: 6, completed: 2, error: null })
})
it('aborts the active request, stops the queue and preserves completed files', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
mockedClient.upload.mockResolvedValueOnce(undefined).mockImplementationOnce((_path, _file, options) => new Promise((_resolve, reject) => {
options!.signal.addEventListener('abort', () => reject(new DOMException('Cancelled', 'AbortError')))
}))
const files = ['one', 'two', 'three'].map(name => new File(['abc'], name))
const job = store.uploadFiles(files)
await Promise.resolve(); await Promise.resolve()
store.cancelUpload(); await job
expect(mockedClient.upload).toHaveBeenCalledTimes(2)
expect(store.upload).toMatchObject({ active: false, completed: 1, cancelled: true, error: null })
store.dismissUpload(); expect(store.upload).toBeNull()
})
it('keeps failures visible and does not start a second overlapping batch', async () => {
const store = useCloudStore(); store.authenticated = true
mockedClient.listDirectory.mockResolvedValue([])
let fail!: (error: Error) => void
mockedClient.upload.mockImplementationOnce(() => new Promise((_resolve, reject) => { fail = reject }))
const file = new File(['x'], 'one')
const job = store.uploadFiles([file]); await store.uploadFiles([file])
expect(mockedClient.upload).toHaveBeenCalledTimes(1)
fail(new Error('No space')); await job
expect(store.upload).toMatchObject({ active: false, error: 'No space', completed: 0 })
})
})
+36
View File
@@ -8,6 +8,41 @@ export const useCloudStore = defineStore('cloud', () => {
const loading = ref(false) const loading = ref(false)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const authenticated = ref(false) const authenticated = ref(false)
const upload = ref<{ active: boolean; filename: string; destination: string; sent: number; total: number; completed: number; count: number; error: string | null; cancelled: boolean } | null>(null)
let uploadController: AbortController | null = null
function cancelUpload() { uploadController?.abort() }
function dismissUpload() { if (!upload.value?.active) upload.value = null }
async function uploadFiles(files: File[]) {
if (!files.length || upload.value?.active) return
const destination = currentPath.value
const controller = new AbortController()
uploadController = controller
const task = { active: true, filename: files[0]!.name, destination, sent: 0, total: files.reduce((n, f) => n + f.size, 0), completed: 0, count: files.length, error: null as string | null, cancelled: false }
upload.value = task
let completedBytes = 0
try {
for (const file of files) {
if (controller.signal.aborted) throw new DOMException('Upload cancelled', 'AbortError')
upload.value.filename = file.name
await fileBrowserClient.upload(destination, file, { signal: controller.signal, onProgress: (sent) => {
if (upload.value?.active) upload.value.sent = completedBytes + sent
} })
completedBytes += file.size
upload.value.sent = completedBytes
upload.value.completed++
}
} catch (error) {
upload.value.cancelled = controller.signal.aborted
if (!upload.value.cancelled) upload.value.error = error instanceof Error ? error.message : 'Upload failed'
} finally {
upload.value.active = false
uploadController = null
// Navigation must never redirect subsequent files into the new folder.
pathCache.delete(destination)
if (currentPath.value === destination) await refresh()
}
}
// Per-path listing cache: re-entering a folder paints the last listing // Per-path listing cache: re-entering a folder paints the last listing
// immediately (no spinner) while the fresh listing loads behind it. // immediately (no spinner) while the fresh listing loads behind it.
const pathCache = new Map<string, FileBrowserItem[]>() const pathCache = new Map<string, FileBrowserItem[]>()
@@ -131,6 +166,7 @@ export const useCloudStore = defineStore('cloud', () => {
} }
return { return {
upload, uploadFiles, cancelUpload, dismissUpload,
currentPath, currentPath,
items, items,
loading, loading,
+3 -16
View File
@@ -56,14 +56,7 @@
</button> </button>
</div> </div>
<div class="app-header-search-wrap flex items-center gap-2"> <div class="app-header-search-wrap flex items-center gap-2">
<input <AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button <button
type="button" type="button"
class="sideload-icon-btn" class="sideload-icon-btn"
@@ -106,14 +99,7 @@
>{{ category.name }}</button> >{{ category.name }}</button>
</div> </div>
<div class="flex items-center gap-2"> <div class="flex items-center gap-2">
<input <AppSearchField v-model="searchQuery" :placeholder="t('apps.searchPlaceholder')" :label="t('apps.searchLabel')" />
v-model="searchQuery"
type="text"
:placeholder="t('apps.searchPlaceholder')"
:aria-label="t('apps.searchLabel')"
data-controller-no-submit
class="app-header-search min-w-0 flex-1 text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<button <button
type="button" type="button"
class="sideload-icon-btn sideload-icon-btn-mobile" class="sideload-icon-btn sideload-icon-btn-mobile"
@@ -374,6 +360,7 @@ let appsAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue' import { computed, ref, watch, onActivated, onBeforeUnmount, onDeactivated, onMounted } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n' import { useI18n } from 'vue-i18n'
+6 -22
View File
@@ -39,6 +39,8 @@
</div> </div>
</div> </div>
<UploadProgress />
<!-- App Not Installed --> <!-- App Not Installed -->
<div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center"> <div v-if="!appRunning" class="glass-card p-12 text-center flex-1 flex flex-col items-center justify-center">
<svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-20 h-20 text-white/15 mb-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -75,19 +77,11 @@
<p class="text-sm text-white/50">Files will be added to the current folder</p> <p class="text-sm text-white/50">Files will be added to the current folder</p>
</div> </div>
</div> </div>
<!-- Upload progress -->
<div v-if="uploading" class="glass-card p-3 mb-3 flex items-center gap-3">
<div class="w-5 h-5 border-2 border-white/20 border-t-white/80 rounded-full animate-spin"></div>
<span class="text-sm text-white/70">Uploading...</span>
</div>
<div v-if="uploadError" class="glass-card p-3 mb-3 flex items-center gap-3 border border-red-500/30">
<span class="text-sm text-red-400">{{ uploadError }}</span>
<button class="text-xs text-white/50 hover:text-white ml-auto" @click="uploadError = null">Dismiss</button>
</div>
<CloudToolbar <CloudToolbar
:breadcrumbs="cloudStore.breadcrumbs" :breadcrumbs="cloudStore.breadcrumbs"
:view-mode="viewMode" :view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath" @navigate="navigateCloudPath"
@refresh="cloudStore.refresh()" @refresh="cloudStore.refresh()"
@upload="handleUpload" @upload="handleUpload"
@@ -105,6 +99,7 @@
:items="cloudStore.sortedItems" :items="cloudStore.sortedItems"
:loading="cloudStore.loading" :loading="cloudStore.loading"
:view-mode="viewMode" :view-mode="viewMode"
:uploading="!!cloudStore.upload?.active"
@navigate="navigateCloudPath" @navigate="navigateCloudPath"
@delete="handleDelete" @delete="handleDelete"
@play="handlePlay" @play="handlePlay"
@@ -159,6 +154,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import UploadProgress from '@/components/cloud/UploadProgress.vue'
import { ref, computed, watch } from 'vue' import { ref, computed, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router' import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useAppStore } from '../stores/app' import { useAppStore } from '../stores/app'
@@ -193,7 +189,6 @@ watch(() => cloudStore.currentPath, (path) => {
}) })
const iframeLoaded = ref(false) const iframeLoaded = ref(false)
const uploading = ref(false)
const folderId = computed(() => route.params.folderId as string) const folderId = computed(() => route.params.folderId as string)
const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/')) const routeFolderPath = computed(() => normalizeCloudPath(route.query.path, section.value?.initialPath || '/'))
@@ -348,7 +343,6 @@ function handleShare(path: string, name: string, isDir: boolean) {
shareTarget.value = { path, name, isDir } shareTarget.value = { path, name, isDir }
} }
const uploadError = ref<string | null>(null)
const draggingOver = ref(false) const draggingOver = ref(false)
let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null let dragLeaveTimer: ReturnType<typeof setTimeout> | null = null
@@ -372,17 +366,7 @@ function onDrop(e: DragEvent) {
} }
async function handleUpload(files: File[]) { async function handleUpload(files: File[]) {
uploading.value = true await cloudStore.uploadFiles(files)
uploadError.value = null
try {
for (const file of files) {
await cloudStore.uploadFile(file)
}
} catch (e) {
uploadError.value = e instanceof Error ? e.message : 'Upload failed'
} finally {
uploading.value = false
}
} }
async function handleDelete(path: string) { async function handleDelete(path: string) {
+3 -16
View File
@@ -49,14 +49,7 @@
{{ section.name }} {{ section.name }}
</button> </button>
</div> </div>
<input <AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search text-white placeholder-white/50 focus:outline-none transition-colors"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" /> <RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div> </div>
@@ -82,14 +75,7 @@
type="button" type="button"
>{{ section.name }}</button> >{{ section.name }}</button>
</div> </div>
<input <AppSearchField v-model="searchQuery" placeholder="Search apps..." label="Search apps" />
v-model="searchQuery"
type="text"
placeholder="Search apps..."
aria-label="Search apps"
data-controller-no-submit
class="app-header-search w-full text-white placeholder-white/50 focus:outline-none transition-colors"
/>
</div> </div>
</div> </div>
@@ -345,6 +331,7 @@ let discoverAnimationDone = false
</script> </script>
<script setup lang="ts"> <script setup lang="ts">
import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue' import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router' import { useRouter, RouterLink } from 'vue-router'
import { useAppStore } from '@/stores/app' import { useAppStore } from '@/stores/app'
+74 -50
View File
@@ -396,7 +396,7 @@
accepts for this item are offered --> accepts for this item are offered -->
<div v-if="payMode === 'choose'" class="space-y-3"> <div v-if="payMode === 'choose'" class="space-y-3">
<button <button
v-if="acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint')" v-if="!lnReceipt && (acceptsMethod(payItem.access, 'ecash') || acceptsMethod(payItem.access, 'fedimint'))"
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left" class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
:disabled="ecashPreparing || downloading === payItem.id" :disabled="ecashPreparing || downloading === payItem.id"
@click="prepareEcashPay" @click="prepareEcashPay"
@@ -420,8 +420,8 @@
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" /> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg> </svg>
<span> <span>
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span> <span class="block text-base text-white">{{ lnPaying ? (lnReceipt ? 'Checking payment…' : 'Paying…') : (lnReceipt ? 'Retry paid download' : 'Pay with my Lightning node') }}</span>
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span> <span class="block text-sm text-white/50">{{ lnReceipt ? 'Uses the saved payment; does not send more sats' : 'Pays the seller’s invoice from your node’s Lightning wallet' }}</span>
</span> </span>
</button> </button>
@@ -843,6 +843,22 @@ const onchainError = ref('')
const onchainCopied = ref(false) const onchainCopied = ref(false)
const lnPaying = ref(false) const lnPaying = ref(false)
const lnError = ref('') const lnError = ref('')
type LightningReceipt = { bolt11: string; payment_hash: string; price_sats: number }
const lnReceipt = ref<LightningReceipt | null>(null)
function receiptKey(onion: string, id: string) { return `peer-file-lightning:${onion}:${id}` }
function readReceipt(onion: string, id: string): LightningReceipt | null {
const raw = localStorage.getItem(receiptKey(onion, id))
if (!raw) return null
const receipt = JSON.parse(raw) as LightningReceipt
if (!receipt.bolt11 || !/^[a-f0-9]{64}$/i.test(receipt.payment_hash)) throw new Error('Saved payment needs recovery. Do not pay again.')
return receipt
}
function keepReceipt(onion: string, id: string, receipt: LightningReceipt) {
// Must succeed before handing an invoice to a payer. A failed transfer or
// navigation must never turn Retry into a second payment.
localStorage.setItem(receiptKey(onion, id), JSON.stringify(receipt))
lnReceipt.value = receipt
}
const onchainPaying = ref(false) const onchainPaying = ref(false)
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
@@ -1095,6 +1111,8 @@ function openPayModal(item: CatalogItem) {
onchainCopied.value = false onchainCopied.value = false
lnPaying.value = false lnPaying.value = false
lnError.value = '' lnError.value = ''
try { lnReceipt.value = readReceipt(props.peerId || currentPeer.value?.onion || '', item.id) }
catch { lnError.value = 'Saved payment could not be read. Do not pay again.' }
onchainPaying.value = false onchainPaying.value = false
} }
@@ -1116,6 +1134,8 @@ function closePayModal() {
* immediately for any external wallet). * immediately for any external wallet).
*/ */
function openQrPay() { function openQrPay() {
payMode.value = 'qr'
if (lnReceipt.value) { qrTab.value = 'lightning'; void payWithInvoice(); return }
payMode.value = 'qr' payMode.value = 'qr'
invoiceData.value = null invoiceData.value = null
invoiceQr.value = '' invoiceQr.value = ''
@@ -1139,6 +1159,10 @@ function openQrPay() {
* forth doesn't silently stop watching for payment). */ * forth doesn't silently stop watching for payment). */
function selectQrTab(tab: 'onchain' | 'lightning') { function selectQrTab(tab: 'onchain' | 'lightning') {
if (qrTab.value === tab) return if (qrTab.value === tab) return
if (tab === 'onchain' && lnReceipt.value) {
invoiceError.value = 'A Lightning payment is saved. Recover it before choosing another payment method.'
return
}
qrTab.value = tab qrTab.value = tab
if (tab === 'onchain') { if (tab === 'onchain') {
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null } if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
@@ -1338,20 +1362,27 @@ async function prepareEcashPay() {
* mobile companion ("paid but never unlocked"); the viewer's Save button * mobile companion ("paid but never unlocked"); the viewer's Save button
* still offers an explicit download. * still offers an explicit download.
*/ */
function openPurchased(item: CatalogItem, base64Data: string, mimeType?: string) { function openPurchased(item: CatalogItem, base64Data: string | undefined, mimeType?: string, seller?: string) {
const onion = props.peerId || currentPeer.value?.onion const onion = seller || props.peerId || currentPeer.value?.onion
const url = base64Data !== undefined
? URL.createObjectURL(base64ToBlob(base64Data, mimeType || item.mime_type))
: `/api/peer-content/${encodeURIComponent(onion || "")}/${encodeURIComponent(item.id)}`
if (onion) {
try { localStorage.removeItem(receiptKey(onion, item.id)) } catch { /* owned cache remains authoritative */ }
lnReceipt.value = null
}
if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id)) if (onion) ownedKeys.value = new Set(ownedKeys.value).add(ownKey(onion, item.id))
const mime = mimeType || item.mime_type const mime = mimeType || item.mime_type
if (mime.startsWith('audio/')) { if (mime.startsWith('audio/')) {
// Straight to the bottom-bar player — the blob URL intentionally stays // Straight to the bottom-bar player — the blob URL intentionally stays
// alive while the bar owns playback. // alive while the bar owns playback.
audioPlayer.play( audioPlayer.play(
URL.createObjectURL(base64ToBlob(base64Data, mime)), url,
item.filename.split('/').pop() || item.filename, item.filename.split('/').pop() || item.filename,
) )
} else { } else {
releaseViewerUrl() releaseViewerUrl()
viewerUrl.value = URL.createObjectURL(base64ToBlob(base64Data, mime)) viewerUrl.value = url
viewerMime.value = mime viewerMime.value = mime
viewerItem.value = item viewerItem.value = item
} }
@@ -1399,7 +1430,7 @@ async function payWithInvoice() {
invoiceError.value = '' invoiceError.value = ''
invoiceWaiting.value = true invoiceWaiting.value = true
try { try {
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({ const res = readReceipt(onion, item.id) as (LightningReceipt & { error?: string }) | null || await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
method: 'content.request-invoice', method: 'content.request-invoice',
params: { onion, content_id: item.id }, params: { onion, content_id: item.id },
timeout: 45000, timeout: 45000,
@@ -1410,6 +1441,7 @@ async function payWithInvoice() {
return return
} }
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) } invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
keepReceipt(onion, item.id, invoiceData.value)
try { try {
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 }) invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
} catch { } catch {
@@ -1424,54 +1456,46 @@ async function payWithInvoice() {
/** /**
* Pay the seller's invoice straight from THIS node's Lightning wallet, then * Pay the seller's invoice straight from THIS node's Lightning wallet, then
* release the file. payLightningInvoice resolves to a real terminal state, so * release the file. Keep the invoice before payment so uncertain outcomes can
* on success the payment_hash is immediately valid as the download gate token. * retry seller verification and delivery without sending a second payment.
*/ */
async function payWithLightning() { async function payWithLightning() {
const item = payItem.value const item = payItem.value
const onion = props.peerId || currentPeer.value?.onion const onion = props.peerId || currentPeer.value?.onion
if (!item || !onion || lnPaying.value) return if (!item || !onion || lnPaying.value) return
lnPaying.value = true lnPaying.value = true
lnError.value = '' lnError.value = ''
try { try {
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement). let inv = readReceipt(onion, item.id)
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({ if (!inv) {
method: 'content.request-invoice', const result = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
params: { onion, content_id: item.id }, method: 'content.request-invoice', params: { onion, content_id: item.id }, timeout: 45000,
timeout: 45000, })
}) if (!result?.bolt11 || !result.payment_hash) throw new Error(result?.error || 'The seller could not create an invoice.')
if (!inv?.bolt11 || !inv?.payment_hash) { inv = { bolt11: result.bolt11, payment_hash: result.payment_hash, price_sats: getItemPrice(item.access) }
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).' keepReceipt(onion, item.id, inv)
return const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
localStorage.removeItem(receiptKey(onion, item.id)); lnReceipt.value = null
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling. Retry checks this payment without sending more sats.'
return
}
} }
// 2. Pay it from our own node. Tracked to a REAL terminal state — a slow lnReceipt.value = inv
// multi-hop route resolves via status polling instead of a false failure. const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
const pay = await rpcClient.payLightningInvoice({ payment_request: inv.bolt11 })
if (pay.status === 'failed') {
lnError.value = `Payment failed: ${pay.failure_reason || 'unknown reason'}`
return
}
if (pay.status === 'pending') {
lnError.value = 'Payment is still settling — this can take a few minutes. Check your wallet transactions before paying again.'
return
}
// 3. Settled — pull the file using the payment hash as the gate token.
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice', method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 120000, timeout: 960000,
}) })
if (dl?.data) { if (dl?.data !== undefined || dl?.owned === true) openPurchased(item, dl.data, dl.mime_type, onion)
openPurchased(item, dl.data, dl.mime_type) else lnError.value = dl?.error || 'Download unavailable. Retry uses this payment without sending more sats.'
} else {
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
}
} catch (e: unknown) { } catch (e: unknown) {
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node' lnError.value = (e instanceof Error ? e.message : 'Payment or download could not be confirmed') + ' Retry checks the saved payment; do not pay again.'
} finally { } finally { lnPaying.value = false }
lnPaying.value = false
}
} }
function scheduleInvoicePoll() { function scheduleInvoicePoll() {
@@ -1487,19 +1511,19 @@ async function pollInvoice() {
try { try {
const res = await rpcClient.call<{ paid?: boolean }>({ const res = await rpcClient.call<{ paid?: boolean }>({
method: 'content.invoice-status', method: 'content.invoice-status',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 30000, timeout: 30000,
}) })
if (res?.paid) { if (res?.paid) {
// Settled — pull the file using the payment hash as the gate token. // Settled — pull the file using the payment hash as the gate token.
invoiceWaiting.value = false invoiceWaiting.value = false
const dl = await rpcClient.call<{ data?: string; mime_type?: string; error?: string }>({ const dl = await rpcClient.call<{ data?: string; owned?: boolean; mime_type?: string; error?: string }>({
method: 'content.download-peer-invoice', method: 'content.download-peer-invoice',
params: { onion, content_id: item.id, payment_hash: inv.payment_hash }, params: { onion, content_id: item.id, payment_hash: inv.payment_hash, filename: item.filename, price_sats: inv.price_sats, cache_only: true },
timeout: 120000, timeout: 960000,
}) })
if (dl?.data) { if (dl?.data !== undefined || dl?.owned === true) {
openPurchased(item, dl.data, dl.mime_type) openPurchased(item, dl.data, dl.mime_type, onion)
} else { } else {
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.' invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
} }
@@ -0,0 +1,92 @@
import { flushPromises, mount } from '@vue/test-utils'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import { createPinia } from 'pinia'
import PeerFiles from '../PeerFiles.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('vue-router', () => ({ useRouter: () => ({ push: vi.fn() }) }))
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn(), federationListNodes: vi.fn(), payLightningInvoice: vi.fn() } }))
vi.mock('@/composables/useAudioPlayer', () => ({ useAudioPlayer: () => ({ play: vi.fn() }) }))
const hash = 'a'.repeat(64)
const item = { id: 'paid-file', filename: 'bought.txt', mime_type: 'text/plain', size_bytes: 4, description: '', access: { paid: { price_sats: 5, accepted: ['lightning'] } } }
const receiptKey = 'peer-file-lightning:peer.onion:paid-file'
const download = vi.fn()
async function open() {
const wrapper = mount(PeerFiles, { props: { peerId: 'peer.onion' }, global: { plugins: [createPinia()], stubs: { Teleport: true } } })
await flushPromises()
// Drive the actual component payment handlers, asserting RPC effects rather
// than a duplicate implementation of the payment state machine.
const vm = (wrapper.vm as any).$.setupState
vm.openPayModal(item)
return { wrapper, vm }
}
beforeEach(() => {
localStorage.clear(); vi.clearAllMocks()
vi.mocked(rpcClient.federationListNodes).mockResolvedValue({ nodes: [] } as never)
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
if (method === 'content.request-invoice') return { bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }
if (method === 'content.download-peer-invoice') return download()
return { items: [] }
})
vi.mocked(rpcClient.payLightningInvoice).mockResolvedValue({ status: 'succeeded' } as never)
})
describe('Lightning file delivery recovery', () => {
it('retries delivery after a seller rejection without paying or requesting another invoice', async () => {
download.mockResolvedValue({ error: 'Seller has not registered this payment yet' })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(JSON.parse(localStorage.getItem(receiptKey)!)).toMatchObject({ payment_hash: hash })
vm.closePayModal(); vm.openPayModal(item)
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(1)
expect(download).toHaveBeenCalledTimes(2)
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ payment_hash: hash, filename: 'bought.txt', price_sats: 5 })
wrapper.unmount()
})
it('restores an uncertain payment on a newly mounted page and only checks/downloads', async () => {
vi.mocked(rpcClient.payLightningInvoice).mockRejectedValue(new Error('Connection lost'))
download.mockResolvedValue({ error: 'Pending' })
const first = await open(); await first.vm.payWithLightning(); first.wrapper.unmount()
const second = await open(); await second.vm.payWithLightning()
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
expect(download).toHaveBeenCalledTimes(1)
second.wrapper.unmount()
})
it('opens cached delivery through HTTP without a base64 file in the response', async () => {
download.mockResolvedValue({ owned: true, mime_type: 'video/mp4', size_bytes: 206165161 })
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(vm.viewerUrl).toBe('/api/peer-content/peer.onion/paid-file')
expect(vm.viewerMime).toBe('video/mp4')
expect(localStorage.getItem(receiptKey)).toBeNull()
expect(vi.mocked(rpcClient.call).mock.calls.find(([v]) => v.method === 'content.download-peer-invoice')![0].params).toMatchObject({ cache_only: true })
expect(rpcClient.payLightningInvoice).toHaveBeenCalledTimes(1)
wrapper.unmount()
})
it('never pays again when the saved receipt is corrupt', async () => {
localStorage.setItem(receiptKey, '{broken')
const { wrapper, vm } = await open()
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => v.method === 'content.request-invoice')).toHaveLength(0)
wrapper.unmount()
})
it('keeps QR recovery on the saved Lightning payment instead of creating another rail', async () => {
localStorage.setItem(receiptKey, JSON.stringify({ bolt11: 'ln-test', payment_hash: hash, price_sats: 5 }))
const { wrapper, vm } = await open()
vm.openQrPay(); await flushPromises()
expect(vm.payMode).toBe('qr')
expect(vm.qrTab).toBe('lightning')
vm.selectQrTab('onchain'); await flushPromises()
expect(vm.qrTab).toBe('lightning')
expect(vi.mocked(rpcClient.call).mock.calls.filter(([v]) => ['content.request-invoice', 'content.request-onchain'].includes(v.method))).toHaveLength(0)
wrapper.unmount()
})
it('does not send payment if the recovery record cannot be saved', async () => {
const { wrapper, vm } = await open()
const save = vi.spyOn(Storage.prototype, 'setItem').mockImplementation(() => { throw new Error('Storage full') })
await vm.payWithLightning()
expect(rpcClient.payLightningInvoice).not.toHaveBeenCalled()
save.mockRestore(); wrapper.unmount()
})
})
@@ -246,3 +246,9 @@ it('does not display a stale Mempool frontend alias beside its live package', ()
expect(shown.map(([id]) => id)).toEqual(['mempool']) expect(shown.map(([id]) => id)).toEqual(['mempool'])
expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web']) expect(filterEntriesForTab([['mempool-web', alias]], 'apps', 'all').map(([id]) => id)).toEqual(['mempool-web'])
}) })
it('shows Immich as one app without internal database/cache cards in either tab', () => {
const entries: [string, PackageDataEntry][] = ['immich', 'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis'].map(id => [id, makePkg(id, id, 'media')])
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['immich'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
})
+3
View File
@@ -29,6 +29,9 @@ export const isServiceContainer = sharedIsServiceContainer
const INTERNAL_TOOLING_NAMES = new Set([ const INTERNAL_TOOLING_NAMES = new Set([
'buildx_buildkit_default', 'buildx_buildkit_default',
// Stack internals belong to their parent app, including cached inventories
// from nodes predating backend alias normalization.
'immich-postgres', 'immich-redis', 'immich_postgres', 'immich_redis',
// Cuprate's dashboard is bundled as a companion of the primary cuprate // Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry // package; showing the generated container as a second Services entry
// defeats the one-app presentation. // defeats the one-app presentation.
+33 -17
View File
@@ -1,30 +1,46 @@
{ {
"changelog": [ "changelog": [
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.", "Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.", "Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.", "Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20." "Fixed companion dashboard builds still referencing a retired image registry.",
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.21-alpha", "current_version": "1.8.22-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.21-alpha", "new_version": "1.8.22-alpha",
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb", "sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
"size_bytes": 64748176 "size_bytes": 65627704
}, },
{ {
"current_version": "1.8.21-alpha", "current_version": "1.8.22-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
"name": "archipelago-frontend-1.8.21-alpha.tar.gz", "name": "archipelago-frontend-1.8.22-alpha.tar.gz",
"new_version": "1.8.21-alpha", "new_version": "1.8.22-alpha",
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620", "sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
"size_bytes": 97152546 "size_bytes": 98131119
} }
], ],
"release_date": "2026-09-30", "release_date": "2026-09-30",
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d", "signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.21-alpha" "version": "1.8.22-alpha"
} }
+437 -4
View File
@@ -141,6 +141,198 @@
}, },
"version": "1.23.0" "version": "1.23.0"
}, },
"angor-indexer": {
"manifest": {
"app": {
"bitcoin_integration": {
"pruning_support": false,
"rpc_access": "none",
"sync_required": true
},
"category": "money",
"container": {
"image": "source.archipelago-foundation.org/chaum/angor-indexer:1.0.1",
"network": "archy-net",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"app_id": "mempool-api",
"version": ">=3.0.0"
},
"bitcoin:archival"
],
"description": "Headless Bitcoin indexer endpoint for Angor. Reuses this node’s Mempool and Electrum index; requires a synced, unpruned Bitcoin node. Add this service’s address as the custom indexer in Angor settings. A relay is optional and installed separately.",
"health_check": {
"endpoint": "http://127.0.0.1:8080",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "8s",
"type": "http"
},
"id": "angor-indexer",
"install_prerequisites": [
"mempool-api"
],
"interfaces": {
"main": {
"description": "Use this origin as Angor’s custom mainnet indexer URL. HTTPS is required for browser clients.",
"name": "Angor Indexer API",
"path": "/",
"port": 8998,
"protocol": "http",
"type": "api"
}
},
"metadata": {
"features": [
"Angor mainnet API",
"Reuses existing Mempool indexing",
"No separate blockchain database",
"Optional independent relay"
],
"icon": "/assets/img/app-icons/angor-green.png",
"repo": "https://github.com/block-core/angor",
"tier": "optional"
},
"name": "Angor Indexer",
"ports": [
{
"auth": "open",
"auth_rationale": "Public Bitcoin chain-data API and validated transaction broadcast for Angor clients; no wallet keys or node RPC credentials are exposed. Browser cookie login would break machine clients.",
"bind": "127.0.0.1",
"container": 8080,
"host": 8998,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 1,
"disk_limit": "128Mi",
"memory_limit": "128Mi"
},
"security": {
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"user": 101
},
"upstream": {
"kind": "github",
"repo": "block-core/angor"
},
"version": "1.0.1"
}
},
"version": "1.0.1"
},
"angor-relay": {
"manifest": {
"app": {
"category": "nostr",
"container": {
"image": "source.archipelago-foundation.org/chaum/angor-relay:1.1.2",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "5Gi"
}
],
"description": "Optional dedicated Nostr relay for Angor project metadata. Separate storage and access settings keep the node’s internal relay private. Add this service’s address to Angor’s relay settings; use WSS for browser clients.",
"files": [
{
"content": "##\n## Default strfry config\n##\n\n# Directory that contains the strfry LMDB database (restart required)\ndb = \"./strfry-db/\"\n\ndbParams {\n # Maximum number of threads/processes that can simultaneously have LMDB transactions open (restart required)\n maxreaders = 256\n\n # Size of mmap() to use when loading LMDB (default is 10TB, does *not* correspond to disk-space used) (restart required)\n mapsize = 10995116277760\n\n # Disables read-ahead when accessing the LMDB mapping. Reduces IO activity when DB size is larger than RAM. (restart required)\n noReadAhead = false\n}\n\nevents {\n # Maximum size of normalised JSON, in bytes\n maxEventSize = 65536\n\n # Events newer than this will be rejected\n rejectEventsNewerThanSeconds = 900\n\n # Events older than this will be rejected\n rejectEventsOlderThanSeconds = 94608000\n\n # Ephemeral events older than this will be rejected\n rejectEphemeralEventsOlderThanSeconds = 60\n\n # Ephemeral events will be deleted from the DB when older than this\n ephemeralEventsLifetimeSeconds = 300\n\n # Maximum number of tags allowed\n maxNumTags = 2000\n\n # Maximum size for tag values, in bytes\n maxTagValSize = 1024\n}\n\nrelay {\n # Interface to listen on. Use 0.0.0.0 to listen on all interfaces (restart required)\n bind = \"0.0.0.0\"\n\n # Port to open for the nostr websocket protocol (restart required)\n port = 7777\n\n # Set OS-limit on maximum number of open files/sockets (if 0, don't attempt to set) (restart required)\n nofiles = 0\n\n # HTTP header that contains the client's real IP, before reverse proxying (ie x-real-ip) (MUST be all lower-case)\n realIpHeader = \"\"\n\n info {\n # NIP-11: Name of this server. Short/descriptive (< 30 characters)\n name = \"Angor Relay\"\n\n # NIP-11: Detailed information about relay, free-form\n description = \"Dedicated public relay for Angor project metadata.\"\n\n # NIP-11: Administrative nostr pubkey, for contact purposes\n pubkey = \"\"\n\n # NIP-11: Alternative administrative contact (email, website, etc)\n contact = \"\"\n\n # NIP-11: URL pointing to an image to be used as an icon for the relay\n icon = \"\"\n\n # List of supported lists as JSON array, or empty string to use default. Example: \"[1,2]\"\n nips = \"\"\n }\n\n # Maximum accepted incoming websocket frame size (should be larger than max event) (restart required)\n maxWebsocketPayloadSize = 131072\n\n # Maximum number of filters allowed in a REQ\n maxReqFilterSize = 200\n\n # Websocket-level PING message frequency (should be less than any reverse proxy idle timeouts) (restart required)\n autoPingSeconds = 55\n\n # If TCP keep-alive should be enabled (detect dropped connections to upstream reverse proxy)\n enableTcpKeepalive = false\n\n # How much uninterrupted CPU time a REQ query should get during its DB scan\n queryTimesliceBudgetMicroseconds = 10000\n\n # Maximum records that can be returned per filter\n maxFilterLimit = 500\n\n # Maximum number of subscriptions (concurrent REQs) a connection can have open at any time\n maxSubsPerConnection = 20\n\n writePolicy {\n # If non-empty, path to an executable script that implements the writePolicy plugin logic\n plugin = \"\"\n }\n\n compression {\n # Use permessage-deflate compression if supported by client. Reduces bandwidth, but slight increase in CPU (restart required)\n enabled = true\n\n # Maintain a sliding window buffer for each connection. Improves compression, but uses more memory (restart required)\n slidingWindow = true\n }\n\n logging {\n # Dump all incoming messages\n dumpInAll = false\n\n # Dump all incoming EVENT messages\n dumpInEvents = false\n\n # Dump all incoming REQ/CLOSE messages\n dumpInReqs = false\n\n # Log performance metrics for initial REQ database scans\n dbScanPerf = false\n\n # Log reason for invalid event rejection? Can be disabled to silence excessive logging\n invalidEvents = true\n }\n\n numThreads {\n # Ingester threads: route incoming requests, validate events/sigs (restart required)\n ingester = 3\n\n # reqWorker threads: Handle initial DB scan for events (restart required)\n reqWorker = 3\n\n # reqMonitor threads: Handle filtering of new events (restart required)\n reqMonitor = 3\n\n # negentropy threads: Handle negentropy protocol messages (restart required)\n negentropy = 2\n }\n\n negentropy {\n # Support negentropy protocol messages\n enabled = true\n\n # Maximum records that sync will process before returning an error\n maxSyncEvents = 1000000\n }\n}\n",
"overwrite": false,
"path": "/var/lib/archipelago/angor-relay-config/angor-relay.conf"
}
],
"health_check": {
"endpoint": "http://127.0.0.1:7777",
"interval": "30s",
"path": "/health",
"retries": 3,
"timeout": "5s",
"type": "http"
},
"id": "angor-relay",
"interfaces": {
"main": {
"description": "Nostr WebSocket endpoint; use ws:// for LAN or wss:// through your HTTPS domain.",
"name": "Angor Relay",
"path": "/",
"port": 8091,
"protocol": "http",
"type": "api"
}
},
"metadata": {
"features": [
"Angor project metadata",
"Separate from the node relay",
"Persistent Nostr event storage"
],
"icon": "/assets/img/app-icons/angor-green.png",
"repo": "https://github.com/hoytech/strfry",
"tier": "optional"
},
"name": "Angor Relay",
"nostr_integration": {
"monetization_enabled": false,
"relay_type": "public"
},
"ports": [
{
"auth": "open",
"auth_rationale": "Dedicated public Nostr relay for Angor project metadata; strfry verifies event signatures. It has separate storage from the private node relay and no wallet or node credentials.",
"bind": "127.0.0.1",
"container": 7777,
"host": 8091,
"protocol": "tcp"
}
],
"resources": {
"cpu_limit": 1,
"disk_limit": "5Gi",
"memory_limit": "512Mi"
},
"security": {
"apparmor_profile": "nostr-relay",
"capabilities": [],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": true,
"seccomp_profile": "default"
},
"upstream": {
"kind": "github",
"repo": "hoytech/strfry"
},
"version": "1.1.2",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/angor-relay",
"target": "/app/strfry-db",
"type": "bind"
},
{
"options": [
"ro"
],
"source": "/var/lib/archipelago/angor-relay-config/angor-relay.conf",
"target": "/etc/strfry.conf",
"type": "bind"
}
]
}
},
"version": "1.1.2"
},
"archipelago-source": { "archipelago-source": {
"manifest": { "manifest": {
"app": { "app": {
@@ -2195,6 +2387,142 @@
] ]
} }
}, },
"manifest_variants": [
{
"manifest": {
"app": {
"backup_before_runtime_change": true,
"category": "development",
"container": {
"image": "source.archipelago-foundation.org/lfg2025/gitea:1.27.3",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "50Gi"
}
],
"description": "Self-hosted Git service with built-in container registry, CI/CD, and package hosting.",
"environment": [
"GITEA__database__DB_TYPE=sqlite3",
"GITEA__server__SSH_PORT=2222",
"GITEA__server__SSH_LISTEN_PORT=22",
"GITEA__server__LFS_START_SERVER=true",
"GITEA__packages__ENABLED=true",
"GITEA__packages__LIMIT_TOTAL_OWNER_SIZE=-1",
"GITEA__packages__LIMIT_SIZE_CONTAINER=-1",
"GITEA__repository_0x2Erelease__FILE_MAX_SIZE=10240",
"GITEA__repository_0x2Erelease__MAX_FILES=20",
"GITEA__repository__ENABLE_PUSH_CREATE_USER=true",
"GITEA__repository__ENABLE_PUSH_CREATE_ORG=true"
],
"files": [
{
"content": "[server]\nDOMAIN = {{HOST_IP}}\nSSH_DOMAIN = {{HOST_IP}}\nROOT_URL = http://{{HOST_IP}}:3001/\n",
"overwrite": false,
"path": "/var/lib/archipelago/gitea/data/gitea/conf/app.ini"
}
],
"health_check": {
"endpoint": "http://localhost:3000",
"interval": "120s",
"path": "/",
"retries": 5,
"timeout": "30s",
"type": "http"
},
"id": "gitea",
"interfaces": {
"main": {
"description": "Gitea web interface",
"name": "Web UI",
"path": "/",
"port": 3001,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"features": [
"Git repositories with web UI",
"Built-in container/package registry",
"Issue tracking and pull requests",
"CI/CD via Gitea Actions",
"Lightweight SQLite deployment"
],
"icon": "/assets/img/app-icons/gitea.svg",
"launch": {
"open_in_new_tab": true
},
"repo": "https://gitea.com",
"tier": "optional"
},
"name": "Gitea",
"ports": [
{
"auth": "open",
"auth_rationale": "Gitea enforces its own account login on every page and API route; git clients authenticate with basic-auth/tokens and cannot complete a browser login challenge.",
"bind": "127.0.0.1",
"container": 3000,
"host": 3001,
"protocol": "tcp"
},
{
"auth": "none",
"auth_rationale": "Git over SSH, authenticated by the user's own SSH keypair. Not HTTP, so the gate cannot serve a login page here.",
"container": 22,
"host": 2222,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "50Gi",
"memory_limit": "256Mi"
},
"security": {
"capabilities": [
"CHOWN",
"FOWNER",
"SETUID",
"SETGID",
"DAC_OVERRIDE",
"NET_BIND_SERVICE",
"SYS_CHROOT"
],
"network_policy": "bridge",
"no_new_privileges": false,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "go-gitea/gitea"
},
"version": "1.27.3",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/gitea/data",
"target": "/data",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/gitea/config",
"target": "/etc/gitea",
"type": "bind"
}
]
}
},
"requires": [
"runtime-migration-backup-v1"
]
}
],
"version": "1.27.3" "version": "1.27.3"
}, },
"grafana": { "grafana": {
@@ -4091,11 +4419,11 @@
"description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).", "description": "Reverse proxy with SSL. Beautiful web interface for managing proxies. On a node, this manages its admin UI and upstream configuration — the proxy's own :80/:443 listeners are not published (the node's web server owns those ports).",
"environment": [], "environment": [],
"health_check": { "health_check": {
"endpoint": "localhost:81", "endpoint": "http://127.0.0.1:81/api/",
"interval": "30s", "interval": "30s",
"retries": 3, "retries": 3,
"timeout": "5s", "timeout": "5s",
"type": "tcp" "type": "http"
}, },
"id": "nginx-proxy-manager", "id": "nginx-proxy-manager",
"interfaces": { "interfaces": {
@@ -4996,6 +5324,111 @@
] ]
} }
}, },
"manifest_variants": [
{
"manifest": {
"app": {
"backup_before_runtime_change": true,
"category": "development",
"container": {
"data_uid": "1000:1000",
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.45.0",
"network": "slirp4netns",
"pull_policy": "if-not-present"
},
"dependencies": [
{
"storage": "1Gi"
}
],
"description": "Container management web UI for the local Podman socket.",
"environment": [],
"id": "portainer",
"interfaces": {
"main": {
"description": "Portainer web interface",
"name": "Web UI",
"path": "/",
"port": 9000,
"protocol": "http",
"type": "ui"
}
},
"metadata": {
"features": [
"Container management dashboard",
"Local Podman socket access",
"Compose stack storage"
],
"icon": "/assets/img/app-icons/portainer.webp",
"launch": {
"open_in_new_tab": true
},
"tier": "optional"
},
"name": "Portainer",
"ports": [
{
"auth": "gated",
"bind": "127.0.0.1",
"container": 9000,
"host": 9000,
"protocol": "tcp"
}
],
"resources": {
"disk_limit": "1Gi",
"memory_limit": "256Mi"
},
"security": {
"capabilities": [
"CHOWN",
"SETUID",
"SETGID",
"DAC_OVERRIDE"
],
"network_policy": "isolated",
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "portainer/portainer"
},
"version": "2.45.0",
"volumes": [
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/portainer",
"target": "/data",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/var/lib/archipelago/portainer/compose",
"target": "/data/compose",
"type": "bind"
},
{
"options": [
"rw"
],
"source": "/run/user/1000/podman/podman.sock",
"target": "/var/run/docker.sock",
"type": "bind"
}
]
}
},
"requires": [
"runtime-migration-backup-v1"
]
}
],
"version": "2.45.0" "version": "2.45.0"
}, },
"router": { "router": {
@@ -5525,7 +5958,7 @@
"tag": "NOSTR IDENTITY // YOUR NODE" "tag": "NOSTR IDENTITY // YOUR NODE"
}, },
"schema": 1, "schema": 1,
"signature": "bbcc938b855c1cb5d803e4510e1aac3259fbf3eabf6f36294c7773634047a3d5edb5b37a17d01d62d1407e5701c62853e15e20e15cc7f486b8975b22eeb94c07", "signature": "66b78a5bc60992222b01ae901c5f4a40802667332a5ae47bdad7f34e149669261012ff6fd543478a4f318e850b0339be497af71a50266d829395a872ad386704",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"storefront": { "storefront": {
"popular": [ "popular": [
@@ -5551,5 +5984,5 @@
} }
] ]
}, },
"updated": "2026-09-29" "updated": "2026-09-30"
} }
+33 -17
View File
@@ -1,30 +1,46 @@
{ {
"changelog": [ "changelog": [
"Fixed Bitcoin and other containers being forcibly stopped after ten seconds during managed updates and restarts.", "Fixed Nginx Proxy Manager launch readiness choosing a proxy listener instead of its admin port after container recreation.",
"Existing installations now receive the same graceful shutdown allowance as new containers, without restarting apps just to apply this setting.", "Network diagnostic failures no longer stop all apps or rebuild shared container networking.",
"Prevented unnecessary Lightning restarts when Bitcoin has stayed running; dependency restarts now require an observed Bitcoin container change.", "Prevented orphaned companion dashboards from repeatedly reinstalling themselves after their backend app was removed.",
"Includes the Cashu payment, optional Bitcoin pruning, Lightning readiness, and explorer improvements from 1.8.20." "Fixed companion dashboard builds still referencing a retired image registry.",
"Fixed Angor Indexer health checks choosing IPv6 localhost for an IPv4 listener and unnecessarily restarting the working service.",
"Prevented false app restarts by probing each published port at its actual bind address; Nginx Proxy Manager now checks its internal admin API.",
"Added a backed-up migration for the recognized legacy Nginx Proxy Manager tunnel/LND port conflict in both OTA and ISO startup paths.",
"Checked Bitcoin and Electrum companion dashboards instead of backend protocol ports, preserving dashboard access during initial sync.",
"Removed web-interface waiting messages from headless services such as Phoenixd and clarified which interface is unavailable for launchable apps.",
"Finished runtime app-file promotion before manifest loading, preventing startup catalog refresh from forgetting disk-only apps.",
"Named the app in compact readiness messages and kept app-card actions aligned at the bottom.",
"Removed duplicate Mempool cards caused by frontend container aliases in restored inventory.",
"Kept installed apps visible through restarts and hard refreshes, and delayed app launches until their web interface is ready.",
"Made Bitcoin version selection readable and usable in the ThinkPad kiosk, above the pruning settings.",
"Restored GitWorkshop build files in installation/update payloads and made slow image-pull progress clearer.",
"Fixed same-node Gitea access from Portainer, with persistent runtime migration, state backups and recovery after failed restarts.",
"Preserved Gitea configuration and SSH operation during fresh setup and upgrades.",
"Improved paid-file delivery, saved-file permissions and repeat-download compatibility; verified Tor-only payment with change, rejection refunds and free repeat downloads.",
"Added a headless Angor Indexer service using the existing Mempool/ElectrumX stack, and an optional separate Angor relay.",
"Prevented manifest command arguments containing apostrophes from being corrupted in generated services."
], ],
"components": [ "components": [
{ {
"current_version": "1.8.21-alpha", "current_version": "1.8.22-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago",
"name": "archipelago", "name": "archipelago",
"new_version": "1.8.21-alpha", "new_version": "1.8.22-alpha",
"sha256": "ff602e85f340aff7e43d9d94f7f84f11f713735c964c0d8ba150e23b065c30eb", "sha256": "e108b78bbbd21cb7d5d47c8d0b7b9b19b63fb0c44678773603202440ec7d6f5b",
"size_bytes": 64748176 "size_bytes": 65627704
}, },
{ {
"current_version": "1.8.21-alpha", "current_version": "1.8.22-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.21-alpha/archipelago-frontend-1.8.21-alpha.tar.gz", "download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.22-alpha/archipelago-frontend-1.8.22-alpha.tar.gz",
"name": "archipelago-frontend-1.8.21-alpha.tar.gz", "name": "archipelago-frontend-1.8.22-alpha.tar.gz",
"new_version": "1.8.21-alpha", "new_version": "1.8.22-alpha",
"sha256": "6c0842ec83a440269a353808a4cf154174f5232c9989b4a5448bc6486e1d0620", "sha256": "2da485a2da75ff2fbe4aba52d6f217150e303be43a031723480c9c4ff9d43f41",
"size_bytes": 97152546 "size_bytes": 98131119
} }
], ],
"release_date": "2026-09-30", "release_date": "2026-09-30",
"signature": "2ba21dde08284a13f511f11f0b925f09a56c1b36e40424558601b9ab6beea17edfa316e0baa51474bf084fd4da25429ec35a77d9a831554b309845c8226d4f0d", "signature": "34e9e3902d5960c977b528c4edbb4366ad862f761076755632296840604c8a84ae1bbb503d8d26b2fe7622ca1eccfaa15cb8d23935bcec6dbecdaf6c53f7f50e",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT", "signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.21-alpha" "version": "1.8.22-alpha"
} }