cuprate publishes only raw JSON RPC (18090 restricted, 18183 p2p), so
launches must land on the companion on :18091, never on the running
node's runtimeUrl — same root-path special-case bitcoin uses, with the
dev vite proxy for /app/cuprate-ui/. Alias cuprate -> cuprate-ui so the
port-auth lookup finds the gated launch port on HTTPS nodes; pin the
companion icon to the cuprate mark.
provisionTollgate/saveTollgateConfig/scanWifi/configureWan all fell
back to the Connect form's local refs (host/sshUser/sshPassword) when
connectedParams was null. Those refs only get populated if the form
was actually submitted this session — on a normal page load the
router reconnects via the server-persisted config instead, leaving
sshPassword at its default ''. Sending that as an explicit
(empty-but-present) ssh_password overrides the backend's saved-config
fallback, so every action auths with a blank password instead of the
real saved one.
Added authParams(): omit host/ssh_user/ssh_password entirely unless
connectedParams is actually set, same as the status poll already does.
Caught live: dropbear on archy-x250-pa3's router logged a single bad
password attempt at the exact moment "Install TollGate" was clicked,
sandwiched between periodic status-poll connections succeeding with
the real saved password.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0176RpCxFNS9ZaSJjL72W9Z5
v1.8.9's move to Router.SendPaymentV2 shipped without fee_limit_sat,
and the v2 route treats an ABSENT fee limit as zero allowed fees.
Every real route carries a routing fee (the 2-hop route here: 1.5
sats), so the pathfinder rejected them all and the wallet answered
"No route to the recipient" on EVERY send — all day, on healthy
channels with plenty of liquidity both ways.
The router debug log makes it unambiguous:
wallet payment (v1.8.9 backend): fee_limit=0 mSAT -> no route
same payment by hand (lncli --fee_limit=100): fee_limit=100000 mSAT -> settles in 0.65s
My earlier "pipeline verified" claim was wrong — the manual lncli
verification set a fee limit by hand and masked this exact bug. The
400k that succeeded this morning went through the pre-update backend
on the pre-update LND.
Payments now carry lncli's own default budget — the payment amount
(100%), preferring the payer-supplied amount for zero-value invoices
and the invoice's own amount otherwise, with a nominal floor so the
limit can never be zero. Unit-pinned so it cannot regress.
LND normally reconnects channel peers after a restart, but not reliably:
after long or repeated downtime (an app update, a node reboot,
reconciler churn) the peer link can stay down for hours while BOTH
endpoints keep the channel flagged disabled in the routing graph. The
node looks perfectly healthy, the wallet shows balance, and every
payment in either direction fails "no route to the recipient" —
observed live on framework-pt (2026-09-01): its only channel sat
disabled on both policy sides for ~17 hours after the LND 0.21.2
update, while shorty had 583k spendable and the user was told, by a
mis-mapped modal, that they had 'no payment channel'.
The channel graph is desired state — every open channel should have a
live peer connection. A daemon-side watchdog now enforces it:
- every 2 minutes, list channels + peers over LND REST
- for each channel whose remote peer is not connected, look the peer's
advertised addresses up in the public graph and dial one
- per-peer retries throttled to 10 minutes so an unreachable peer is
not hammered; 'already connected' counts as done; a peer with no
advertised address is logged once per pass (cannot be dialed)
- no-ops quietly on nodes without LND (missing macaroon) and while a
wallet is locked (503 body has no channels)
Unit tests pin the selection against the live REST shapes
(remote_pubkey in /v1/channels vs pub_key in /v1/peers).
v1.8.10 CHANGELOG + What's New entries staged so the next release run
is clean first time.
"LND thinks I do not have a channel" while the wallet showed plenty of
liquidity (framework-pt, 2026-09-01): the send gate sums outbound over
FULLY-OPEN channels only, which is correct — a just-opened channel
sits in LND's pending list until it has ~3 confirmations, and an
open channel can have all its balance on the far side — but the modal
then claimed the node had NO channel at all, in every one of those
states, and pointed the user at opening another one.
The gate already fetched the full channel list; it now records WHY
liquidity is zero and the modal says the truth per state:
- pending channels -> "your new channel is waiting for on-chain
confirmations, it unlocks automatically, nothing is needed from you"
(and no "Open a channel" button — that would send the user to fix
a problem they don't have, possibly opening a second channel)
- open channels, zero on the needed side -> "balance is on the far
side — you can receive but there's nothing to send right now"
- payment refused with a routing/liquidity error -> says so, instead
of claiming no channels
- only a genuinely channel-less node keeps the open-one guidance
Eleven unit tests pin the state machine, including the regression
case (pending-only -> 'pending', not 'none') and fail-open on RPC
errors.
The release gate requires the freshly-built bundle to embed the new
version, and the version reaches the bundle through the What's New
modal in AccountInfoSection — there was no v1.8.9-alpha block yet, so
create-release.sh correctly refused to ship a bundle that looked stale.
This adds the block (the user-facing summary of today's LND/HTTPS/
launcher/NPM/Portainer fixes) and carries the version bumps the
aborted run had already written (Cargo.toml, Cargo.lock, package.json,
package-lock), so the re-run starts from a clean tree.
Verified: npm run build now produces assets containing 1.8.9-alpha
(Settings chunk), i.e. the exact check the script runs passes.
Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:
1. portAuth() looked the launch port up under the name the user clicks
('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
declares those ports under the manifest id that owns them
(archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
portIsGateFronted answered false, and an HTTPS dashboard handed app
frames http:// URLs — blocked as mixed content: mempool and IndeeHub
'did not connect', bitcoin knots/core opened http:// in a new tab.
Resolution now follows launch aliases, then a port-wide catalog scan
that only answers when every declarer of that port agrees (a port
any app publishes as plain HTTP is never upgraded to https).
2. The signed-catalog cache was only warmed by the Store/Discover
views, so a user who went straight to My Apps launched apps with an
empty cache. Warmed at dashboard mount now — fetchAppCatalog()
already memoizes with a 1h TTL.
3. The NIP-07 bridge compared event.origin for strict equality with the
recorded (http) app URL and replied to the recorded URL as the
postMessage targetOrigin — both break the moment a frame is scheme-
upgraded (cached HSTS did exactly that): every nostr request was
silently dropped and replies to the stale origin threw. The bridge
now matches host+port (scheme deliberately ignored) and always
replies to event.origin — the frame's real origin.
Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
Converting Nginx Proxy Manager to a platform manifest (fc68c5b6) dropped
two things its image hard-requires, and the result was an endless
start/die loop — shorty-s watched it restart 3,176 times:
1. /etc/letsencrypt mount: NPM's s6 'prepare' service refuses to boot
without it ('ERROR: /etc/letsencrypt is not mounted!'). Mounted from
the same persistent app directory as before
(/var/lib/archipelago/nginx-proxy-manager/letsencrypt), so existing
certificates are preserved — no data moves, no migration.
2. NET_BIND_SERVICE: NPM's internal nginx listens on 80, 443 AND 81,
and the orchestrator runs --cap-drop=ALL. The legacy podman-run path
defaulted to the full capability set (and the legacy repair path in
package/config.rs always listed it), which is why this only broke
once the manifest became the source of truth.
The signed catalog embeds manifests with origin-wins semantics, so the
catalog carries the fix for every catalog-covered node — regenerate it
here (plus the generated store/launcher-port artifacts, which also pick
up drift from bf6ef964's retired apps). Catalog re-signing follows the
usual ceremony.
First upstream sweep since v1.8.5: the safe patch/minor pins, mirrored
into our registry first (source.archipelago-foundation.org/lfg2025/*).
Held for their own careful passes: the majors (grafana 11, nextcloud,
uptime-kuma 2, bitcoin-core 29, the DBs) and consensus-sensitive apps
(fedimint, electrumx 2.0). LND 0.21.2 needs an in-house image build —
upstream publishes none.
Settings gains the SSH-over-mesh card (danger-zone confirmation for the
any-peer scope, sshd preflights, fipssh copy hint). The signed-catalog
merge filters components via the shared serviceNames module; Discover
grids get the standard icon container; install no longer yanks the user
to My Apps; v1.8.8 release notes.
directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
New-tab apps and the companion WebView got hardcoded http:// URLs, so a
node reached over HTTPS opened Vaultwarden, BTCPay, Grafana et al in
cleartext. Every app port is gate-owned and serves TLS on the same port
(appgate/tls.rs), so directAppUrl(), the legacy open() path, and
resolveRuntimeLaunchUrl() now follow the page's scheme. HTTP pages (the
kiosk, LAN) are unchanged; netbird keeps its unconditional https.
The companion-agent queue from the 2026-08-30 handoff, complete:
- Backup & Restore: hub sub-page, SAF export/import sealed in the
node's ADR-005 envelope (Argon2id + ChaCha20-Poly1305, byte-compatible
with core backup.rs), merge-only restore, no cloud.
- Remote Signer: the phone is the NIP-46 bunker — nsec generate/import,
nostrconnect:// QR pairing (scanner + OS deep link), per-request
approve/deny card, NIP-44 v2 transport with NIP-04 receive fallback,
wire-faithful to rust-nostr's reference bunker. Crypto pinned to the
official NIP-44 + BIP-340 vectors; e2e harness included.
- #61 residual: banner + manual intro trigger + overlay all gate on
isCompanionApp() (web-side, vitest-covered).
- Hub modal: new sub-pages like Nodes/FIPS, 70% height cap, node ULA
display/copy in the Nodes list, fipssh Termux helper (npub→ULA is a
pure public-key function — verified against the fips crate).
Issues #61 (comment), #128, #139 closed on the tracker.
The RELEASE_NOTES_BACKLOG gate for cutting the next release, closed out:
- Eight sections backfilled to the curated standard, from the Settings
What's New blocks, the old-lineage release commits, and the hotfix
diffs: v1.7.44 (was four raw commit-hash lines), v1.7.47/48/64/65
(thin), and v1.7.50/51/107 (real tagged releases whose sections were
missing entirely — v1.7.107 restored verbatim from the curated copy
at 35e9c624 that later went missing).
- Mechanical inventory across all 92 sections in range: every section
now has ≥3 curated bullets, zero raw-hash entries.
- What's New modal regenerated for the three restored versions
(sync-whats-new --check passes, 92 versions present).
- Manifest-notes-only confirmed by construction: the manifest reads its
changelog from CHANGELOG.md and check-release-manifest.sh rejects raw
or thin entries before publishing.
Evidence trail for the backfills is recorded in
docs/RELEASE_NOTES_BACKLOG.md.
Only the auto-popup was companion-gated — inside the companion WebView
users still saw the 'install the companion' banner in the App Store and
could pop the intro overlay through it. Gates all three paths on
isCompanionApp(): CompanionBanner self-hides, openCompanionIntro() is a
no-op, and the manual-open watcher in CompanionIntroOverlay refuses to
open (the overlay's raw window check also moves to the canonical helper
so there is exactly one detection). No APK change.
Two reports from a fresh install without a cable:
(a) No way to see the WiFi password being typed. Every password field in
the app was a bare type=password input. PasswordRevealInput is the
reusable fix — masked by default, one-tap eye toggle, v-model and
enter pass-through — first applied to the WiFi prompt in ServerModals
so a long key typed from across the room can be verified.
(b) WiFi settings are undiscoverable with no wired internet. New
OnboardingNetworkCallout floats over every onboarding step when the
node has NO physical link at all (no ethernet up, no WiFi associated
— polled from network.list-interfaces, self-dismissing the moment a
link exists) and deep-links 'Connect to WiFi' to
/dashboard/server?open=wifi, which Server.vue consumes by popping the
WiFi picker on arrival. Deliberately scoped the other way too:
Archipelago is offline-first, so 'no internet' never nags — only 'no
link at all', only during onboarding (the wrapper hosts /login too;
the callout is restricted to /onboarding/* routes), and a failed probe
stays silent. The query is consumed via history.replaceState so a
KeepAlive tab-return never re-pops the modal, and Server.vue keeps
reading it from the real URL rather than vue-router — its
KeepAlive-mounted tests have no router context to give.
Verification: full frontend suite 1023/1023; type-check clean; production
build clean with both new strings confirmed in the emitted bundles
(OnboardingWrapper + Server chunks).
Curated release notes for the pending v1.8.5-alpha: Cuprate (with the
two review catches), kdump/rasdaemon + the host-fixup OTA channel, the
uninstall-abort fix, federation inline-picture routing, honest disk
usage, the three lying-screens fixes (#143/#127/#129), durable mesh
notifications + router recovery (#57/#103), and upstream-release tracking
with the first-sweep safe bumps.
What's New modal synced via scripts/sync-whats-new.py (--check passes;
89 versions, all present). Per docs/RELEASE_NOTES_BACKLOG.md the
v1.7.44-alpha -> current section audit remains the open item before the
tag.
Pin bumps (all verified pullable from their public registries before
editing, so none can become an image-not-found on a node):
strfry 1.1.1 -> 1.1.2 (dockurr/strfry, direct pull)
btcpay-server 2.4.2 -> 2.4.3 (docker.io/btcpayserver, direct pull)
netbird (nginx) 1.31.3-alpine -> 1.31.4-alpine
pine (nginx) 1.31.3-alpine -> 1.31.4-alpine
image-versions.sh moved in lockstep for BTCPAY_IMAGE — it is the baseline
the update badge compares against. Held back deliberately, per the risk
policy from the Aug-17 pass: gitea (four minors of DB migrations),
portainer (six minors), filebrowser (2.27 -> 2.63), fedimint/gateway
(0.8 -> 0.12, real migrations), lnd (money-critical), netbird-server/
netbird-dashboard (0.x, must move in lockstep), and everything with a
major jump or a data migration.
Cuprate also gets its curated store entry (category money, tier optional,
icon, repo) — same shape as the Alby Hub / phoenixd entries — synced
through generate-app-catalog.py into both store catalogs and the
app-session config. The fips launch-port list is unchanged (Cuprate has
no UI port; the generated file round-trips to the committed bytes after
cargo fmt).
Three further bumps are prepared and parked on the
app-bumps-mirror-pending branch, blocked only on a registry-push token:
vaultwarden 1.37.2-alpine, archy-nbxplorer 2.6.11, home-assistant
2026.8.3 — all mirror-backed, and the push credential on record for the
lfg2025 namespace is dead.
Drift gate: check-app-catalog-drift.py --release --strict clean
(31 store entries, 0 drift, 0 missing). appSessionConfig tests 7/7.
create-release aborted at [4/8] with "web/dist/neode-ui does not contain
v1.8.4-alpha — the frontend build no-opped or its output is stale". The
build had not no-opped: it was fresh, and simply had no 1.8.4 string to
embed.
sync-whats-new.py only matches '## vX.Y.Z (YYYY-MM-DD)'. The entry read
'## v1.8.4-alpha (draft — date set at cut)', so the version was invisible
to it: the gate's whats-new-sync stage reported "87 versions, all present"
while the release being cut had no What's New block. That modal is the
only place a version string appears in the frontend, so the bundle carried
none and the freshness check — correctly — refused it, while naming the
wrong cause. Step [5/8] only greps for '^## v1.8.4-alpha (' so it passed
the draft too.
Three changes: date the v1.8.4-alpha entry, insert the modal block it was
owed, and make the sync tool refuse any version header without a real date
instead of skipping it. Skipping is what let a wrong "all present" through.
Verified: the draft header now fails the check with an explicit message,
the dated one passes (88 versions, up from 87), and a rebuilt bundle
contains 1.8.4-alpha where it did not before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Regenerates both catalogs from the manifests so the 15 pin bumps become
real. The catalog overrides on-disk manifests on every node, so until
now those bumps were edited but inert.
There are two catalogs and regenerating one is not enough:
generate-app-catalog.sh writes releases/app-catalog.json (the signed one
nodes fetch), while generate-app-catalog.py writes app-catalog/catalog.json
and neode-ui/public/catalog.json (the source pair, the second baked into
the frontend app store). check-app-catalog-drift.py --release --strict
reads the *source* catalog, so regenerating only the release one left it
failing and would have aborted the ISO gate at stage 1 — after the
signing and tagging were already done. Drift is now 0.
The regeneration also rewrote fips/app_ports.rs, which had not been
regenerated since the initial open-source import. Diffing the port values
rather than the reformat: 36 -> 37, a single addition, **8187 — Alby
Hub**. Its port has never been in the FIPS firewall allow-list, and by
the same token neither has any app onboarded since that import. Nothing
else changed.
Catalog signed by the pinned release root and verified with
`ceremony verify`; registry trust floor checked before signing, both
hosts trusted by the deployed fleet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.
Acknowledgement now lives in localStorage, capped at 300 keys.
That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.
Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.
Frontend: 1000 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Chasing the "selecting incoming clears a pending token, and there's a
timeout if you don't click" report led here. Instant rails — Lightning,
Cashu, Fedimint, Ark — settle immediately, so there is no confirmation
to wait for and no natural moment for a receipt to leave the Incoming
badge. It was leaving on a five-minute wall clock instead.
So a payment could arrive, raise the badge, and evaporate before anyone
looked; and opening the panel a few minutes late showed nothing, because
the payment you came to check on had already aged out. Worse, once the
count hit zero the badge silently changed meaning — the same click that
opened the panel now navigated to the transactions view instead.
For ecash that is the worst case available. It leaves no public ledger
entry, so this panel was the only place the receipt was ever shown; once
it timed out there was nowhere left to look.
Instant-rail receipts now stay until they have actually been seen, which
is the same unread model the mesh inbox uses. Closing the panel is what
marks them seen, not opening it — marking on open would make a row
vanish under the cursor of someone still reading it. On-chain is
untouched: a confirmation count is a real signal and already does this
job.
Also keys the list on a derived id. Instant rails have no txid, so
`:key="tx.tx_hash"` was `""` for every one of them.
This is my reading of the reported symptoms rather than a confirmed
repro — the operator should check it matches what they saw.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
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>
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>
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>
window.confirm blocks the JS event loop, which froze companion remote
input while open — the remote user could raise the mesh "Clear" prompt
(or reboot / backup-delete / uninstall confirms) and then never dismiss
it, because the synthetic events that would dismiss it queue behind the
dialog itself.
New promise-based appConfirm() (useAppConfirm.ts) + one AppConfirmModal
mounted globally in App.vue, built on BaseModal (Teleport-to-body,
full-viewport backdrop, glass card — the canonical modal contract). All
six native confirm() call sites migrated: mesh clear-all, mesh message
delete, dashboard reboot, backup delete, backup USB copy, app uninstall.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth.isSetup failing (backend warming up after boot, transient proxy
blip) dropped Login.vue into its catch and showed 'Set Up Your Node' on
a fully-onboarded node — seen on framework-pt right after its network
move, and the same fail-open class RootRedirect already fixed for the
intro flash. Errors now fail toward the ordinary login form and a
background probe re-asks until the backend answers; a genuinely fresh
node flips to the setup form on the first successful probe.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Kammergut gloss v3 opt-in had been applied to the /login badge as
well; per operator the glossed disc belongs ONLY on the screensaver and
the onboarding intro (and the splash tap-logo that fronts them). The
login page returns to the plain gradient-ring badge it always had, same
as the dashboard sidebar fix before it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
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>
The Routstr backend (Cashu-paid inference fallback, shipped 1.7.127) was
fully wired but permanently dormant: its D-05 gate requires an
operator-set sats allowance and nothing in the UI ever called
assistant.budget-get/set — default 0 meant never selected. New Settings
panel (below AI Data Access): allowance/spent/remaining, set-allowance
with 0-disables semantics, enabled/off badge. Backend untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- NetworkMap3D: kiosks keep static PLACEMENT (the rAF-fragile intro was
the blank-screen cause) but re-attach the half-rate ticker — the calm
orbit is back; and measure() now renders explicitly when no ticker runs,
so resizes repaint instead of leaving a stale/blank/mis-scaled
projection (also fixes reduced-motion users on any screen).
- Gloss v3 scoped to .logo-gloss opt-in (screensaver, intro, login,
splash tap-logo) — it had leaked onto every logo-gradient-border user,
including the dashboard header, via AnimatedLogo's default border.
- normalize-app-icon.py output now carries intrinsic 512x512 dimensions:
a viewBox-only SVG collapses to nothing in auto-sized tiles (the
'transparent icon in My Apps' report); both app icons regenerated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- alby-hub + phoenixd icons re-set with the standard 12% inner margin
(they shipped edge-to-edge; every other icon carries whitespace).
- scripts/normalize-app-icon.py: wraps any third-party SVG mark onto the
house canvas — the system applies the tile plate (archy-app-icon)
automatically but deliberately no runtime inset, so the margin must be
baked; the guide now says exactly that.
- MarketplaceAppDetails: the icon now carries archy-app-icon like the
store tiles — the treatment no longer stops at the detail page.
- v1.8.2 changelog: third curated bullet (the ceremony gate requires 3).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An app whose frame never loads while its backend reports Running (the
embed-refusal signature: frame-busting JS, top-level-origin apps,
SameSite=Strict logins — everything the gate's header stripping cannot
fix) is remembered in localStorage; every later launch opens a tab
straight from the click (user gesture, so no popup blocker), and
opensInTab() gives it the tab-launch icon. A successful iframe load
clears the memory and entries expire after 7 days, so nodes that gain
embedding (gate improvements) get re-probed instead of being remembered
broken forever. Dev guide updated; v1.8.2 changelog + What's New curated.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>