Commit Graph
34 Commits
Author SHA1 Message Date
archipelagoandClaude Opus 5 fa6fe32ef9 feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s
An unloaded balance rendered as `0`. Zero is not a loading state — it is
a number, and it is the one number that frightens people. Someone
opening the dashboard while the RPCs were still in flight was told, in
the wallet's own typeface, that their money was gone.

There is no formatting fix for that. The fix is to stop claiming a
figure we do not have, so `null` now means "not known yet" and `0` means
"none", and the two are kept apart end to end: the refs start at null,
a rail becomes a number only when its call actually succeeds, and a
snapshot key that was never written stays unknown instead of becoming a
zero.

In place of the figure, a small dot-matrix scans in the rail's own
colour. It inherits currentColor, so on-chain shimmers orange, Lightning
yellow, Cashu purple, Fedimint blue and Ark teal with no colour table to
keep in sync — and it is sized to the figure it stands in for, so
nothing jumps when the real number lands. It carries role="status" and
names what it is waiting for; a shimmering box with no text is nothing
at all to a screen reader.

Two consequences worth stating. The total is withheld until every rail
that makes it up is known — summing nulls as zero would show a total
*lower* than the rails beneath it, which is worse than showing nothing
because it looks authoritative. And the Ark row stays hidden while its
balance is unknown, since "unknown" must not be read as "> 0" on the
many nodes with no Ark sidecar.

The LND app UI had the same bug in a different shape: its tiles start as
an em-dash, but renderBalances() runs on every poll including before the
first response, and `num(null && …)` is 0 — so the dashes were painted
over with "0 sats" almost immediately. Same treatment, in plain CSS.

Also fixes a stale assertion in AppHeroSection's suite, which has been
red since 9ccc325a changed "Restarting..." to a real ellipsis; and two
test proofs that used a plausible-looking hex string for `C`. The V3
codec never parses that field so it went unnoticed, but the V4 encoder
hands it to the reference implementation, which checks the point is
actually on secp256k1. Real curve points now.

Frontend: 996 tests green. Backend: 1436 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:54:12 -04:00
archipelagoandClaude Opus 5 e30516316b fix(ecash): give every node a backup phrase, and prove restore works
Demo images / Build & push demo images (push) Failing after 2m11s
Running the route suite on this box surfaced that the backup was
unreachable here: `identity/master_seed.enc` is written during
onboarding, and any node onboarded before that step existed simply does
not have one. Reveal bailed with "this node has no encrypted seed
backup", and restore followed it down.

But the choice on such a node was never "derived phrase or independent
phrase" — it was "independent phrase or no backup at all", and a wallet
whose coins can be restored from words the operator holds beats one
whose coins die with a single file. So it now generates one, recorded as
`independent`, and every surface that shows it says plainly that
restoring the node will not bring the ecash back — only these words
will. `derivable_from_node_seed` lets the card say which kind you are
about to get *before* you write anything down.

Also: a mint that never implemented NUT-09 answered restore with a bare
404, which surfaced as "mint returned 404 with no further detail" —
true, and useless to someone trying to get their coins back. It now
names the limitation.

The route suite was reading `result.amount_sats` from mint-claim, which
answers with `minted_sats`. A working claim had been reporting as a
failure; that was one of the two reds carried over from yesterday.

The real gap, though, was that "recovered 0 sats" passes on a wallet
with nothing to find — exactly the shape of a backup that looks fine
until the day you need it. test-ecash-restore.sh does the test that
settles it: mint, **delete the wallet file**, restore, check the coins
came back. On this box: 87 sats before the wipe, 0 after, 61 recovered
from the phrase alone — every coin minted since the phrase existed, and
none of the 26 sats minted before it, which used random secrets and
never could come back. Testnet only, and it refuses to run otherwise.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:27:01 -04:00
archipelagoandClaude Opus 5 59fffc809f feat(ecash): the wallet can now be restored from a phrase (NUT-13)
Demo images / Build & push demo images (push) Failing after 2m15s
Until now every Cashu proof this node held was backed by a secret drawn
from OsRng and written to exactly one file. Losing wallet/ecash.json
lost the coins outright — no phrase to write down, and nothing the mint
could do about it. Ecash is a bearer instrument, so "one file, no
backup" was the sharpest edge in the wallet.

NUT-13 derives each proof's secret and blinding factor from (seed,
keyset id, counter) instead. The wallet becomes a phrase, and the coins
can be re-derived and re-claimed — here or in any other NUT-13 wallet.

The phrase is its own 24 words, derived from the node master seed over a
fixed HKDF path. Both halves matter: it is still covered by the node's
recovery phrase, so there is nothing extra to write down; but it is
portable, so restoring ecash into Minibits or cdk-cli does not mean
handing over the key to the entire node.

It sits on disk unencrypted, deliberately. The master seed needs the
operator's password to open, which no background mint or swap can ask
for; and this file lives beside wallet/ecash.json, which already holds
spendable bearer secrets in plaintext. It regenerates exactly those
secrets, so it is the same sensitivity class as the file next to it.
0600, like identity/nostr_secret, which is derived and persisted the
same way.

Counters are reserved *before* the mint call and never rolled back. A
gap costs a restore scan a few extra probes; a reused counter costs a
coin, because two proofs with the same secret can only be spent once.

Restore is the half that cannot be done offline: a re-derived secret is
not money until the mint's signature over it exists. /v1/restore returns
those signatures; unblinding reconstitutes the proofs. It is additive
and idempotent — coins already held are skipped by secret, spent ones
are counted but not added — so it is safe to press on a working wallet,
which is when someone is most likely to reach for it.

Existing nodes activate on the first visit to Settings → Ecash backup
phrase: that password prompt is the only moment the master seed can
legitimately be opened. New nodes get it at onboarding. Until then the
behaviour is exactly as before — valid proofs, no backup — and the card
says so rather than implying a backup already exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:34 -04:00
archipelagoandClaude Opus 5 579287ba48 feat(ecash): emit cashuB tokens, and share one payment success screen
Most wallets — Minibits, Nutstash, cdk-cli — default to reading cashuB
(V4) now, so that is what we send. cashuA stays as the fallback rather
than the default: it is still valid everywhere, so a token this wallet
cannot express in V4 (a multi-mint one) is worth sending in V3 rather
than failing the send outright. That path warns, because by the time
`send_token_at` serializes, the proofs are already marked spent.

The V4 encoder is the reference implementation's, not ours. The envelope
puts the keyset id and signature on the wire as raw CBOR bytes under
single-letter keys, and a token subtly wrong there is money the receiver
cannot redeem — so upstream owns the encoding, the way it already owns
keyset-id resolution. Our own hand-written decoder reads what upstream
writes in the new test, which is agreement between two independent
implementations rather than a round trip through one codec.

Two refusals are deliberate and tested: a multi-mint token has no V4
form, and a truncated v2 keyset id must never be baked into a token we
emit (the framework-pt case) — it is only resolvable against the mint's
keyset list.

Also folds SendBitcoinModal onto the shared PaymentSuccessPane it had a
private copy of, so on-chain, Lightning and ecash all show the same
screen and the copyable-identifier row is defined once.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 07:56:15 -04:00
archipelagoandClaude Fable 5 f4a1c47429 feat(wallet): ecash gets the real payment success screen, with copyable proof
Demo images / Build & push demo images (push) Failing after 2m37s
Redeeming ecash reported success as one line of small green text, while an
on-chain or Lightning payment got the full moment — amount, verb, and the
identifiers you can copy. That asymmetry matters most for ecash: it leaves
no public ledger entry, so if the payment is ever questioned there is
nothing to look up afterwards. Whatever isn't copyable at that instant is
simply gone.

The success pane is extracted from SendBitcoinModal into a shared
PaymentSuccessPane so Cashu and Fedimint show the *same* screen rather
than a lookalike, and the copyable-row treatment is defined once. Each
caller passes the identifiers its protocol actually has; ecash receive now
shows the issuing mint (newly returned by wallet.ecash-receive) and the
redeemed token itself, clamped so a long token doesn't flood the pane.

Also: the test-ecash switch is a proper toggle (role="switch", keyboard
focusable) rather than a checkbox — it selects which purse the wallet is
looking at, so it should read as a mode you are in.

SendBitcoinModal still carries its own copy of the markup; consolidating it
onto the shared component is a follow-up, deliberately not done in the same
change as the money-path wiring.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:08:15 -04:00
archipelagoandClaude Fable 5 ffec7d3114 fix(ecash): pay the mint's input fee, and stop a damaged wallet from being erased
Two independent fixes, both found while exercising the routes headlessly.

**Mint fees (NUT-02).** A mint may charge a per-input fee and rejects any
swap whose outputs don't equal inputs minus that fee — `11005 Transaction
inputs should equal outputs less fee`, which is what sending hit against
testnut.cashu.space. We ignored the fee entirely, so the wallet could not
spend at ANY fee-charging mint; Minibits charges zero, which is why
production never saw it. `MintKeyset`/`KeysetInfo` now carry
`input_fee_ppk`, `swap_fee_for` computes the NUT-02 sum (rounded up), and
`MintClient::swap` reduces its outputs to cover it — applied there rather
than at each call site so send, receive and cross-mint swaps are all
covered at once. Inputs from a keyset the mint doesn't list contribute no
fee: the mint is the authority, and guessing high would burn the sender's
coins.

**Damaged-wallet erasure.** `load_wallet` used `unwrap_or_default()`, so a
truncated `ecash.json` read as an EMPTY wallet — and because the next
operation saves the wallet back, that empty state was then written over the
only copy of the proofs. A corrupt file became permanent loss. Now a file
that exists but doesn't parse fails with a message naming the file and
stating the coins are still in it, and the bytes are left untouched for
recovery; an empty file is still treated as a fresh wallet, since a create
that never got its first write is not damage. The accepted-mints list gets
the same treatment, where corruption would have silently reset the operator
to trusting only the default mint.

Writes are now atomic (temp + fsync + rename) for both files. The previous
plain write truncated the real file first, which is exactly how a wallet
ends up unparseable after a crash or power cut.

Tests cover: a damaged file errors and survives on disk, an empty file is
fresh, saving leaves no temp behind and round-trips, and — guarding the
on-disk contract against exactly this update — a verbatim pre-update wallet
file still loads with its balance, proofs and history intact.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 07:01:45 -04:00
archipelagoandClaude Fable 5 26638aa621 fix(ecash): sign with the mint's SAT keyset, not whichever came first
`get_active_sat_keyset` picked the first keyset with a non-empty key map,
and `MintKeyset` had no `unit` field to filter on — so on a multi-unit mint
the wallet signed sat-denominated mint/swap requests against a usd or eur
keyset. The mint refuses that with `11013 Unit unsupported`, which is
exactly what claiming minted coins hit against testnut.cashu.space (it
serves usd, eur, msat and sat keysets). Minibits is sat-only, so this
latent bug never surfaced in production — the test-mint switch found it on
its first run.

MintKeyset now carries `unit` and `active`, both defaulted so a sat-only
mint that omits them still parses, and selection filters to sat and prefers
an active keyset.

Also: pin BIP-39 seed derivation to the specification's own test vectors.
The node's entire identity hangs off `Mnemonic::to_seed("")`, and the
`bip39` crate is no longer version-pinned (the exact pin had to be relaxed
so `cashu` could resolve). A bump that changed derivation would silently
re-key every node on the fleet and orphan every backup; both vectors —
empty passphrase and the NFKD-exercising passphrase arm — now fail the
suite instead. Verified byte-identical under the newly resolved 2.2.2.

And the route script polls the mint's quote state before claiming: the test
mint settles its own invoices, but not instantly, so claiming immediately
raced the settlement and reported a spurious "Quote not paid".

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 06:07:27 -04:00
archipelagoandClaude Fable 5 be2cfb8293 feat(ecash): adopt the reference NUT-02 resolver + real/test network switch
Demo images / Build & push demo images (push) Failing after 2m26s
Executes steps 1-3 of docs/cashu-cdk-migration-plan.md, plus the test-coin
switch needed to exercise these routes without spending real sats.

Protocol layer: depend on `cashu` 0.17.5 (MIT, the crate CDK is built on,
default-features off, `wallet` only). Keyset ids now go through upstream's
`Id::from_short_keyset_id` / `ShortKeysetId` instead of the prefix match
hand-rolled in 2277fc46 — same repair, but implemented by the reference
code that defines the rule, so the next spec turn is a version bump rather
than another incident. `MintClient` feeds it the mint's `/v1/keysets` in
upstream's own `KeySetInfo` shape, parsing entries individually so one
keyset in an unmodelled unit can't block resolving the id we need.

Adding the crate required relaxing `bip39 = "=2.1.0"` to `"2.1"` (resolves
2.2.2): the exact pin held `unicode-normalization` at 0.1.22 and no
resolution existed otherwise. The pin carried no recorded rationale; seed
tests cover the bump.

Network switch: `wallet.ecash-network` / `wallet.ecash-set-network`, with a
Test mode toggle in Wallet Settings → Cashu. Cashu has no testnet, so this
points the wallet at the public `testnut` mint — but crucially each network
gets its OWN wallet and accepted-mints file, because test and real proofs
in one purse would be spendable interchangeably and the balance would be a
lie. Mainnet keeps the original filenames, so existing funds files are
untouched and switching is reversible: tests assert a real balance survives
a round trip through test mode.

Headless coverage: scripts/test-ecash-routes.sh drives every ecash RPC over
the real HTTP path (network get/set, balance, history, mint quote + claim,
send, receive, double-redeem refusal, garbage input, melt quote), restores
the node's original network on exit, and exits non-zero with the failure
count.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 05:04:25 -04:00
archipelagoandClaude Fable 5 2277fc4684 fix(ecash): redeem tokens whose keyset id was truncated to the old length
A Minibits token could not be redeemed on framework-pt: the mint answered
POST /v1/swap with a bare 422, which the RPC sanitizer turned into
"Operation failed. Check server logs for details." The journal had the
real reason:

  inputs[0].id: NUT02: ID length invalid, expected 8 bytes (short/v1)
  or 33 bytes (v2)

The token carried keyset id 01fc0ec0e59cd6fa — exactly the first 8 bytes
of the mint's active 33-byte id 01fc0ec0e59cd6fa01b7a88f…a821. NUT-02 v2
ids are 33 bytes behind a 0x01 version byte; the sending wallet cut it to
the 8 bytes that were the whole id under v1. The mint reads the version,
expects 33 bytes, and rejects it — so the length complaint is right even
though 8 bytes is legal for a 0x00-prefixed v1 id.

The id only names which keyset signed a proof, and the short form is a
prefix of the full one, so it can be repaired: before swapping, any
8-byte 0x01-prefixed id is expanded against GET /v1/keysets (new
MintClient::get_keysets — it lists inactive keysets too, and coins from a
retired keyset stay spendable). Preferring the active keyset on a prefix
tie. Attempting this is safe: an id naming the wrong keyset fails
signature verification at the mint and no coins move. Anything already
valid, or with no unambiguous match, is passed through so the mint's own
error still reaches the operator.

Token decoding now also checks keyset ids locally, so an id that is not
hex or is neither NUT-02 length fails with a message naming the format
instead of a raw 422 from the mint.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-17 04:06:30 -04:00
archipelagoandClaude Fable 5 9ccc325a4d fix(container): reap ghost containers so an app can't be locked out of itself
Demo images / Build & push demo images (push) Successful in 3m24s
A ghost is a container whose process tree is still running while podman
has no record of it: the exit-command's `cleanup --rm` deletes the record,
conmon and the payload survive. It keeps owning exactly what the app needs
— the published host port and the file locks in its data dir — so the
replacement container either fails to bind ("address already in use") or
starts and dies on the lock, and Restart=always loops it there forever.
Nothing in the stack could see it: every podman-level stop/rm/recreate
misses a container podman lost.

Seen twice now: 752 restarts on a fleet node (2026-08-10) and again on the
dev box today, where Gitea flapped until it fell out of My Apps. Both were
cleared by hand; container-doctor.sh has the same logic but is an
out-of-band script the daemon never calls.

- New container::ghost_reaper: finds conmon processes whose 64-hex
  container id is absent from `podman ps -a --no-trunc -q`, then kills the
  payload's children and conmon (TERM, 5s grace, then KILL — the Gitea
  ghost ignored TERM). Id-based, never name-based: killing by name would
  hit the live managed container. A failed `podman ps` reaps nothing
  rather than treating every container as a ghost.
- Hooked at repair_before_package_start (covers package.start,
  package.restart and the orchestrator start path) and in the boot
  reconciler's 30s tick, so ghosts are cleared before an app is asked to
  start and swept for every app continuously.

Restart feedback: the lifecycle RPCs return {"status":"restarting"} in
milliseconds and work in the background, so "Restarting..." flashed for a
few frames and the buttons went idle while the app was still down — the
click read as a no-op. The hero buttons now show a spinner and hold it off
the node's own state (starting/stopping/restarting/updating, plus running
+ health=starting), and the just-clicked action is held until the backend
confirms it picked the work up, with a 12s cap so an unresponsive node
still releases the controls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 13:45:45 -04:00
archipelagoandClaude Fable 5 58cdea5e79 feat(appgate): apps with their own login can skip the node login
Demo images / Build & push demo images (push) Successful in 3m33s
Some apps carry a complete account system and are broken by an upstream
challenge: git clients speak basic-auth (not browser cookies), and a
BTCPay checkout link handed to a customer must open for that customer.
Both were behind the gate's login page — the "non-browser clients need an
access token" gap disclosed in five consecutive releases.

- New manifest port policy `auth: open`: the daemon still fronts the port
  exactly like `gated` (loopback pin, external binds, frame-header fixes,
  app-down retry page, Tor upstream) but serves it without the login
  challenge. Requires auth_rationale, same burden of proof as `none`.
  Gitea 3001 and BTCPay 23000 declare it.
- Runtime operator override per app (security.set-app-gate → app-configs/
  <id>.json "gateEnabled"), surfaced as Settings → app → Access control.
  Wins over the manifest in both directions and applies on the next
  request — no restart, and it works today on catalog-covered apps whose
  signed manifest still says `gated`.
- The gate resolves policy per-request from the live port map, so a
  toggle takes effect without waiting for the 60s rebind sweep. "Off"
  never releases the port: gated apps are loopback-pinned, so releasing
  would strand them, not open them.
- security.app-gate-status now reports gate_enabled + any override.
- New guard test pins the `auth: open` set (both entries reviewed); the
  `auth: none` count moves 25 → 26, absorbing pre-existing drift from the
  phoenixd onboarding (loopback JSON API with its own generated password).
- Docs: the manifest spec's ports row documented only host/container/
  protocol — bind, auth, auth_rationale and session_passthrough were
  undocumented. Added a full "Ports & the app gate" section plus a
  developer-guide entry telling app authors to enforce their own auth
  regardless, since the operator can flip the gate either way.

Verified live on archi-dev-box from an external address: gated → 401 gate
page; override off → Gitea 200 own page, BTCPay 302 to its own login,
git-over-HTTP info/refs 200; override on → 401 again; clear → default.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 11:40:07 -04:00
archipelagoandClaude Fable 5 3a3077529b feat(kiosk): companion remote drives app iframes via trusted CDP input
Demo images / Build & push demo images (push) Successful in 3m11s
Companion tap/scroll/type now works INSIDE cross-origin app iframes and
kiosk tabs. The web relay synthesizes untrusted DOM events in the top
document, which can never cross an origin boundary — so apps served
through the appgate were dead to the remote. The kiosk Chromium now
exposes a loopback-only CDP port (default origin check intact, no
--remote-allow-origins) and a backend bridge (api/handler/cdp.rs)
dispatches validated companion input as Input.dispatchKeyEvent /
dispatchMouseEvent / mouseWheel — trusted events that hit-test through
any frame, move real focus, and insert text like a physical device.

- Session keeper self-heals across kiosk Chromium restarts; inert on
  nodes without a kiosk unit (falls back to the existing relay path).
- The kiosk relay subscriber self-tags (?kiosk=1) and the backend mutes
  its key/click/scroll messages while the bridge is live, so input never
  applies twice; cursor moves still flow for the on-screen cursor.
- While companion input is active the native OS pointer is hidden
  (cursor:none, auto-restores 30s after the last event) so the dead
  physical-mouse cursor doesn't sit next to the virtual one.
- docs/tv-input-iframe-apps.md scope note updated: gamepad keys stay on
  uinput; CDP is for companion pointer/typing only.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 05:28:53 -04:00
archipelagoandClaude Fable 5 458444d700 fix(mesh): report real RSSI/SNR for Reticulum peers instead of a fake 0
Every Reticulum-heard peer surfaced as rssi=0 — indistinguishable from a
real 0 dBm reading and, worse, from "heard over the TCP bridge with no
radio involved at all", which made a TCP-fed mesh look like working RF
during the 2026-08-16 radio diagnosis.

- Sidecar: announce handler now uses the 4-arg RNS dispatch to get the
  announce packet hash and reports per-announce rssi/snr from Reticulum's
  packet-stat cache; LXMF deliveries report message.rssi/snr/q (LXMF
  already populates them on direct RNode hops). All None over TCP or
  multi-hop — the honest RF-vs-internet discriminator.
- Rust: ReticulumPeer caches last_rssi/last_snr from announce and recv
  events (a TCP-relayed announce never blanks a real RF reading), and
  get_contacts surfaces them so refresh_contacts propagates real values.
- Identity discovery no longer hardcodes rssi 0: unknown is now None
  end-to-end and logged as such.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:52:04 -04:00
archipelagoandClaude Fable 5 519fa68c72 fix(federation): end the perpetual peer-joined "Invalid signature" storm
Root cause observed live 2026-08-16: onboarding/seed-restore rewrite
identity/node_key on disk but server_info.pubkey is only seeded at boot,
so until the next restart every peer-joined advertised the stale boot key
while signing with the new seed-derived key — deterministically rejected
by every receiver, once per 90s heal tick, forever.

- seed.generate / seed.restore now refresh server_info.pubkey in the live
  snapshot immediately (mirrors the DID-rotation handler).
- The 90s heal loop advertises the SAME key it signs with (disk identity,
  like federation sync already did) instead of the boot snapshot.
- notify_join no longer logs "delivered" for an HTTP-200 JSON-RPC
  rejection; in-band errors are terminal (identical signed bytes can
  never succeed on retry).
- The heal loop backs off per peer (doubling toward a daily re-assert)
  instead of re-notifying every 90s forever — Observer-held peers never
  appear in Trusted-only exported hints, so they_list_us could never
  become true for them.
- Receiver now binds the DID to the advertised pubkey (the old check was
  self-referential) and logs malformed signatures distinctly from
  genuine mismatches.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:45:39 -04:00
archipelagoandClaude Fable 5 809f7649a4 fix(mesh): plug-and-play radio detection in all situations
Three root causes from the 2026-08-16 framework-pt incident where a
replugged radio detected but never connected:

- detect_serial_devices scanned a hardcoded ttyUSB0-2/ttyACM0-2 list, so
  a radio enumerating at index 3+ was permanently invisible. Now scans
  /dev for all ttyUSB*/ttyACM* nodes (deterministic order, /dev/mesh-radio
  alias still first and still wins the dedup).
- An operator rnode-rf-settings.json port override silently outranked the
  device_path the user just chose in the detection modal. mesh.configure
  now clears a stale override when a different device is configured
  (symlink-resolved compare keeps /dev/mesh-radio aliases intact).
- Espressif native-USB boards (303a, ESP32-S2/S3/C3 RNodes) had no udev
  rule, so they never got the stable /dev/mesh-radio alias and a persisted
  alias path dangled after a port move.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-16 04:38:49 -04:00
archipelagoandClaude Fable 5 c5cd751bcf fix(nginx): a moved or slow-to-DHCP node no longer loses its whole web UI
setup-node-ca.sh writes one 'listen <addr>:443 ssl;' per LAN address at
the moment it runs (per-address on purpose — Tailscale holds :443 on the
tailnet address) and its idempotency guard never revisits them. nginx
REFUSES TO START while any listen address is missing, so this takes the
entire dashboard down, not just HTTPS:
  1. the node moves networks and the old address is gone; or
  2. nginx starts before DHCP assigns the address — and nginx.service
     ships no Restart=, making that single race permanent.
Both hit archi-dev-box today: nginx dead since boot on 'bind() to
192.168.63.240:443 failed (99: Cannot assign requested address)', the
dashboard simply unreachable, which is exactly the symptom a user with
no screen cannot diagnose.

run_nginx_listener_repair drops listeners for absent addresses, adds one
per present address (CGNAT excluded), installs behind  with
rollback, then starts nginx if it is down and gives it a
Restart=on-failure drop-in so the boot race stops being fatal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:49:40 -04:00
archipelagoandClaude Fable 5 203c2b6e50 fix(async): finish the blocking-call sweep — scan, 3 SSH handlers, DNS
A codebase sweep for siblings of e282c059 (blocking network I/O parked
on the tokio runtime) found the openwrt fix was incomplete:

- openwrt.scan: scan_subnet is async in name only — up to 255 SEQUENTIAL
  blocking TCP probes at 500ms each (~2 min on a /24 that silently
  drops) plus a blocking SSH verify per candidate. One click of 'scan
  for routers' held a worker for that whole time. Now spawn_blocking.
- provision-tollgate / scan-wifi / configure-wan still ran their SSH
  exchanges inline; bounded_tcp caps each socket op but a session is
  many sequential ops (provision runs opkg install over SSH), so worst
  case was minutes. All three now spawn_blocking.
- network::check_dns: blocking glibc to_socket_addrs with no app-level
  bound, on every Server-tab load via network.diagnostics. Against a
  stale resolver — the moved-network case — that is 5-40s per refresh.
  Now spawn_blocking plus a 5s cap, so the tile reports 'no DNS'
  instead of hanging.

Verified false positives left alone: every other bare TcpStream::connect
targets 127.0.0.1 (fails instantly), and every remote reqwest client
already sets a timeout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 11:03:08 -04:00
archipelagoandClaude Fable 5 6e89acced7 fix(network): SSDP discovery no longer parks a tokio worker for 3s
check_upnp_available uses a blocking std UdpSocket and, on a network
with no UPnP gateway (the normal case right after a node moves), runs
out its full 3s read timeout. Inline on the runtime that blocked a
worker on every call, from four call sites. Same class as the OpenWrt
SSH stall (e282c059), smaller blast radius — move it to spawn_blocking.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 10:22:44 -04:00
archipelagoandClaude Fable 5 e282c05911 fix(openwrt): unbounded blocking SSH connect no longer stalls the whole API
Router::connect/connect_password did a blocking std TcpStream::connect
with no timeout, inline on the tokio runtime. Against a router that
stayed behind when its node moved networks (framework-pt, 2026-08-15),
every dashboard poll of openwrt.get-status parked a worker thread for
the OS connect timeout (~2 min) — overlapping polls stalled unrelated
RPCs for 25s+ at a time, sessions timed out, and TOTP codes expired
before the backend verified them.

- bounded_tcp(): 5s connect timeout + 30s read/write timeouts on the
  session socket, shared by both connect paths.
- openwrt.get-status runs its SSH exchange on spawn_blocking, so even a
  slow router can only slow its own tile, never the API.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:52:03 -04:00
archipelagoandClaude Fable 5 6833920778 feat(bitcoin): autoprune default raised 550 → 50000 MB
Small-disk nodes (<1000 GB data volume) keep the same dynamic
prune-vs-archival logic but now retain ~50 GB of recent blocks instead
of the bare 550 MB minimum. Takes effect for catalog-covered installs
at the next catalog regeneration + signing.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:23:57 -04:00
archipelagoandClaude Fable 5 ec2e6375ed feat(bootstrap): two OTA heals for network moves + registry renames
Both failure modes are from framework-pt relocating (2026-08-15):

- archy-ha-btc-rpc-proxy bound socat to the LAN IP baked in at unit
  generation; after a move the address no longer exists and the unit
  restart-looped forever (counter 2446). run_ha_rpc_proxy_bind_repair
  rewrites ExecStart to compute the bind address at each start, so
  Restart=always itself heals any future move.
- homeassistant's quadlet pointed at the domain image ref with --pull
  never while local storage held the same name:tag under the bare-IP
  registry ref (catalog signing rename) — 761 restarts on 'image not
  known'. run_pull_never_image_repair retags a matching local image;
  it deliberately never pulls.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 09:23:57 -04:00
archipelagoandClaude Fable 5 1587853ce2 fix(banner): console banner shows the reachable LAN address, not the WG tunnel IP
The welcome banner picked its address with 'hostname -I | awk {print $1}',
so a node with WireGuard up advertised 10.44.0.1 — its own tunnel address,
present on EVERY node — as its web ui / ssh address. Off-tunnel that is
unreachable, and after a headless box moves to a new network it is exactly
the wrong thing to trust (framework-pt, 2026-08-15).

- Pick the default route's source address; fall back to the first address
  that is not WireGuard 10.44/16, CGNAT 100.64/10, or loopback.
- Also print http://<hostname>.local when avahi is up — the one address
  that survives any DHCP change, which is the real answer for headless
  boxes that move between networks.
- scripts/welcome-banner.sh is the new canonical copy, embedded in the
  binary (tor-helper pattern): bootstrap::run_welcome_banner_sync rewrites
  /etc/profile.d/archipelago.sh on ISO-installed nodes at startup, so the
  fix reaches the deployed fleet with the next OTA instead of only fresh
  ISOs. Machines without an installer-baked banner are left untouched.
- Same fix inlined in the live ISO builder's PROFILE heredoc
  (image-recipe/_archived/build-auto-installer-iso.sh).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:37:13 -04:00
archipelagoandClaude Fable 5 56d6396142 feat(appgate): gate login gets the real dashboard badge + glass-button states
Demo images / Build & push demo images (push) Successful in 3m0s
- The badge is now the dashboard login's AnimatedLogo, square for square:
  inline SVG (20 white rects, 100ms stagger, 3s loop) inside the same
  gradient ring. The old <img> of favico-black-v2.svg baked a second ring
  into the ring and couldn't animate; the asset leaves the gate allowlist
  since nothing references it now.
- The submit button is .glass-button longhand: hover lift + lightening +
  rim glow, active press, disabled dim — the flat darken-only hover read
  as broken next to /login.
- Loading state: submitting flips the button to spinner + 'Signing in…'/
  'Verifying…' and disables it, via a single inline script admitted by
  CSP sha256 hash (not unsafe-inline; injected markup stays inert, and
  the page still works as a plain POST without JS).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-15 08:24:27 -04:00
archipelagoandClaude Fable 5 e3bd340725 feat(aiui): Routstr as an explicit, session-gated AI provider path
The D-04 Routstr leg was fallback-only — never user-selectable, and its
Nostr discovery parses a docs-shaped event content ({endpoints, models,
pricing}) that live kind-38421 announcements don't actually carry
({name, about}), so it could never match a real provider. This adds the
explicit path AIUI's model picker needs: /aiui/api/routstr/models
passes through the live aggregator catalog (the instance routstr.com's
own frontend queries; the canonical api.routstr.com 404s), and
/aiui/api/routstr/chat/completions makes one paid, non-streaming,
OpenAI-shaped call — session-gated, egress-screened (S3), refused
without an armed operator budget (D-05), paid via auto_pay_token,
change and refused-request tokens redeemed back into the wallet so a
failed attempt nets zero (verified live: quoted=1 reclaimed=1 net=0).
nginx template gains the location in both server blocks (T-13-15).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 13:41:07 -04:00
archipelagoandClaude Fable 5 ced95a60d1 feat(wallet): Lightning gets the arrival screen; copy buttons unified
Demo images / Build & push demo images (push) Successful in 3m27s
- lnd.createinvoice now returns r_hash_hex; new lnd.invoicestatus RPC
  looks the invoice up (SETTLED + amt_paid_sat). E2E-verified on this
  box: real invoice minted, status polls settled:false until paid.
- Receive modal: Lightning polls settlement every 3s and flips to the
  on-chain-style success view — straight to the green check + amount
  (no broadcast step; settlement is final). Raw bolt11 text removed:
  QR + CopyButton only. State fully reset per open/close.
- CopyButton is now the wallet's only copy affordance: the ark-address
  and ecash-token holdouts swapped in, their ad-hoc handlers deleted.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:43:30 -04:00
archipelagoandClaude Fable 5 a0bd9e53f8 feat(settings): CA generation from the UI; Routstr panel beside the API key
Demo images / Build & push demo images (push) Successful in 3m36s
WebUI RULE (operator, 2026-08-14): never point users at a terminal. The
certificate section told users to run setup-node-ca.sh by hand — it now
has a Generate button backed by system.node-ca.generate, which runs the
idempotent script server-side (live-tested: generated and /ca.crt serves).
Routstr budget panel moves directly under the Claude API key card.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-14 09:19:05 -04:00
archipelago 135fb5650b chore: release v1.8.3-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
2026-08-14 06:34:47 -04:00
archipelago 2b7e92e770 chore: release v1.8.2-alpha
Demo images / Build & push demo images (push) Successful in 3m43s
2026-08-14 04:28:12 -04:00
archipelago 246916c77b chore: release v1.8.1-alpha
Demo images / Build & push demo images (push) Successful in 3m48s
2026-08-13 14:30:16 -04:00
archipelagoandClaude Fable 5 9243babcdb feat(kiosk): graphics tiers + Settings knob; network map kiosk mode
Demo images / Build & push demo images (push) Successful in 3m51s
The animated federation map froze the framework-pt 4K TV: the launcher
held every machine to the HD 5500-era choppy-audio flags (single raster
thread, GpuRasterization banned) while the map wrote SVG attrs at 60fps.

- Launcher: two flag tiers. legacy = the proven conservative set; modern
  (Intel gen8+, 'NNth Gen' models, AMD Ryzen) = default raster threads +
  GPU rasterization. Classified from /proc/cpuinfo (11 model strings
  covered by tests in-session); KIOSK_GRAPHICS=performance|quality in
  kiosk-display.conf overrides; headless unchanged. Reaches deployed
  kiosks via the include_str! self-heal, same as the vsync fix.
- system.kiosk-display.get/set: carries a 'graphics' field alongside
  'preset'; setting one no longer clobbers the other.
- Settings → Display: Graphics picker (Auto / Compatibility / Quality).
- NetworkMap3D: kiosks default to the 2D projection (remembered toggle
  still works) and tick at half rate with carried-over deltas — same
  spin speed, half the paint cost.
- Changelog: curated Unreleased notes for all of the above + the gate
  frame-embedding fix.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 09:23:07 -04:00
archipelagoandClaude Fable 5 a80712963c feat(appgate): proxied apps become embeddable — gate neutralizes frame blocking
Apps that ship X-Frame-Options (Alby Hub: DENY) or a CSP frame-ancestors
directive rendered as a dead grey pane in the dashboard's embedded app
session; the historical fix was a bespoke per-app nginx strip proxy
(gitea). The gate now removes X-Frame-Options and strips ONLY the
frame-ancestors directive from proxied responses — the rest of the app's
CSP passes through untouched. The clickjacking threat those headers
address is handled the same way the gate's own pages handle it: every
proxied request is authenticated first, and the gate already declares
permissive frame-ancestors on its own responses. Unit-tested; verified
live on archi-dev-box (Alby Hub embeds, CSP intact).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 13:05:25 -04:00
archipelago a7a6528b08 chore: release v1.8.0-alpha
Demo images / Build & push demo images (push) Successful in 4m40s
2026-08-12 08:59:14 -04:00
archipelagoandClaude Fable 5 a500a75235 style: rustfmt update.rs — unblock release gate
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-12 07:35:54 -04:00
Archipelago b67e1527a2 Archipelago — open-source initial import 2026-08-12 10:55:50 +00:00