Writing the first tests for this section found the bug they were written to look
for. `rotate()` started the poll, then the `load()` immediately behind it took a
status snapshot that did not yet carry `running: true` and cancelled the interval
— so the screen froze on the one action that most needs to show progress. The
operator has just invalidated every credential their wallet holds, the rotation
is genuinely running on the node, and the page tells them nothing is happening
until they reload it by hand.
It survived manual review because the backend flips `running` inside the same
critical section that accepts the request, so the happy path usually wins the
race. "Usually wins a race" is not a property to ship on a credential rotation.
Polling now continues for a bounded window after a request the node accepted,
and stops early as soon as `running` is observed. Bounded, so a request that was
accepted but never acted on stops polling rather than hammering the node.
12 component tests cover the states that carry consequences: the channel census
shown before the button is offered, the stale-BTCPay warning, the difference
between "BTCPay has no internal node" (silence — an absence, not a fault) and
"BTCPay's credential is dead" (a warning), the block on rotating while LND is
unreachable, both poll races above, and that an idle tab does not wake the node.
Verified: 12/12 new, 880/880 frontend tests, vue-tsc clean, and the rebuilt
bundle contains the new strings.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Rotating LND's macaroons was an SSH-only script, which in practice meant it did
not happen — while a macaroon is a bearer token with no revocation and no expiry,
so anything that ever read one keeps the ability to spend until they are
replaced. Settings → Lightning credentials now does it behind the node password,
shows a step checklist, and refuses to report success unless it has confirmed the
node identity and channel census are unchanged.
Three findings from performing a real rotation on a dev node, each fixed here:
1. BTCPay was left holding a dead credential, silently. Its connection string
carries the macaroon INLINE (LND's datadir is owned by its container subuid,
so btcpay cannot bind-mount the file), and the daemon only regenerates that
secret when LND's TLS cert thumbprint changes — which macaroon rotation does
not touch. Result: btcpay up, LND up, both healthy, every Lightning payment
failing, nothing anywhere saying why.
2. Rewriting the secret is not enough to fix it. `secret_env_hash` makes the
change visible as env drift, but the reconcile loop runs `ExistingOnly` at
boot AND periodically, and there it deliberately leaves running
restart-sensitive apps untouched — observed once per tick for half an hour on
the dev node. So this reuses FED-07's `credential_rotated` carve-out via a new
default-no-op `ContainerOrchestrator::mark_credential_rotated`, on the same
reasoning: restart sensitivity protects apps that are working, and this one is
working only in appearance. The shell script cannot reach an in-process flag,
so it removes the container and lets desired-state recovery rebuild it.
3. LND stayed locked forever on a loaded node. The unlocker is only served after
channel.db/graph.db/wallet.db open, measured at 2m38s on a box running 30
containers; the unlock helper gave up at ~60s. That is not a harmless retry —
reconcile records the post-start hook as failed, restarts LND, and the slow
open begins again, so the wallet never opens and every LND-dependent app stays
broken. The not-ready budget is now ~10 minutes; a genuinely wrong password
still exits on the first pass via `all_rejected`.
Safety properties worth not regressing:
- No macaroon content in any response, error, log line or the polled progress
feed — digests and byte counts only.
- Rotation unlocks via a new `unlock_existing_wallet_no_wipe`, so there is no
code path from "rotate my credentials" to `recreate_wallet_destructively`. A
wallet whose password this node lacks fails the rotation with the wallet intact.
- Channels are compared as active+inactive totals, not `num_active_channels`,
which legitimately dips after any restart while peers reconnect.
- Backup verified by file count before anything is deleted.
Verified: cargo check + fmt clean, 6 new unit tests and the 6 existing
container::lnd tests pass, vue-tsc clean, and the built bundle contains the three
new RPC method names (the frontend build can silently no-op).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both default update mirrors resolve to the SAME host — the primary by name over
HTTPS, the fallback by IP over plain HTTP — while SystemUpdate.vue told the
operator "Servers this node checks for updates. The primary is tried first; if
it's slow or unreachable, the next one in the list is tried automatically."
That promises availability redundancy the pair cannot provide: if the origin is
down, both entries are down. Reported by the operator, who read the list and
correctly concluded the fallback made no sense.
The mechanism is fine and deliberate — it recovers a node whose DNS is broken
or whose clock is wrong, both of which fail TLS while plain HTTP still works,
and it is safe because the manifest carries an Ed25519 signature verified
against the pinned release-root anchor, so transport integrity is not what
protects the update. (That last part only became true once Workstream B pinned
the anchor; before then this fallback would have been a real hole.)
So the bug was the labelling, not the design:
- Backend label "Direct (fallback)" -> "Same server, no DNS/TLS", and the
comment now states plainly that it is the same host, what it recovers, and
that real redundancy needs a different one.
- UI copy now scopes the redundancy sentence to genuine mirrors and adds a
paragraph saying the two built-in entries are one server, what the second
actually recovers, that it does not help if the server is down, why an
unencrypted fetch is acceptable, and how to get real redundancy.
The relabel reaches existing nodes: force_ovh_update_primary rewrites labels for
the two default URLs on every load, while the merge matches on URL and never on
label — without that rewrite path a renamed default would have sat in the code
and never propagated to a single deployed node. Noted inline so it is not
re-broken.
Verified: 40/40 update tests pass (including the mirror load/merge/strip ones),
vue-tsc clean, build green, and the new copy is present in the freshly built
SystemUpdate chunk. Nothing in the tree pinned the old label string.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The backend verifies DID signatures as of f0c289a4, but the card only rendered
`trust_tier` / `trust_score`, so the verdict reached the frontend and died
there. Adds a badge next to the existing trust pill.
Deliberately a *separate* badge rather than folding into the trust tier: the
score blends relay count, provenance and policy compliance, while this answers
one narrow question — did the author prove control of the key their `author.did`
names. Merging them would hide the distinction that the signature layer exists
to draw.
- `valid` → green "signed" with a lock glyph
- `missing` → neutral grey "unsigned" (an unsigned publisher is unproven, not
hostile, so it reads as absence rather than alarm)
- `invalid` → red "bad signature". Discovery drops these before they reach the
cache, so it should be unreachable; rendered anyway so the UI
fails visibly rather than silently if that ever changes.
Two fail-safe details:
- The mapping defaults a missing field to `{status:'missing'}` rather than
leaving it undefined. A node on an older backend returns no field at all, and
"we couldn't check" must never render as "signed".
- The `invalid` arm is typed in the RPC client for the same reason: an
unhandled status falls through to "unsigned", not to the green badge.
The tooltip carries the meaning the two-word badge can't. "Signed" is easy to
misread as "safe", so it says what was actually proven — who published it — and
explicitly that this is not a statement about the app being safe.
Verified: vue-tsc clean, build green, and the new strings are present in the
freshly built Marketplace chunk (the build can silently no-op).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create-release.sh builds the frontend at step 4 and validates the curated
changelog at step 5, then requires the freshly built bundle to contain the new
version. The version reaches the bundle only through the hand-written What's
New list, so on a fresh release that check can only pass if the changelog and
What's New entries are written BEFORE the script runs. Writing them after is
what aborted the first attempt.
Leads with the downgrade bug, since that is the one users saw: an Update button
offering the release withdrawn for an actively exploited 2FA bypass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The app store offered "update to 2.3.9" on a node already running 2.4.2 — the
release that fixes an actively exploited 2FA bypass. Taking it would have
rolled the node back onto the vulnerable version.
Root cause: available_update_for_images compared tags for inequality only.
Same repo + different tag meant "update available", with no ordering. Every
version claim upstream of it can go stale — the signed catalog, a legacy
catalog entry, the image-versions.sh baseline pin — and any one of them
lagging turned into a backwards Update button.
Guard added: when both tags parse as dotted-numeric versions, a lower pinned
version is never offered. Tags that cannot be ordered (RELEASE.2024-11-07…,
14-vectorchord0.4.3) keep the previous behaviour rather than silently losing
updates. This makes stale data fail safe, which matters more than any single
pin being correct.
Four sources still named 2.3.9, three of them able to act on it:
- releases/app-catalog.json — a LEGACY `btcpay` entry, distinct from
`btcpay-server`, carrying a concrete 2.3.9 image. catalog_primary_image
treats that as authoritative, so this is what drove the button. Fixed, but
held back from this commit: it needs re-signing.
- scripts/image-versions.sh — the baseline pin used when the catalog does not
cover an app.
- stacks.rs — the legacy BTCPay installer, twice. The fallback install path
would have deployed 2.3.9 outright.
- neode-ui curatedApps/marketplaceData and public/catalog.json — the store's
displayed version, hardcoded rather than read from the catalog, which is why
it still showed 2.3.9 after the update landed.
Audited every other installer for the same shape. The remaining literals are
the immich stack, which currently agrees with its manifests; hits in
set_config.rs and app_catalog.rs are test fixtures. To keep it that way,
scripts/check-installer-image-pins.py asserts that any installer literal
naming the same repository as an app manifest carries the same tag, and runs
blocking in CI. Verified it catches a simulated revert to 2.3.9.
Tests: 13/13 in image_versions including the exact BTCPay case, a genuine
upgrade still offered, equal versions silent, prerelease suffixes ordered on
their numbers, and opaque tags unchanged.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Replaces the registry host across 86 files: 309 references, covering all 40
app manifests, the orchestrator and container crates, the release and catalog
scripts, both demo-images workflows, the ISO builder, demo-deploy, and the
frontend marketplace data.
Verified the domain actually serves the registry before rewriting anything,
rather than assuming the web host implies the registry:
- TLS verifies clean, HTTP/2 on the web root
- an anonymous token grants a manifest fetch (HTTP 200) with no credentials
- skopeo inspect --no-creds resolves an image and lists its tags
That last check is the one that matters: an outside developer with no account
can now pull, which was the functional blocker for publishing at all.
Plain-HTTP references become HTTPS in the same pass, so OTA downloads stop
crossing the network in the clear.
Deliberately NOT rewritten:
- The public FIPS anchor on port 8444. It is a functional network endpoint
every node dials to bootstrap the mesh — closer to Bitcoin Core's hardcoded
seeds than to leaked infrastructure. The domain does resolve to the same
host, so it could become a hostname, but that adds a DNS dependency to the
path used precisely when things are broken. Worth a deliberate decision,
not a side effect of this change.
- The companion APK on port 2100. The domain returns 404 for that path, so
rewriting it would swap a working URL for a broken one. The Releases page
does serve (200), which is where the plan already wants those binaries.
- releases/app-catalog.json, releases/manifest.json and release-manifest.json.
These carry `signature` and `signed_by`; editing their contents invalidates
the signature and the fleet refuses artifacts that fail verification. They
were rewritten in a first pass and reverted — they must be regenerated and
re-signed through the signing ceremony instead, which needs the mnemonic.
So the catalog still advertises the old host until that ceremony runs. Nodes
resolve images through the signed catalog, not the on-disk manifests, so this
commit alone does not change what a node pulls.
Verified: archipelago-container 75/75; every manifest still parses with a
top-level app block; no signed artifact modified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Keeps the dev and test tooling an outside contributor would want, and takes
our node addresses out of it.
Scripts that silently defaulted to one of our nodes now require an explicit
host and exit 2 without one: smoke-test.sh, trust-archipelago-cert.sh,
dev-container-test.sh (which also derives its RPC and health URLs from the
SSH target instead of a second hardcoded copy), and image-recipe/dev-branding.sh.
A default that points at a machine the user does not own is worse than no
default: it fails confusingly, or reaches a stranger's device.
Usage examples, mock data and test fixtures move to the RFC 5737
documentation range (192.0.2.0/24). CGNAT test values stay inside
100.64.0.0/10 so the range-check semantics they exercise still hold, and
192.168.1.0/.1/.254 are left alone — those are gateway logic and UI
placeholders, not our addresses.
Playwright and the perf spec defaulted their baseURL to one of our nodes;
they now default to localhost:8100, the local dev server.
Removed neode-ui APP_URLS entirely. It is dead code — exported, never
imported — and it pinned fedimint's *prod* launch URL to 192.168.1.228:8175.
Had anything consumed it, every user's node would have tried to reach an
address that on their LAN is either nothing or someone else's machine.
Deleting beats sanitizing dead config.
Verified: frontend 868/868 vitest across 108 files; archipelago-container
75/75; mesh tests 9/9; audit-secrets 5/5. Zero node addresses and zero node
names remain in tracked files.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo is source code and guidelines only. Nothing about how Archipelago's
own fleet is run, or how the team works, stays in it.
Untracked (kept on disk, gitignored) — 250 files:
- .planning/ (199) and loop/ — internal development process
- fleet operations tooling that targets specific nodes: deploy-to-target,
deploy-tailscale, deploy-config-defaults, setup-target-dev, setup-aiui-server,
setup-https-dev, debug-frontend, node-profile, fleet-fips-pair/unpair,
image-recipe/sync-from-live.sh
- image-recipe/INTEGRATION-GUIDE.md and docs/multinode-testing-plan.md, both of
which are live-server workflow and fleet node inventories
- the Phase 10 on-node verification and evidence records, which cite .planning/
as their evidence base
KEY-05-ENTROPY-ENFORCEMENT.md was initially moved out with the other Phase 10
docs and then put back: it is cited as normative rationale from ten places in
the codebase, including core/clippy.toml, which bans rand::thread_rng and
points at it for the reason. That makes it a guideline, not an internal record.
Node names removed from source (48 occurrences across comments, manifests and
test fixtures): archi-dev-box, archy-x250*, shorty-s, framework-pt,
zaza-optiplex, archi-thinkpad. Comments keep the engineering context and the
date, which is what carried the meaning; the machine name did not.
Three of those were live test values rather than comments and were replaced
with valid stand-ins, not prose: two mDNS hostnames and a mesh peer name.
An earlier pass substituted "a test node" into a hostname assertion, producing
an invalid hostname; caught and fixed as test-node.local.
Wipe mechanism: .local-only/manifest.txt inventories every local-only path and
.local-only/wipe.sh deletes them on one confirmation, refusing to touch
anything git still tracks. Both are themselves untracked, so the public repo
does not carry a map of internal filenames.
Verified: cargo check -p archipelago --all-features clean; archipelago-container
75/75 tests pass; appOrigin vitest 7/7; audit-secrets 5/5; every relative link
in tracked markdown resolves (0 broken).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both schemes now work, and each one works properly:
- HTTP dashboard -> http app origin (unchanged; no certificate needed)
- HTTPS dashboard -> https app origin (needs the node CA + TLS on the port)
The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed
content — blocked outright, before the SameSite cookie question the symptom
was filed under. It is also what made the two origins schemefully cross-site,
so following the page's scheme fixes both causes at once.
Backend-reported runtime URLs get the same treatment: the daemon reports
http:// because that is how the app binds locally, which is right for the node
and wrong for a browser on an HTTPS page.
pageScheme() defaults to http when location.protocol is absent (non-browser
contexts) — the safe direction, since inventing an https URL for a port that
serves no TLS would break a working setup. That default is also why the three
existing resolveAppUrl tests, whose fixture stubs location without a protocol,
keep passing unmodified rather than being edited to fit.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node served a bare self-signed leaf, so a browser exception had to be
granted per ORIGIN — scheme + host + port. The dashboard on :443 and an app
on :8334 are different origins, and a certificate interstitial CANNOT be
accepted inside an iframe, so a gated app embedded over HTTPS could never
render no matter how many warnings the user clicked through. (Mixed content
blocks the plain-HTTP variant first, before the SameSite cookie question the
symptom was originally filed under.)
A CA fixes it structurally: ports are not part of a certificate's identity, so
one leaf with the right SANs covers every port on the host, and one installed
CA trusts them all.
- scripts/setup-node-ca.sh generates the CA (4096-bit, pathlen:0, keyCertSign
only) and issues a 397-day leaf covering archipelago.local, the hostname, the
Tailscale MagicDNS name and every global address the host holds. Idempotent —
re-running reuses the CA and only reissues the leaf, so gaining an address
does not invalidate copies users already installed. --force-ca is the
deliberate escape hatch and says what it costs.
- nginx serves the public CA at /ca.crt on both schemes, unauthenticated by
design: a device fetches it before it can validate the node, so gating it
behind HTTPS or a login would be a chicken-and-egg.
- Settings → System shows the fingerprint and per-platform install steps.
crypto.subtle does not exist outside a secure context — precisely the case
this feature exists to fix — so an HTTP dashboard gets the openssl command
to verify by hand instead of a blank field.
Verified locally: chain validates, key pairs with the leaf, CA:TRUE/CA:FALSE
are correct, keys are 0600. Two TLS servers on different ports both verify
(ssl_verify_result=0) against the CA alone and are rejected without it — the
one-CA-covers-every-port claim, tested rather than assumed.
Not yet wired: app ports still serve plain HTTP. Putting TLS on them is the
next step and is what actually closes the iframe-login bug.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A container that is up but hasn't answered its probe yet rendered the hard
failure overlay — padlock icon, "App not reachable", "the container is
stopped". Both bitcoind (RPC -28 for its whole warm-up) and lnd (unreachable
until the wallet unlocks) sit in that window on every boot, so the node
looked broken while it was working normally.
The retry machinery was already correct: 6 × 10s of automatic re-checks, and
the app appears on its own when it answers. Only the headline was wrong. While
those retries are in flight AND the package reports running/starting/restarting
(or health "starting"), the overlay now shows the app's own pulsing icon,
"<App> is starting…", and says the container is running. Once retries are
exhausted the failure is real again and the original copy returns.
Follows the ElectrumX sync-screen precedent already in this file, which
suppresses the same overlay for the same reason. The explicit blocked-reason
and must-open-new-tab paths are untouched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The device-detected modal's region selector now drives real RNode
settings instead of a "managed by the daemon config" shrug: choosing a
region shows its concrete plan (frequency/bw/SF/CR/power) and Apply &
Connect writes it through mesh.rnode-config-apply — the same
radio-confirmed round-trip as the Device panel, best-effort so a plan
failure never aborts the connect. RNODE_REGION_PLANS moves to
utils/loraRegions (single source shared by panel + modal).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Applying RF settings deliberately restarts the radio daemon (~15-20s).
Two things treated that healthy, expected gap as a fault (operator,
2026-08-06):
- radio_state was single-shot: a query landing inside the restart
window reported "The radio daemon did not answer the state query"
for a restart that was working correctly. It now retries for ~30s
and says the radio is restarting while it waits. A real device-level
refusal (not an RNode) still returns immediately.
- The device-setup modal auto-opens for any detected-but-unconnected
port, so the restart looked like a newly plugged stick and
interrupted the apply. Apply and Reboot now suppress auto-detect for
90s via mesh.suppressDeviceDetect().
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The LoRa device panel's Reticulum section (operator .126 top priority):
- Shows the device's CURRENT settings first — the radio-confirmed r_*
values from mesh.rnode-config (online badge, port, frequency, bw,
SF, CR, txpower, airtime limits), with a Refresh action.
- Every RNodeInterface parameter is editable: enabled, serial port
(auto-detect when blank), frequency, bandwidth (RNode's discrete
set), SF 5-12, CR 4/5-4/8, txpower, airtime short/long %.
- "Set recommended for <region>" fills the fields from per-region
plans (EU868 = the operator-validated Portugal plan incl. 25%/10%
duty-cycle locks); driven by the existing region selector above.
- Apply & Confirm on Device: persists, restarts the radio daemon, and
reports the radio's own confirmation (green ✓ only when the device
read-back matches; amber/red messages say what actually happened).
- Action buttons stack in a column (operator layout request).
- Reboot Radio surfaces the backend's real acknowledgement message.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Standalone static guide (same pattern as /architecture/) covering how the
master seed entropy is drawn (explicit OsRng, sealed KeyGenRng allowlist,
degenerate-draw refusal, CSPRNG readiness ledger), how it is stored
(Argon2 + ChaCha20-Poly1305 envelope), the full derivation tree (HKDF
labels, NIP-06, LND aezeed one-way gate, second-order keys), what is NOT
seed-derived, every failure/fallback path, and the restore flow — in
paired layman/technical language. Linked from the Recovery-phrase card
in Settings → Backup. CSP-safe: no inline scripts.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Operator-reported (2026-08-05):
- Send/Receive modals reset to a blank slate on every open. Stale state —
destination, amount, memo, and above all an armed "send all funds"
toggle — silently carried into the next payment.
- Arming "send all funds" now shows the swept balance in the (disabled)
amount field instead of a confusing 0; disarming or leaving the
on-chain tab clears it.
- The scan modal no longer hides "Scan with camera" on plain-http desktop
(browsers only allow getUserMedia on secure origins): the option stays
visible with a one-line explanation, and choosing it surfaces the HTTPS
requirement with photo/paste fallbacks. The companion app's native
scanner path is untouched and still takes priority.
- What's New entry for v1.7.125-alpha.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Portainer's image reaches the public catalog (the release gate caught the
manifest and catalog disagreeing), and fips-ui 8336 joins the mesh relay's
port list now that it declares a port — it is auth: gated, so the relay
withholds it rather than bridging it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leads with the update that switched nodes off and left them unable to
switch back on — the one an operator most needs to understand, and the
reason to take this release promptly.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
auth.session-policy.get/set plus a card under Account. Presented as two
plain questions rather than the token mechanism underneath, because the
distinction that matters to an operator is which control actually ends a
session: the dashboard polls constantly, so an idle timeout alone never
fires on an open tab — the absolute cap is what guarantees it.
Values are clamped server-side and the stored result is echoed back, so
the bounds are discoverable instead of an error. Presets rather than a free
number field: a box accepting '5' invites locking yourself out. A short
idle choice warns that it is the payments-industry posture.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mesh right panel: a >=2560px screen hid the tab bar and stacked all five
tool panels in fixed grid rows. On a real display that clipped the Bitcoin,
Dead Man and AI headings to a few pixels each, letterboxed the map, and
pushed Radio Settings into a scroll — more screen producing a worse view.
Very wide now uses the same tabbed column as every other desktop width,
with the selected panel filling the column and the map running edge to edge
(it is the one panel with nothing to scroll).
Session policy: idle timeout, absolute cap and a re-prompt-for-funds flag,
persisted and clamped. Two tokens already existed — a session token and a
30-day login token — so the knob changes how long a quiet tab stays usable
without putting a long-lived credential on every request. Kiosk screens are
exempt from the idle timeout (nobody is there to log a TV back in) but keep
the absolute cap so a stolen box does not stay authenticated forever. The
cap is not optional theatre: idle alone never fires on a polling dashboard.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leads with the honest version: five screens were open and the previous
release's own audit reported them as fine, found by scanning from another
machine rather than asking the node. States plainly that what leaked was
the page, not credentials — the macaroon path was verified, not assumed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Leads with what changes for the operator: app screens now require the node
password across LAN, Tailscale, mesh and Tor; the wallet/protocol ports that
must stay open stayed open; the mesh leak found during on-node verification;
nodes repairing their own legacy containers; and the signing-key rotation.
Known gaps disclosed, including the eleven still-undeclared ports and that
non-browser clients will now meet the login page.
The new block uses <strong> rather than the literal ** markers in earlier
entries, which render as asterisks in the modal.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The release gate requires every CHANGELOG version to have a matching
block in Settings > What's New. Generated by scripts/sync-whats-new.py.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A vertical line crossing both dashboard cards, appearing at random on
hover and hard to catch deliberately.
Diagnosed from the screenshot rather than by reproduction. Decoding it
and scanning column by column found a lone brightness step at CSS x=633
that never returns — every legitimate container edge in the page shows
up as a PAIR of steps 2px apart (the card borders at CSS 255, 288, 850,
875, 1437), so an unpaired one is not a border. Sampling by region
placed it inside the cards and nowhere else: 10/13 rows inside My Apps,
11/11 inside Wallet, 2/10 in the gap between them, 2/13 above them. Same
screen x in both cards, which means the boundary lives in screen space
and cuts whatever backdrop-filter surface it crosses.
style.css already neutralises backdrop-filter for the shared glass
classes inside the dashboard's animated perspective/scroll containers,
because Chromium/Brave mis-rasterise it there — that block was written
for the black-rectangle corruption. `.home-card-shell` declares its own
`backdrop-filter: blur(18px)` in Home.vue and was never added to the
list, so it was the only unmitigated blur surface on the dashboard.
That is exactly the set of pixels the seam appears in. A hover repaint
re-rasterises part of the backdrop, and the refreshed half meets the
stale half at the damage boundary.
Adding it to the existing list also makes the shell consistent with the
tiles beside it: its fill is already rgba(0,0,0,0.65), the same as
.glass-card, which renders unblurred here.
The list is hand-maintained, which is how this shipped — a component
declaring backdrop-filter in its own <style> is simply not covered and
nothing fails. So the fix comes with a test that parses Home.vue for
locally-declared backdrop-filter rules and asserts each is in the
mitigation list. Verified it catches the real bug: reverting the
one-line fix makes it fail naming `.home-card-shell`.
Tests: 3/3 new, vue-tsc clean, mitigation confirmed in the built CSS.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Promotion to Trusted is a privilege escalation — a Trusted peer can read
node state, be deployed to, and is exempt from the `!= Untrusted` gates
federation/DWN/messaging use. It must therefore cost a fresh proof that
the person at the keyboard is the operator, not merely that a session
cookie exists. Same reasoning as node.rotate-identity and TOTP setup,
both of which already re-verify.
Both entry points are covered:
- `federation.invite` gates on the RESOLVED level, not on an explicit
request for Trusted: "Link Your Nodes" sends no `trust_level` at all
and falls through to the Trusted default. The invite is a bearer grant
of Trusted to whoever redeems it, so minting it IS the escalation.
Observer invites are untouched.
- `federation.set-trust` gates only when the peer is not already
Trusted, so the dropdown re-emitting its own value doesn't demand a
password for a no-op.
Demotion is deliberately NOT gated: making something less privileged
must never be harder than leaving it alone, or the safe action becomes
the inconvenient one.
The backend is the sole authority on what counts as an escalation — it
returns a `PASSWORD_REQUIRED:`-prefixed error and the UI prompts and
retries only on that, so the rule lives in exactly one place and the
frontend never pre-judges. TrustPasswordModal.vue (modelled on
RotateDidModal.vue) serves both flows. NodeDetailModal's select snaps
back to the node's real level on change, since a cancelled or failed
promotion would otherwise leave the dropdown displaying a level the node
never accepted.
The operator path stamps TrustSource::Manual; set_trust_level grew an
`Option<TrustSource>` so automatic adjustments (the discovery-handshake
demotion safety net) pass None and leave the recorded provenance alone
rather than laundering an uninvited-join peer into looking approved.
Follow-up, deliberately out of scope: `federation.join` also reaches
Trusted when redeeming someone else's Trusted invite, with no re-auth.
Tests: 44/44 federation, 79/79 rpc-client, vue-tsc clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Generated by scripts/sync-whats-new.py, which the release gate checks.
Without it the Settings > What's New modal would have skipped straight
from v1.7.119 to v1.7.121 — the release notes users actually read, as
opposed to CHANGELOG.md which they do not.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: the nav above the bottom bar — back buttons, the mesh tabs —
stayed stuck across other screens.
Cause is the KeepAlive work from phase 2, and specifically the half of it
that is invisible from the view's own file. Main tabs are KeepAlive'd, so
navigating DEACTIVATES a view instead of unmounting it. Content the view
Teleports to <body> is not in the view's DOM subtree, so deactivation
does not remove it and it keeps rendering over the destination screen.
Two offenders, matching the report exactly:
- Mesh.vue teleports its mobile TAB BAR and its chat BACK BUTTON to
<body>, gated only on `mobileShowChat` — never on whether Mesh was the
screen you were looking at.
- components/BackButton.vue teleports the shared mobile back button with
NO gate at all, so it leaked out of every view that uses it. Fixing the
shared component fixes every caller at once: Vue propagates
activated/deactivated from the KeepAlive boundary down through the
subtree, so a child can guard itself.
BaseModal already solved the transient-dialog half of this class in
204d4523 by closing on route change. That is the right fix for a dialog
and the wrong one for chrome: a tab bar has no "closed" state to fall
back to, and forcing one would lose the user's place. New
useViewActive() composable instead — chrome is simply not rendered while
its owner is off screen, and returns exactly as it was.
THE PERFORMANCE IS NOT SACRIFICED, which was the explicit constraint.
The Teleport is gated, not the view, so the instance stays cached and
revisiting a tab is still instant. A test pins this: setup() must run
exactly ONCE across a navigate-away-and-back round trip. If someone
later "fixes" this by dropping KeepAlive, that test fails.
Deliberately untouched: AppSession.vue, whose teleport is load-bearing —
its own comment records that moving the iframe node reloads the app, and
app-session is excluded from KeepAlive anyway so it cannot leak. Toasts,
the app launcher and the connection banner are app-level rather than
view-owned; gating those would be wrong.
Verified: 3 new tests; full suite 105 files / 848 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A failed federation sync existed only as a `debug!` line on the node, so a
peer that had not synced in days looked identical in the UI to one that
synced a minute ago. Now the failure is persisted per peer and rendered.
- `FederatedNode.last_sync_error` / `.last_sync_error_at` — the failure-side
mirror of the existing `last_transport` / `last_transport_at` pair.
- `federation::record_sync_result(data_dir, did, outcome)` — records the
message on `Err`, CLEARS both fields on `Ok` so the badge disappears when
the peer recovers. Runs under FEDERATION_STORE_LOCK via the `*_inner`
load/save convention established by plan 01-01. An unknown DID is a silent
Ok that writes nothing, so a peer removed mid-pass is never resurrected by
an in-flight sync's error write. Skips the save entirely when nothing
changed, keeping the steady state read-only rather than rewriting
nodes.json (and contending for the lock) every 90s.
- Message truncated to MAX_SYNC_ERROR_CHARS (256), counted in chars not
bytes so truncation cannot split a UTF-8 sequence (T-01-18).
- The 90s auto-sync loop calls it on both arms; the existing `debug!` line
is kept — persisting is additive, not a replacement for logs.
- `federation.list-nodes` emits both fields when set, omits them when unset.
- NodeList renders a red SYNC badge beside the transport badge on both the
trusted-node and peer rows, message + age in the `title` so the row stays
single-line.
Tests (written first, confirmed failing — 16 compile errors, E0425 on
`record_sync_result` and E0609 on `last_sync_error`):
- persists_error / success_clears_error / missing_did_is_noop /
on_empty_store_is_noop / truncates_long_error
- NodeList: badge present when set, ABSENT when unset (the guard against a
badge that always renders), and present on an observer peer row.
cargo test -p archipelago federation — 42 passed, 0 failed.
vitest NodeList.test.ts — 4 passed. npm run build — green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audited every transport-pill render site in the cloud surfaces at 390x740
and 320x640 in a real browser. Two sites render a pill (Cloud.vue peer
cards, PeerFiles.vue header) and both already appear on a phone; three
file-level sites carry none, by decision recorded in the SUMMARY.
- Cloud.vue peer-card badge row: add flex-wrap + shrink-0 on the transport
badge. Measured at 320px, a longer trust label squeezed the badge until
its own text broke mid-label ("TOR ." / "120.0s"). It now drops to a
second line intact. Inert whenever the row fits, so desktop is unchanged.
- New TransportPills.test.ts: one site-specific assertion per render site,
so removing a pill fails the build. Dorian asked that these never be
removed in a future cleanup; nothing in the repo pinned them before.
- Unknown-transport cases assert no pill is fabricated (T-01-78), and the
labels/colours are asserted against PeerFiles.vue's canonical mapping
rather than a duplicated table.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A running LND with zero channels happily mints an invoice — it is simply
unpayable, because nobody has a route in. So the state-only gate let receive
through and handed the user a useless invoice, and let send walk to confirm.
Neither errored, so the funding modal (wired to failures) never fired.
requireLightningReady(direction) now asks lnd.listchannels and checks the
liquidity that actually matters for the attempt: total_inbound to receive,
total_outbound to send. It fails OPEN on an RPC error — a transient blip
should not block a working wallet.
The no-funds mode says plainly that a channel is needed, in the direction's
own terms (inbound vs outbound), and offers both routes: "Open a channel"
straight to the channels screen where the Zeus/Olympus flow is already
prefilled, and "Setup Guide" to the run-lightning-node walkthrough for someone
who wants the whole path explained. Buttons wrap rather than squeeze on
narrow screens.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Creating a Lightning invoice with no Lightning implementation installed failed
at the RPC layer — lnd.createinvoice returned connection-refused and the
Receive screen rendered it as a red error. That reads as the wallet being
broken when the node simply has no Lightning node installed yet.
useLightningRequired() gates the three invoice paths (wallet Receive, the Web5
send/receive sheet, and the app launcher's paywall — both arms there, since
paying an invoice needs a node as much as minting one). With none installed it
raises a modal offering to install one and the caller bails without surfacing
an error at all.
The modal lists the choice rather than assuming LND: LND installs today, Core
Lightning is listed greyed as "Coming soon" so the platform doesn't read as
LND-only. When CLN ships it is two lines — flip `available` and add the id to
LIGHTNING_NODE_APP_IDS.
Detection is install state, NOT reachability, deliberately: an installed node
that is merely stopped or still starting is a different problem ("start it")
and must not be answered with "install a Lightning node".
Also fixes the credentials modal, which painted its own rgba(8,10,18,.98)
navy card instead of the house glass-card — it read as blue against every
other modal. It existed twice (Apps.vue and apps/AppIconGrid.vue); both now
use BaseModal, so they also inherit Esc/focus handling, body scroll lock and
the standard pinned-header/footer scroll contract they were missing. Dead
panel CSS removed from both.
Verified: 4 new tests; full suite 103 files / 826 tests green; npm run build
clean with the new strings present in the built bundle.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
On a short viewport the seed-confirmation tickbox sits below the fold inside
the step's scrolling area while Continue stays pinned and disabled in the
fixed footer — onboarding reads as broken rather than incomplete.
The cue is a sticky-bottom scrim and glass pill inside the scroll region, and
its visibility comes from real geometry: scrollHeight vs clientHeight for
overflow, then a getBoundingClientRect comparison of the tickbox's bottom
against the container's. On a tall screen the element does not render at all,
so those screens are unchanged. Rects rather than offsetTop because offsetTop
is relative to the nearest positioned ancestor — here the outer card, not the
scroll container.
It is wayfinding only: activating it scrolls the tickbox into view and never
sets confirmed, focuses Continue, or auto-ticks, which a test pins.
Listener setup was moved onto both onMounted paths — the sessionStorage
restore path returned early, so a user navigating back would have had no cue.
Verified: 6 new tests plus the full frontend suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dorian verified the sibling-height match, internal scroll, and unchanged
stacked layout on his running dev session — all correct. The only issue was
the xl:min-h-[20rem] floor (an unmeasured judgement call, flagged as such in
01-12-PLAN.md): when node discovery is disabled, Web5NodeVisibility renders
short, the floor takes over, and 20rem left the Connected Nodes card looking
stunted. Doubled to xl:min-h-[40rem] per his direct instruction ("twice as
tall"). Test updated to pin the new value.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Cloud.vue's viewPaidItem() called window.open() instead of the in-app
MediaLightbox, and its content.owned-get fetch had a 60s timeout with no
loading indicator and a swallowed catch. Moves the fetch/decode/route logic
into a new usePaidItemViewer composable: image/video route to a second
MediaLightbox instance fed a synthetic FileBrowserItem, audio still goes to
the global bottom-bar player, and anything with no in-app viewer keeps
today's browser-tab fallback. The Paid Files row now shows an "Opening…"
spinner (matching PeerFiles' existing treatment) for the fetch's duration,
becomes non-interactive to prevent double-fetch, and a real error surfaces
through the view's existing alert-error block instead of an empty catch.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The three tab panes carried max-h-72 xl:max-h-none, so at the xl breakpoint
(where the Web5 row becomes two grid columns) the cap lifted with nothing to
replace it: the visible pane grew to fit every row, stretched the grid row,
and the scrollbar the user expects never appeared.
Give each pane xl:flex-1 xl:basis-0 xl:max-h-none instead — zero flex-basis
means the pane contributes no intrinsic height, so the grid row is sized by
the Web5NodeVisibility sibling alone, grid's default align-items: stretch
gives the card that height, and flex-1 hands the leftover height back to the
pane, which scrolls inside it via the existing overflow-y-auto. The card root
gets min-h-0 (so the flex column can shrink below content height) plus an
xl:min-h-[20rem] floor so a short sibling still leaves a usable list area
instead of collapsing to the header+tabs strip.
Below the row breakpoint nothing changes: the stacked cap (max-h-72) and
scroll are untouched, and dropping flex-auto (replaced by nothing, i.e. the
default 0 1 auto) has no visible effect since single-column stacked cards
have no extra flex space to distribute anyway.
Adds Web5ConnectedNodesScroll.test.ts to pin the contract across all three
tab panes and the card root so a future cleanup cannot reintroduce the
grow-to-fit regression a third time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create-release-manifest.sh's changelog extraction pulls every non-blank
line between the version header and the next "## ", not just "- "
bullets — so the previous commit's "### Known gap" markdown heading and
its paragraph leaked into releases/manifest.json (and, via
sync-whats-new.py, the Settings "What's New" modal) as a malformed,
truncated entry (the closing clarification sentence was cut by the
extractor's 10-line cap).
Rewritten as a single "- " bullet, matching every other CHANGELOG entry,
so it renders cleanly and completely in both the OTA manifest and the
in-app modal instead of showing raw "### " syntax to node operators.
Also folds in core/Cargo.lock's version bump, which create-release.sh's
own commit step omits from its `git add` list.
Same binary/frontend artifacts as the prior commit (identical sha256/
size in the regenerated manifest) — only the changelog text changed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
scripts/sync-whats-new.py --check (part of the release gate) requires
every CHANGELOG version to have a matching block in the Settings
"What's New" modal. Also strips CHANGELOG markdown bold/italic markup
from the v1.7.119-alpha bullets first — the modal renderer only
strips backticks, not **/* emphasis, so it would have leaked literal
asterisks into the user-facing modal text.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fleet.vue's useFleetData() (60s telemetry.fleet-status/-alerts poll),
Server.vue's FipsNetworkCard.vue (15s fips.status poll), and Web5.vue's
Web5Monitoring.vue (30s system.stats poll — redundant with Home.vue's
own correctly-gated 10s poll of the same store) all armed their
setInterval in onMounted and only disarmed it in onUnmounted/
onBeforeUnmount. That was harmless before 02-04 registered their
owning views in KEEP_ALIVE_PATHS (the view was destroyed on every
tab-away, so the teardown hook fired every time); once KeepAlive keeps
the instance alive, the teardown hook never fires again and the poll
ran forever in the background regardless of which dashboard tab was
showing.
Gated arm/disarm to onActivated/onDeactivated, mirroring Server.vue's
own vpnPollInterval fix from 02-04 exactly. Added regression tests to
keepAliveLifecycle.test.ts mounting each real component under a
synthetic KeepAlive with fake timers; confirmed RED against the
pre-fix code (git stash) before confirming GREEN with the fix restored.
Full suite (95 files/788 tests), type-check and build all green.
keepAliveTabs.test.ts is byte-for-byte unmodified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Now that persist is required (no default) on useCachedResource()/refresh(),
every call site that previously relied on the implicit persist:true default
needs an explicit decision. Full audit, decision rule: money/identity/
peer-identity payloads -> false; static/aggregate/non-identifying data ->
true; ambiguous cases fail safe to false and are called out below.
persist:false (financial / identity / peer-identity payload):
- LightningChannelsPanel.vue: lnd.channels, lnd.closed-channels (open/closed
Lightning channel balances — wallet data, same class as CR-01's lnd-info)
- Cloud.vue: cloud.paid-items (carries paid_sats + purchase history),
cloud.peer-nodes (PeerNode carries did/pubkey/onion)
- Cloud.vue/PeerFiles.vue: cloud.my-files — not a clean money/identity/
peer-identity case, but a private per-user file listing; chosen false as
the fail-safe default per the audit rule, flagged here for review
- Credentials.vue: credentials.identities, credentials.list
- Federation.vue: federation.nodes (FederatedNode carries did — matches
Mesh.vue's already-persist:false federation.nodes decision)
- FipsSeedAnchorsCard.vue: server.fips-seed-anchors (SeedAnchor carries npub)
- Server.vue + FipsNetworkCard.vue: server.fips-summary corrected from
persist:true to persist:false — this shared cache key's real fips.status
response carries npub (this node's own FIPS identity key), which
Server.vue's narrower local type didn't surface but FipsNetworkCard.vue's
fuller FipsStatus type does; both call sites must agree since a mismatch
trips the dev-only entry() persist-consistency warning. Found during this
audit, not part of the originally-scoped call-site list — corrected as a
same-class T-02-01 violation. serverTabCache.test.ts updated to match.
persist:true (aggregate/status/public data, no identity or money):
- AppDetails.vue: app-details:bitcoin-sync (block height/sync progress)
- Cloud.vue: cloud.section-counts (bare per-section item counts);
cloud.peer-browse (browsePeer()/loadCatalog()'s direct resources.refresh()
calls now pass { persist: true } explicitly, matching the pre-existing
decision already documented at peerBrowseEntry())
- Federation.vue: federation.dwn-status (sync status/counters only)
- MarketplaceAppDetails.vue: app-details:versions (public catalog metadata)
- Monitoring.vue: monitoring.current/history/alerts/alert-rules (system
metrics and alert metadata only)
- OpenWrtGateway.vue: server.openwrt-status (network/router status, matches
sibling server.* resources)
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 2 (no-op branch, per plan): Task 1's evidence positively proved
Server.vue and Web5.vue's instances already survive tab round-trips —
the "remounts" reading was a probe artifact (02-FINDINGS.md), not a real
defect. No change to DashboardRouterView.vue, dashboardViewWrappers.ts,
keepAliveRoutes.ts or Server.vue's KeepAlive/lifecycle wiring.
Lands 4 regression tests in keepAliveLifecycle.test.ts using Vue's own
component-instance identity (vm.$.uid) instead of a CSS selector, so the
pin can't inherit the same generic-.view-container ambiguity Task 1 found:
round-trip identity for Server (Test 1) and Web5 + a second tab (Test 2),
include-list correctness (Test 3), and the LRU cap staying intact (Test 4).
All four pass immediately against the unmodified code — that pass is
itself the pin, per the plan's explicitly anticipated no-change path.
Full suite green (95 files / 778 tests), type-check clean, build succeeds.
keepAliveTabs.test.ts confirmed byte-for-byte unmodified and still green.
No deploy: nothing in neode-ui/src changed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
isFullBleedPath(path) only returns true for /dashboard/chat and
/dashboard/mesh, both always in KEEP_ALIVE_PATHS today, so this branch of
wrapperFor()'s key derivation is currently unreachable. Add a comment
(per the review's own "non-blocking; a comment is sufficient" fix) so a
future reader doesn't mistake the defensive branch for dead code to delete.
Co-Authored-By: Claude <noreply@anthropic.com>
server.network-summary's fetcher batches four RPCs but only two forwarded
the AbortSignal useCachedResource provides for abort-on-unmount;
rpcClient.vpnStatus()/dnsStatus() had no signal parameter at all, so
aborter.abort() couldn't cancel them, partially defeating the documented
abort-on-unmount contract for this resource.
Add an optional signal parameter to both convenience methods (mirroring
the pattern used throughout rpc-client.ts) and forward it from Server.vue.
Co-Authored-By: Claude <noreply@anthropic.com>
entry(key, persist = true) and optimistic(key, update) silently defaulted
to persist:true after the first call for a key, and optimistic() didn't
accept a persist argument at all. Every current call site happened to be
safe, but the invariant was unenforced: a future caller invoking
store.optimistic() before any useCachedResource({persist:false}) has run
for that key in the same tick would silently start writing to
sessionStorage with no indication anything is wrong (T-02-01).
persist is now a required argument on both functions (no default), and the
per-key decision is recorded and asserted (dev-only warning) against any
later call that disagrees. useCachedResource's optimistic() wrapper now
threads its own already-resolved persist value through automatically, so
no existing composable caller changes behavior. The two call sites that
use the resources store directly (Cloud.vue/PeerFiles.vue's per-peer
browse cache) now pass persist:true explicitly, matching their existing
behavior exactly.
Co-Authored-By: Claude <noreply@anthropic.com>
load(params) routed every call -- including the Connect form's own
credentials -- through routerResource.refresh(), which resources.ts dedupes
per key. A second load({host, ssh_user, ssh_password}) call arriving while
an unrelated refresh was already in flight (e.g. useCachedResource's own
TTL-gated auto-revalidation) would just await that already-in-flight
promise; the caller's own params were silently never sent, with no error
surfaced.
load(params) now bypasses routerResource.refresh() entirely when explicit
params are supplied, calling rpcClient directly and writing the resolved
result into routerResource.entry so cache/TTL/status-panel rendering stays
consistent with a normal refresh() success. The plain reconnect path
(no params) is unchanged. The now-redundant pendingParams indirection is
removed since the fetcher only ever needs `{}` params going forward.
Co-Authored-By: Claude <noreply@anthropic.com>
Marketplace.vue and Discover.vue both register a useCachedResource against
the shared 'app-catalog' key with different fetchers; resources.ts's
in-flight dedup means whichever view's fetcher wins a given race governs
the shared entry, silently dropping Discover's catalogFeatured side effect
when Marketplace's simpler fetcher wins.
Give the featured-banner payload its own cache key ('app-catalog:featured')
subscribed only by Discover.vue, so it always gets its own data regardless
of which view's fetcher wins the shared 'app-catalog' race. fetchAppCatalog()
already memoizes internally (1h TTL + localStorage fallback), so this is
normally a cache hit rather than an extra network request. The shared
'app-catalog' key and its dedup behavior are unchanged.
Co-Authored-By: Claude <noreply@anthropic.com>
Web5.vue's lndInfoRes and profitsRes defaulted to persist:true (via
useCachedResource's default), writing live LND wallet balances and
channel balances to sessionStorage in plaintext -- a T-02-01 violation.
Add explicit persist:false to both, matching the "never defaulted"
rule this phase established everywhere else. Also updates Home.vue's
comment, which previously documented this as a known unfixed gap.
Co-Authored-By: Claude <noreply@anthropic.com>
Instrumented the exact trigger the fresh-mount guard (previous commit)
didn't fully eliminate: on a fresh session, loadPeerFiles() fired one
content.browse-peer RPC per connected peer with zero concurrency cap
and a 30s per-call timeout. Confirmed on archi-dev-box: 13 of 14
concurrent browse-peer calls never settled at all (dead/unreachable
peers with no server-side timeout on that path) — that many
simultaneously open, indefinitely-pending same-origin requests starved
Chromium's connection pool, silently breaking every other same-origin
fetch for the rest of the session, including the lazy route chunk any
later folder/tab navigation needs. This — not the router or the click
handler — was the actual cause of "no folders open on click" after a
first Cloud visit.
Fix, mirroring PeerFiles.vue's existing PREVIEW_CONCURRENCY pattern for
the identical class of problem (content.preview-peer fan-out):
- Cap the browse-peer fan-out at 3 concurrent requests
(BROWSE_PEER_CONCURRENCY, a queue+worker pool in loadPeerFiles()).
- Shorten each call's timeout from 30s to 10s (BROWSE_PEER_TIMEOUT_MS) —
bounds how long any one dead peer can hold a connection.
- Wire an AbortController (aborted onUnmounted) through rpc-client's
signal option for clean teardown.
- A timed-out/failed peer already resolved silently through
resources.ts's own error-state path (no throw, no toast) — confirmed
unchanged; the muted "N peers unreachable" line is the only surface.
No visual/behavioral change to the working case (D-01 rule) — peers
that answer still render exactly as before, just no longer share the
page with a dozen never-ending requests.
Full suite green (95 files / 774 tests), type-check and build clean,
keepAliveTabs.test.ts structural DOM assertions untouched.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
User-reported checkpoint regression: on a genuinely first visit to Cloud
this session, no folder opened on click (subsequent visits fine).
Root-caused on archi-dev-box, not guessed:
- The click DID register (confirmed via a direct DOM listener) and DID
call router.push (confirmed by patching the live router instance) —
but the push's promise never settled, because Vue Router awaits the
target route's async component, and that dynamic import() itself
never resolved.
- Confirmed via a manual import() from the page console: importing ANY
lazy route chunk (Fleet, CloudFolder, AppDetails — unrelated views)
hangs identically after visiting Cloud once, but works instantly
before ever visiting Cloud. Not chunk-specific, not router-specific.
- Traced to exactly one permanently-pending network request: a File
Browser `GET /app/filebrowser/api/resources/Photos` call from Cloud's
own onMounted burst, confirmed hung via request-lifecycle tracking
(never finishes or fails, still pending after 10s). The identical
request, issued manually with a fresh token outside of Cloud.vue,
returns in 29ms — ruling out the backend/File Browser itself.
- Mechanism: Cloud.vue's syncOnEntry() (loadCounts/loadPeers/
loadPeerFiles) fires from BOTH onMounted and onActivated with no
fresh-mount guard (02-04 exempted it, reasoning each resource is
individually staleness/inflight-deduped — true per-resource, but the
two back-to-back passes still double the concurrent request volume
at the single riskiest instant in a session: first KeepAlive
activation, stacked on whatever other cached view's own onMounted
burst is firing at the same moment). On real hardware that volume
was enough to leave one File Browser request stuck, which then
starves Chromium's per-origin connection pool — breaking every
subsequent same-origin fetch, including the lazy chunk any later
navigation needs. Not a router or click-handler bug; the click and
push both worked correctly the whole time.
Fix: give Cloud.vue the same fresh-mount guard already used in
Home.vue/Web5.vue/Mesh.vue/Server.vue (skip onActivated's redundant
first-activation re-fire since onMounted just ran it). Removes the
duplicate-burst mechanism without changing steady-state reactivation —
onActivated still re-syncs normally on every later KeepAlive round-trip.
No visual/behavioral change (D-01 rule).
Full test suite green (95 files / 774 tests), type-check and build clean.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deployed this phase's frontend + AIUI to archi-dev-box (the dev pair;
x250-dev is currently offline via Tailscale, recorded rather than
silently skipped) via scripts/deploy-to-target.sh --frontend-only.
Verified the served bundle (not just local dist) contains this phase's
changes.
Then took a real on-device measurement instead of leaving the FA-D
KEEP_ALIVE_MAX estimate unexamined: a headless Chromium session on
archi-dev-box cycled all 11 main tabs through 4 full round-trips (44
navigations), reading the JS heap via CDP before/after each cycle.
Memory fluctuated 10-21MB with no monotonic growth across cycles that
each exceed the cap 10 distinct registered paths against KEEP_ALIVE_MAX=6.
Left the constant at 6, now backed by a recorded measurement.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>