From e3775771ca8e5e970a417e816555a75987b057f3 Mon Sep 17 00:00:00 2001 From: archipelago Date: Tue, 6 Oct 2026 17:42:11 -0400 Subject: [PATCH] Verify recovery support before spending and route node catalogs through nginx --- core/archipelago/src/bootstrap.rs | 32 +++++++++- core/archipelago/src/wallet/ecash.rs | 13 ++++- core/archipelago/src/wallet/mint_client.rs | 4 +- core/archipelago/src/wallet/payment_tests.rs | 30 ++++++++++ docs/node-demo-catalog-and-media.md | 61 +++++++++++++++++++- docs/paid-content-recovery-followup.md | 7 +++ docs/post-1.9.0-work-backlog.md | 11 ++++ image-recipe/configs/nginx-archipelago.conf | 4 +- 8 files changed, 152 insertions(+), 10 deletions(-) diff --git a/core/archipelago/src/bootstrap.rs b/core/archipelago/src/bootstrap.rs index 84e96f7f..887e4cea 100644 --- a/core/archipelago/src/bootstrap.rs +++ b/core/archipelago/src/bootstrap.rs @@ -83,7 +83,7 @@ const RUNTIME_ASSETS_DIR: &str = "/opt/archipelago/web-ui/archipelago-runtime"; /// Inserted into every server block of the nginx config that lacks the /// `/api/app-catalog` proxy. Kept in sync with the canonical block in /// image-recipe/configs/nginx-archipelago.conf. -const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location /api/app-catalog {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n"; +const NGINX_APP_CATALOG_BLOCK: &str = "\n # App Store catalog proxy — backend fetches from configured registries\n # so the browser doesn't hit CORS/CSP. Without this block nginx falls\n # through to the SPA index.html and the frontend gets HTML back instead\n # of JSON.\n location ~ ^/api/(?:app-catalog|node-app-catalog)$ {\n proxy_pass http://127.0.0.1:5678;\n proxy_http_version 1.1;\n proxy_set_header Host $host;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header Cookie $http_cookie;\n proxy_connect_timeout 15s;\n proxy_read_timeout 30s;\n proxy_send_timeout 15s;\n error_page 502 503 = @backend_unavailable;\n error_page 504 = @backend_timeout;\n }\n\n"; const NGINX_SOURCE_PROXY_BLOCK: &str = " # GitWorkshop follows the dashboard origin so LAN, Tailscale, FIPS, Tor,\n # hostnames and reverse proxies all use the connection that already works.\n location /app/archipelago-source/ {\n proxy_pass http://127.0.0.2:8337/;\n proxy_http_version 1.1;\n proxy_set_header Host $http_host;\n proxy_set_header Cookie $http_cookie;\n proxy_set_header X-Real-IP $remote_addr;\n proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n proxy_set_header X-Forwarded-Proto $scheme;\n proxy_set_header X-Forwarded-Prefix /app/archipelago-source;\n proxy_hide_header X-Frame-Options;\n add_header X-Frame-Options \"SAMEORIGIN\" always;\n add_header X-Content-Type-Options \"nosniff\" always;\n proxy_read_timeout 300s;\n }\n"; @@ -1783,6 +1783,14 @@ fn heal_missing_nostr_signer(content: &str) -> Option { .then(|| content.replace(anchor, &format!("{}{}", NGINX_NOSTR_SIGNER_BLOCK, anchor))) } +/// Keep both authenticated catalog endpoints on the backend in every vhost. +fn heal_node_catalog_route(content: &str) -> String { + content.replace( + "location /api/app-catalog {", + "location ~ ^/api/(?:app-catalog|node-app-catalog)$ {", + ) +} + async fn patch_nginx_conf(path: &str) -> Result { let content = fs::read_to_string(path) .await @@ -1795,7 +1803,8 @@ async fn patch_nginx_conf(path: &str) -> Result { || content.contains(" location /electrs-status {"); let missing_app_catalog = content .contains(" # DWN endpoints — peer access over Tor (no auth)") - && !content.contains("location /api/app-catalog"); + && !content.contains("location /api/app-catalog") + && !content.contains("location ~ ^/api/(?:app-catalog|node-app-catalog)$ {"); let missing_bitcoin_status = content.contains(" location /electrs-status {") && !content.contains("location /bitcoin-status"); let missing_lnd_proxy = has_lnd_anchor && !content.contains("location /proxy/lnd/"); @@ -1816,7 +1825,9 @@ async fn patch_nginx_conf(path: &str) -> Result { let missing_source_proxy = heal_missing_source_proxy(&content).is_some(); let missing_source_prefix = heal_source_forwarded_prefix(&content).is_some(); let missing_nostr_signer = heal_missing_nostr_signer(&content).is_some(); + let legacy_catalog_route = content.contains("location /api/app-catalog {"); if !missing_app_catalog + && !legacy_catalog_route && !missing_bitcoin_status && !missing_lnd_proxy && !missing_peer_content @@ -1833,7 +1844,7 @@ async fn patch_nginx_conf(path: &str) -> Result { return Ok(false); } - let mut patched = content.clone(); + let mut patched = heal_node_catalog_route(&content); if let Some(p) = heal_stale_web_search_block(&patched) { patched = p; @@ -2012,6 +2023,21 @@ async fn patch_nginx_conf(path: &str) -> Result { #[cfg(test)] mod tests { + #[test] + fn catalog_routes_upgrade_both_vhosts_without_changing_access_guards() { + let old = "server { if ($guard) { return 404; } location /api/app-catalog { proxy_pass http://127.0.0.1:5678; } }\nserver { location /api/app-catalog { proxy_set_header Cookie $http_cookie; } }"; + let fixed = super::heal_node_catalog_route(old); + assert_eq!(fixed.matches("location ~ ^/api/(?:app-catalog|node-app-catalog)$ {").count(), 2); + assert!(fixed.contains("if ($guard) { return 404; }")); + assert!(fixed.contains("proxy_set_header Cookie $http_cookie;")); + assert_eq!(super::heal_node_catalog_route(&fixed), fixed); + let route = regex::Regex::new(r"^/api/(?:app-catalog|node-app-catalog)$").unwrap(); + assert!(route.is_match("/api/node-app-catalog")); + assert!(route.is_match("/api/app-catalog")); + assert!(!route.is_match("/api/node-app-catalog/extra")); + assert!(!route.is_match("/api/unrelated")); + } + use super::*; #[tokio::test] diff --git a/core/archipelago/src/wallet/ecash.rs b/core/archipelago/src/wallet/ecash.rs index 3737f351..1b4d18eb 100644 --- a/core/archipelago/src/wallet/ecash.rs +++ b/core/archipelago/src/wallet/ecash.rs @@ -849,10 +849,19 @@ pub async fn send_token_recoverable( } else { let mut denominations = amount_to_denominations(amount_sats); denominations.extend(amount_to_denominations(excess)); - let prepared = mint_client(data_dir, &binding.mint_url) - .await? + let client = mint_client(data_dir, &binding.mint_url).await?; + let prepared = client .prepare_swap_at_least(&proofs, &denominations, amount_sats) .await?; + // Establish recovery support before reserving or spending inputs. + // Newly derived outputs must not already exist at the mint. + let existing = client.restore_prepared_swap(&prepared).await.map_err(|_| { + anyhow::anyhow!("The mint could not verify payment recovery support; no funds spent") + })?; + anyhow::ensure!( + existing.is_none(), + "Payment outputs already exist at the mint; no funds spent" + ); Request::Swap(prepared) }; journal.prepare(binding.clone(), request).await? diff --git a/core/archipelago/src/wallet/mint_client.rs b/core/archipelago/src/wallet/mint_client.rs index 47761297..972baca2 100644 --- a/core/archipelago/src/wallet/mint_client.rs +++ b/core/archipelago/src/wallet/mint_client.rs @@ -991,13 +991,13 @@ impl MintClient { let echoed: Vec = serde_json::from_value( body.get("outputs") .cloned() - .unwrap_or(serde_json::json!([])), + .context("Mint restore response omitted outputs")?, ) .context("Failed to parse restored outputs")?; let signatures: Vec = serde_json::from_value( body.get("signatures") .cloned() - .unwrap_or(serde_json::json!([])), + .context("Mint restore response omitted signatures")?, ) .context("Failed to parse restored signatures")?; diff --git a/core/archipelago/src/wallet/payment_tests.rs b/core/archipelago/src/wallet/payment_tests.rs index 589448b4..78a0a71e 100644 --- a/core/archipelago/src/wallet/payment_tests.rs +++ b/core/archipelago/src/wallet/payment_tests.rs @@ -717,6 +717,32 @@ async fn journal_recovers_lost_swap_reply_and_commits_change_once() { ); } +#[tokio::test] +async fn recoverable_send_rejects_broken_recovery_before_reserving_or_spending() { + let mint = Mint::start(0, None).await; + let root = tempfile::tempdir().unwrap(); + let mut wallet = WalletState::default(); + wallet.mint_url = mint.url.clone(); + wallet.add_proofs(&mint.url, vec![proof(ACTIVE, 8)]); + save_wallet(root.path(), &wallet).await.unwrap(); + *mint.restore_reply.lock().unwrap() = Some(json!({})); + let id = uuid::Uuid::new_v4().to_string(); + let context = "ab".repeat(32); + let error = send_token_recoverable( + root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, + ).await.unwrap_err(); + assert!(error.to_string().contains("recovery support")); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 8); + assert!(mint.requests.lock().unwrap().is_empty()); + // Once the mint responds correctly the same unspent operation can proceed. + *mint.restore_reply.lock().unwrap() = None; + assert!(send_token_recoverable( + root.path(), &id, EcashNetwork::Mainnet, &mint.url, 4, &context, + ).await.is_ok()); + assert_eq!(mint.requests.lock().unwrap().len(), 1); + assert_eq!(load_wallet(root.path()).await.unwrap().balance(), 4); +} + #[tokio::test] async fn recoverable_send_reuses_original_operation_after_ambiguous_mint_response() { let mint = Mint::start(0, None).await; @@ -916,6 +942,10 @@ async fn seed_restore_matches_points_and_rejects_foreign_or_duplicated_metadata( let mut wrong_amount = signature.clone(); wrong_amount["amount"] = json!(8); for response in [ + json!({}), + json!({"outputs":[]}), + json!({"signatures":[]}), + json!({"outputs":null,"signatures":[]}), json!({"outputs":[output.clone(),output.clone()],"signatures":[signature.clone(),signature.clone()]}), json!({"outputs":[foreign],"signatures":[signature.clone()]}), json!({"outputs":[output.clone()],"signatures":[wrong_keyset]}), diff --git a/docs/node-demo-catalog-and-media.md b/docs/node-demo-catalog-and-media.md index 67a136bf..478dba68 100644 --- a/docs/node-demo-catalog-and-media.md +++ b/docs/node-demo-catalog-and-media.md @@ -1,6 +1,7 @@ # Node-scoped demo apps and persistent media -Status: implementation under qualification; **not deployed or accepted**. +Status: managed demo deployed to the authorized node on6October; playback and +full lifecycle acceptance remain open. This is not a global catalog release. The V4V demo is restricted to Yaya. The global catalog and other nodes must not receive an install button or banner for this prototype. Its manifest lives in @@ -104,3 +105,61 @@ node catalog, copied-data installation, lifecycle checks, physical companion checks and final dashboard cold-launch regression remain required. Deployment writes to dev and Yaya are paused because another session installed a mining candidate on both nodes; reconcile source before replacing either build. + +## Signed managed installation — 6October + +The operator signed the prepared node-only catalog. Pinned-root verification +passed, and canonical payload comparison matched the reviewed unsigned file. +The signer reordered JSON keys; raw-file reconstruction was not a valid payload +comparison. Neither catalog contents nor its audience were changed. + +A fresh consistent backup preserved the original demo data/media and password +hash. The unused managed volumes were refreshed and verified byte-for-byte with +ownership/modes retained. An initial copy attempt found rsync unavailable; the +copy was completed using the standard library before catalog activation. The +original demo remains running on its original port with its volumes untouched. + +Catalog activation preserved the backend binary, session key and all preexisting +app container identities/start times. The first public endpoint check exposed +missing nginx routing: the SPA returned HTML for the node catalog. Both dashboard +vhosts now route the two exact catalog endpoint names to the authenticated +backend. The original nginx configuration was backed up and nginx validation and +reload passed. Source template and upgrade repair are updated; their new backend +regression run is pending. Public management guards were not modified. + +Both HTTP and HTTPS catalog checks return401 without authentication and200 with +the existing owner session. HTTPS diagnostics ignored the previously documented +legacy certificate trust problem; ordinary browser trust is not claimed fixed. +The signed response contains only the node-demo-v4v entry. The initial manual RPC +omitted required dockerImage and failed before creating the app; the corrected +normal install request used the exact image from the signed manifest. + +The installed app reports running/ui-ready and its container health endpoint +returns200. A real deployed dashboard browser, with no catalog/package fixtures, +shows the Sovereign Music listing and launches the retained-password login screen +at390px with no app-gate screen. The operator login/song check has been requested. +Managed restart, playback controls, desktop/browser/companion acceptance and +audience/lifecycle checks remain open until their results are recorded. + +Qualification update: normal managed restart returned to running/ui-ready with +container health200; the original demo remained running. Desktop1440px also +passes real listing/launch-to-login checks. The full isolated backend suite for +recovery preflight and catalog route migration passed1,785tests, zero failures, +five existing skips (`/tmp/archy-node-catalog-route-tests.log`). + +### Operator changes and live player regression + +The operator now explicitly requests Nostr sign-in and native signer integration +instead of the alpha password mode. This supersedes the earlier instruction to +omit native signing for this demo. Preserve cryptographic login and user consent; +qualify actual platform signer, cancellation, logout, browser and companion flows. +A newly qualified app image/manifest and node-only catalog signature are required. + +The operator reports music continues after closing the deployed app but the native +bottom player does not appear. Earlier fixture tests do not close this real +installation regression. Test the actual signed catalog and package state, close, +controls and reopening the same frame before accepting the repair. + +The requested promotion uses the final intro cymatic still as its background, +with a music/play graphic on the right or the app's For You banner treatment. +The previously captured login background does not satisfy this updated request. diff --git a/docs/paid-content-recovery-followup.md b/docs/paid-content-recovery-followup.md index 4c41dcab..8485fd86 100644 --- a/docs/paid-content-recovery-followup.md +++ b/docs/paid-content-recovery-followup.md @@ -289,3 +289,10 @@ zero failures and five existing skips: `/tmp/archy-recoverable-send-executor-final-tests.log`. No real payment or deployment was performed. Purchase RPC integration, durable seller settlement/receipt recovery and the end-to-end acceptance remain open. + +Recovery preflight now rejects malformed or unsupported restore responses before +reserving or spending inputs, and refuses already-issued newly derived outputs. +Required restore fields cannot silently default to empty. The malformed-response +regression verifies unchanged spendable balance/no swap, then successful retry +when the mint responds correctly. Full isolated1,784passed initially; combined +catalog-route qualification1,785passed, zero failures/five existing skips. diff --git a/docs/post-1.9.0-work-backlog.md b/docs/post-1.9.0-work-backlog.md index d090fdfe..2e02cc14 100644 --- a/docs/post-1.9.0-work-backlog.md +++ b/docs/post-1.9.0-work-backlog.md @@ -432,6 +432,17 @@ functional/UX review. These are additions to existing groups, not closed work. the selected relationship; describe exactly what changed. - Anchor removal buttons at card bottoms. Show an immediate per-action spinner, prevent duplicate submissions, and show an accurate completion/error toast. +- Add a bottom-anchored Network map button to the Connected Nodes container, + linking directly to the network map screen on desktop and mobile. +- Include peer and trusted-node counts in the Connected Nodes top summary row, + with explicit labels and a compact responsive layout. Derive counts from the + authoritative relationship/trust state, update them automatically, and avoid + double-counting identities in the overall node total when categories overlap. +- Lay out these card-footer actions for mobile as well as desktop: maintain + bottom alignment within the card, clear labels, comfortable tap targets and + consistent spacing. Stack actions when needed instead of squeezing them; + prevent clipping, overlap and horizontal overflow. Check narrow phone widths, + enlarged text and loading states while preserving the existing design system. - Measure and reduce removal latency. Verify whether an authenticated existing FIPS connection is preferred; implement that priority where supported, with bounded fallback and no weakening of identity or authorization checks. diff --git a/image-recipe/configs/nginx-archipelago.conf b/image-recipe/configs/nginx-archipelago.conf index 8aa671ab..906a804a 100644 --- a/image-recipe/configs/nginx-archipelago.conf +++ b/image-recipe/configs/nginx-archipelago.conf @@ -473,7 +473,7 @@ server { # so the browser doesn't hit CORS/CSP. Without this block nginx falls # through to the SPA index.html and the frontend gets HTML back instead # of JSON. - location /api/app-catalog { + location ~ ^/api/(?:app-catalog|node-app-catalog)$ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host; @@ -1380,7 +1380,7 @@ server { # so the browser doesn't hit CORS/CSP. Without this block nginx falls # through to the SPA index.html and the frontend gets HTML back instead # of JSON. - location /api/app-catalog { + location ~ ^/api/(?:app-catalog|node-app-catalog)$ { proxy_pass http://127.0.0.1:5678; proxy_http_version 1.1; proxy_set_header Host $host;