From cedfbb2b0705a94a15359372948b610c709e03d1 Mon Sep 17 00:00:00 2001 From: archipelago Date: Mon, 5 Oct 2026 19:52:07 -0400 Subject: [PATCH 1/5] docs: record public release verification and storage follow-up --- docs/release-1.9.0-acceptance.md | 32 +++++++++++++++++++++++++ docs/release-1.9.0-known-limitations.md | 21 +++++++++++++++- 2 files changed, 52 insertions(+), 1 deletion(-) diff --git a/docs/release-1.9.0-acceptance.md b/docs/release-1.9.0-acceptance.md index cd04d66d..a4ac2db2 100644 --- a/docs/release-1.9.0-acceptance.md +++ b/docs/release-1.9.0-acceptance.md @@ -978,3 +978,35 @@ and companion0.5.34 APK all passed full download byte-count and SHA256 checks: locally verified pinned-root signatures. Promote those exact bytes to the live metadata paths; no artifact or signature changed. ISO upload and demo deployment are separate ongoing operations, not inferred passed from OTA asset publication. + +### Publication status and subsequent storage regression + +OTA metadata promotion proposal +`0e90b3847ff377ad5c9e400037651b565defa4ee229eac785a7897c30423a4e7` +was marked applied. Exact main/tag parity and public metadata bytes passed. Dev, +Yaya and Shorty now use the public catalog; their app IDs/start times and guard +configuration were preserved. Thirty-two external management-denial probes and +tailnet UI access passed after the change. Framework reaches the public manifest +and its saved current version is already1.9.0-alpha; this is a read-only result, +not a fresh authenticated update RPC acceptance. + +The public demo was deployed with immutable qualified images and preserved +configuration/rollback. Public version is1.9.0-alpha-demo. Mobile login/dashboard +passed on retry after the initial30-second form wait timed out. Public resumable +upload recovery/exact bytes/visitor isolation and replace/zero-byte/cancel cases +passed. Fixed-quota and interrupted-TCP scenarios retain their isolated evidence. + +The raw ISO and both checksum files passed full public-download hash checks. +The public signed checksum also verifies against the pinned release root. Logs: +`/tmp/archy-190-publish-iso.log` (ISO PASS before the idle upload tunnel closed), +`/tmp/archy-190-publish-iso-checksums.log` (small sidecars retried over HTTPS). +No ISO re-upload or artifact change was needed. A subsequent +follow-up backend suite exposed the pre-existing storage-prefix bug documented in +[known limitations](release-1.9.0-known-limitations.md). Its correction is being +qualified separately. Artifact-byte verification is not a claim that this newly +identified issue has been fixed in the already published release. + +Public assets: [1.9.0-alpha release](https://source.archipelago-foundation.org/lfg2025/archy/releases/tag/v1.9.0-alpha). +The separate follow-up branch now passes1,683 backend tests (four existing ignored) +and all1,222 frontend tests after correcting the storage classifier. These are +source test results, not inclusion in the published artifacts or live acceptance. diff --git a/docs/release-1.9.0-known-limitations.md b/docs/release-1.9.0-known-limitations.md index cf2e5632..4a10eaba 100644 --- a/docs/release-1.9.0-known-limitations.md +++ b/docs/release-1.9.0-known-limitations.md @@ -1,4 +1,6 @@ -# 1.9.0-alpha: Angor historical discovery +# 1.9.0-alpha: known limitations + +## Angor historical discovery Historical project discovery is incomplete. Funding commitments for all35 reference projects were verified, but34 original signed announcements were not @@ -16,3 +18,20 @@ The dev node is still syncing; full-chain evidence comes from Shorty. Evidence and inventory: [client acceptance](angor-client-acceptance-20261001.md), [project recovery inventory](angor-project-recovery-20261001.json), and [release acceptance](release-1.9.0-acceptance.md). + +## Chat/contact storage migration — discovered during follow-up testing + +A subsequent full backend test run exposed an existing random-prefix collision +in `storage_crypto::is_plaintext_json`: an encrypted store whose nonce begins +with `{` or `[` can be mistaken for legacy plaintext. This can prevent chat or +contact state loading correctly, and the message migration path can overwrite +that state. This helper is used for chat messages and mesh contact customizations, +not wallet databases. + +The follow-up branch replaces the prefix-only heuristic with complete JSON +validation and adds deterministic encrypted-prefix regression cases. Qualification +is in progress; the published 1.9.0-alpha artifacts do not include that fix. +Existing release signatures and tags must not be silently replaced. Track a +corrective update and preserve affected state before further node restarts. +The earlier green release run did not detect this probabilistic failure; do not +interpret it as proof that this newly discovered issue is absent. From a64dd77efa342efb552802816b7a499a1147e3ca Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 07:25:44 -0400 Subject: [PATCH 2/5] Bound federated sync and show progress feedback --- .../src/api/rpc/federation/handlers.rs | 90 ++++++++++++------- docs/post-1.9.0-work-backlog.md | 14 +++ neode-ui/src/views/Federation.vue | 2 + .../src/views/federation/QuickActions.vue | 8 +- .../federation/__tests__/QuickActions.test.ts | 38 ++++++++ 5 files changed, 121 insertions(+), 31 deletions(-) create mode 100644 neode-ui/src/views/federation/__tests__/QuickActions.test.ts diff --git a/core/archipelago/src/api/rpc/federation/handlers.rs b/core/archipelago/src/api/rpc/federation/handlers.rs index cd821174..c901086d 100644 --- a/core/archipelago/src/api/rpc/federation/handlers.rs +++ b/core/archipelago/src/api/rpc/federation/handlers.rs @@ -7,6 +7,8 @@ use crate::mesh; use crate::network::dwn_store::DwnStore; use crate::nostr_handshake; use anyhow::Result; +use futures_util::stream::{FuturesUnordered, StreamExt}; +use std::sync::Arc; use tracing::{debug, info, warn}; const FEDERATION_PROTOCOL: &str = "https://archipelago.dev/protocols/federation/v1"; @@ -460,37 +462,65 @@ impl RpcHandler { let identity_dir = self.config.data_dir.join("identity"); let node_identity = identity::NodeIdentity::load_or_create(&identity_dir).await?; - let mut synced = 0u32; - let mut failed = 0u32; + // A dead Tor peer can take the bounded transport timeout. Serialising + // those waits made one bad peer block every healthy peer behind it. + // Keep concurrency bounded so a large federation cannot exhaust the + // node's sockets or overwhelm a peer, while allowing healthy peers to + // finish independently. Results are tagged and sorted below so the + // response remains stable for callers and tests. + const MAX_CONCURRENT_SYNCS: usize = 4; + let semaphore = Arc::new(tokio::sync::Semaphore::new(MAX_CONCURRENT_SYNCS)); + let identity = Arc::new(node_identity); + let data_dir = self.config.data_dir.clone(); + let mut pending = FuturesUnordered::new(); + + for (index, node) in nodes + .into_iter() + .filter(|node| node.trust_level != TrustLevel::Untrusted) + .enumerate() + { + let data_dir = data_dir.clone(); + let local_did = local_did.clone(); + let identity = identity.clone(); + let semaphore = semaphore.clone(); + pending.push(async move { + let permit = semaphore + .acquire_owned() + .await + .expect("sync semaphore lives for all pending syncs"); + let result = federation::sync_with_peer(&data_dir, &node, &local_did, |bytes| { + identity.sign(bytes) + }) + .await; + drop(permit); + (index, node.did, result) + }); + } + let mut results = Vec::new(); - - for node in &nodes { - if node.trust_level == TrustLevel::Untrusted { - continue; - } - - let did_clone = local_did.clone(); - match federation::sync_with_peer(&self.config.data_dir, node, &did_clone, |bytes| { - node_identity.sign(bytes) - }) - .await - { - Ok(state) => { - synced += 1; - results.push(serde_json::json!({ - "did": node.did, - "status": "ok", - "apps": state.apps.len(), - })); - } - Err(e) => { - failed += 1; - results.push(serde_json::json!({ - "did": node.did, - "status": "error", - "error": e.to_string(), - })); - } + while let Some((index, did, result)) = pending.next().await { + let row = match result { + Ok(state) => serde_json::json!({ + "index": index, + "did": did, + "status": "ok", + "apps": state.apps.len(), + }), + Err(e) => serde_json::json!({ + "index": index, + "did": did, + "status": "error", + "error": e.to_string(), + }), + }; + results.push(row); + } + results.sort_by_key(|row| row["index"].as_u64().unwrap_or(u64::MAX)); + let synced = results.iter().filter(|row| row["status"] == "ok").count() as u32; + let failed = results.len() as u32 - synced; + for row in &mut results { + if let Some(object) = row.as_object_mut() { + object.remove("index"); } } diff --git a/docs/post-1.9.0-work-backlog.md b/docs/post-1.9.0-work-backlog.md index 578d5f95..3d97dd92 100644 --- a/docs/post-1.9.0-work-backlog.md +++ b/docs/post-1.9.0-work-backlog.md @@ -284,6 +284,20 @@ ahead of that work or as an untested addition to the current release. - Keep catalog and player integration within the Yaya-only demo scope until separately approved for general release. +## Federated Nodes state sync reliability and progress + +- Federated Nodes sync currently gives too little feedback and can take a long + time when one peer is unreachable. Show an accessible spinner, the number of + eligible nodes, current phase and a clear success/partial-failure result. +- Bound concurrent peer syncs so one slow or dead peer cannot serialize the + entire federation refresh. Prefer the authenticated FIPS route when + available, preserve the existing bounded fallback, and keep per-peer errors + visible without discarding successful results. +- Keep sync state convergent across retries and reconnects; prevent duplicate + clicks, stale responses or an incomplete refresh from looking like success. + Qualify healthy, slow, offline, mixed-transport, large-federation and mobile + layouts before acceptance. + ## 14. FIPS media transport requirement - Operator explicitly requires FIPS for streaming peer files and distributed diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue index 0eec7cba..79c59190 100644 --- a/neode-ui/src/views/Federation.vue +++ b/neode-ui/src/views/Federation.vue @@ -62,6 +62,7 @@ :invite-type="inviteType" :invite-code="inviteCode" :syncing="syncing" + :sync-node-count="syncableNodeCount" @generate-invite="handleGenerateInvite" @show-join="showJoinModal = true" @sync="syncAll" @@ -311,6 +312,7 @@ const joinSuccess = ref(false) const syncing = ref(false) const syncResults = ref([]) +const syncableNodeCount = computed(() => nodes.value.filter(node => node.trust_level !== 'untrusted').length) const deploying = ref(false) const deployResult = ref('') diff --git a/neode-ui/src/views/federation/QuickActions.vue b/neode-ui/src/views/federation/QuickActions.vue index bf37b8f5..c8546600 100644 --- a/neode-ui/src/views/federation/QuickActions.vue +++ b/neode-ui/src/views/federation/QuickActions.vue @@ -74,11 +74,16 @@ @@ -121,6 +126,7 @@ const props = defineProps<{ inviteType: 'trusted' | 'observer' inviteCode: string syncing: boolean + syncNodeCount: number }>() defineEmits<{ diff --git a/neode-ui/src/views/federation/__tests__/QuickActions.test.ts b/neode-ui/src/views/federation/__tests__/QuickActions.test.ts new file mode 100644 index 00000000..07909c4a --- /dev/null +++ b/neode-ui/src/views/federation/__tests__/QuickActions.test.ts @@ -0,0 +1,38 @@ +import { mount } from '@vue/test-utils' +import { describe, expect, it } from 'vitest' +import QuickActions from '../QuickActions.vue' + +describe('QuickActions federation sync', () => { + it('shows bounded progress context while sync is running', () => { + const wrapper = mount(QuickActions, { + props: { + generatingInvite: false, + inviteType: 'trusted', + inviteCode: '', + syncing: true, + syncNodeCount: 3, + }, + }) + + const button = wrapper.get('[data-testid="federation-sync-button"]') + expect(button.attributes('disabled')).toBeDefined() + const status = button.get('[role="status"]') + expect(status.attributes('aria-live')).toBe('polite') + expect(status.text()).toContain('Syncing 3 nodes') + expect(status.find('.animate-spin').exists()).toBe(true) + }) + + it('keeps the action concise for a single node', () => { + const wrapper = mount(QuickActions, { + props: { + generatingInvite: false, + inviteType: 'trusted', + inviteCode: '', + syncing: true, + syncNodeCount: 1, + }, + }) + + expect(wrapper.get('[data-testid="federation-sync-button"]').text()).toContain('Syncing 1 node…') + }) +}) From 6e38d0c093bf8e8f6cb6f4c230d069204c082d21 Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 08:05:56 -0400 Subject: [PATCH 3/5] Enable companion Cloud video picture-in-picture --- Android/app/src/main/AndroidManifest.xml | 4 +++- .../components/__tests__/MediaLightboxPip.test.ts | 13 +++++++++++++ neode-ui/src/utils/pip.ts | 5 ++++- 3 files changed, 20 insertions(+), 2 deletions(-) diff --git a/Android/app/src/main/AndroidManifest.xml b/Android/app/src/main/AndroidManifest.xml index 335a8837..a31e29f1 100644 --- a/Android/app/src/main/AndroidManifest.xml +++ b/Android/app/src/main/AndroidManifest.xml @@ -39,9 +39,11 @@ android:name=".MainActivity" android:exported="true" android:launchMode="singleTask" + android:supportsPictureInPicture="true" + android:resizeableActivity="true" android:theme="@style/Theme.Archipelago.Splash" android:windowSoftInputMode="adjustResize" - android:configChanges="orientation|screenSize|screenLayout|keyboardHidden"> + android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize|keyboardHidden"> diff --git a/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts b/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts index 45ac2394..ca9e2dd3 100644 --- a/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts +++ b/neode-ui/src/components/__tests__/MediaLightboxPip.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest' import { mount, flushPromises } from '@vue/test-utils' import MediaLightbox from '../cloud/MediaLightbox.vue' import { usePipSession } from '../../composables/usePipSession' +import { togglePip } from '../../utils/pip' import type { FileBrowserItem } from '../../api/filebrowser-client' // jsdom has no picture-in-picture implementation — stub the pieces the @@ -75,6 +76,18 @@ async function mountLightbox() { } describe('MediaLightbox picture-in-picture handoff', () => { + it('checks PiP support when the action is invoked in a WebView', async () => { + const video = document.createElement('video') + const request = vi.spyOn(video, 'requestPictureInPicture') + Object.defineProperty(document, 'pictureInPictureEnabled', { value: false, configurable: true }) + await togglePip(video) + expect(request).not.toHaveBeenCalled() + + Object.defineProperty(document, 'pictureInPictureEnabled', { value: true, configurable: true }) + await togglePip(video) + expect(request).toHaveBeenCalledOnce() + }) + it('entering PiP emits close exactly once and adopts the video before doing so', async () => { const wrapper = await mountLightbox() const video = findVideo() diff --git a/neode-ui/src/utils/pip.ts b/neode-ui/src/utils/pip.ts index 7399a980..0d1292a6 100644 --- a/neode-ui/src/utils/pip.ts +++ b/neode-ui/src/utils/pip.ts @@ -20,7 +20,10 @@ export function isPipSupported(): boolean { } export async function togglePip(video: HTMLVideoElement | null | undefined): Promise { - if (!video || !pipSupported) return + // Check at call time. Embedded WebViews can expose the document capability + // after the module has been evaluated, and companion navigation can restore + // a page with a different media capability than its first load. + if (!video || !isPipSupported()) return try { if (document.pictureInPictureElement === video) await document.exitPictureInPicture() else await video.requestPictureInPicture() From beb0dbc1f49c022f6d099ea4a0a9810e9da3c2ce Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 08:36:25 -0400 Subject: [PATCH 4/5] Add central firewall and tunnel status screen --- neode-ui/src/router/index.ts | 5 + neode-ui/src/views/Server.vue | 16 ++- .../views/server/FirewallTunnelSettings.vue | 104 ++++++++++++++++++ 3 files changed, 123 insertions(+), 2 deletions(-) create mode 100644 neode-ui/src/views/server/FirewallTunnelSettings.vue diff --git a/neode-ui/src/router/index.ts b/neode-ui/src/router/index.ts index adca8ea9..493c99bc 100644 --- a/neode-ui/src/router/index.ts +++ b/neode-ui/src/router/index.ts @@ -190,6 +190,11 @@ const router = createRouter({ name: 'openwrt-gateway', component: () => import('../views/server/OpenWrtGateway.vue'), }, + { + path: 'server/firewall-tunnels', + name: 'firewall-tunnels', + component: () => import('../views/server/FirewallTunnelSettings.vue'), + }, { path: 'monitoring', name: 'monitoring', diff --git a/neode-ui/src/views/Server.vue b/neode-ui/src/views/Server.vue index 8b7cce92..9ffc769f 100644 --- a/neode-ui/src/views/Server.vue +++ b/neode-ui/src/views/Server.vue @@ -94,13 +94,18 @@ Refreshing network... -
+
Firewall Active
Protected -
+ +
@@ -159,6 +164,13 @@
+ + Firewall & tunnels + + diff --git a/neode-ui/src/views/server/FirewallTunnelSettings.vue b/neode-ui/src/views/server/FirewallTunnelSettings.vue new file mode 100644 index 00000000..e7c8453c --- /dev/null +++ b/neode-ui/src/views/server/FirewallTunnelSettings.vue @@ -0,0 +1,104 @@ + + + From c57119e9a7f0c4fa9f6d15ecb5e438bd07add87b Mon Sep 17 00:00:00 2001 From: archipelago Date: Wed, 7 Oct 2026 08:42:08 -0400 Subject: [PATCH 5/5] Explain unaffordable fee bump quotes --- core/archipelago/src/api/rpc/lnd/fee_bump.rs | 88 ++++++++++++++++++- .../components/__tests__/BumpFeeModal.test.ts | 12 +++ 2 files changed, 99 insertions(+), 1 deletion(-) diff --git a/core/archipelago/src/api/rpc/lnd/fee_bump.rs b/core/archipelago/src/api/rpc/lnd/fee_bump.rs index 6938dbcc..7acef035 100644 --- a/core/archipelago/src/api/rpc/lnd/fee_bump.rs +++ b/core/archipelago/src/api/rpc/lnd/fee_bump.rs @@ -120,6 +120,63 @@ fn fee_budget( Ok(budget) } +/// Find the highest rate that fits the already selected wallet output. This is +/// only a quote-time calculation: it never asks LND to reserve or spend the +/// output. Keeping it here lets the caller give an actionable answer when the +/// requested target is too expensive. +fn highest_affordable_rate( + requested: u64, + parent_size: u64, + parent_fee: u64, + size: u64, + old_fee: u64, + relay: u64, + input: u64, +) -> Option { + if requested <= 1 { + return None; + } + let mut low = 1; + let mut high = requested.saturating_sub(1); + let mut best = None; + while low <= high { + let mid = low + (high - low) / 2; + if fee_budget(mid, parent_size, parent_fee, size, old_fee, relay, input).is_ok() { + best = Some(mid); + low = mid.saturating_add(1); + } else { + high = mid.saturating_sub(1); + } + } + best +} + +fn quote_budget_error( + requested: u64, + parent_size: u64, + parent_fee: u64, + size: u64, + old_fee: u64, + relay: u64, + input: u64, +) -> anyhow::Error { + let available = input.saturating_sub(1000); + let suggestion = highest_affordable_rate( + requested, + parent_size, + parent_fee, + size, + old_fee, + relay, + input, + ) + .map(|rate| format!(" Try {rate} sat/vB or lower.")) + .unwrap_or_else(|| " No fee rate can currently fit this output.".into()); + anyhow::anyhow!( + "Not enough wallet change for {requested} sat/vB: at most {available} sats is spendable for this bump.{suggestion}" + ) +} + async fn lnd( client: &reqwest::Client, macaroon: &str, @@ -501,7 +558,22 @@ impl RpcHandler { old_fee, relay.max(floor), input_sats, - )?; + ) + .map_err(|error| { + if error.to_string().contains("Not enough wallet change") { + quote_budget_error( + rate, + parent_size, + parent_fee, + size, + old_fee, + relay.max(floor), + input_sats, + ) + } else { + error + } + })?; let tip: String = self .bitcoin_rpc_call(&client, "getbestblockhash", &[]) .await?; @@ -797,6 +869,20 @@ mod tests { assert!(fee_budget(0, 142, 144, 112, 0, 1, 21126).is_err()); } #[test] + fn unaffordable_quote_explains_spendable_change_and_viable_rate() { + let suggested = highest_affordable_rate(5000, 142, 144, 112, 0, 1, 21126); + assert_eq!(suggested, Some(79)); + let error = quote_budget_error(5000, 142, 144, 112, 0, 1, 21126).to_string(); + assert!(error.contains("at most 20126 sats is spendable")); + assert!(error.contains("Try 79 sat/vB or lower")); + } + #[test] + fn no_affordable_rate_is_reported_without_mutating_the_output() { + let error = quote_budget_error(10, 142, 144, 112, 9_000, 1, 10_000).to_string(); + assert!(error.contains("at most 9000 sats is spendable")); + assert!(error.contains("No fee rate can currently fit this output")); + } + #[test] fn rbf_pays_incremental_relay_cost_and_counts_only_extra_cost() { let fee = fee_budget(3, 142, 144, 112, 650, 1, 21126).unwrap(); assert_eq!(fee, 763); diff --git a/neode-ui/src/components/__tests__/BumpFeeModal.test.ts b/neode-ui/src/components/__tests__/BumpFeeModal.test.ts index 92b4c278..d1e8007d 100644 --- a/neode-ui/src/components/__tests__/BumpFeeModal.test.ts +++ b/neode-ui/src/components/__tests__/BumpFeeModal.test.ts @@ -47,6 +47,18 @@ describe('Bump review', () => { expect(rpcClient.call).toHaveBeenLastCalledWith(expect.objectContaining({ method: 'lnd.bump-quote', params: { txid: quote.txid, sat_per_vbyte: 10 } })) wrapper.unmount() }) + it('shows an actionable wallet-change shortfall without offering submit', async () => { + vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => { + if (method === 'lnd.bump-status') return { status: 'none' } as never + throw new Error('Not enough wallet change for 5000 sat/vB: at most 20126 sats is spendable for this bump. Try 79 sat/vB or lower.') + }) + const { wrapper } = open(); await flushPromises() + expect(wrapper.text()).toContain('at most 20126 sats is spendable') + expect(wrapper.text()).toContain('Try 79 sat/vB or lower') + expect(wrapper.text()).not.toContain('Confirm bump') + expect(vi.mocked(rpcClient.call).mock.calls.some(([request]) => request.method === 'lnd.bump-submit')).toBe(false) + wrapper.unmount() + }) it('blocks expired quotes and duplicate clicks; never retries a mutation', async () => { const { wrapper, vm } = open(); await flushPromises() vm.quote.expires_at = 1; await vm.submit()