Phase 1 of docs/nostr-vpn-integration-plan.md's Phase 0->4 plan (seller-side
rootless feasibility already confirmed there). Two apps, one image:
- apps/nostr-vpn: the daemon. Own network namespace (container.network:
pasta), NET_ADMIN+NET_RAW scoped to that netns, /dev/net/tun, and the
net.ipv4.ip_forward sysctl via the primitive added in e42bd26. UDP 51822
(not upstream's default 51820, which collides with archipelago-wg on
fleet nodes per the Phase 0 log). Seller mode stays off until an operator
explicitly enables it (paid_exit.enabled defaults to false upstream).
- apps/nostr-vpn-web: the control panel, gated behind 127.0.0.1:38080,
talking to the daemon only through the shared /data volume (state-file
status + shelling out to the nvpn CLI) -- no network link between the
two containers, matching upstream's own umbrel/docker-compose.yml.
- docker/nostr-vpn: upstream's umbrel/Dockerfile, unchanged except for how
the pinned commit arrives (shallow git fetch of a verified SHA, since
codeload.github.com archive tarballs 404 from this environment and
GitHub won't fetch an arbitrary SHA directly). Entrypoint seeds a minimal
config.toml with the chosen listen_port on first boot only -- every
AppConfig field is `serde(default = ...)`, confirmed by reading
nostr-vpn-core directly, so this merges with nvpn's own identity/wallet
bootstrap instead of needing a generated_secrets entry or full config
template, and never touches a config that already exists.
Both volumes point at /var/lib/archipelago/nostr-vpn, adopting state from
the old root-mode install. Build and the seed-config path were verified
against the real `nvpn daemon` binary, not just read -- see the plan doc's
Phase 1 log for what that caught (a fabricated commit SHA, the codeload
404, wrong default branch name, and confirming identity/wallet persistence
actually survives container recreation).
Not done here, flagged in the plan doc instead: removing the old root-mode
path (rpc/vpn.rs, rpc/auth.rs's auto-enable-on-login) touches live
onboarding on every node, not just this app -- needs explicit sign-off.
Also missing: a stop-hook/uninstall-guard manifest primitive (doesn't
exist yet -- LifecycleHooks only has post_install/pre_start) for the
collect-due-on-stop and non-zero-wallet uninstall guard, and registry
mirroring + catalog signing (need credentials this pass doesn't have).
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Monero's get_info returns target_height == 0 when the node is FULLY
SYNCED — the field is the height being caught up to, not the chain tip.
The '??' fallback left 0 in place, so every healthy node rendered
"Syncing — 0.00%, 0 blocks behind" forever. Treat 0/absent as
target = own height, the same sentinel electrs_status.rs branches on.
Same companion shape as bitcoin-ui/electrs-ui: host-networked nginx
bound to 127.0.0.1:18091 (auth: gated + session_passthrough), serving
a dark glass status page that polls the node's restricted RPC via a
session-gated /cuprate-rpc/ proxy — sync height/target with progress
bar, peers, mempool, chain size and free disk (from get_info), plus a
wallet 'remote node' endpoint. The offline state explains the disk gate
so a refused node says why.
No secret rendering: the restricted RPC is Monero's safe-for-public
subset, so nginx.conf is baked into the image (no pre_start hook, no
bind mount). companion.rs auto-provisions archy-cuprate-ui alongside
cuprate and reaps it when cuprate goes.
Catalog regenerated (cuprate-ui entry + manifest embed, 18091 into the
mesh launch-port list). NOTE: releases/app-catalog.json is UNSIGNED as
committed — run scripts/sign-catalog.sh before publishing.
Seeing it on the node settled the shape. Two rows of per-cell delays
read as a dense flicker — closer to a progress bar than a display, and
short enough against the row's text to look like an underline.
Three rows laid out column-first fixes both: the three cells of a column
now share a delay, so the lit column travels across as a single scan
line, and at 11px the matrix sits with the text rather than under it.
Verified in a real browser against the live node with the balance RPCs
held open: five placeholders, five distinct rail colours (white, orange,
yellow, purple, blue), 42 cells each, all animating, each announcing
what it is waiting for — and no "0 sats" anywhere on screen while the
calls were in flight. They gave way to real figures on arrival, with
Lightning's genuine 0 correctly shown as a figure rather than left
shimmering.
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>