Compare commits

...
Author SHA1 Message Date
archipelago 339c2673c9 chore: release v1.7.129-alpha
Demo images / Build & push demo images (push) Successful in 3m36s
2026-08-10 16:41:48 -04:00
archipelagoandClaude Fable 5 1dcfd7774b docs(changelog): fold the federation map into v1.7.129-alpha notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:37:37 -04:00
archipelagoandClaude Fable 5 e53b3ac72c merge: ux-at-last — federation network map (3D orbital, 2D toggle, live peer requests)
Branch stays alive for continued UX work; fast-forwarded to main after this
merge so it continues from the current tip.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:35:46 -04:00
archipelagoandClaude Fable 5 ebc38407e0 docs(changelog): fold the appgate retry-page fix into v1.7.129-alpha notes
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:30:25 -04:00
archipelagoandClaude Fable 5 ec8af1ba50 chore: lockfile drift + fmt shape in crash_recovery test
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:30:13 -04:00
archipelagoandClaude Fable 5 ee15626127 fix(appgate): dead upstream serves a named, self-retrying page
Demo images / Build & push demo images (push) Successful in 3m45s
The gate answered a dead app with the bare string "app is not responding",
which on the app's own port reads as the node itself being broken — reported
against Gitea on a fleet node (the actual fault was a ghost container holding
Gitea's LevelDB lock, crash-looping the managed container). Serve the same
styled page as the login challenge instead: names the app, says it may be
restarting, and retries via a Refresh header (page CSP allows no script).
Status stays 502 so machine clients still see an upstream failure.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 15:29:19 -04:00
archipelagoandClaude Fable 5 2cee14dd47 docs(changelog): curate v1.7.129-alpha notes + What's New block
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:49:33 -04:00
archipelagoandClaude Fable 5 cd795c98ea feat(container): pasta apps join the Quadlet migration — units over scopes
The pasta exclusion paired with the transient-scope machinery: daemon-started
pasta/conmon inherit the starter's cgroup, so scopes kept app networking
alive across daemon restarts. A quadlet unit provides the same independence
(user.slice parentage) with supervision on top — Restart=always and
RestartSec=10, which also spaces restarts past pasta's port teardown (the
race observed live on strfry, 2026-08-10). Renderer needed nothing:
Network=pasta, ports and RestartSec were already in place.

Lifted in all four places: migrate_to_quadlet_if_needed, install_fresh, and
the missing-container unit-start path; the scoped start/restart helpers are
now unit-aware — quadlet-managed pasta routes through systemctl --user
(bare podman would fight systemd over an --rm container), legacy pasta
keeps its scope, so mixed fleets stay coherent during rollout.

Container suite 221/221.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:31:34 -04:00
archipelagoandClaude Fable 5 a0ffc5ca3d feat(container): re-wire the companion reaper behind the durable registry
The 2026-08-08 unwire set the bar: a DURABLE record of 'this app is
installed' must drive reaping, never runtime inference. installed-apps.json
is that record (written on install, cleared on uninstall, backfilled from
live containers, held through container absence). The reconciler's
companion loop now reaps against it — and only when the registry file
actually exists and parses: 'I could not look' and 'nothing is installed'
both surface as an empty set from the lossy loader, so a new
load_installed_apps_if_recorded keeps the distinction alive. The
runtime-derived set is deliberately not a fallback; it is the input class
that caused the incident. ORPHAN_GRACE still applies on top.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 14:05:26 -04:00
archipelagoandClaude Fable 5 dbe37f7ffe fix(grafana): manifest and curation move onto the fleet trust floor
The signed catalog has homed grafana at lfg2025/grafana:10.2.0 in the fleet
registry all along; the manifest still pulled Docker Hub's grafana/grafana,
so the repo-mismatch guard (correctly) discarded the catalog image on every
tick and warned every ~75s on every node. Registry verified to carry the
image; aligning the repo path makes the already-signed catalog entry
effective — no re-signing needed, no behavioural change beyond pull source.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 13:45:10 -04:00
archipelago 604722c96c chore: release v1.7.128-alpha
Demo images / Build & push demo images (push) Successful in 3m41s
2026-08-10 13:42:39 -04:00
archipelagoandClaude Fable 5 c9a70f9d2e style: cargo fmt
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:56:32 -04:00
archipelagoandClaude Fable 5 7f258b508e docs(changelog): curate v1.7.128-alpha notes + regenerate What's New block
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:54:54 -04:00
archipelagoandClaude Fable 5 2786c0727f feat(nostr): optional display name in the presence event, asked at toggle-on
Turning discovery on prompts for a name; it rides the public announcement
(clean_display_name both directions: single line, control-stripped, 32-char
cap — it round-trips through untrusted relays). Blank lists as npub only;
off/on keeps the stored name; sending an empty name clears it. Discovery
lists show the name with the npub beneath. Own-npub display switches to
middle-ellipsis so the comparable tail stays visible.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:54:54 -04:00
archipelagoandClaude Fable 5 a2ff3502bd fix(onboarding): provisioned-guard refusal reads as operator guidance
'Re-keying requires the authenticated system.factory-reset' is precise and
useless to an operator standing at the seed screen. Map it to what they can
do: sign in, or factory-reset from Settings.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:28:08 -04:00
archipelagoandClaude Fable 5 61b380d266 feat(web5): discoverability container shows the node's npub, not the onion
The presence event deliberately never contains the onion — the npub is
what's actually visible on the relays, and the UI previously showed neither
it nor any way to find it. Same container, same styling; the copy button
copies the npub.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:28:08 -04:00
archipelagoandClaude Fable 5 81858ab630 fix(nostr): discovery events expire, heartbeat, and tombstone — stale nodes age out
Presence gets a NIP-40 expiration (48h) and a 12h re-publish heartbeat that
honours the runtime toggle (UI-enabled nodes previously never re-published
at boot). discover() drops pre-TTL events client-side for relays that
ignore NIP-40. Switching discovery off publishes an empty tombstone, and
factory-reset tombstones BEFORE wiping identity — after the wipe the key is
gone and the stale event could never be replaced by anyone.

nostr.discovery-status now also returns the node's own npub (load-only).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 11:27:59 -04:00
archipelagoandClaude Fable 5 c3b532eaf4 fix(onboarding): seed screen's lock stays steady through silent retries
Demo images / Build & push demo images (push) Successful in 3m42s
The catch block dropped `loading` before branching, so every failed poll
unmounted the lock icon and status text for the 4s retry gap — the screen
flashed in and out on a live install test (zaza-optiplex, 2026-08-09).
`loading` now stays true across isServerStartingError retries; only success
or a genuine failure dismisses the waiting state.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-10 08:53:15 -04:00
archipelagoandClaude Fable 5 5a0c1f06b1 fix(federation): anisotropic scene fill + centred key on mobile
- The fit now scales screen X and Y independently (capped at 1.75x
  anisotropy), so both 2D and 3D stretch to the container's aspect ratio —
  a portrait phone uses its full height instead of shrinking the orbit to
  the narrow width, and wide desktop panels spread horizontally.
- Legend/key centres at the top on mobile, mirroring the bottom-centre
  2D/3D toggle.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:31:49 -04:00
archipelagoandClaude Fable 5 a5d81fd5ff fix(federation): mobile map readability — fill the space, taller 3D, toggle bottom-centre
- Compact containers (<480px) shrink the fit margins so the scene fills the
  phone screen instead of floating in padding.
- 3D mode steepens its tilt on portrait (-0.95 vs -0.5) so the orbit reads
  as a tall ellipse with depth, not a squashed horizontal band; projection
  params are resolved per-aspect via modeParams() and re-resolve on resize.
- The 2D/3D toggle moves to bottom-centre on mobile (thumb reach, frees the
  top edge); the hint line tucks above it.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 18:29:54 -04:00
archipelagoandClaude Fable 5 4ffd632506 refactor(federation): keyed diff-reconciled map scene — stable intro, smooth revisits
Root causes of the flaky intro / janky revisits, all fixed structurally:

- Graph updates now RECONCILE against the live scene by identity (peer DID /
  request id) instead of wiping the SVG: in-place updates for label/online/
  trust changes, animated arrivals for new nodes, animated departures for
  removed ones, and short-way-around slot glides when the ring layout shifts.
  The old wipe-and-rebuild orphaned every in-flight intro tween whenever the
  5s poll or the async self-DID fetch landed mid-intro — that was the
  'sometimes parts don't show'.
- The intro is gated on readiness: first real container measurement (post-
  layout, via ResizeObserver) AND the self node being present, with a 1.2s
  fallback. Graph changes during the intro queue and apply on completion.
- Layout is deterministic per node: peers sort by DID for slot assignment and
  jitter/phase hash from the DID, so backend response order can't shuffle
  the map between polls.
- The long cinematic intro plays once per browser session; revisits get a
  shorter, lighter entrance that doesn't fight the route transition.
- selfDid seeds from the cached localStorage value (re-cached on fetch) so
  the centre node and links exist from the first frame.
- Requests' waiting-room orbit pushed clearly outside the peer rings (+1.0)
  with loose per-request hover jitter — outside until accepted, then the
  join animation glides them in.
- Full unmount hygiene: every tween killed by target (incl. the repeating
  sonar pulse), fallback timer cleared, window listeners removed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:53:40 -04:00
archipelagoandClaude Fable 5 6ba24b0f4a feat(federation): inbound peer requests live on the map — blinking nodes + accept/reject
- Inbound pending requests render as blinking yellow point-globes on an
  orbit outside the peers, dotted-linked to self, with their own dashed
  guide ring and a Request legend chip.
- Tapping one opens a black-glass popover (name, request message,
  Accept/Reject) centred over the scene; tap-away or ✕ dismisses.
- Reject: the node swells and pops out of existence, taking its link.
- Accept: green burst ring, the point cloud/link morph to the trusted
  colour and the globe glides inward onto the peer orbit, then the data
  refresh replaces it with the real peer node.
- Wired to the same approve/reject RPCs as the pending panel; new
  motionTokens.color.pending (#facc15) token.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:37:39 -04:00
archipelagoandClaude Fable 5 e0aebf50f8 feat(federation): 2D/3D projection toggle + interaction fixes + mobile layout polish
- Small glass 2D/3D toggle top-right of the map: tweens tilt/perspective/fit
  (0.9s inOut) so the scene folds flat into the original radial 2D layout and
  back. Default: portrait/mobile opens 2D, desktop 3D; last choice saved to
  localStorage.
- Fix: setPointerCapture retargeted pointerup to the container, suppressing
  click synthesis on children — node taps and the toggle never fired. Drag
  now tracks via window listeners, no capture.
- Fix: stale post-drag distance made the click-suppressor swallow toggle taps
  indefinitely ('stuck' toggle). Distance resets on every pointerdown and the
  suppressor is one-shot.
- Map no longer slides under the floating mobile back pill: the
  mobile-scroll-pad-back panel keeps its full 64px clearance when filled.
- Mobile DID copy/rotate card moved out of the header to below the view tabs
  (new DidCardMobile.vue) and hidden on the Network Map tab.
- Node labels: dark stroke halo removed; 10px on mobile.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:31:07 -04:00
archipelagoandClaude Fable 5 aea90984d6 feat(federation): flat 2D-style top-down view on portrait/mobile
Portrait containers now render near top-down and near-orthographic (tilt
-1.35, persp 16) so the map reads like the previous 2D radial layout —
rings become circles with no perspective squash. Desktop keeps the 3D
depth camera. Globe point-spheres unchanged on both.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:22:31 -04:00
archipelagoandClaude Fable 5 abb4c95337 fix(federation): keep view tabs pill auto-width on desktop in map view
The map view turns the root into a flex column; default stretch alignment
was pulling the mode-switcher full-width on desktop. md:self-start restores
the content-fit pill (mobile stays full-width by design).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:21:35 -04:00
archipelagoandClaude Fable 5 8354db8e14 feat(federation): point-cloud globe nodes, auto-fit centering, calmer motion
- Nodes are now spheres made of points: fibonacci point-cloud globes with
  depth-shaded dots, limb outline, and a slow local spin (drag adds parallax).
  Self node is black — dark dots over a soft light backing disc with the
  brand-orange sonar pulse marking 'you'. Spheres and dots sized down.
- Scene auto-fits and centres to the container on every device: the outermost
  orbit is sampled through the real camera projection to get true bounds,
  then scaled/centred between the overlays. Portrait screens tilt the camera
  towards top-down so the orbit uses the full height (mobile readability).
- No idle orbiting: nodes hold position with a gentle side-to-side sway;
  drag inertia now settles to a stop. Intro dolly pushes in (0.82→1) instead
  of pulling back, so nothing clips during the intro.
- Labels get a dark paint-order halo and bump to 12.5px on mobile.
- Point clouds update at half frame rate to keep mobile/companion smooth.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:20:24 -04:00
archipelagoandClaude Fable 5 fa14f6ebaa feat(federation): GSAP-powered 3D orbital node map, fills viewport to bottom
- Add gsap 3.15 + design-system-aware motion module (src/utils/motion.ts):
  shared colour/duration/ease tokens mirrored from style.css, gsap.defaults,
  live prefers-reduced-motion check.
- Replace the d3 force NetworkMap with NetworkMap3D.vue: peers on projected
  3D orbital rings around the self node, cinematic intro (camera dolly +
  staggered fly-in + ring draw), idle rotation with drag-to-orbit inertia,
  depth-sorted painter's order, trust-colour palette, online/offline states,
  sonar pulse on self, tap-a-node opens the detail modal.
- Map view now fills the dashboard panel to the bottom edge on desktop,
  mobile and companion: .dashboard-scroll-panel:has(.node-map-stage) turns
  the panel into a column (tab-bar/safe-area/audio-player aware padding)
  instead of leaving the old dead bottom margin.
- Reduced motion: intro/idle skipped, scene renders static.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 17:09:09 -04:00
archipelagoandClaude Fable 5 7fbdabf136 chore(release): carry Cargo.lock version bump + regenerated UI catalog for v1.7.127-alpha
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-09 16:46:12 -04:00
35 changed files with 2343 additions and 343 deletions
+18
View File
@@ -1,5 +1,23 @@
# Changelog
## v1.7.129-alpha (2026-08-10)
- **Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.
- **Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" — and a helper must be orphaned for a sustained period before it is touched.
- **A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.
- **The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.
- **An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).
## v1.7.128-alpha (2026-08-10)
- **The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays — nothing holds its key anymore — so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off — or factory-resetting the node — actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.
- **You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name — it travels inside the public announcement, so other nodes' discovery lists show "Dorian's basement node" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.
- **The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address — which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.
- **The seed screen stops flashing while the node starts.** During first boot, the lock icon and "server starting" text blinked in and out every few seconds while the node came up — each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.
- **A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about "the authenticated system.factory-reset". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node.
## v1.7.127-alpha (2026-08-09)
- **Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.
+1 -1
View File
@@ -390,7 +390,7 @@
"author": "Grafana Labs",
"category": "data",
"tier": "recommended",
"dockerImage": "grafana/grafana:10.2.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/grafana:10.2.0",
"repoUrl": "https://github.com/grafana/grafana",
"containerConfig": {
"ports": [
+1 -1
View File
@@ -5,7 +5,7 @@ app:
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
image: grafana/grafana:10.2.0
image: source.archipelago-foundation.org/lfg2025/grafana:10.2.0
image_signature: cosign://...
pull_policy: if-not-present
data_uid: "472:472"
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.7.126-alpha"
version = "1.7.128-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.7.127-alpha"
version = "1.7.129-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+45 -4
View File
@@ -21,7 +21,7 @@ use anyhow::{Context, Result};
use nostr_sdk::FromBech32;
use serde::{Deserialize, Serialize};
const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
use crate::nostr_handshake::DISCOVERY_STATE_FILE as NOSTR_STATE_FILE;
/// Runtime override for `Config::nostr_discovery_enabled`. The OS-level
/// config file is read once at boot and is OFF by default; this state file
@@ -32,6 +32,9 @@ const NOSTR_STATE_FILE: &str = "nostr_discovery_state.json";
struct NostrDiscoveryState {
#[serde(default)]
enabled: bool,
/// Operator-chosen display name carried in the presence event.
#[serde(default, skip_serializing_if = "Option::is_none")]
name: Option<String>,
}
async fn load_discovery_state(data_dir: &std::path::Path) -> NostrDiscoveryState {
@@ -55,10 +58,16 @@ async fn save_discovery_state(
}
impl RpcHandler {
/// Read the current runtime discoverability flag.
/// Read the current runtime discoverability flag. Also returns the npub
/// this node publishes as (the discoverability UI shows it — that npub,
/// not the onion, is what's actually visible on the relays). Load-only:
/// null until discovery keys exist.
pub(super) async fn handle_nostr_discovery_status(&self) -> Result<serde_json::Value> {
let state = load_discovery_state(&self.config.data_dir).await;
Ok(serde_json::json!({ "enabled": state.enabled }))
let npub = nostr_handshake::own_npub(&self.config.data_dir.join("identity"))
.await
.unwrap_or(None);
Ok(serde_json::json!({ "enabled": state.enabled, "npub": npub, "name": state.name }))
}
/// Set the runtime discoverability flag. If turning ON, publish presence
@@ -78,7 +87,22 @@ impl RpcHandler {
.and_then(|v| v.as_bool())
.ok_or_else(|| anyhow::anyhow!("Missing enabled"))?;
save_discovery_state(&self.config.data_dir, &NostrDiscoveryState { enabled }).await?;
// Optional display name. Absent param = keep the stored name (so a
// plain off/on toggle doesn't forget it); present-but-empty clears it.
let prior = load_discovery_state(&self.config.data_dir).await;
let name = match params.get("name") {
Some(v) => v.as_str().and_then(nostr_handshake::clean_display_name),
None => prior.name,
};
save_discovery_state(
&self.config.data_dir,
&NostrDiscoveryState {
enabled,
name: name.clone(),
},
)
.await?;
if enabled && !self.config.nostr_relays.is_empty() {
let (data, _) = self.state_manager.get_snapshot().await;
@@ -88,11 +112,13 @@ impl RpcHandler {
let version = data.server_info.version.clone();
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
let publish_name = name.clone();
tokio::spawn(async move {
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
&version,
publish_name.as_deref(),
&relays,
tor_proxy.as_deref(),
)
@@ -101,6 +127,21 @@ impl RpcHandler {
tracing::warn!("Initial presence publish failed: {}", e);
}
});
} else if !enabled {
// Switching off: overwrite our presence with an empty tombstone so
// the node disappears from other nodes' discovery lists now, not
// at the next TTL expiry.
let identity_dir = self.config.data_dir.join("identity");
let relays = self.handshake_relays().await;
let tor_proxy = self.config.nostr_tor_proxy.clone();
tokio::spawn(async move {
if let Err(e) =
nostr_handshake::publish_tombstone(&identity_dir, &relays, tor_proxy.as_deref())
.await
{
tracing::warn!("Presence tombstone publish failed: {}", e);
}
});
}
Ok(serde_json::json!({ "enabled": enabled }))
@@ -921,6 +921,29 @@ impl RpcHandler {
return Err(anyhow::anyhow!("Password Incorrect"));
}
// Overwrite our Nostr presence with a tombstone BEFORE the wipe: the
// discovery keys die with the identity dir, and once they're gone the
// stale presence event can never be replaced by anyone — it would
// list this dead install to the whole network until relays expire it.
// Best-effort with a hard cap so a dead relay can't stall the reset.
{
let identity_dir = self.config.data_dir.join("identity");
let relays = crate::nostr_relays::merged_relay_list(
&self.config.data_dir,
&self.config.nostr_relays,
)
.await;
let _ = tokio::time::timeout(
std::time::Duration::from_secs(15),
crate::nostr_handshake::publish_tombstone(
&identity_dir,
&relays,
self.config.nostr_tor_proxy.as_deref(),
),
)
.await;
}
tracing::warn!("Factory reset initiated — wiping ALL user data and containers");
let data_dir = &self.config.data_dir;
+46 -8
View File
@@ -446,7 +446,7 @@ async fn proxy_to_app(
.to_string();
let uri = match format!("http://127.0.0.1:{port}{path_and_query}").parse::<hyper::Uri>() {
Ok(uri) => uri,
Err(_) => return bad_gateway(),
Err(_) => return app_down_page(app),
};
let (mut parts, body) = req.into_parts();
@@ -501,7 +501,7 @@ async fn proxy_to_app(
let client = hyper::Client::new();
let mut upstream_resp = match client.request(upstream_req).await {
Ok(resp) => resp,
Err(_) => return bad_gateway(),
Err(_) => return app_down_page(app),
};
if upstream_resp.status() == StatusCode::SWITCHING_PROTOCOLS {
if let Some(client_upgrade) = client_upgrade {
@@ -521,7 +521,7 @@ async fn proxy_to_app(
let client = hyper::Client::new();
match client.request(Request::from_parts(parts, body)).await {
Ok(resp) => resp,
Err(_) => bad_gateway(),
Err(_) => app_down_page(app),
}
}
@@ -583,11 +583,32 @@ fn redirect_to_app() -> Response<Body> {
.expect("static response builds")
}
fn bad_gateway() -> Response<Body> {
Response::builder()
.status(StatusCode::BAD_GATEWAY)
.body(Body::from("app is not responding"))
.expect("static response builds")
/// Served when the app behind the gate does not answer on loopback.
///
/// A real page rather than the bare string `app is not responding`: the gate
/// answers on the app's own port, so this text IS the app as far as the
/// operator can tell, and the raw string read as the node itself being broken
/// (reported against Gitea on a fleet node, 2026-08-10 — the actual fault was
/// a ghost container crash-looping the app). Name the app, say the node is
/// fine, and retry on our own: an app that is restarting comes back without
/// the user knowing to reload. Status stays 502 so machine clients still see
/// an upstream failure rather than a success with HTML in it.
fn app_down_page(app: &GatedPort) -> Response<Body> {
let body = format!(
r#"{icon}
<h1>{name} is not responding</h1>
<p class="sub">The app is not answering right now it may be stopped or still
starting. This page retries automatically. If it does not recover, open the
dashboard and check {name} under My Apps.</p>"#,
icon = icon_markup(app),
name = esc(&app.app_name),
);
let mut resp = page("App not responding", app, &body, StatusCode::BAD_GATEWAY);
// Header-based refresh, not <meta> or script: page()'s CSP allows no
// script, and the header keeps the retry out of the document entirely.
resp.headers_mut()
.insert("Refresh", header::HeaderValue::from_static("5"));
resp
}
fn not_found() -> Response<Body> {
@@ -1065,6 +1086,23 @@ mod tests {
assert!(csp.contains("form-action 'self'"));
}
/// A dead upstream must render as a page that names the app and retries,
/// not the bare string "app is not responding" — that string standing
/// alone on the app's own port read as the node being broken (Gitea on a
/// fleet node, 2026-08-10). The 502 status must survive so machine
/// clients still see an upstream failure.
#[tokio::test]
async fn a_dead_app_gets_a_named_retrying_page_not_a_bare_string() {
let resp = app_down_page(&app());
assert_eq!(resp.status(), StatusCode::BAD_GATEWAY);
assert_eq!(resp.headers()["Refresh"], "5");
assert_eq!(resp.headers()[header::CACHE_CONTROL], "no-store");
let body = hyper::body::to_bytes(resp.into_body()).await.unwrap();
let html = String::from_utf8_lossy(&body);
assert!(html.contains("Strfry Relay is not responding"));
assert!(html.contains("<html"), "must be a page, not a bare string");
}
/// The login page must render entirely from the gate's own origin: the
/// CSP allows no external host, so a background or logo that 404s leaves
/// a black page rather than the dashboard's art.
@@ -107,6 +107,7 @@ impl BootReconciler {
let companion_handle = if self.companion_stage {
let orchestrator = self.orchestrator.clone();
let interval = self.interval;
let data_dir = orchestrator.data_dir().to_path_buf();
Some(tokio::spawn(async move {
let mut failure_rounds: u32 = 0;
loop {
@@ -128,34 +129,48 @@ impl BootReconciler {
continue;
};
let failures = crate::container::companion::reconcile(&installed).await;
// `reap_orphans` is deliberately NOT called here. It is
// implemented and tested, and it must stay unwired until a
// DURABLE record of "this app is installed" exists.
// Reaper, RE-WIRED 2026-08-10 — driven by the DURABLE
// installed-apps registry, never by runtime inference.
//
// Proven harmful on archi-dev-box 2026-08-08: it removed
// archy-bitcoin-ui (36 minutes of no Bitcoin UI, until the
// operator reinstalled the backend) and archy-lnd-ui, both
// for apps that ARE installed. It was not a logic error —
// it did exactly what it was told. The inputs lied: the
// backends' containers were missing because of the
// clean-exit vanishing bug, and both had already aged out
// of running-containers.json, which only ever records what
// is CURRENTLY RUNNING. So container-presence and
// installation-evidence, the two independent signals the
// reaper trusts, were false at the same time and for the
// same underlying reason.
// History: this call was unwired on 2026-08-08 after it
// removed archy-bitcoin-ui and archy-lnd-ui for apps that
// WERE installed. Not a logic error — the inputs lied:
// `installed_app_ids` infers installation from runtime
// state (containers present + running-containers.json),
// and the clean-exit vanishing bug falsified both signals
// at once. The unwire commit set the re-wire bar: a
// durable record of "this app is installed".
//
// Reaping turns one lost app into two, which is strictly
// worse than the orphan it cleans up. Leaving an orphan
// costs a stale UI tile; reaping a live app's companion
// costs the operator a working screen. Until "installed"
// can be answered without inferring it from runtime state,
// absence is not evidence of uninstallation.
// That record now exists — installed-apps.json, written on
// install, cleared on deliberate uninstall, backfilled at
// boot from demonstrably-present containers, and immune to
// container absence by construction (89b03c47 holds
// entries while a container is gone). A vanished backend
// no longer looks uninstalled, so the failure mode that
// burned archi-dev-box cannot recur through this path.
//
// The provisioning half above is the actual fix for
// "fedimint installs but does not work" and stands on its
// own: a companion is never stood up for an app nobody
// installed, so no NEW orphans are created.
// `None` = the registry could not be read (missing or
// corrupt) — which is "I could not look", NOT "nothing is
// installed". The reaper stays idle in that case; the
// runtime-derived `installed` set above is deliberately
// NOT used as a fallback (it is exactly the input class
// that caused the 2026-08-08 incident). ORPHAN_GRACE still
// applies on top: a companion must be orphaned for the
// full grace period before it is touched.
if let Some(durable) =
crate::crash_recovery::load_installed_apps_if_recorded(&data_dir).await
{
let durable: Vec<String> = durable.into_iter().collect();
for (companion, err) in
crate::container::companion::reap_orphans(&durable).await
{
tracing::warn!(
companion = %companion,
error = %err,
"companion reap failed"
);
}
}
for (companion, err) in &failures {
tracing::warn!(
companion = %companion,
+6 -2
View File
@@ -682,8 +682,12 @@ fn due_after_grace(
/// Stop and remove any companion whose backend app is not installed.
///
/// ⚠️ NOT WIRED, ON PURPOSE. Do not call this from the reconciler until a
/// DURABLE record of "this app is installed" exists to drive it.
/// ⚠️ WIRED (2026-08-10) to exactly one caller — the boot reconciler's
/// companion loop — and ONLY behind the durable installed-apps registry
/// (`crash_recovery::load_installed_apps_if_recorded`). That satisfies the
/// bar the 2026-08-08 unwire set: a DURABLE record of "this app is
/// installed" drives it, never runtime inference. Do not add callers fed
/// from runtime state; the history below is why.
///
/// It ran on archi-dev-box on 2026-08-08 and removed two companions whose
/// backends were installed — archy-bitcoin-ui (36 minutes of no Bitcoin UI)
@@ -1039,7 +1039,9 @@ async fn repair_manifest_host_ports_after_stability(
container = %name,
"host listener disappeared after startup; restarting container"
);
if uses_pasta_network(manifest) {
if uses_pasta_network(manifest) && !quadlet::unit_exists(name).await {
// Legacy (pre-quadlet) pasta app: no unit owns it, so a transient
// scope keeps its networking's cgroup independent of the daemon.
podman_user_scope(&["restart", name])
.await
.with_context(|| format!("podman restart {name}"))?;
@@ -1085,9 +1087,16 @@ async fn start_container_scoped_if_pasta(
name: &str,
) -> Result<()> {
if uses_pasta_network(manifest) {
// Rootless pasta/conmon inherit the cgroup of the process that starts
// them. Starting through archipelago.service lets backend restarts kill
// app networking; a transient user scope keeps app daemons independent.
// Quadlet-managed pasta app: the unit owns the cgroup and the
// container is rendered --rm — bare `podman start` would fight
// systemd over it. Restart-through-the-unit starts a stopped one.
if quadlet::unit_exists(name).await {
return quadlet::restart_service(&format!("{name}.service")).await;
}
// Legacy pasta app: rootless pasta/conmon inherit the cgroup of the
// process that starts them. Starting through archipelago.service lets
// backend restarts kill app networking; a transient user scope keeps
// app daemons independent.
podman_user_scope(&["start", name]).await
} else {
runtime.start_container(name).await
@@ -1100,6 +1109,9 @@ async fn restart_container_scoped_if_pasta(
name: &str,
) -> Result<()> {
if uses_pasta_network(manifest) {
if quadlet::unit_exists(name).await {
return quadlet::restart_service(&format!("{name}.service")).await;
}
podman_user_scope(&["restart", name]).await
} else {
let _ = runtime.stop_container(name).await;
@@ -1503,6 +1515,10 @@ impl ProdContainerOrchestrator {
self.data_dir = data_dir;
}
pub fn data_dir(&self) -> &std::path::Path {
&self.data_dir
}
#[cfg(test)]
pub fn set_lnd_paths(&mut self, paths: lnd::EnsurePaths) {
self.lnd_paths = paths;
@@ -2263,7 +2279,15 @@ impl ProdContainerOrchestrator {
// after proving the container exists. Boot reconciliation must
// not create every catalog app just because a Quadlet unit is
// absent.
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
//
// Pasta apps included since 2026-08-10: the old exclusion
// paired with the transient-scope machinery (daemon-started
// pasta died with the daemon's cgroup). A quadlet unit gives
// pasta the same independence with systemd supervision on top
// — Restart=always + RestartSec=10, which also spaces restarts
// past pasta's port teardown. The scoped start/restart helpers
// now defer to the unit whenever one exists.
if self.use_quadlet_backends {
if let Some(action) = self.migrate_to_quadlet_if_needed(lm, &name).await? {
return Ok(action);
}
@@ -2535,10 +2559,7 @@ impl ProdContainerOrchestrator {
// lost the container record after a crash/reboot. Sync the unit
// bytes first (clears stale Notify=healthy/nc probes), then ask
// user systemd to start the generated service.
if self.use_quadlet_backends
&& !uses_pasta_network(&resolved_manifest)
&& self.quadlet_unit_exists(&name).await?
{
if self.use_quadlet_backends && self.quadlet_unit_exists(&name).await? {
self.prepare_for_start(&resolved_manifest).await?;
self.sync_quadlet_unit(lm, &name).await?;
self.ensure_resolved_source_available(lm).await?;
@@ -2721,11 +2742,13 @@ impl ProdContainerOrchestrator {
self.prepare_for_start(&resolved_manifest).await?;
self.ensure_container_network(&resolved_manifest).await?;
if self.use_quadlet_backends && !uses_pasta_network(&resolved_manifest) {
if self.use_quadlet_backends {
// Phase 3.2 path: declarative .container unit + systemctl.
// Containers parented under user.slice instead of
// archipelago.service's cgroup → no FM3 cascade SIGKILL on
// archipelago restart.
// archipelago restart. Pasta apps included since 2026-08-10 —
// the unit gives them the same cgroup independence the transient
// scopes provided, plus Restart=always supervision.
self.install_via_quadlet(&resolved_manifest, &name).await?;
} else {
self.remove_quadlet_unit_if_present(&name).await?;
+13
View File
@@ -661,6 +661,19 @@ fn parse_memory_mib(raw: &str) -> Option<u32> {
num_part.trim().parse::<u32>().ok()?.checked_mul(mul)
}
/// Does a quadlet `.container` unit exist for this container name?
/// Errors count as "unknown" and return false — callers use this to decide
/// whether systemd owns the container, and claiming ownership on an
/// unreadable answer would route lifecycle ops around a live unit.
pub async fn unit_exists(name: &str) -> bool {
let Ok(dir) = unit_dir().await else {
return false;
};
tokio::fs::try_exists(dir.join(format!("{name}.container")))
.await
.unwrap_or(false)
}
/// Resolve the per-user quadlet dir under $HOME. Created if missing.
pub async fn unit_dir() -> Result<PathBuf> {
let home = std::env::var_os("HOME")
+38
View File
@@ -204,6 +204,19 @@ pub async fn load_installed_apps(data_dir: &Path) -> std::collections::HashSet<S
}
}
/// Like `load_installed_apps`, but keeps "no record" distinguishable from
/// "empty record". The companion reaper must only ever run on `Some`:
/// "I could not look" and "nothing is installed" both come back as an empty
/// set from the lossy loader, yet they demand opposite behaviour — the
/// distinction has to survive to the caller (see `reap_orphans`' contract).
pub async fn load_installed_apps_if_recorded(
data_dir: &Path,
) -> Option<std::collections::HashSet<String>> {
let path = data_dir.join(INSTALLED_APPS_FILE);
let content = fs::read_to_string(&path).await.ok()?;
serde_json::from_str(&content).ok()
}
async fn save_installed_apps(data_dir: &Path, installed: &std::collections::HashSet<String>) {
let path = data_dir.join(INSTALLED_APPS_FILE);
if let Ok(json) = serde_json::to_string_pretty(installed) {
@@ -1193,6 +1206,31 @@ mod tests {
use super::*;
use tempfile::TempDir;
#[tokio::test]
async fn if_recorded_distinguishes_no_record_from_empty_record() {
let tmp = TempDir::new().unwrap();
// No file: the reaper must see "could not look", never "empty".
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
// Corrupt file: same — refuse to answer rather than guess.
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "{ not json")
.await
.unwrap();
assert!(load_installed_apps_if_recorded(tmp.path()).await.is_none());
// A real (even empty) record answers.
tokio::fs::write(tmp.path().join(INSTALLED_APPS_FILE), "[]")
.await
.unwrap();
assert_eq!(
load_installed_apps_if_recorded(tmp.path()).await,
Some(std::collections::HashSet::new())
);
mark_installed(tmp.path(), "bitcoin-knots").await;
assert!(load_installed_apps_if_recorded(tmp.path())
.await
.unwrap()
.contains("bitcoin-knots"));
}
#[tokio::test]
async fn installed_record_survives_and_forgets_on_uninstall() {
let tmp = TempDir::new().unwrap();
+125 -5
View File
@@ -35,6 +35,59 @@ use tracing::warn;
const NOSTR_SECRET_FILE: &str = "nostr_secret";
/// Runtime discoverability override written by the `nostr.set-discovery` RPC.
/// Lives here (not api/rpc) so the server's heartbeat can honour the same
/// state the toggle writes.
pub const DISCOVERY_STATE_FILE: &str = "nostr_discovery_state.json";
/// How long a presence event stays valid. Published as a NIP-40 expiration
/// tag AND enforced client-side in `discover` (relay NIP-40 support varies).
/// Must be comfortably longer than the re-publish heartbeat (12h in
/// server.rs) so a node that misses one heartbeat doesn't vanish: 48h
/// tolerates three misses.
pub const PRESENCE_TTL_SECS: u64 = 48 * 3600;
/// Read the runtime discovery override and the operator-chosen display name.
/// Enabled `None` means the toggle has never been used on this node —
/// callers fall back to the config flag.
pub async fn discovery_overrides(data_dir: &Path) -> (Option<bool>, Option<String>) {
let Ok(raw) = fs::read_to_string(data_dir.join(DISCOVERY_STATE_FILE)).await else {
return (None, None);
};
let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) else {
return (None, None);
};
let enabled = v.get("enabled").and_then(|e| e.as_bool());
let name = v
.get("name")
.and_then(|n| n.as_str())
.and_then(clean_display_name);
(enabled, name)
}
/// Display names travel in a PUBLIC relay event and come back from untrusted
/// peers — normalise both directions: single line, control chars stripped,
/// hard length cap, empty collapses to None.
pub fn clean_display_name(raw: &str) -> Option<String> {
let cleaned: String = raw
.chars()
.filter(|c| !c.is_control())
.take(32)
.collect::<String>()
.trim()
.to_string();
(!cleaned.is_empty()).then_some(cleaned)
}
/// This node's own published npub (bech32), if discovery keys exist.
/// Load-only: never mints keys on a read.
pub async fn own_npub(identity_dir: &Path) -> Result<Option<String>> {
Ok(load_nostr_keys(identity_dir)
.await?
.map(|k| k.public_key().to_bech32().unwrap_or_default())
.filter(|s| !s.is_empty()))
}
/// Message types exchanged inside NIP-44 encrypted DMs (kind 4).
///
/// Note: NONE of these variants carry an onion address. The onion is only
@@ -130,6 +183,7 @@ pub async fn publish_presence(
identity_dir: &Path,
did: &str,
version: &str,
name: Option<&str>,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<()> {
@@ -145,14 +199,20 @@ pub async fn publish_presence(
let nostr_npub = keys.public_key().to_bech32().unwrap_or_default();
let client = build_client(keys, tor_proxy)?;
let content = serde_json::json!({
let mut fields = serde_json::json!({
"did": did,
"nostr_pubkey": nostr_pubkey,
"nostr_npub": nostr_npub,
"version": version,
// No onion address — exchanged only via encrypted DM
})
.to_string();
});
// Operator-chosen display name (optional, already normalised). Public by
// construction: it exists to label this node in other nodes' discovery
// lists, so only ever include what clean_display_name lets through.
if let Some(n) = name.and_then(clean_display_name) {
fields["name"] = serde_json::Value::String(n);
}
let content = fields.to_string();
for url in relays {
let _ = client.add_relay(url).await;
@@ -164,8 +224,13 @@ pub async fn publish_presence(
warn!("Nostr relay connection timed out after 10s, continuing anyway");
}
let builder =
EventBuilder::new(Kind::Custom(30078), content).tag(Tag::identifier("archipelago-node"));
// NIP-40 expiration: relays that honour it garbage-collect the event if
// this node stops heartbeating (reinstall, decommission, long outage).
// `discover` enforces the same window client-side for relays that don't.
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), content)
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
let _ = client.send_event_builder(builder).await;
client.disconnect().await;
@@ -176,6 +241,43 @@ pub async fn publish_presence(
Ok(())
}
/// Overwrite this node's presence with an empty tombstone (NIP-33: same
/// author + kind + d-tag replaces). Called when discovery is switched off
/// and — critically — during factory-reset BEFORE the keys are wiped: once
/// the secret is gone, nothing can ever replace the stale event.
pub async fn publish_tombstone(
identity_dir: &Path,
relays: &[String],
tor_proxy: Option<&str>,
) -> Result<()> {
if relays.is_empty() {
return Ok(());
}
let Some(keys) = load_nostr_keys(identity_dir).await? else {
return Ok(()); // never published — nothing to tombstone
};
let client = build_client(keys, tor_proxy)?;
for url in relays {
let _ = client.add_relay(url).await;
}
if tokio::time::timeout(Duration::from_secs(10), client.connect())
.await
.is_err()
{
warn!("Nostr relay connection timed out after 10s, continuing anyway");
}
// Tombstone also expires: after TTL the relay may drop it entirely,
// which is the desired end state (nothing left to list).
let expires = Timestamp::from(Timestamp::now().as_u64() + PRESENCE_TTL_SECS);
let builder = EventBuilder::new(Kind::Custom(30078), "{}")
.tag(Tag::identifier("archipelago-node"))
.tag(Tag::expiration(expires));
let _ = client.send_event_builder(builder).await;
client.disconnect().await;
tracing::info!("🔒 Published presence tombstone to {} relays", relays.len());
Ok(())
}
/// Discover other Archipelago nodes (presence-only — no onion addresses).
/// Returns Nostr pubkeys and DIDs of discoverable nodes.
#[derive(Debug, Clone, Serialize, Deserialize)]
@@ -186,6 +288,9 @@ pub struct DiscoverableNode {
pub nostr_npub: String,
pub did: String,
pub version: String,
/// Operator-chosen display name from the presence event. Untrusted peer
/// input — normalised through `clean_display_name` on the way in.
pub name: Option<String>,
}
pub async fn discover_nodes(
@@ -221,7 +326,17 @@ pub async fn discover_nodes(
client.disconnect().await;
let mut nodes = Vec::new();
let stale_cutoff = Timestamp::from(Timestamp::now().as_u64().saturating_sub(PRESENCE_TTL_SECS));
for event in events {
// Client-side staleness enforcement: pre-TTL events (and events from
// relays that ignore NIP-40) would otherwise list dead installs
// forever — every reinstall mints a new key, so the old author can
// never replace its own event.
if event.created_at < stale_cutoff {
continue;
}
// A tombstone ("{}" content) parses but yields no pubkey — the
// nostr_pubkey.is_empty() guard below already drops it.
if let Ok(content) = serde_json::from_str::<serde_json::Value>(&event.content) {
let nostr_pubkey = content
.get("nostr_pubkey")
@@ -250,11 +365,16 @@ pub async fn discover_nodes(
.ok()
.and_then(|pk| pk.to_bech32().ok())
.unwrap_or_default();
let name = content
.get("name")
.and_then(|v| v.as_str())
.and_then(clean_display_name);
nodes.push(DiscoverableNode {
nostr_pubkey,
nostr_npub,
did,
version,
name,
});
}
}
+36 -13
View File
@@ -212,7 +212,15 @@ impl Server {
// Publish presence-only to Nostr (DID + Nostr pubkey, NO onion address).
// Onion addresses are exchanged privately via NIP-44 encrypted DMs.
if config.nostr_discovery_enabled && !config.nostr_relays.is_empty() {
//
// This is a heartbeat, not a one-shot: presence events carry a NIP-40
// expiration of PRESENCE_TTL_SECS, so a node that stops re-publishing
// ages out of discovery instead of lingering forever. First tick runs
// immediately (preserving the old startup-publish behaviour); the
// runtime toggle (nostr.set-discovery) is re-read every tick, so a
// node switched on via the UI heartbeats too — not just ones with the
// config flag baked in.
{
let identity_dir = config.data_dir.join("identity");
let did =
identity::did_key_from_pubkey_hex(&data.server_info.pubkey).unwrap_or_default();
@@ -221,21 +229,36 @@ impl Server {
// where handshake peers actually read (2026-07-22 unification).
let data_dir_for_relays = config.data_dir.clone();
let config_relays = config.nostr_relays.clone();
let config_flag = config.nostr_discovery_enabled;
let tor_proxy = config.nostr_tor_proxy.clone();
tokio::spawn(async move {
let relays =
crate::nostr_relays::merged_relay_list(&data_dir_for_relays, &config_relays)
const HEARTBEAT_SECS: u64 = 12 * 3600; // < PRESENCE_TTL_SECS/3
loop {
let (enabled_override, display_name) =
nostr_handshake::discovery_overrides(&data_dir_for_relays).await;
let enabled = enabled_override.unwrap_or(config_flag);
if enabled {
let relays = crate::nostr_relays::merged_relay_list(
&data_dir_for_relays,
&config_relays,
)
.await;
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
&version,
&relays,
tor_proxy.as_deref(),
)
.await
{
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
if !relays.is_empty() {
if let Err(e) = nostr_handshake::publish_presence(
&identity_dir,
&did,
&version,
display_name.as_deref(),
&relays,
tor_proxy.as_deref(),
)
.await
{
tracing::debug!("Nostr presence publish (non-fatal): {}", e);
}
}
}
tokio::time::sleep(std::time::Duration::from_secs(HEARTBEAT_SECS)).await;
}
});
}
+9 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.127-alpha",
"version": "1.7.129-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.127-alpha",
"version": "1.7.129-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
@@ -16,6 +16,7 @@
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
@@ -7293,6 +7294,12 @@
"dev": true,
"license": "ISC"
},
"node_modules/gsap": {
"version": "3.15.0",
"resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz",
"integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==",
"license": "Standard 'no charge' license: https://gsap.com/standard-license."
},
"node_modules/has-bigints": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz",
+2 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.127-alpha",
"version": "1.7.129-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
@@ -33,6 +33,7 @@
"dompurify": "^3.3.3",
"fast-json-patch": "^3.1.1",
"fuse.js": "^7.1.0",
"gsap": "^3.15.0",
"leaflet": "^1.9.4",
"pinia": "^3.0.4",
"qr-scanner": "^1.4.2",
+1 -1
View File
@@ -19,7 +19,7 @@
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:29.3.knots20260210",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
+6 -3
View File
@@ -884,14 +884,16 @@ class RPCClient {
// `handshake.poll` queues inbound requests into the federation pending
// inbox for manual approval (it does NOT auto-accept).
async nostrDiscoveryStatus(): Promise<{ enabled: boolean }> {
async nostrDiscoveryStatus(): Promise<{ enabled: boolean; npub?: string | null; name?: string | null }> {
return this.call({ method: 'nostr.discovery-status', params: {} })
}
async nostrSetDiscovery(enabled: boolean): Promise<{ enabled: boolean }> {
async nostrSetDiscovery(enabled: boolean, name?: string): Promise<{ enabled: boolean }> {
// `name` omitted = backend keeps the stored display name; empty string
// clears it. Only sent when the caller explicitly provides it.
return this.call({
method: 'nostr.set-discovery',
params: { enabled },
params: name === undefined ? { enabled } : { enabled, name },
timeout: 30000,
})
}
@@ -902,6 +904,7 @@ class RPCClient {
nostr_npub: string
did: string
version: string
name?: string | null
}>
}> {
return this.call({ method: 'handshake.discover', params: {}, timeout: 30000 })
@@ -12,7 +12,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree (or MeshMap.vue's); the only D3 force simulation
// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of
// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of
// this plan's scope). This file therefore only covers the Leaflet map's
// activate/deactivate lifecycle — the D3-specific truths from the plan are
// vacuously satisfied (there is nothing to leak).
@@ -1,182 +0,0 @@
<template>
<div ref="containerRef" class="network-map-container">
<svg ref="svgRef" class="w-full h-full"></svg>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
import * as d3 from 'd3'
interface MapNode {
did: string
label: string
trust_level: 'trusted' | 'observer' | 'untrusted'
online: boolean
app_count: number
is_self: boolean
}
interface MapLink {
source: string
target: string
}
const props = defineProps<{
nodes: MapNode[]
links: MapLink[]
}>()
const containerRef = ref<HTMLDivElement>()
const svgRef = ref<SVGSVGElement>()
type SimNode = MapNode & d3.SimulationNodeDatum
type SimLink = d3.SimulationLinkDatum<SimNode> & { source: string | SimNode; target: string | SimNode }
let simulation: d3.Simulation<SimNode, SimLink> | null = null
let resizeObserver: ResizeObserver | null = null
const graphSignature = computed(() => JSON.stringify({
nodes: props.nodes.map(n => [n.did, n.label, n.trust_level, n.online, n.app_count, n.is_self]),
links: props.links.map(l => [l.source, l.target]),
}))
function trustColor(level: string): string {
switch (level) {
case 'trusted': return '#4ade80'
case 'observer': return '#fb923c'
case 'untrusted': return '#ef4444'
default: return '#9ca3af'
}
}
function nodeRadius(n: MapNode): number {
return n.is_self ? 18 : Math.max(10, Math.min(16, 8 + n.app_count * 0.5))
}
function render() {
simulation?.stop()
const svg = d3.select(svgRef.value!)
svg.selectAll('*').remove()
const container = containerRef.value!
const width = container.clientWidth
const height = container.clientHeight
svg.attr('viewBox', `0 0 ${width} ${height}`)
const simNodes: SimNode[] = props.nodes.map(n => ({ ...n }))
const simLinks: SimLink[] = props.links.map(l => ({ ...l }))
// Center the self-node
const selfNode = simNodes.find(n => n.is_self)
if (selfNode) {
selfNode.fx = width / 2
selfNode.fy = height / 2
}
simulation = d3.forceSimulation(simNodes)
.force('link', d3.forceLink<SimNode, SimLink>(simLinks).id(d => d.did).distance(120))
.force('charge', d3.forceManyBody().strength(-300))
.force('center', d3.forceCenter(width / 2, height / 2))
.force('collision', d3.forceCollide<SimNode>().radius(d => nodeRadius(d) + 5))
const g = svg.append('g')
// Links
const link = g.append('g')
.selectAll('line')
.data(simLinks)
.join('line')
.attr('stroke', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? '#4ade8060' : '#6b728050'
})
.attr('stroke-width', 2)
.attr('stroke-dasharray', (d: SimLink) => {
const src = typeof d.source === 'object' ? d.source : simNodes.find(n => n.did === d.source)
const tgt = typeof d.target === 'object' ? d.target : simNodes.find(n => n.did === d.target)
return (src as MapNode)?.online && (tgt as MapNode)?.online ? 'none' : '6 4'
})
// Node groups
const node = g.append('g')
.selectAll<SVGGElement, SimNode>('g')
.data(simNodes)
.join('g')
.attr('cursor', 'pointer')
.call(d3.drag<SVGGElement, SimNode>()
.on('start', (event, d) => {
if (!event.active) simulation!.alphaTarget(0.3).restart()
d.fx = d.x
d.fy = d.y
})
.on('drag', (event, d) => {
d.fx = event.x
d.fy = event.y
})
.on('end', (event, d) => {
if (!event.active) simulation!.alphaTarget(0)
if (!d.is_self) { d.fx = null; d.fy = null }
})
)
// Node circles
node.append('circle')
.attr('r', d => nodeRadius(d))
.attr('fill', d => trustColor(d.trust_level))
.attr('fill-opacity', d => d.online ? 0.8 : 0.3)
.attr('stroke', d => d.is_self ? '#fb923c' : trustColor(d.trust_level))
.attr('stroke-width', d => d.is_self ? 3 : 1.5)
.attr('stroke-opacity', d => d.online ? 1 : 0.4)
// Node labels
node.append('text')
.text(d => d.label)
.attr('dy', d => nodeRadius(d) + 14)
.attr('text-anchor', 'middle')
.attr('fill', 'rgba(255,255,255,0.7)')
.attr('font-size', '11px')
.attr('font-family', "'Avenir Next', sans-serif")
// Tooltip
node.append('title')
.text(d => `${d.did}\nApps: ${d.app_count}\n${d.online ? 'Online' : 'Offline'}`)
simulation.on('tick', () => {
link
.attr('x1', d => (d.source as SimNode).x!)
.attr('y1', d => (d.source as SimNode).y!)
.attr('x2', d => (d.target as SimNode).x!)
.attr('y2', d => (d.target as SimNode).y!)
node.attr('transform', d => `translate(${d.x},${d.y})`)
})
}
onMounted(() => {
render()
resizeObserver = new ResizeObserver(() => render())
if (containerRef.value) resizeObserver.observe(containerRef.value)
})
onUnmounted(() => {
simulation?.stop()
resizeObserver?.disconnect()
})
watch(graphSignature, () => render())
</script>
<style scoped>
.network-map-container {
background: rgba(0, 0, 0, 0.6);
backdrop-filter: blur(24px);
border-radius: 0.75rem;
border: 1px solid rgba(255, 255, 255, 0.1);
box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.22);
min-height: 400px;
width: 100%;
}
</style>
File diff suppressed because it is too large Load Diff
+2
View File
@@ -443,6 +443,7 @@
"nodeVisibility": "Node Visibility",
"nodeVisibilityDesc": "Control how other nodes can discover you",
"yourTorAddress": "Your Tor address",
"yourNodeNpub": "Your node's npub",
"discoverableWarning": "Making your node discoverable lets other Archipelago users find and connect with you.",
"noPeers": "No peers yet. Add a peer manually or use Discover to find nodes on Nostr.",
"noRequests": "No pending connection requests.",
@@ -493,6 +494,7 @@
"failedToUpdatePrice": "Failed to update price",
"failedToConnectPeer": "Failed to connect to peer",
"onionAddressCopied": "Onion address copied",
"npubCopied": "npub copied",
"streamUrlCopied": "Stream URL copied",
"playerError": "Unable to load media. The content may only be accessible over Tor.",
"connectionAccepted": "Connection accepted",
+2
View File
@@ -441,6 +441,7 @@
"nodeVisibility": "Visibilidad del nodo",
"nodeVisibilityDesc": "Controle c\u00f3mo otros nodos pueden descubrirle",
"yourTorAddress": "Su direcci\u00f3n Tor",
"yourNodeNpub": "El npub de su nodo",
"discoverableWarning": "Hacer su nodo descubrible permite que otros usuarios de Archipelago le encuentren y se conecten con usted.",
"noPeers": "A\u00fan no hay pares. Agregue un par manualmente o use Descubrir para encontrar nodos en Nostr.",
"noRequests": "No hay solicitudes de conexi\u00f3n pendientes.",
@@ -491,6 +492,7 @@
"failedToUpdatePrice": "Error al actualizar precio",
"failedToConnectPeer": "Error al conectar con el par",
"onionAddressCopied": "Direcci\u00f3n onion copiada",
"npubCopied": "npub copiado",
"streamUrlCopied": "URL de transmisi\u00f3n copiada",
"playerError": "No se pudo cargar el contenido multimedia. Es posible que solo sea accesible a trav\u00e9s de Tor.",
"connectionAccepted": "Conexi\u00f3n aceptada",
+47
View File
@@ -3149,3 +3149,50 @@ select {
select::-ms-expand {
display: none;
}
/* =========================================================================
Federation 3D node map fill-to-bottom layout
When the map stage is on screen, the dashboard scroll panel switches from
a scrolling document to a column that hands all remaining height to the
map, killing the big bottom margin on every form factor. List view (no
.node-map-stage in the DOM) is untouched, and browsers without :has()
gracefully fall back to the old scrolling behaviour via the stage's
min-height.
========================================================================= */
.dashboard-scroll-panel:has(.node-map-stage) {
display: flex;
flex-direction: column;
/* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
padding-bottom: 1rem;
}
/* The routed view stretches; DashboardRouterView tags it .view-container
(with Tailwind's flex-none, which this outranks on specificity). */
.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* The wrapper's bottom scroll spacer is dead weight in a filled column */
.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
display: none;
}
/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
not under it the bar is viewport-fixed and would cover the map. */
@media (max-width: 920px) {
.dashboard-scroll-panel:has(.node-map-stage) {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
}
}
/* Pages with the floating mobile back button (.mobile-scroll-pad-back) keep
its full clearance under the filled map so the stage never slides beneath
the button. */
@media (max-width: 920px) {
.dashboard-scroll-panel.mobile-scroll-pad-back:has(.node-map-stage) {
padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 64px);
}
}
+71
View File
@@ -0,0 +1,71 @@
/**
* Design-system-aware GSAP setup the single place animation code pulls
* timing, easing, and colour tokens from, so every GSAP-driven surface moves
* (and is coloured) like the rest of the glass UI instead of inventing its
* own physics per component.
*
* Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
* never `import gsap from 'gsap'` directly, or the shared defaults are lost.
*/
import { gsap } from 'gsap'
/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
* dark-only (style.css pins `color-scheme: dark`), so these are constants,
* not theme-dependent lookups. */
export const motionTokens = {
color: {
/** Brand accent the orange used for focus glows and highlights
* (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
accent: '#fb923c',
/** Trust-level palette — matches NodeList / trust badges. */
trusted: '#4ade80',
observer: '#fb923c',
untrusted: '#ef4444',
neutral: '#9ca3af',
/** Pending/attention — inbound peer requests awaiting a decision. */
pending: '#facc15',
/** Text/line opacities on the dark glass ground. */
textPrimary: 'rgba(255, 255, 255, 0.95)',
textSecondary: 'rgba(255, 255, 255, 0.7)',
textFaint: 'rgba(255, 255, 255, 0.45)',
line: 'rgba(255, 255, 255, 0.18)',
lineFaint: 'rgba(255, 255, 255, 0.08)',
glassDark: 'rgba(0, 0, 0, 0.35)',
glassDarker: 'rgba(0, 0, 0, 0.6)',
},
/** Durations (seconds) align with the CSS transitions already shipped
* (modal 0.3s, press feedback 0.1s). */
duration: {
fast: 0.18,
base: 0.3,
slow: 0.6,
/** Scene-setting intros (map fly-in, hero moments). */
cinematic: 1.4,
},
ease: {
/** Default UI ease — matches the snappy glass feel. */
out: 'power3.out',
inOut: 'power2.inOut',
/** Playful overshoot for elements "arriving" (node pop-ins). */
arrive: 'back.out(1.6)',
/** Springy attention pulse. */
pulse: 'sine.inOut',
},
} as const
// Shared defaults: any tween that doesn't say otherwise moves like the rest
// of the design system.
gsap.defaults({
ease: motionTokens.ease.out,
duration: motionTokens.duration.base,
})
/** Live reduced-motion check. Query at animation-build time (not module
* scope) so OS-level toggles apply without a reload. Callers should skip
* intros / idle loops and jump to end state when this is true. */
export function prefersReducedMotion(): boolean {
return typeof window !== 'undefined'
&& window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
}
export { gsap }
+55 -7
View File
@@ -1,5 +1,8 @@
<template>
<div class="pb-6">
<!-- Map view: no pb-6 the .dashboard-scroll-panel:has(.node-map-stage)
rules turn this view into a column that hands remaining height to the
map, so bottom padding would just re-create the dead margin. -->
<div :class="mapActive ? undefined : 'pb-6'">
<FederationHeader
:self-did="selfDid"
:server-name="appStore.serverName"
@@ -16,7 +19,9 @@
/>
<!-- View Tabs (same style as Home Dashboard/Setup tabs; full-width on mobile) -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto">
<!-- md:self-start: in map view the root is a flex column, and stretch
alignment would otherwise pull the pill full-width on desktop -->
<div v-if="nodes.length > 0" role="tablist" class="mode-switcher mb-6 w-full md:w-auto md:self-start">
<button
v-for="tab in viewTabs"
:key="tab.id"
@@ -30,9 +35,25 @@
</button>
</div>
<!-- Network Map View -->
<div v-if="activeView === 'map' && nodes.length > 0" class="mb-6">
<NetworkMap :nodes="mapNodes" :links="mapLinks" />
<!-- Mobile DID card: below the tabs per UX; hidden on the map tab where
vertical space belongs to the map (desktop keeps the header card) -->
<DidCardMobile
v-if="!mapActive"
:self-did="selfDid"
:server-name="appStore.serverName"
@rotate="showRotateModal = true"
/>
<!-- Network Map View fills all remaining height to the bottom edge -->
<div v-if="mapActive" class="flex-1 min-h-0">
<NetworkMap3D
:nodes="mapNodes"
:links="mapLinks"
:requests="mapRequests"
@select="onMapSelect"
@approve="approvePending"
@reject="rejectPending"
/>
</div>
<template v-if="activeView === 'list'">
@@ -243,8 +264,9 @@ import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
import NetworkMap from '@/components/federation/NetworkMap.vue'
import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
import FederationHeader from './federation/FederationHeader.vue'
import DidCardMobile from './federation/DidCardMobile.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue'
import NodeList from './federation/NodeList.vue'
@@ -308,7 +330,22 @@ function setView(id: ViewId) {
localStorage.setItem('federation-view', id)
}
const selfDid = ref('')
const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
/** Map click-through: tapping a peer opens the same detail modal as the list
* view. Tapping the self node is a no-op (its actions live in the header). */
function onMapSelect(did: string) {
const node = nodes.value.find(n => n.did === did)
if (node) selectedNode.value = node
}
/** Seeded from the cached DID so the map's centre node (and its links) exist
* on the very first frame; the authoritative fetch in onMounted refreshes it
* and re-caches. Without this the intro raced the RPC and often played with
* no centre. */
const selfDid = ref<string>((() => {
try { return localStorage.getItem('neode_did') || '' } catch { return '' }
})())
const mapNodes = computed(() => {
const result = []
@@ -343,6 +380,16 @@ const mapLinks = computed(() => {
}))
})
/** Inbound pending requests for the map blinking yellow nodes the user can
* accept/reject in place (same RPCs as the pending panel). */
const mapRequests = computed(() => pendingRequests.value
.filter(r => !r.outbound && r.state === 'pending')
.map(r => ({
id: r.id,
label: r.from_name || `${r.from_nostr_npub.slice(0, 12)}`,
message: r.message,
})))
const dwnStatusRes = useCachedResource<DwnStatus>({
key: 'federation.dwn-status',
fetcher: (signal) => rpcClient.call<DwnStatus>({ method: 'dwn.status', signal, dedup: true, maxRetries: 1 }),
@@ -778,6 +825,7 @@ onMounted(async () => {
try {
const result = await rpcClient.getNodeDid()
selfDid.value = result.did
try { localStorage.setItem('neode_did', result.did) } catch { /* private mode */ }
} catch {
// Self DID not available
}
+13 -3
View File
@@ -259,9 +259,11 @@ async function generateSeed() {
loading.value = false
waitingForServer.value = false
} catch (err) {
loading.value = false
if (isServerStartingError(err)) {
// Backend not ready yet keep waiting, retry silently.
// Backend not ready yet keep waiting, retry silently. `loading` stays
// true through the whole retry loop: dropping it here unmounts the lock
// icon and status text for the 4s between attempts, which reads as the
// screen flashing in and out (reported on a live install test).
if (!waitingForServer.value) {
waitingForServer.value = true
startElapsedTimer()
@@ -270,8 +272,16 @@ async function generateSeed() {
} else {
// Genuine failure stop the silent loop and surface it with a manual retry.
stopTimers()
loading.value = false
waitingForServer.value = false
errorMessage.value = err instanceof Error ? err.message : 'Failed to generate seed'
const raw = err instanceof Error ? err.message : 'Failed to generate seed'
// The backend's provisioned-guard refusal is precise but written for
// developers ("authenticated system.factory-reset"). Operators hit it
// when a node that already has an identity lands on this screen
// translate it into what they can actually do about it.
errorMessage.value = raw.startsWith('Not supported: this node is already provisioned')
? 'This node already has an identity, so a new seed cannot be created. Sign in normally — or to start this node over, run a factory reset from Settings first.'
: raw
}
}
}
@@ -6,7 +6,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to
// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak).
@@ -0,0 +1,39 @@
<template>
<!-- Mobile-only DID copy/rotate card. Lives BELOW the view tabs in
Federation.vue (not in the header) and is hidden by the parent on the
Network Map tab, where vertical space belongs to the map. -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mb-6 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue'
import { shortDid } from './utils'
import { safeClipboardWrite } from '../web5/utils'
const props = defineProps<{
selfDid: string
serverName: string
}>()
defineEmits<{
rotate: []
}>()
const didCopied = ref(false)
const shortDidDisplay = computed(() => shortDid(props.selfDid))
function handleCopy() {
if (props.selfDid) {
safeClipboardWrite(props.selfDid)
didCopied.value = true
setTimeout(() => { didCopied.value = false }, 2000)
}
}
</script>
@@ -18,15 +18,8 @@
</div>
</div>
</div>
<!-- Mobile: DID below title -->
<div v-if="selfDid" class="md:hidden glass-card px-4 py-3 mt-3 flex items-center gap-3">
<div class="min-w-0 flex-1">
<p class="text-[10px] text-white/40 mb-0.5">{{ serverName }}</p>
<p class="text-xs text-white/80 font-mono truncate cursor-pointer" :title="selfDid" @click="handleCopy">{{ didCopied ? 'Copied!' : shortDidDisplay }}</p>
</div>
<button @click="handleCopy" class="glass-button px-2.5 py-1 rounded text-[10px]">{{ didCopied ? 'Copied!' : 'Copy' }}</button>
<button @click="$emit('rotate')" class="glass-button px-2.5 py-1 rounded text-[10px] text-orange-300">Rotate</button>
</div>
<!-- Mobile DID card moved to DidCardMobile.vue, rendered by
Federation.vue below the view tabs (hidden on the map tab). -->
</div>
</template>
@@ -362,6 +362,36 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.129-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.129-alpha</span>
<span class="text-xs text-white/40">August 10, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Every app is now supervised the same way the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.</p>
<p>**Leftover companion screens are cleaned up again driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed it had been guessing "installed" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — "I couldn't check" is never treated as "nothing is installed" and a helper must be orphaned for a sustained period before it is touched.</p>
<p>**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.</p>
<p>**The federation map became a real map.** The network view is now a 3D orbital scene of your federation nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro the scene updates in place.</p>
<p>**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle).</p>
</div>
</div>
<!-- v1.7.128-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.7.128-alpha</span>
<span class="text-xs text-white/40">August 10, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**The discovery list stops showing ghosts.** Every reinstall of a node mints a new discovery identity, and the old identity's announcement could never be removed from the public relays nothing holds its key anymore so the "Discoverable nodes" list slowly filled with entries that led nowhere. Announcements now expire: your node re-announces itself twice a day, each announcement carries a 48-hour expiry that relays honour, anything older than that is ignored when reading, and switching discovery off or factory-resetting the node actively overwrites the announcement before it can become a ghost. Old ghosts from earlier versions stop being shown immediately and age off the relays on their own.</p>
<p>**You can name your node when you make it discoverable.** Turning discovery on now asks for an optional display name it travels inside the public announcement, so other nodes' discovery lists show "Dorian's basement node" instead of a bare npub. The name is public by construction, capped at 32 characters, and blank is fine: you list as npub only. Toggling discovery off and on remembers the name; you can clear it the same way you set it.</p>
<p>**The discoverability panel now shows what the network actually sees: your node's npub.** It previously showed your Tor address which is precisely the thing the announcement never contains (your address stays private until you approve a peer). The npub, the identity other nodes discover you by and send peering requests to, is now displayed there with a copy button.</p>
<p>**The seed screen stops flashing while the node starts.** During first boot, the lock icon and "server starting" text blinked in and out every few seconds while the node came up each silent retry briefly emptied the screen. The waiting state now holds steady, with its elapsed timer, until the node answers.</p>
<p>**A node that already has an identity now explains itself on the seed screen.** Reaching seed creation on a provisioned node used to surface a developer message about "the authenticated system.factory-reset". It now says what you can actually do: sign in normally, or factory-reset the node from Settings to start it over.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the changes were verified by operator UAT on a live node.</p>
</div>
</div>
<!-- v1.7.127-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+91 -15
View File
@@ -49,14 +49,18 @@
/>
</div>
<!-- Onion address (shown when public) -->
<div v-if="discoverEnabled && nodeOnionAddress" class="mt-4 p-3 bg-white/5 rounded-lg">
<!-- The node's published npub (shown when discoverable) this, not the
onion, is what the presence event actually posts to the relays -->
<div v-if="discoverEnabled && nodeNpub" class="mt-4 p-3 bg-white/5 rounded-lg">
<div class="flex items-center justify-between gap-2">
<div class="min-w-0">
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourTorAddress') }}</p>
<p class="text-xs font-mono text-white/80 truncate" :title="nodeOnionAddress">{{ nodeOnionAddress }}</p>
<p class="text-xs text-white/50 mb-1">{{ t('web5.yourNodeNpub') }}</p>
<p v-if="nodeName" class="text-sm text-white/90 truncate mb-0.5">{{ nodeName }}</p>
<!-- Middle-ellipsis, never CSS truncate: the tail is the part a
human compares against another listing, so it must stay visible -->
<p class="text-xs font-mono text-white/80 truncate" :title="nodeNpub">{{ midNpub(nodeNpub) }}</p>
</div>
<button @click="copyOnionAddress" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<button @click="copyNpub" class="shrink-0 p-2 rounded-lg text-white/50 hover:text-white hover:bg-white/10 transition-colors" title="Copy">
<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="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
@@ -82,7 +86,8 @@
class="p-3 bg-white/5 rounded-lg border border-white/10 flex items-start justify-between gap-3"
>
<div class="min-w-0 flex-1">
<div class="text-sm text-white truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-sm text-white truncate">{{ node.name || shortNpub(node.nostr_npub) }}</div>
<div v-if="node.name" class="text-[11px] text-white/50 font-mono truncate">{{ shortNpub(node.nostr_npub) }}</div>
<div class="text-[11px] text-white/40 font-mono truncate">{{ node.did }}</div>
<div class="text-[10px] text-white/30 mt-1">version {{ node.version || '?' }}</div>
</div>
@@ -114,6 +119,32 @@
@send="confirmPeerRequest"
@cancel="requestModalTarget = null"
/>
<!-- Name prompt on the way to discoverable: the announcement is public,
so the name travels with it. Blank is fine npub-only listing. -->
<Teleport to="body">
<Transition name="modal">
<div v-if="showNameModal" class="fixed inset-0 z-[3000] flex items-center justify-center p-4" @click.self="cancelNameModal">
<div class="absolute inset-0 bg-black/60 backdrop-blur-sm"></div>
<div class="glass-card p-6 max-w-md w-full relative z-10">
<h3 class="text-lg font-semibold text-white mb-2">Name your node</h3>
<p class="text-sm text-white/60 mb-4">Other nodes will see this name next to your npub in their discovery list. It's public. Leave blank to list as npub only.</p>
<input
v-model="nameInput"
type="text"
maxlength="32"
placeholder="e.g. Dorian's basement node"
class="w-full bg-black/30 border border-white/10 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 focus:outline-none focus:border-orange-500/50 mb-4"
@keyup.enter="confirmNameModal"
/>
<div class="flex gap-3">
<button @click="cancelNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm">Cancel</button>
<button @click="confirmNameModal" class="flex-1 glass-button px-4 py-2 rounded-lg text-sm font-medium bg-orange-500/20 border-orange-500/30">Turn on discovery</button>
</div>
</div>
</div>
</Transition>
</Teleport>
</div>
</template>
@@ -137,16 +168,22 @@ const emit = defineEmits<{
}>()
const nodeVisibility = ref<VisibilityLevel>('hidden')
const nodeOnionAddress = ref<string | null>(null)
const nodeNpub = ref<string | null>(null)
const nodeName = ref<string | null>(null)
const visibilityLoading = ref(false)
const settingVisibility = ref(false)
const discoverEnabled = ref(false)
// Name-prompt state: turning discovery ON routes through a small dialog so
// the operator can (optionally) name the node before it announces itself.
const showNameModal = ref(false)
const nameInput = ref('')
interface DiscoverableNode {
nostr_pubkey: string
nostr_npub: string
did: string
version: string
name?: string | null
}
const discoveredNodes = ref<DiscoverableNode[]>([])
@@ -154,6 +191,12 @@ const discovering = ref(false)
const requestingPeer = ref<string | null>(null)
const requestedPeers = ref(new Set<string>())
/** Own-npub display: keep the start and the FULL tail visible, ellipsis in
* the middle. (shortNpub below stays as-is it formats the discovered list.) */
function midNpub(npub: string): string {
return npub.length > 24 ? `${npub.slice(0, 12)}${npub.slice(-10)}` : npub
}
function shortNpub(npub: string): string {
if (!npub) return 'unknown'
return npub.length > 21 ? `${npub.slice(0, 12)}${npub.slice(-6)}` : npub
@@ -173,8 +216,9 @@ async function loadVisibility() {
.catch(() => null),
])
discoverEnabled.value = !!disc.enabled
nodeNpub.value = disc.npub || null
nodeName.value = disc.name || null
nodeVisibility.value = (vis?.visibility as VisibilityLevel) || 'hidden'
nodeOnionAddress.value = vis?.onion_address || vis?.tor_address || null
if (discoverEnabled.value) void discoverNodes()
} catch {
discoverEnabled.value = false
@@ -185,10 +229,33 @@ async function loadVisibility() {
async function toggleDiscoverable(enabled: boolean) {
if (settingVisibility.value) return
if (enabled) {
// Turning ON goes through the name dialog: the node is about to announce
// itself publicly, and this is the natural moment to (optionally) name it.
nameInput.value = nodeName.value || ''
showNameModal.value = true
return
}
await applyDiscovery(false)
}
function cancelNameModal() {
showNameModal.value = false
// The switch never actually flipped server-side; snap the UI back.
discoverEnabled.value = false
}
async function confirmNameModal() {
showNameModal.value = false
// Send exactly what's in the box: text sets the name, blank clears it.
await applyDiscovery(true, nameInput.value.trim())
}
async function applyDiscovery(enabled: boolean, name?: string) {
settingVisibility.value = true
try {
// Public means public: the switch drives nostr presence publishing.
const res = await rpcClient.nostrSetDiscovery(enabled)
const res = await rpcClient.nostrSetDiscovery(enabled, name)
discoverEnabled.value = !!res.enabled
// Keep the legacy visibility string in sync (cosmetic; best-effort).
const level: VisibilityLevel = enabled ? 'public' : 'hidden'
@@ -197,8 +264,17 @@ async function toggleDiscoverable(enabled: boolean) {
.then(() => { nodeVisibility.value = level })
.catch(() => {})
emit('toast', enabled ? 'Node is now publicly discoverable' : 'Node hidden from discovery')
if (enabled) void discoverNodes()
else discoveredNodes.value = []
if (enabled) {
if (name !== undefined) nodeName.value = name || null
// Re-read status so the npub/name shown reflect post-enable state
// without a page reload.
rpcClient.nostrDiscoveryStatus()
.then((s) => { nodeNpub.value = s.npub || null; nodeName.value = s.name || null })
.catch(() => {})
void discoverNodes()
} else {
discoveredNodes.value = []
}
} catch {
emit('toast', t('web5.failedToUpdateVisibility'))
} finally {
@@ -245,10 +321,10 @@ async function requestToPeer(node: DiscoverableNode, message?: string) {
}
}
function copyOnionAddress() {
if (!nodeOnionAddress.value) return
safeClipboardWrite(nodeOnionAddress.value)
emit('toast', t('web5.onionAddressCopied'))
function copyNpub() {
if (!nodeNpub.value) return
safeClipboardWrite(nodeNpub.value)
emit('toast', t('web5.npubCopied'))
}
defineExpose({ loadVisibility })
+20 -23
View File
@@ -1,35 +1,32 @@
{
"changelog": [
"**Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.",
"**Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said \"Connected\" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it.",
"**Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it.",
"**The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again.",
"**Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions.",
"**Your Bitcoin node will not silently change software versions anymore.** \"Latest\" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update.",
"**Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment.",
"**You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.",
"**Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap."
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
],
"components": [
{
"current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago",
"current_version": "1.7.129-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.127-alpha",
"sha256": "19c5f4573e49ba5a1339a358f5d422da4c3dbf68fba3391a62588049a52da207",
"size_bytes": 59282264
"new_version": "1.7.129-alpha",
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
"size_bytes": 59531400
},
{
"current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago-frontend-1.7.127-alpha.tar.gz",
"name": "archipelago-frontend-1.7.127-alpha.tar.gz",
"new_version": "1.7.127-alpha",
"sha256": "bedd662105e53ce800caa610cc099a47d7f0786af5fec601169a8906af760244",
"size_bytes": 95433702
"current_version": "1.7.129-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
"new_version": "1.7.129-alpha",
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
"size_bytes": 95452033
}
],
"release_date": "2026-08-09",
"signature": "dc418fc08b2b0e288ab0f4b307562d966774d8b5ee73789229d6bef627f61013510054a89d8e314676464a55bcb631e1d1a63e0de394b2d152abd42c90f4120f",
"release_date": "2026-08-10",
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.127-alpha"
"version": "1.7.129-alpha"
}
+20 -23
View File
@@ -1,35 +1,32 @@
{
"changelog": [
"**Your node now has its own assistant.** This is the first release to ship AIUI: a conversational screen that can answer from your node's own content — your films, music and files come first, the open web second — and can act on the node itself: install or remove an app, check what's running, or queue up your media, all through a fixed list of vetted actions rather than free rein. It is off-limits to your data until you say otherwise: every data category starts closed, grants are made in Settings → AI Data Access and live on the node itself, and anything that changes the node asks you to confirm in the dashboard's own chrome first — a declined action stays declined. What leaves the node is screened: your API key is stored encrypted and never written in plain text, credential-shaped strings are scrubbed from app logs before the model sees them, your public address and Wi-Fi name are stripped from network answers, web search is gated behind your login session, and cloud-bound text passes a secret scan on the way out. Three model backends are supported — Anthropic's API, a local Ollama, and pay-per-use Routstr with a hard prepaid budget ceiling — and mesh peers can reach the same loop with `!ai`.",
"**Tor now tells you the truth, heals itself, and the Restart button really restarts it.** Three nodes ran for days with Tor completely dead while the dashboard said \"Connected\" — the indicator was reading a leftover address file, not the daemon, and the restart button reported success without checking. The cause was a configuration line Tor can never bind on our systems; a node could re-break itself from a single settings change. The node now refuses to write that line, checks Tor with a real connection instead of a leftover file, repairs its own Tor configuration at every start, and the Restart button only claims success once Tor is actually answering. Onion addresses that had silently never been published (BTCPay's included) come back with it.",
"**Inviting another node as Trusted works again — on every node.** Generating a Trusted invite, or promoting a peer from the dropdown, silently failed everywhere: the security prompt that asks for your node password could never appear, because the message requesting it was being scrubbed out of the reply on its way to your browser. The prompt now opens, and if a trust change fails, the error appears inside the window you are looking at instead of hidden behind it.",
"**The mempool explorer actually connects now.** The page loaded but sat empty forever. Three separate causes stacked up: the block index had spent days rebuilding without anything saying so, and then two different layers of the node's plumbing were dropping the live-data connection the page depends on — so everything reported healthy while your screen showed nothing. All three are fixed, and the node's own health checks now test the real connection a browser makes, so this cannot pass unnoticed again.",
"**Apps no longer vanish after stopping cleanly.** A stopped app's container is deleted by design, but the restart policy meant an app that exited cleanly was never brought back — it simply disappeared until reinstalled. Backends now restart in every case, the node remembers what you have installed so a missing app is recreated rather than forgotten, and this release repairs the incorrect policy on apps installed by earlier versions.",
"**Your Bitcoin node will not silently change software versions anymore.** \"Latest\" previously meant different things in different places — one path installed a newer build that deliberately halts until you make a network-rules decision, which froze one node's sync at a fixed block while it reported itself fully synced. Bitcoin Knots is now pinned to an explicit, known-good version; changing it is a decision you make, never a side effect of an update.",
"**Smaller fixes:** the AI data-access settings now say plainly which categories the assistant can see but not act on; the transactions window's tab bar is transparent glass instead of a black block; BTCPay logins no longer fail with a server error when the node is under heavy load right at that moment.",
"**You can now replace your Lightning connection keys from Settings, without touching a terminal.** The tokens wallet apps like Zeus use to reach your node are bearer keys: anything that has ever seen one can spend from your node until they are replaced, and there is no way to cancel one individually. Replacing them was previously a script you had to SSH in and run, which in practice meant it never happened. Settings → Lightning credentials now shows when yours were issued, which node they belong to and how many channels must survive, then does the whole job behind your node password — with a step-by-step progress list, and a refusal to call it a success unless it has confirmed your node identity and every channel came back. Your coins and channels are not touched: nothing is closed, and the wallet is never re-created. Afterwards you re-pair Zeus by scanning the Lightning app's QR code again.",
"**Replacing those keys no longer silently breaks BTCPay Server.** BTCPay holds its own copy of the key, and that copy cannot repair itself — so a node that replaced its keys ended up with BTCPay running, healthy, and unable to take a single Lightning payment, with nothing anywhere saying why. The dashboard now updates BTCPay's copy as part of the run and restarts it around its existing data, and the Settings screen warns you if it finds a node already stuck in that state. The command-line script fixes the same gap."
"**Every app is now supervised the same way — the last stragglers moved under systemd.** Five apps (Jellyfin, Nextcloud, Home Assistant, Uptime Kuma, Vaultwarden) still ran outside the node's per-app service management for a technical reason: their networking style died with whatever process started it, so they were kept alive by a separate workaround. That workaround is retired: these apps now migrate themselves onto the same managed units as everything else — own service, restart-on-anything, a ten-second breather between restarts so their networking can release its ports cleanly. The migration happens automatically on the node's next housekeeping pass, touches no app data, and was watched live on a real node: both test apps moved over on the first pass and came back healthy.",
"**Leftover companion screens are cleaned up again — driven by real records this time.** When an app is uninstalled, its helper screen (the UI tile that fronts it) should go too. That cleanup was switched off in an earlier release after it wrongly removed the Bitcoin screen from a node whose Bitcoin was installed — it had been guessing \"installed\" from what happened to be running, and a separate bug made a running app look absent. The node now keeps a durable record of what you have installed, written at install time and cleared only by a real uninstall, and the cleanup consults only that record. If the record can't be read, the cleanup does nothing at all — \"I couldn't check\" is never treated as \"nothing is installed\" — and a helper must be orphaned for a sustained period before it is touched.",
"**A warning that fired every minute on every node is gone.** The app catalog and the node disagreed about where Grafana's software comes from, so the node ignored the catalog's answer and logged a complaint roughly every 75 seconds, forever. The catalog was right — Grafana is served from the fleet's own registry, like Bitcoin Knots — and the node's records now agree with it.",
"**The federation map became a real map.** The network view is now a 3D orbital scene of your federation — nodes as a point-cloud globe with calm motion, auto-fit centring, and a 2D top-down toggle that portrait and mobile screens use by default, with the scene filling the viewport instead of sitting in a letterbox. Inbound peer requests appear live on the map as blinking nodes you can accept or reject in place, and revisiting the view no longer replays the whole intro — the scene updates in place.",
"**An app that's mid-restart shows a page that says so — and comes back by itself.** When an app's screen was briefly unreachable behind the gate, the browser got a bare error; it now gets a named page for that app that retries on its own until the app answers.",
"Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release; the supervision migration and the cleanup re-enable were verified live on one node (both apps migrated and healthy, cleanup correctly idle)."
],
"components": [
{
"current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago",
"current_version": "1.7.129-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago",
"name": "archipelago",
"new_version": "1.7.127-alpha",
"sha256": "19c5f4573e49ba5a1339a358f5d422da4c3dbf68fba3391a62588049a52da207",
"size_bytes": 59282264
"new_version": "1.7.129-alpha",
"sha256": "675e7dafc855d59b38c5a12d8b9405894677ed8701580227beca95ec2912e3f2",
"size_bytes": 59531400
},
{
"current_version": "1.7.127-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.127-alpha/archipelago-frontend-1.7.127-alpha.tar.gz",
"name": "archipelago-frontend-1.7.127-alpha.tar.gz",
"new_version": "1.7.127-alpha",
"sha256": "bedd662105e53ce800caa610cc099a47d7f0786af5fec601169a8906af760244",
"size_bytes": 95433702
"current_version": "1.7.129-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.7.129-alpha/archipelago-frontend-1.7.129-alpha.tar.gz",
"name": "archipelago-frontend-1.7.129-alpha.tar.gz",
"new_version": "1.7.129-alpha",
"sha256": "53af2743308f4ae6a627255aaa288706d331d567c99e1cb615684dc3abba534d",
"size_bytes": 95452033
}
],
"release_date": "2026-08-09",
"signature": "dc418fc08b2b0e288ab0f4b307562d966774d8b5ee73789229d6bef627f61013510054a89d8e314676464a55bcb631e1d1a63e0de394b2d152abd42c90f4120f",
"release_date": "2026-08-10",
"signature": "902778710674486d5919e4abb1bf5540521c9ef55b50a44a9d64b750812738d51cc193f6675a9969b1c45ae86c2e9e44ab3d8daf8aa1d439799d7f7846e27d04",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.7.127-alpha"
"version": "1.7.129-alpha"
}