Signed release manifest for v1.7.126-alpha, verified against the pinned
release root before committing.
Committed by hand rather than by re-running create-release.sh: the script
regenerates the manifest at step 6, which would overwrite the signature
applied at step 6b. Its own "sign it, then re-run this script" advice destroys
the thing it just asked for.
Version bump, changelog and What's New landed earlier in 1cd068e4 — the
frontend build embeds the version via the curated What's New list, so those
must exist before the build step rather than after it.
Release gate: 8 of 9 stages passed in-run. cargo-test-weekly hit its 1500s
ceiling (exit 124, a timeout not a failure) because the non-incremental
all-targets compile does not fit on this machine; the suites were then run
separately and passed 100/100, including the downgrade guard and 30 update::
tests over the OTA apply/rollback path. Right-sizing that ceiling is a
follow-up — an override that becomes routine stops being a gate.
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 release gate failed cargo-fmt. The domain that replaced the IP-based
registry is longer, pushing several test assertions past the width limit, so
rustfmt wanted to re-wrap them. Pure line re-wrapping — no semantic change.
Caught by the pre-flight gate rather than after tagging, which is what it is
for.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The legacy `btcpay` entry (distinct from `btcpay-server`, no embedded manifest)
still carried a concrete 2.3.9 image. catalog_primary_image treats that as
authoritative, which is what drove the UI to offer "update to 2.3.9" on nodes
already running 2.4.2 — a rollback onto the actively exploited release.
Now 2.4.2 in both entries, signed by the pinned release root and verified.
This lands the fix for every node immediately, without waiting for the binary
carrying the downgrade guard (cbfda305) to reach them. The guard remains the
durable fix: it makes any future stale pin fail safe rather than relying on
every version claim being correct.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
package.versions answered installedVersion "15.17" for btcpay-server while
offering "2.4.2" — 15.17 being its postgres dependency's tag. With BTCPay's
own container absent, installed_version fell back to `containers.first()`,
which for a multi-container stack is an arbitrary sibling.
That is the number the update decision is made from, and it is what the UI
shows next to the available version, so a nonsense pair like "installed 15.17,
available 2.4.2" is presented as a legitimate upgrade.
The fallback now only applies when there is exactly one container, which still
covers apps whose container is named differently from their id (immich_server
for immich). With several containers and no identifiable backend, the honest
answer is "unknown" rather than a guess at a sibling.
Extracted as select_backend_container so the rule is testable directly.
Tests: the BTCPay stack case, the lone differently-named container, and the
archy- prefixed preference. Full suite 1157/1157.
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>
d0af38e8 shipped check-catalog-registry-trust.py without the file it reads:
`releases/**` in .gitignore silently dropped registry-trust-floor.json, so the
guard would have failed in CI and on any fresh clone. app-catalog.json only
stays tracked because it predates that rule.
Both are source rather than build output — nodes fetch the catalog from this
path on main, and the floor is what the guard checks it against — so both now
have explicit negations, with the reason recorded next to them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Encodes the sequencing rule that nearly shipped a fleet-wide outage today.
The signed catalog is authoritative on every node — catalog_image_override
makes its image refs beat the on-disk manifest. TRUSTED_REGISTRIES in the
working tree describes a binary being built now; nodes run whatever was last
shipped to them. Those two diverge for exactly as long as an OTA takes to
reach the fleet, and that window is when regenerating the catalog silently
breaks every install with "not from a trusted registry".
Regenerating today would have done precisely that: the generator embeds each
app's manifest, and those now name the new registry domain, which no deployed
binary trusts.
- releases/registry-trust-floor.json records the hosts DEPLOYED binaries
trust, separately from what the source tree accepts, with the new domain
parked under `pending` until an OTA carries it. The migration order is
written down there rather than living in someone's memory.
- scripts/check-catalog-registry-trust.py compares the catalog's hosts against
that floor and explains the ordering fix when they diverge.
- sign-catalog.sh runs it as a preflight BEFORE prompting for the mnemonic, so
a bad catalog is refused at the last reversible moment.
- CI runs it blocking, plus the drift report advisory (drift between a manifest
landing and the next signed release is expected, since only the ceremony can
close it).
Also installs PyYAML in the manifests job. That job passed only because GitHub
runners happen to ship ruby, which the validator used to require; it now needs
python3+PyYAML.
Verified: passes on the published catalog (2 hosts, both trusted); refuses a
simulated full regenerate (79 refs on the untrusted domain) and blocks the
ceremony without requesting the mnemonic.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed by the pinned release root and verified before publishing
(`ceremony verify` → OK). A present-but-invalid signature is a hard reject on
nodes, so verification is the gate, not the presence of a signature field.
Surgical edit rather than a regenerate: only the btcpay-server entry changed
(66 apps in, 66 out; 49 entries still resolve through the legacy registry,
untouched). A full regeneration would have embedded the repo's manifests,
which now name the new registry domain that no deployed binary trusts yet —
publishing that would have broken every app install fleet-wide, during a
security push. That sequencing is being fixed separately.
BTCPay's image comes from docker.io, so it is unaffected by the registry
migration either way.
Nodes pick this up via package.check_updates → refresh_catalog → reload
manifests, then package.update (stop → pull → remove → recreate → verify,
with rollback on failure).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
BTCPay 2.4.2 fixes a critical vulnerability that upstream reports as actively
exploited: a TOTP two-factor bypass via Greenfield Basic authentication
(btcpayserver/btcpayserver#7491).
Checked the 2.3.9 -> 2.4.2 breaking changes against how Archipelago actually
configures BTCPay; both are inert here:
- 2.4.0 removed the LNBank and Lightning Charge backends. Ours is a direct LND
connection built by container::lnd::ensure_btcpay_lnd_connection_secret.
- 2.4.2 disables Greenfield Basic auth five minutes after account creation.
Nothing in the daemon or frontend consumes BTCPay's API.
The manifest bump alone does NOT reach nodes: catalog_image_override makes the
signed catalog authoritative whenever the image repo matches, so a node would
be forced back to 2.3.9. The catalog edit is held locally until the signing
ceremony runs, because an unsigned catalog published to main would be accepted
by nodes (absent signatures are allowed) and would quietly drop authenticity.
check-app-catalog-drift.py only understood app-catalog/catalog.json, where
`apps` is a list. releases/app-catalog.json — the SIGNED catalog nodes actually
resolve apps through — keys `apps` by id and wraps each app's full manifest
under manifest.app. So the checker parsed the file that governs nothing and
raised ValueError on the file that governs everything. It now reads both shapes.
Running it against the release catalog shows the repo and the catalog agree on
content: of 34 image differences, all 34 are the registry host alone and every
tag is identical. The remaining version-string drift (v1.18.0 vs 1.18.0,
1.30.0-alpine vs 1.30.0) is cosmetic metadata, not image drift.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Fixes 4 test failures introduced by 8e814ca0, which I pushed after running
only the container-crate tests while the full suite was still compiling. Both
failures were real defects, not stale assertions.
1. Catalog-driven installs would have failed fleet-wide.
8e814ca0 dropped the old registry address from TRUSTED_REGISTRIES, but the
signed catalog still advertises image refs on it — deliberately, since
rewriting a signed artifact invalidates its signature. Nodes resolve apps
through the catalog, so every install would have been refused with "not
from a trusted registry". Reinstated as LEGACY_REGISTRY_HOST, documented
as transitional and removable only once the catalog is re-signed.
2. The update fallback lost the property it exists for.
update.rs keeps two mirrors on purpose: the domain as primary, and the
old IP over plain HTTP as a fallback, because a node whose DNS or clock is
wrong (both break TLS) must still be able to update itself — the signature,
not the transport, is what makes either source safe. The bulk rewrite
pointed both constants at the domain, leaving the escape hatch dependent on
exactly what it exists to survive. Restored to its original value.
Separately, validate-app-manifest.sh is ported from ruby to python3+PyYAML.
It shelled out to ruby with stderr discarded, so on any machine without ruby
a missing interpreter was reported as "Valid YAML with top-level app block:
FAIL" and every manifest came back REJECTED. This is the first tool an app
developer runs, and it sent them to fix YAML that was never broken. Ruby was
also the odd dependency out — the repo already ships three python scripts.
It now checks for python3 and PyYAML up front and names what is missing, then
parses with PyYAML. Missing keys resolve to an absent-value object that
indexes to itself and prints empty, so call sites lost their per-hop guards:
(((app["container"] || {})["build"] || {})["context"])
becomes app["container"]["build"]["context"]. Booleans still print as
true/false rather than Python's True/False — call sites compare == "true",
so Python's capitalisation would have silently inverted the readonly_root
and no_new_privileges security checks.
Verified: full rust suite 1148/1148, 0 failed. All 56 app manifests validate
(0 rejected, 0 errored) where previously every one was rejected. No signed
artifact modified.
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>
Untracking the ops docs broke every reference to them. Repoints or removes
those references across README, architecture, ROADMAP, the archive index,
lifecycle TESTING, the hardening plan, and the security docs — pointing at
the issue tracker where a live task list was meant, and dropping the entry
entirely where it only existed to link an internal file.
Also fixes two pre-existing broken links found by validating every relative
link in the tracked docs:
- README linked docs/OPEN_SOURCE_READINESS.md, which never existed
(underscores vs hyphens).
- reticulum-daemon/README.md linked a local Claude session plan at
../../.claude/plans/enchanted-strolling-rocket.md — outside the repo, and
a path that would have shipped publicly pointing at nothing.
All relative links in tracked markdown now resolve: 0 broken.
audit-secrets.sh still 5/5.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operations docs move out of git entirely rather than being sanitized. They
stay on disk for local use and are gitignored, so the Phase 6 export (which
takes HEAD) can never carry them. 15 files: the fleet runbook, hotfix
process, node inventories, internal trackers, session handoffs, the key
rotation/signing-posture records, and the open-source plan itself.
For the docs that remain public, infra identifiers are replaced with things
that are better documentation rather than placeholders: curl examples now
use `archipelago.local`, the product's own mDNS name, so a reader can run
them as-is instead of substituting an address that was never theirs.
Deliberately NOT scrubbed, both verified as functional rather than leaked:
- `tx1138.com` is the shipped default block explorer (DEFAULT_TX_EXPLORER in
useTxExplorer.ts, surfaced in WalletSettingsModal). Product behavior.
- `git.tx1138.com` in core/container/{image_policy,registry}.rs is a retired-
registry constant the code matches on to strip stale entries from legacy
node configs. Removing it would break migration for older nodes.
- `192.168.1.254` in bulletproof-containers.md is the LAN gateway in a podman
bug description, and `192.168.1.x` in user-walkthrough.md is already generic.
Whether a personal domain should be the shipped explorer default in a public
product is a separate product question, not a security one.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sequencing change per user decision: credential rotation/revocation runs
last, immediately before Phase 6, instead of first. Safe under fresh-history
publish — the scrub commits never become public — but recorded as a HARD
blocking gate on Phase 6, with an explicit rotation sign-off added as a
numbered pre-publish step so "scrubbed" cannot be mistaken for "rotated".
Also corrects the plan against what execution actually found:
- the fleet password was in 8 tracked files, not 7 (3 in .planning/)
- both Gitea tokens are already dead (401); only the `ai` password is live
- the Framework node's SSH password was rotated out-of-band and is unrecorded,
which would block it from receiving the fleet rotation
- .planning/ is 199 tracked files of internal agent state — added to Phase 2
as the largest un-triaged internal block still in the tree
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Scrubs the fleet SSH/UI password from every tracked file (22 occurrences)
and removes inline credentials from the code paths that used them.
Docs and trackers keep the surrounding context — these are published under
docs/history/ per the open-source plan — with the literals replaced by
<FLEET_PW> / <FLEET_PW_ALT> so the "two variants exist" detail survives
without the values.
Three of the eight files were in .planning/ and were NOT in the plan's
enumerated list; the reworked audit-secrets.sh found them.
Code changes:
- neode-ui/test-openwrt.mjs: node URL and password come from ARCHY_NODE_URL /
ARCHY_NODE_PW; the SSH target derives from the URL instead of a hardcoded
tailnet IP; exits 2 when unset.
- scripts/run-post-install-tests.sh: drops the built-in "testpass123!"
default and adds --password-stdin; refuses to run unauthenticated instead
of silently trying a known password. --phase1-only still needs no password.
- .gitea/workflows/post-install-tests.yml: sshpass with an inline literal
replaced by key auth (NODE_SSH_KEY secret); password comes from the
NODE_UI_PASSWORD secret and is piped over stdin rather than argv, so it
stays out of the node's process list and the job log. Default target IP
removed.
scripts/audit-secrets.sh now reports 5/5 pass, 0 fail.
Note: rotation of the exposed credentials is deliberately deferred to the
pre-publish gate and is NOT done by this commit — these values are still
live. See Phase 0.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit passed for months while two live Anthropic keys and the fleet
SSH password sat in tracked files. Three independent reasons:
- ALLOW_PATTERNS was matched against the whole "file:line:content" string,
not the path, so bare words like "test", "demo" and "example" dropped any
hit whose *content* merely mentioned them.
- `\.md$` was in that same allowlist and `--include` never listed *.md or
*.yml, so docs and CI workflows — where every real leak has lived — were
never scanned at all.
- The false-positive filter spelled the single-quote class `\x27\x27`, which
GNU grep does not expand in an ERE, so the empty-string rule never fired.
Now: scans tracked files via `git ls-files` (exactly the set that would be
published), covers md/yml/mjs/kt/toml, allowlists by path only, and adds
patterns for credentialed URLs and inline `sshpass -p`. Test fixtures under
testdata/ are exempted narrowly rather than by substring.
Verified by planting canary secrets in docs/api-reference.md and
.gitea/workflows/build-iso.yml — both file types the old version ignored —
and confirming the audit fails on them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found on archi-dev-box the moment the gate tried to serve TLS: the key was
installed root:root 0600, nginx's master reads it as root, but the archipelago
daemon runs as User=archipelago and got "Permission denied (os error 13)".
Every app port then quietly stayed plain HTTP — the exact fail-open shape the
gate exists to prevent, and it would have looked like "TLS just doesn't work"
with no obvious cause. The warn-level log the tls module deliberately emits for
a present-but-unloadable certificate is what turned this into a ten-second
diagnosis instead of a hunt; it earned its keep on its first real deployment.
Key is now group-owned by the service user at 0640, with a fallback to the
user's primary group and a clear message when no such user exists. Nothing
wider than that.
Verified on the node afterwards, on one gated port (8096):
https 401 verify=0 TLS terminated, chain valid against the node CA
http 401 same port, plain HTTP, unchanged
no CA verify=20 untrusted client correctly rejected
The reissued key was also picked up with NO daemon restart — the mtime reload
path proven in production, not just in a unit test.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
An app port must answer whatever the browser asks for: an HTTP dashboard
embeds http://host:PORT, an HTTPS one embeds https://host:PORT, and an HTTPS
page cannot embed an HTTP frame at all. So the choice is per-node, not
per-fleet, and a second port number would mean every manifest changes and
torrc doubles.
Instead the gate peeks the first byte. A TLS ClientHello is 0x16; no HTTP
method starts with it. peek() leaves the bytes in the socket buffer, so the
acceptor still sees a complete, untouched ClientHello. TLS and plain share one
generic serve_http(), so authentication, proxying and upgrade handling cannot
drift apart by scheme.
EXISTING NODES ARE UNAFFECTED BY CONSTRUCTION. Anything that is not a TLS
handshake takes the identical path as before, and a node with no certificate
serves plain HTTP exactly as today — TLS is strictly additive.
rustls does NOT verify that a private key matches its certificate. Established
by test, not assumed: with_single_cert accepted a pair from two different keys
and would only have failed mid-handshake in a user's browser — a security
control that reports success and does nothing, the exact shape this module's
own docs warn about. So the pairing is now proven explicitly (sign a fixed
message with the key, verify against the certificate's public key) and a
mismatch refuses to serve.
Also: cert and key mtimes are stamped as a PAIR, because reissuing writes them
separately and keying on one would serve a certificate that no longer matches
its key; a 15s first-byte timeout closes the slowloris window one step earlier
than the existing header-read timeout; PKCS#8 and PKCS#1 keys are both
accepted so a hand-made key does not silently downgrade a working node.
Deps pinned to the rustls 0.21 line reqwest already resolves — no new vendor,
no second rustls major. Test fixtures are throwaway (localhost SANs only), not
any node's identity.
38/38 appgate tests pass.
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>
Both had been sitting untracked in the working tree since 2026-08-05 —
exactly the "finished work lost because it was never committed" failure
CLAUDE.md's #1 process rule exists to prevent.
APP-PORT-AUTH-GATE.md carries the gate's design rationale ("you cannot
gate a socket you do not own") and, in its open questions, the TLS/scheme
fork that still blocks the gated-app iframe login: if the dashboard is
HTTPS and app ports are HTTP, a Secure session cookie is never sent.
RESUME-2026-08-05-appgate-fixes.md carries the .122-.125 release trail,
the two self-inflicted .124 bugs and their guards, and the open indeedhub
crash-loop (indeedhub-minio absent on .38/.88).
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>
Replaces the bespoke doc-page chrome with the app's real components:
DashboardSidebar shell (256px, rgba(0,0,0,.25) + 18px blur, staggered
nav-item entrance), the AnimatedLogo neode mark with its 20 staggered
squares, sidebar-nav-item + nav-tab-active for section state, and the
Settings wallpaper behind it all.
Every bespoke container (.card/.card-sm/.step/.score-card/.callout-*/
.diagram) is gone — .glass-card from style.css is now the only box on
the page, with layout-only grids inside it.
Scroll-spy lives in nav.js rather than inline, since the node's CSP is
script-src 'self'.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deploying the .126 LoRa panel ahead of its daemon made every button
report "Operation failed. Check server logs for details." — the panel
was calling RPCs the older binary doesn't have, and the sanitizer
masked "Unknown method: mesh.rnode-config" into that generic string.
Read as "the feature is broken" rather than "this node needs its
update" (operator, 2026-08-06).
Allowlisted: "Unknown method" (a frontend newer than its daemon should
say so), every RNode RF validation message (each names the field and
its legal range — the entire point of validating before touching the
radio), and the actionable mesh preconditions (no device connected,
mesh service not running, MeshCore has no remote reboot, radio daemon
did not answer, RNode interface disabled).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The port map deferred to DISK manifests for any app with a build source
— which is exactly the four companion UIs (lnd-ui, bitcoin-ui,
electrs-ui, fips-ui). Their disk manifests reach nodes only via the
frontend runtime payload or a per-node repo checkout, and in the
v1.7.125 rollout both proved stale or entirely absent: one node had no
checkout at all, others restored an older payload over apps/ at every
boot. Result: session_passthrough never reached the gate, so the node's
own screens 401'd on every data call, and on nodes whose UI rebuilt
from a stale context the app held its port UNGATED.
Classification now uses a ports-only overlay that accepts build-source
manifests (install/orchestration still defers to disk — unchanged). The
signed catalog is the freshest, operator-signed source, and the gate's
address binds fail safely against a container publishing differently
(logged CANNOT PROTECT), so this can only tighten policy, never expose.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Layer the page over /assets/img/bg-settings.webp (fixed, cover, with a
dim gradient so prose between glass panels stays readable) and load the
actual Montserrat Bold/ExtraBold faces from the node's own web root,
instead of the flat-black background the /architecture/ guide uses.
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>
- mesh.rnode-config: persisted RF settings + best-effort live radio
state (radio-confirmed r_* values) for the LoRa panel.
- mesh.rnode-config-apply: validate → persist → restart the radio
daemon → poll the read-back until the radio reports online, returning
{applied, confirmed, live, message}. Failure modes report what
actually happened instead of pretending success.
- RebootRadio carries a reply channel: Meshtastic reboots firmware,
Reticulum restarts the sidecar (re-detect + reapply RF config),
MeshCore honestly reports it has no remote reboot — previously the
Reticulum/MeshCore arms returned Ok(()) doing NOTHING: the operator's
"button gives no feedback" bug.
- MeshCommand::QueryRadioState plumbs the sidecar's radio_state to the
service layer with a timeout instead of fire-and-forget.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The .126 LoRa panel's Rust half:
- mesh::rnode_settings: RNodeRfSettings persisted at
<data_dir>/rnode-rf-settings.json — every RNodeInterface parameter
(enabled, port override, frequency, bandwidth, sf, cr, txpower,
airtime_limit_short/long), validated against the bounds RNS itself
enforces. Defaults are byte-identical to the sidecar's historical
argparse defaults.
- FIRST-RUN ADOPTION (operator requirement: the update must change NO
device's applied settings): with no settings file yet, the node's
existing RNS config (~/.archy-reticulum, else ~/.reticulum) is parsed
and its RNodeInterface values adopted verbatim as the initial
settings — proven by a test carrying the operator's literal
"RNode LoRa Portugal" config.
- Serial spawns pass the settings as explicit sidecar args (frequency/
bandwidth/txpower/sf/cr + airtime locks); the operator port override
wins over auto-detect but still passes the KISS probe gate; a
disabled interface refuses to open with a readable error.
- ReticulumLink::query_radio_state(): asks the sidecar for the live
RNodeInterface state (radio-confirmed r_* values) — the panel's
apply-confirmation read-back source.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Groundwork for the .126 LoRa settings panel (operator top priority):
- --airtime-limit-short/--airtime-limit-long (percent duty-cycle locks,
e.g. EU868 25/10) written into the RNode interface config when set;
default None writes nothing — identical to older daemons.
- New socket RPC {"cmd":"radio_state"} returns the live RNodeInterface
state: requested config values AND the radio-confirmed r_* values
(r_frequency/r_bandwidth/r_txpower/r_sf/r_cr/r_st_alock/r_lt_alock,
online, port, airtime utilisation). The r_* values are what the RADIO
reported after detect/configure — the settings panel's proof that the
device is actually using what was applied.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Curl-and-pipe repair for nodes whose companion-UI manifests are stale
(no session_passthrough): fetches the four current manifests from the
public repo, installs them into /opt/archipelago/apps AND the frontend
runtime payload (which restores over apps/ at every boot), restarts,
and reports the gate probe. Long paste-blocks kept mangling in the
operator's terminal — this replaces them with one short line.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
chown_for_rootless_container prefers `podman unshare chown` (which maps
container uid N through the userns), but when that failed once it fell
back to `sudo chown -R <literal>` — writing e.g. host uid 999 for
container uid 999 and reporting success. Host-999 maps to nobody inside
the userns, so the app could not open its own data while everything
claimed the chown worked: botfights on framework-pt crash-looped every
10s on SQLITE_CANTOPEN over a data dir the daemon itself had just
"fixed".
The sudo fallback now translates container ids (1..99999) to
subuid_base + id - 1 (fleet base 100000; container root maps to the
service user, 1000). Already-mapped ids and uid 0 pass through.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A sweep of 92 unconfirmed/dust sats failed with LND's debug-flavored
"insufficient input to create sweep tx: input_sum=0 BTC, output_sum=
0.00000092 BTC" — and the RPC sanitizer then masked even that behind
"Operation failed. Check server logs." (framework-pt, 2026-08-06).
The sweep mechanics are untouched (balance minus fee, as always) —
this only makes the refusal say WHY in plain language.
- lnd.sendcoins translates the sweep refusal: balance below Bitcoin's
dust minimum or not yet confirmed, so no transaction can be built
(LND's original message kept in parens).
- "Failed to send" joins the sanitizer's user-facing allowlist — the
same lesson as "Insufficient balance"/"Payment failed" before it.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The periodic reconcile runs ExistingOnly — merely listing a catalog
manifest must never install an app — and its only absent-container
recovery keyed on the last running-names snapshot, which ages out after
a few daemon restarts. An absent member of an installed stack then stays
absent forever: .38 ran indeedhub with no minio/postgres for days, nginx
down on 'host not found in upstream "minio"', and nothing ever put the
members back.
A live sibling container is proof the stack is installed on this node,
so an absent member is now treated as a hole to repair, not a choice to
respect: the recovery guard also fires when another member of the same
stack (app_ops::stack_member_app_ids) has a container in any state.
A stack with no containers at all is left untouched, and sibling app ids
resolve through the loaded-manifest container names (immich-postgres
runs as immich_postgres).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Carries the two manifest-side halves of the .125 fix batch: the four
companion UIs (lnd-ui, bitcoin-ui, electrs-ui, fips-ui) declare
session_passthrough on their gated ports so the gate forwards the node
session their nginx proxies to the daemon, and indeedhub-redis gains
CHOWN+DAC_OVERRIDE so its entrypoint can traverse its own data dir.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Both predate this session's changes and were masked by the release
gate's cargo-test-weekly compile timeout:
- login_page_sources_its_art_from_the_gate still asserted the retired
wordmark (logo-archipelago.svg); the login page ships the sidebar A
mark (favico-black-v2.svg) since the 2026-08-05 rework.
- unauthenticated_ports_are_all_accounted_for lagged at 17; the
v1.7.123 port-policy round grew the rationale-carrying exempt set
to 25 (reviewed and enumerated in the test comment).
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>
- lnd-ui/bitcoin-ui/electrs-ui/fips-ui declare session_passthrough: true
on their gated ports — their nginx forwards the browser's node session
to the daemon's authenticated endpoints, which the gate's cookie strip
was discarding (every data call 401'd behind the gate).
- indeedhub-redis gains CHOWN + DAC_OVERRIDE: the alpine entrypoint runs
as capability-stripped container-root and could not traverse the 0700
appendonlydir owned by the redis uid — crash-looped ~4k restarts on
archi-dev-box under the quadlet migration.
These reach nodes via the signed catalog re-sign (manifest overlay).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two daemon bugs, one debugging arc (2026-08-05, operator-reported):
1. The app gate removed the ENTIRE Cookie header before proxying. That
broke the data plane of every first-party companion UI behind the gate
(lnd-ui/bitcoin-ui/electrs-ui/fips-ui render their shell, then every
/proxy/* and /lnd-connect-info call 401s — observed as "LND UI
unreachable"), and silently logged users out of every gated app with
its own cookie login (vaultwarden, nextcloud, gitea) on each request.
The gate now strips only its own cookie pairs (session, csrf_token);
a new per-port manifest opt-in `session_passthrough: true` forwards
the node session to first-party UIs whose nginx proxies the daemon's
authenticated endpoints. Undeclared ports never get passthrough.
2. podman_client::create_container sent named volumes to the libpod API
as bind mounts with the bare volume name as source, so creating any
manifest app with a `type: volume` mount failed. On .38 the reconciler
removed indeedhub-postgres/-minio for env drift and then could never
create their replacements, leaving the stack half-missing forever.
Named volumes now ride the spec's `volumes` field ({Name, Dest,
Options}). Also: the reconcile-failure log now prints the full anyhow
chain — `%e` showed only "create_container X" and hid the real error.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Declaring the Guardian UI port on the fedimint app made the orchestrator try
to publish 8175 from fedimintd, colliding with archy-fedimint-ui which
already holds it: start_container failed on every reconcile and fedimint
crash-looped (100.82.34.38). The companion's nginx pinned to 127.0.0.1 is
what actually closes that port; the gate reports it rather than fronting it.
Also: app-login page uses the sidebar's 'A' mark instead of the full
wordmark, is pinned to the small viewport so it stays centred and the
keyboard overlays rather than scrolls it, and the install-version modal
icon uses object-contain so a non-square icon is no longer cropped.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Unbreaks Bitcoin on every node running the 1.7.124 catalog: the embedded
start script had a shell syntax error, so bitcoind never launched and the
app vanished. Delivered by catalog rather than a release because manifests
reach nodes through the signed catalog.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The bitcoin app vanished from updated nodes: the container exited instantly
with 'sh: Syntax error: "fi" unexpected'. My 1.7.124 change added an
explanatory comment INSIDE the manifest's folded YAML scalar (>-), where
'#' is not a comment — it is literal text that reaches the shell. Folding
joins lines with spaces, so the comment swallowed the 'if ... then' while
the more-indented echo survived as its own line, leaving an orphan 'fi'.
bitcoind never ran, the container exited, and the app disappeared from the
UI because detection is container-based.
Explanations now live above the '- >-' line where YAML really treats them
as comments. The loopback-conf tolerance (-allowignoredconf=1) is unchanged
and still needed.
Adds scripts/check-manifest-shell.py to the release gate: it runs 'sh -n'
over every embedded manifest script and rejects '#' inside these scalars.
Nothing validated this shell before — no YAML parse or Rust test could have
caught it, and it only failed on the node, after signing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
generate-app-catalog.py writes APP_LAUNCH_PORTS one entry per line; rustfmt
packs it. The release gate checks formatting, so the generated file has to
be formatted after regeneration or every catalog sync fails the gate.
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>
Pre-bumped so the release gate compiles the test profile at the final
version — create-release bumps after the gate, so the gate would otherwise
run on the old version and the bump would invalidate the cache, timing out
cargo-test-weekly on the compile rather than the tests.
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>
austin-sapien (100.70.96.88) sat dead for over two hours after taking
v1.7.122 — 'server starting' in the UI, service inactive, exit status
0/SUCCESS. It did not crash: the in-process updater replaces the binary and
exits cleanly for systemd to restart it, and that node's unit still carried
Restart=on-failure from an older install. systemd read the clean exit as
success and left it stopped. Every node with the old unit has this waiting
for it on the next update.
self-update.sh does refresh units, but the in-process update path never
runs it, so nothing was repairing them. The daemon now checks its own unit
at boot and rewrites only the Restart= line, so a node that starts even
once ends up with a policy that survives the next update.
Also carries the session-policy wiring: validate() now honours the
configured idle and absolute limits and the per-device class, instead of
the single hard-coded 24h constant.
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>
Portainer: nodes have been running :latest — which is 2.39.1 — while the
manifest pinned 2.19.4 from two years ago. The port migration recreated the
container onto that old pin and Portainer refused to start: it migrates a
database forward, never backward, so an existing install died with 'schema
version does not align' and My Apps showed 'app is not responding'
(100.82.34.38). 2.39.1 published as an immutable tag and pinned forward, so
existing databases keep working and older ones migrate up.
Bitcoin: complements PR #131. That removes the code which kept writing a
datadir bitcoin.conf; -allowignoredconf=1 additionally makes an existing
one non-fatal, so a node already carrying the file recovers on restart
instead of crash-looping until something reinstalls it.
App gate login: rebuilt against the dashboard's own design — rotating
intro backgrounds served from the gate, the glass panel, the Archipelago
mark in its gradient ring, the app's icon as a My Apps tile, and the glass
button. Crucially it no longer sends X-Frame-Options: DENY, which made
every gated app render as unreachable inside My Apps' embedded frame;
frame-ancestors expresses 'only this node may frame me', which
X-Frame-Options cannot.
OTA origin: primary mirror is now source.archipelago-foundation.org over
TLS instead of a bare IP on plaintext. The IP stays as an automatic
fallback for nodes whose DNS or clock is broken — both break TLS, and the
signature, not the transport, is what establishes trust.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Root cause of the Bitcoin crash-loop on 100.82.34.38: since a597c1d9
bitcoind launches with -conf=/tmp/rpc.conf and never reads the datadir
bitcoin.conf, but write_bitcoin_conf / ensure_bitcoin_rpc_config /
run_bitcoin_rpc_repair kept writing one on every install and restart.
Bitcoin Core's own datadir-conflict check then refuses to start at all.
Conflict resolved in favour of the PR: HEAD still carried
write_bitcoin_conf, whose deletion is the fix.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mint failures surfaced raw JSON ({"detail":"proofs already spent"}) to
the user; now the top-level message is actionable while the raw body stays
in logs via {:#}.
The manifest advertises exactly one version, so publishing .123 (new-key
signed) removed the only stepping stone across the rotation. A node on
.121 pins the OLD root, fetches the .123 manifest, fails signature
verification and refuses — permanently, because .122 is no longer offered
anywhere. Reproduced against the live URL: 'signed_by does not match the
pinned release-root anchor'. archy-shorty-s (.228) is on 1.7.121-alpha-dev
and in exactly this state.
Restoring the old-key-signed .122 manifest as the OTA pointer lets those
nodes take .122, which installs the new pin; .123 is republished once the
fleet has crossed. Safe in both directions: is_newer() is a strict
greater-than on the version triple, so a node already on .123 sees .122 as
older and does not downgrade.
The .123 release itself is untouched — tag, assets and catalog stand; only
the pointer moves.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A rebuilt image never reached a running companion. ensure_image_present
rebuilds in place under the same tag, so the quadlet body is identical,
write_if_changed reports no change, and enable_now is a no-op on a running
service — the container keeps the old layers indefinitely.
That is precisely how archi-dev-box kept serving the LND, FIPS, Electrs and
Guardian screens on 0.0.0.0 after v1.7.123 rebuilt every one of those images
to bind loopback: correct images on disk, three-day-old containers still
running. Closing those ports needed a manual 'podman rm -f' per container,
which no other node would ever get. Compare the running container's image ID
against the built one and restart when they diverge.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
v1.7.122-alpha was the last release signed with the old root — it is the
release that installed the new pin on every node. From v1.7.123 the new
root signs, and a node running .122+ rejects an old-key signature. The
ARCHY_RELEASE_ROOT_PUBKEY override is no longer needed either: the signer
built from this tree pins the same key we now sign with.
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>
Fresh installs pull *-ui images from the registry, so the source fix alone
left a newly flashed node serving the Bitcoin, LND, Electrs, FIPS and
Guardian screens with no login. All five rebuilt and pushed to
146.59.87.168:3000/lfg2025 as 1.7.123-alpha AND :latest — both tags,
because first-boot resolves the pinned tag from image-versions.sh while the
daemon's companion installer hardcodes :latest, and a stale :latest would
have quietly undone the fix on exactly the path that rebuilds companions.
Verified by pulling each image back from the registry anonymously and
reading /etc/nginx/conf.d/default.conf inside it — a private package would
make fresh nodes fall back to a stale local image without saying so.
Also fixes the FOURTH copy of bitcoin-ui's listen directive
(scripts/reconcile-containers.sh wrote 'listen 8334' into the rendered
nginx.conf on every reconcile, which would have re-opened the port after
the image and template were both corrected).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Scanning archi-dev-box from OUTSIDE found five ports serving their screens
with no login — lnd-ui 18083, bitcoin-ui 8334, fips-ui 8336, electrs-ui
50002 and the Fedimint Guardian 8175 — none of which appeared in the gate's
unprotected list. They are host-networked, so Podman publishes nothing to
pin and their manifests declared 'ports: []'; the gate builds its map from
declared ports, so it neither protected them nor reported them. An audit
that reports success while five screens are open is worse than no audit.
Their nginx now listens on 127.0.0.1 instead of 0.0.0.0, and each port is
declared 'auth: gated' so the daemon owns the outside. 'bind:' on a
host-networked app is a statement of where the container listens, not a
publish instruction — quadlet already skips PublishPort in host mode.
Guardian 8175 is declared on the fedimint app because its companion has no
manifest, and the gate keys on port, not container.
Credential paths were NOT exposed and are verified so: /lnd-connect-info,
the /proxy/lnd/ passthrough, container logs and every RPC method through
these screens all return 401 unauthenticated. What leaked was the page
shell.
Also fixes the delivery gap that would have made this unshippable: only
bitcoin-ui, lnd-ui and electrs-ui were ever rsynced to
/opt/archipelago/docker, so edits to fips-ui and fedimint-ui reached nodes
through no path at all. All five now sync; the two whose rebuilds the
daemon owns are synced without being handed to container-specs.
Every remaining undeclared port is now declared with a stated reason —
gated: botfights 9100, router 8084, pine 10380; exempt with rationale:
fedimint consensus 8173/8174, gateway 8176/9737, netbird 8086/8087 (TLS +
own auth, and enrolled devices cannot hold a session), pine TLS 10381,
lightning-stack REST 8091 (macaroon, mirrors lnd). Zero undeclared ports
remain across all 56 manifests.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The rotation commit pointed create-release.sh and publish-release-assets.sh
at the NEW root in the same commit that pins it in the binary. But the
release CARRYING the rotation must be signed with the OLD root: every node
is still running the previous binary, which pins the old key. So the
tooling would have rejected the only signature the fleet can accept, and
the signature it demanded would have ended OTA fleet-wide.
Both checks now expect the old DID for this cycle, with the flip to the new
one called out for v1.7.123+. sign-manifest.sh documents the
ARCHY_RELEASE_ROOT_PUBKEY override needed because the signer built from
this tree already pins the new anchor and would fail to verify its own
correct output.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Mint HTTP failures (swap/melt/mint-quote) were surfacing raw JSON bodies
like {"detail":"proofs already spent","code":11001} straight to the
user. Add a translator for the NUT-02/03/04/05 transaction-validation
error codes (10001-11017, 12001-12003; see
https://github.com/cashubtc/nuts/blob/main/error_codes.md) and layer it
onto the mint_client bail sites via anyhow context, so the top-level
message is actionable while the raw status/body stays available via
{:#} for logs. receive_token now surfaces the real reason (e.g. "This
ecash has already been redeemed") instead of a generic "Failed to
receive any proofs from token" when every mint in a token fails.
Co-Authored-By: Claude Sonnet 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>
DO NOT MERGE INTO A RELEASE SIGNED WITH THE NEW KEY. See below.
The previous release root (z6Mkkid…q7ur, pinned 2026-07-02) was exposed
in a chat transcript and is treated as compromised. It signs both OTA
manifests and the app catalog, so anyone holding it could sign updates
the fleet would install.
Pins the new key in trust::anchor and moves EXPECTED_DID in all three
signing/publishing scripts.
ORDERING IS CRITICAL — nodes pin the OLD key:
* The release CARRYING this commit must be signed with the OLD key.
That is the only signature a node running the previous binary will
accept, and it is what installs the binary pinning the new key.
* Only the release AFTER that may be signed with the new key.
* Signing this release with the new key makes every node reject it,
ending OTA fleet-wide and requiring hands-on recovery per node.
sign-catalog.sh moves in the same commit, so the app catalog must also be
re-signed with the new key once this ships, or nodes accept the binary
and reject the catalog.
Key verified before pinning: the hex and the did:key are the same
keypair, checked with a base58 decoder round-tripped against the previous
known-good pair. An earlier candidate hex (cb830e13…) was rejected
because it decoded to a different DID than the one supplied — pinning it
would have made every node reject every future update.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Caught verifying the gate fixes on archi-dev-box: [fips0-ULA]:32838
answered HTTP 200 straight from nbxplorer with no credential. The
catalog declares that port auth: local — host-local by intent, pinned to
loopback, the gate deliberately keeps its hands off — but the mesh relay
bridges a STATIC port list to 127.0.0.1, so it republished it to the
whole mesh. Same bug class as the Tor onion gap: a transport that
converges on the app loopback without consulting the declaration.
PortMap now records declared-local ports and the relay withholds them
(tearing down an existing bridge if a catalog refresh newly declares
one), alongside the declared-gated withhold. Undeclared ports keep
todays behaviour — silence is not an instruction in either direction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Dev-box verification of the Tor/FIPS fixes caught a pre-existing split
brain: the orchestrator publishes containers from the signed catalog's
embedded manifests (origin-wins), but the gate classified ports from the
stale disk manifests — so it externally bound nbxplorer 32838, a port
the catalog declares auth: local and pins to loopback. Reachable behind
a login, but reachable where it deliberately was not.
- build_port_map now consults the catalog overlay first, via the same
parse/validate/image-only filter the orchestrator uses (moved to
app_catalog::catalog_manifest_overlay so the two cannot diverge again).
- GatedPort carries . The gated set still includes undeclared
Session-default ports for challenge/audit, but every action that
REDIRECTS traffic — the torrc 127.0.0.2 repoint, the FIPS relay
stand-down, the Tor-upstream bind — now keys on the declaration.
- The sweep releases held claims whose port left the gated set, so a
catalog refresh that withdraws a port (gated → local/none) takes
effect without a daemon restart.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Legacy pre-quadlet containers kept publishing 0.0.0.0 after the catalog
pinned their app to loopback, because host_port_bindings_drifted only
compared host PORT numbers — closing them needed a manual package.update
per app per node. The drift check now also compares the bind ADDRESS,
but only when the manifest declares one: an empty bind never fires,
since recreating a loopback-published container to wildcard on silence
is exactly the v1.7.121 Bitcoin-RPC incident. With this, every node
recreates its legacy containers to the declared state on its own after
the OTA.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The mesh relay is a raw unauthenticated forward to the app's loopback,
and whether it or the gate owned a fips0 ULA port was decided by a bind
race — the dev box happened to be safe because the gate bound first.
The relay now skips ports declared auth: gated and tears down any
existing bridge for a port that became gated since it was bridged
(catalog refresh), releasing the bind for the gate's next sweep.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Tor carries no session cookie, so HiddenServicePort → 127.0.0.1:<port>
reached the app around the gate — the last transport the gate did not
cover. The gate now binds 127.0.0.2 (its own loopback, distinct from the
app's 127.0.0.1, so no app needs a second port), and regenerate_torrc
forwards declared-gated ports there. Undeclared ports keep today's
target: absence of the field is not an instruction.
The 127.0.0.2 claim deliberately does not count toward the unprotected
audit — a port whose only claim is the Tor loopback is still wide open
on the LAN and must keep warning.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Embeds the manifest port declarations (bind: 127.0.0.1 + auth: gated on 20
HTTP UIs, auth: local on loopback backends, auth: none + rationale on
protocol ports) into the signed catalog so nodes enforce the app gate.
Also carries bitcoin-ui/lnd-ui 1.7.119 version drift.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
20 HTTP UIs move to bind: 127.0.0.1 + auth: gated (the daemon owns their
external addresses and authenticates every connection); 5 loopback-only
backends declare auth: local so the gate keeps its hands off. Protocol
ports (LND, bitcoin p2p, electrum, CLN, gitea SSH, Wyoming, mDNS/SSDP)
were already declared auth: none with rationales in earlier commits.
Inert until the catalog is re-signed: nodes act only on declared fields
delivered via the signed catalog, and the catalog overlay overrides these
disk manifests everywhere they are installed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Publishing v1.7.121-alpha failed on auth after the manifest had already
passed every check. The script required an `http://user:token@` remote,
which left only `gitea-vps2` — whose token is dead — and rejected
`gitea-ai`, the https remote whose credential actually works for git
push. Same Gitea instance (146.59.87.168, v1.27.1) either way, so the
restriction bought nothing and blocked the one usable path.
Accepts http and https, and carries the scheme through to the API URL
instead of hardcoding it.
Note for diagnosis next time: `/api/v1/repos/.../releases` is publicly
readable, so a 200 there does NOT prove the credential works. Use
`/api/v1/user`, which requires real auth.
Co-Authored-By: Claude Opus 5 (1M context) <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>
Two live incidents on archi-dev-box today, one bug. Both times a safety
decision read an ABSENT manifest field as if it were a value, and a
node's installed manifests always lag the binary — so "absent" is the
state of essentially every port on every node.
1. Gating any `session` port regardless of `bind` published Bitcoin's
loopback-only RPC 8332 on the LAN, Tailscale and IPv6 within seconds
of deploy.
2. The `bind`-keyed replacement looked safe because it protected
`bind: 127.0.0.1` ports — but LND's gRPC 10009 and REST 18080 carry
an EMPTY bind, so they fell through. One container recreate from
pinning them to loopback and breaking Zeus and every remote wallet.
`auth` is now `Option<PortAuth>`, separating two questions that were
conflated:
* `auth_policy()` — what to CLASSIFY the port as. Undeclared reports as
Session, i.e. shows in the audit as something that should be behind
the gate. Reporting is always safe.
* `auth_is_declared()` — whether the daemon may ACT. Only an explicit
declaration authorises changing how a port is published.
Also reverts the daemon-side publish rewriting entirely. The node proved
it wrong twice over: the recreate path that actually ran was in
package::install, not podman_client, so the pin never fired; and even
`bind: 127.0.0.1` written directly into the node's manifest was
overridden by the signed catalog. Publishes are built in several places
and all of them already honour `bind`, so the migration belongs in the
catalog as data — not in daemon-side inference that can only ever cover
one path and guess wrong on the rest.
Tests: 75/75 container, incl. the LND wallet-port shape (`host: 10009`,
empty bind, no auth) asserted to be non-actionable.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Peering a node was not enough to message it — you had to be in radio
range once before chat worked, which defeats the point of federating.
`send_message` chose its transport from the attached radio:
let use_typed_envelope =
archy && matches!(device_type, Meshcore | Reticulum);
Only the typed path knows about FIPS/Tor. Everything else fell through to
`peer_dest_prefix`, which resolves an over-the-air ROUTING key — so on a
node running Meshtastic, or with no radio at all, sending to a federated
peer failed. It only worked once a LoRa advert had created a radio twin
for the same archipelago identity, which is precisely the "connect on
LoRa first" the operator hit.
Federation contacts are reachable off-radio by definition — that is what
`upsert_federation_peer` records with `reachable: true` — so the
transport choice must not depend on which radio is plugged in. A
federation-synthetic contact id now always takes the typed path.
This loses no radio-first behaviour: `send_typed_wire` already prefers a
REACHABLE radio twin when the payload fits the frame, and only then falls
back to FIPS and Tor. The fix routes federation contacts INTO that logic
rather than around it.
Test pins the predicate across every device type, including the two that
failed (Meshtastic, Unknown), and asserts ordinary radio contacts and
stock clients still route exactly as before.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Observed live on archi-dev-box: archy-fedimint-ui rebuilt 45 times in 10
minutes, every ~35s, indefinitely. bitcoin-ui, lnd-ui and electrs-ui were
all one reconcile away from the same loop.
`context_is_newer_than_image` decides to rebuild when the build context's
newest mtime is later than `podman image inspect .Created`. The rebuild
that follows is a full layer-cache hit, so podman reuses the identical
image and leaves .Created untouched — the condition that triggered the
rebuild is still true afterwards. The check cannot converge: it rebuilds
on every reconcile tick forever, burning CPU and churning the container.
It bites after any deploy that refreshes /opt/archipelago/docker/*, which
makes the contexts newer than the shipped images — so this is fleet-wide
on every OTA, not local to one node.
Fix: stamp the context mtime that was built into an image label and
compare against that instead. A label is part of the image config, so a
cache-hit build with a new value still produces a new image — the thing
being tested does change, and the comparison settles after exactly one
rebuild. Verified against real podman before writing it: two cache-hit
builds with different label values produced distinct image IDs
(6cfdbc9bcd3e vs a9b10eb9a558), each carrying its stamp; the indexed
inspect format was checked against an image with real labels, and a
missing label prints empty (handled, along with "<no value>").
Images built before this carry no label and fall back to .Created, so
behaviour is unchanged for them and each self-heals on its first
reconcile after upgrade — nodes fix themselves rather than needing the
manual `podman build --no-cache` pass this needed by hand.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both found while setting up the on-node test, and both fail silently in
the same direction — the gate reports success while protecting nothing,
which is the exact failure the module was written to prevent.
1. Loopback-pinned ports were skipped entirely.
`identity.rs` dropped any port whose manifest sets `bind: 127.0.0.1`,
reasoning that a loopback publish is not externally reachable. But
`listener.rs` requires loopback-pinning as the PRECONDITION for gating —
while an app holds 0.0.0.0:<port> the kernel will not let the gate bind
that port at all. So the two contradicted each other: pinning an app, the
one action that lets the gate take over, was also what removed it from
the gated set. Completing the entire migration would have gated nothing,
and GateStatus would have reported zero unprotected ports while doing it.
`bind` cannot carry this decision, because two unrelated intentions
produce an identical loopback publish: Bitcoin's RPC 8332 is pinned so
the LAN CANNOT reach it (fronting it would newly expose it on every host
address, behind a login but exposed where it deliberately was not),
whereas a migrated app is pinned precisely so the gate CAN. Inferring
from `bind` breaks one or the other, so the intent is now declared:
`PortAuth::Local` means the first case. The three ports that are
host-local by intent (bitcoin-core/knots 8332, aiui 5180 — all already
`bind: 127.0.0.1`) say so, and a loopback publish with `auth: session`
stays gated. A test pins that property.
2. The port map was never refreshed.
`AppGate::refresh()` existed, was documented as making catalog changes
apply without a restart, and was called by nothing. The map was built
once in `new()`, so an app installed while the daemon runs would never be
gated — and would never appear in `unprotected` either, so the node would
report itself fully enforced while serving a brand-new app to anyone who
asked. The sweep now refreshes before classifying.
Tests: 22/22 appgate, 73/73 archipelago-container.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Every cycle has needed a manual check that releases/manifest.json got
signed, because the script would happily commit and tag one that hadn't.
The signing step is conditional: with no TTY and no
RELEASE_MASTER_MNEMONIC it prints a warning and falls through. The commit
at step 7 then ran regardless, so the release commit — and its tag —
carried an unsigned manifest.
publish-release-assets.sh already refuses to ship one, but that backstop
arrives a step too late. Nodes fetch releases/manifest.json straight from
branch `main` (the same URLs this script prints for verification), so the
COMMIT is what exposes it to the fleet, not the publish. By the time
publishing is refused, the unsigned manifest is already on main and nodes
are already declining to auto-apply.
So the same gate now runs before the commit: presence of a signature,
signed_by matching the release root, and `ceremony verify` for the crypto.
A release commit carrying a manifest no node will accept has no valid use,
so this refuses to create one rather than leave a tag that has to be
re-cut. The earlier warning is corrected too — it promised the run would
continue, which is no longer true.
Verified the predicate against three manifests: signed -> allow, signature
stripped -> refuse, signed_by swapped to another DID -> refuse.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reproduced again on this node today: with no session cookie, six app
ports answered HTTP 200 with their real UIs (18083 LND, 8334, 8175
Fedimint Guardian, 8336 FIPS Mesh, 8090, 7777), all bound 0.0.0.0 and so
served on every host address. Same bug class as the /lnd-connect-info
and /bitcoin-rpc/ leaks closed in v1.7.120, but across every app.
LAN, Tailscale, Tor and the FIPS mesh all converge on 127.0.0.1:<port>,
so this is one gate rather than four. It lives in the daemon rather than
a per-app sidecar (umbrel's app_proxy model): rootless, no extra
container per app, and it can reuse machinery that already exists.
It invents no authentication policy. verify_password, TOTP secret
decryption, verify_code with used-step replay protection, the session
store, and — importantly — the SAME LoginRateLimiter instance as the
JSON-RPC path, so an attacker cannot get a fresh budget of password
guesses by moving to an app port. Only the transport differs, an HTML
form instead of JSON-RPC, because a browser being sent to an app cannot
speak JSON-RPC.
2FA comes for free: a session still pending its TOTP step fails
validate(), so the gate rejects it without knowing what a second factor
is.
Details worth keeping:
- 401, not a redirect. A redirect to a login page is indistinguishable
from the app itself redirecting, and machine clients would follow it
and parse HTML as their API response.
- Cookie and Authorization are stripped before proxying. The app has no
use for the node session and must never be able to log or forward it.
- The challenge page names and pictures the app being opened, so the
visitor can confirm what they are authenticating to.
- device_tokens grew `apps: Option<Vec<String>>` and verify_for_app for
machine clients. None = node-wide, which every existing companion
token is; migrating them by guessing a scope would silently revoke
access nobody asked to revoke. An empty list is rejected rather than
minted, since it reads as unrestricted while authorising nothing.
The rollout is necessarily per-app and the gate is built to say so. A
container publishing 0.0.0.0:<port> claims every host address, so the
gate cannot bind that port until the app is pinned to bind: 127.0.0.1
and recreated — gate-first is impossible, and all-at-once would recreate
every container on a node simultaneously. Every port it cannot claim is
logged at warn each sweep and recorded in GateStatus::unprotected,
surfaced by security.app-gate-status. The failure mode being designed
against is a gate that binds nothing, logs at debug, and reports success
while every app stays exactly as open as before — worse than no gate,
because it stops anyone looking. Same reasoning that ruled out an
nft drop-in, whose absence is a silent no-op.
Not yet done: pinning the 39 gated ports to loopback, repointing
HiddenServicePort at the gate, and on-node verification.
Tests: 21/21 appgate, workspace builds clean.
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>
Groundwork for the app gate (item 1): before anything can enforce
authentication on app ports, the node has to know which ports are
*supposed* to be reachable without it.
`PortMapping` grows `auth` (PortAuth::Session | None, defaulting to
Session) and `auth_rationale`. The default is deliberately the protected
one. Every app port on this node answered with no credential at all over
LAN, Tailscale, Tor and the FIPS mesh alike — reproduced 2026-08-03 —
precisely because exposure was what you got by saying nothing. Inverting
the default means a new app is protected unless its manifest argues for
an exemption.
Validation makes the argument mandatory: `auth: none` without a
rationale is rejected, and so is a rationale without `auth: none` (that
combination means the author wrote an exemption and did not get one —
shipping it silently would leave them believing otherwise).
17 ports across 12 apps are declared exempt, each with its reason. They
are the ports that cannot sit behind an HTTP login page at all: Lightning
p2p (BOLT-8 noise), LND gRPC/REST and CLN gRPC (macaroon / mutual TLS —
Zeus and remote wallets dial these directly), Bitcoin p2p gossip,
electrum wire protocol, Wyoming voice streams, git-over-SSH, and the UDP
discovery protocols (mDNS, SSDP, STUN). Everything else — 39 published
ports — now defaults to gated.
Bitcoin's RPC 8332 is deliberately NOT exempted: it is already
`bind: 127.0.0.1`, so the gate never sees it, and claiming an exemption
it does not need would put a line in the audit list that means nothing.
If the loopback bind is ever dropped, it fails closed.
Two corpus tests keep this honest: every shipped manifest must parse
under the new rules, and the exempt set is pinned at 17 so any change to
the node's unauthenticated surface has to be a deliberate edit.
Tests: 73/73 archipelago-container, workspace builds clean.
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>
Plan-checker revision iteration 1 — 1 blocker + 2 warnings.
BLOCKER (context_compliance, D-13): the music track blocked phase
completion despite being locked as non-blocking. 13-15 depended on
13-11, which chains back through 13-07 to 13-04, so the phase could
not close without the entire music chain. Took the checker's option
(b): 13-15 depends_on is now ["13-06","13-09","13-14"] — 13-06 added
so the content-grid check stays a real gate, 13-11 dropped so no path
reaches 13-04/13-07/13-11. UAT step 7 is now content-only and blocking;
new step 7b is the music view as record-and-defer, the same shape step
10 already used for Routstr. Verified: 13-15's transitive closure
contains no music plan.
WARNING (scope_reduction): T-13-32 claimed the filebrowser-client.ts
JWT-in-query-string leak was "fixed" while only guaranteeing it was not
propagated. Now actually fixed — streamUrl returns a query-free
same-origin URL and relies on the path=/ cookie login() already sets;
filebrowser-client.ts and a new regression test are in 13-06's
files_modified. T-13-32 is scoped to new code; new T-13-39 owns the
pre-existing leak and names the residual (the JWT is still 24h, now
confined to the cookie jar).
WARNING (verification_derivation): the edge-probe reconciliation did
not match the files. Corrected in 13-VALIDATION.md — 10 probe findings
vs 9 edge entries kept apart, 13-07's 3 truths retagged as authored
rather than probe-surfaced. No truths deleted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIUI conversational node control & content surfaces, decomposed tracer-first:
13-01 leads with one end-to-end read-only tool proving the whole spine
(AIUI chat -> postMessage -> authenticated RPC -> Rust agent loop -> real
node data), then expands.
Waves 1-8 across three tracks that stay independent per D-13:
- control/assistant: 13-01, 13-05, 13-08, 13-10, 13-12, 13-13, 13-14
- content: 13-06
- music library: 13-04, 13-07, 13-11 (no control/content plan depends on it)
- security & delivery: 13-02, 13-03, 13-09
- on-device sign-off: 13-15
Notable decisions recorded in the plans:
- Open Q1: delete-and-replace the live unauthenticated port-3142 Claude proxy
with a session-gated Rust forwarder; the OpenRouter open relay is removed.
- Open Q2: /aiui/-scoped CSP connect-src plus a per-session rate limit;
the iframe sandbox attribute is explicitly rejected with reasons.
- Open Q3: a live Routstr spike (13-03) gates the Routstr backend (13-13).
- Open Q4: one "assistant." dispatcher prefix arm, so the existing
session/CSRF/RBAC gate applies unchanged before dispatch.
- Promote (not add-alongside) CallerScope as the primary caller/permission
noun; the mesh-specific controls become one variant's resolution inputs.
- schemars rejected as an unaudited crate; JSON Schema is hand-written.
- AI-SPEC's `cargo test --test assistant_evals` corrected to an in-crate
module: core/archipelago is a binary-only crate with no lib target.
Also adds COVERAGE.md (Routstr capability matrix, every opt-out reasoned).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
v1.7.120-alpha is shipped and verified; do not re-cut it. Two fixes landed
after it: federation trust escalation (c0cfc72a) and the lnd-ui OTA pin +
host networking (5088aef5).
The task file now carries a RESUME HERE block with the groundwork already
located for the next item (the password gate on granting Trusted) — the
exact helper, both entry points with line numbers, and the rule that
demotion stays ungated — so the next session does not repeat the search.
Paused here deliberately rather than starting the app-port auth work at
low context: it is the largest item, the operator asked for umbrelOS and
StartOS research first, and it is the same bug class as the leaks fixed
in v1.7.120 but across every app port and transport.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: Framework PT took the OTA and got the new bitcoin-ui but not
lnd-ui. Two causes, both in the update path rather than the app.
1. LND_UI_IMAGE was "lnd-ui:latest" while BITCOIN_UI_IMAGE was pinned to
1.7.119-alpha. Podman does not re-pull a tag it already holds locally,
so a node that ever pulled lnd-ui:latest keeps that copy forever and
every subsequent release silently no-ops. Pinned to 1.7.119-alpha, so
a version change is what triggers the pull — the same mechanism that
made bitcoin-ui update correctly.
2. first-boot-containers.sh declared lnd-ui as bridge with -p 18083:80.
docker/lnd-ui/nginx.conf listens on 18083 DIRECTLY (it must, to proxy
the backend on 127.0.0.1:5678 same-origin), so that maps a host port
onto a container port nothing serves — reproduced on-node as HTTP 000.
This is the THIRD copy of the same declaration: container-specs.sh and
apps/lnd-ui/manifest.yml were both already corrected, this one was
missed, and it is the copy fresh installs use. Now host-networked with
no published ports, matching its siblings and the other two copies.
The underlying hazard is that one container spec lives in three files
that can disagree; recorded as a follow-up rather than refactored here.
Also opens .planning/RELEASE-1.7.121-TASKS.md — every outstanding item
for the next release with its evidence, so nothing in a fast-moving queue
gets lost between sessions.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reported: "peers seem to be slipping into trusted status somehow which is
absolutely terrible for security". Two independent fail-open paths, both
granting Trusted with no operator decision anywhere in the loop.
1. federation.peer-joined is UNAUTHENTICATED (middleware's no-session
list — federated peers call it over Tor without cookies) and reachable
on /rpc/v1, which is peer-allowed. It does verify an ed25519 signature,
but against THE PUBKEY THE CALLER SUPPLIED, so it proves the caller
holds its own key and nothing about whether we ever invited it. A join
presenting no invite_token fell through to
None => TrustLevel::Trusted.min(claimed_trust)
and claimed_trust itself defaults to Trusted when the field is absent.
So anything able to reach the node could generate a keypair, omit the
token, and be recorded as Trusted. Now capped at Observer: an invite
WE minted is the only path to Trusted. `min` is kept so a peer's own
lower claim is still honoured — this can only ever reduce trust.
2. merge_transitive_peers added every peer advertised by a Trusted source
as Trusted. That makes trust viral rather than transitive-by-one-hop:
the merged node is itself synced with, its peers merged in turn, so a
single invite anywhere in the graph eventually marked the entire graph
Trusted on every node. Now Observer — which is what this feature's own
spec always said. NodeStateSnapshot.federated_peers is documented as
"adds them as Observers on her side… doesn't auto-promote Observer-via-
Bob to Trusted". The code contradicted the comment directly above it.
Observer is deliberate rather than Untrusted: the merge exists for
routing, and Observer still passes the `!= Untrusted` gates that
federation, DWN and messaging actually check, so a legacy peer degrades
instead of breaking. Per the operator's decision, existing peers are NOT
auto-demoted — silently rewriting live trust relationships across the
fleet would be worse than the bug.
Instead they are made auditable: FederatedNode.trust_source records WHY a
level was granted (invite | uninvited-join | transitive-merge | manual).
It deliberately has no default provenance — None means "recorded before
this existed", which is exactly the population worth reviewing.
The one failing test was asserting the vulnerable behaviour
(merge_transitive_peers_skips_source_and_local_node expected Trusted); it
now asserts the security property and says why, so the escalation cannot
be reintroduced by making a test go green.
Verified: 42/42 federation tests, cargo check --all-targets clean.
Still open, tracked in .planning/RELEASE-1.7.121-TASKS.md: surface
trust_source in the UI, and require the node password to grant Trusted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verifies the AIUI-01 gating question against source (no tool-calling
anywhere in this codebase today; Pine's HA intents are read-only Q&A,
not an action-executing loop), surfaces a live unauthenticated
Claude-proxy exposure (port 3142) and a same-origin iframe sandbox
gap not previously named, and maps existing Cashu/Nostr primitives
onto the Routstr integration.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records the two release-process traps for the next cut: the manifest is
committed before signing (so the fleet would refuse the OTA), and
gitea-vps2 is the same server as gitea-ai with a dead token.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
create-release.sh builds and commits the manifest BEFORE the signing
step, so the release commit carried an UNSIGNED manifest. Nodes fetch
releases/manifest.json from branch main and refuse to auto-apply an
unsigned one, so publishing without this would have shipped an OTA the
fleet silently declines.
Signature verified against the pinned release root before committing:
signed_by did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur
Cargo.lock carries the 1.7.120-alpha version bump from the release build.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AIUI is embedded and styled but not functional: the chat cannot act on the
node and its content views are not wired to real data. Phase 13 scopes making
it work — Pine's human-language intent->action capability reachable from typed
chat, conversational settings, and the peer-files/music/movies/node-content
surfaces rendered live.
The gating requirement is AIUI-04: a user-granted capability sandbox. An LLM in
the browser is now adjacent to wallet keys, macaroons and node identity, so
secrets stay server-side behind scoped tokens, capability grants default closed
and stay revocable, destructive operations need a human confirmation, and
peer-supplied text is treated as untrusted input to the model context. This must
not widen the Phase 10 hard-refuse gates.
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>
The release gate's cargo-fmt stage failed on my own additions — the
tests in message_types.rs and lnd/info.rs and the reconcile branch in
prod_orchestrator.rs were written programmatically and never passed
through rustfmt. Formatting only; rustfmt is semantics-preserving and
the gate re-runs the suites before building.
Caught by the gate rather than in review, which is the gate working. Also
a reminder that a piped command's exit code is the pipe's, not the
script's: the task notification reported success while the log said
CREATE_RELEASE_EXIT=1.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bookkeeping left uncommitted by an earlier session. It records work that
is already shipped — bc9a210c routed Paid Files pictures/videos into the
app lightbox with a visible wait — so the checklist and the mapping table
were simply lagging the code.
Landed as its own commit rather than swept into the release: it is not my
edit, and the release script refuses to run with a dirty tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
States plainly that the speed is unchanged — the fix gates the teleported
chrome, not the KeepAlive caching that made tab switching instant.
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>
Includes what was NOT verified — the torrc block is deployed but dormant,
since regenerate_torrc only fires on a Tor services change and the change
is inert until bitcoind gets an -onion flag in Phase 12.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Leads with the reason to take the update: two ports handed anyone who
could reach them full control of the node's money. Written for an
operator, not a developer — what was exposed, who could reach it, and
what to treat as compromised.
Includes the gaps rather than burying them: the 5x lifecycle gate was not
run, two fleet nodes still share SSH host keys (rotation is a deliberate
operator decision, not an oversight), and Core can now reach Tor but is
not yet routed through it.
create-release.sh hard-fails without this section, so it lands before the
release run rather than during it.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Per the operator: every option umbrelOS surfaces must be reachable in the
UI, Knots-only options surfaced separately from the ones Core shares, and
network mode a setting whose DEFAULT is Tor rather than clearnet.
Scoped as a phase rather than done inline because bitcoind's arguments are
currently hardcoded in three places (first-boot-containers.sh,
container-specs.sh, apps/bitcoin-knots/manifest.yml) — the same
triplication that produced the lnd-ui HTTP 000 defect. There is nowhere
for a UI to write, so BTCSET-01 is a settings model those three render
FROM, not another restatement.
Two constraints recorded up front so they are not discovered late:
- Knots-only flags gated to Knots is a CORRECTNESS requirement — offering
one on Core yields a node that refuses to start.
- Several options are not freely reversible: txindex forces a reindex,
prune is destructive and needs a full resync to undo. On a node that is
somebody's wallet backend those must be labelled and gated, not
silently applied. Any change at all restarts bitcoind, interrupting
LND, electrs and the fedimint gateways.
Inbound onion is explicitly out of scope: it needs Tor's ControlPort,
which is deliberately disabled for security, so the node reaches .onion
peers but stays unlisted. The UI must say so rather than imply otherwise.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Enabling half of "Bitcoin Core has no Tor proxy at all", handed over from
the app-UI work. Core reported `onion reachable=False, proxy=''` with all
11 peers on clearnet, and the reason was not a missing bitcoind flag: the
container sits on the archy-net bridge (10.89.0.0/24 here), so
127.0.0.1:9050 inside it is its OWN loopback. The host's Tor was
genuinely unreachable, and no flag on bitcoind could have fixed that
alone.
torrc now binds a second SOCKS listener on the archy-net gateway.
The gateway is DERIVED at runtime via `podman network inspect`, never
hardcoded: archy-net is created without an explicit subnet, so podman
allocates one. It is 10.89.0.0/24 on this node with no guarantee of that
elsewhere, and a hardcoded guess would fail silently — binding SOCKS to
an address no container can reach, which looks identical to working.
Two deliberate safety properties:
- FAIL CLOSED. If archy-net is absent or its inspect output does not
parse, no second listener is emitted and SOCKS stays loopback-only. An
exposure boundary is not something to widen on a guess.
- 127.0.0.1 is accepted FIRST in the SocksPolicy. SocksPolicy applies to
every SocksPort, so an accept-list naming only the bridge subnet would
have locked the daemon out of its own loopback SOCKS — breaking the
node's Tor usage in a way that looks nothing like "we added a
listener". The list is accept-loopback, accept-subnet, reject *.
This widens Tor SOCKS from loopback-only to the archy-net subnet, which
is a real change to the node's exposure surface and was explicitly
approved by the operator rather than assumed. Inbound onion for Core
remains impossible without reversing the deliberate "ControlPort disabled
for security" decision — this is outbound only, and the node stays
unlisted on Tor.
Not yet wired: bitcoind still has no -onion flag, because the operator
wants network mode to be a UI setting with Tor rather than clearnet as
the default. Hardcoding the flag in the three places that currently
define bitcoind's arguments would be the wrong shape for that, so it is
deferred to the settings work rather than done twice.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Taking over a parked item from the app-UI work. AIUI's built index.html
emits ABSOLUTE /assets/<hashed> paths, so the browser asks for
/assets/index-BC2fBBaW.js. That lands in the MAIN UI's assets dir, where
it does not exist — the real files are in aiui/assets/. Both of AIUI's
two entry assets 404'd, so the embedded sidebar loaded nothing.
The config already contained a /aiui-assets/ location whose comment names
this exact problem ("AIUI may reference /assets/ without /aiui/ prefix"),
but it only catches requests to /aiui-assets/, a path AIUI never asks
for. It described the bug without fixing it.
/assets/ now falls back to a named location that rewrites into
aiui/assets/ and 404s from there. A fallback rather than copying the two
files up one level, because a frontend deploy replaces web-ui wholesale —
update.rs preserves the aiui/ DIRECTORY, not copies made into assets/ —
so a copy is erased by the very next deploy while this survives one.
Both server blocks (HTTP and HTTPS) are patched; named locations are
per-server, so each needs its own.
Verified on archi-dev-box after reload:
/assets/index-BC2fBBaW.js 200, 305256 bytes, application/javascript
/assets/index-BJkaQ2c4.css 200, 150716 bytes, text/css
/assets/does-not-exist.js 404 (the fallback is not over-broad)
/assets/index--lyLAgu1.js 200 (real main-UI chunks still come from
/assets/vendor-CmYeCqL_.js 200 the main dir — try_files hits them
/assets/index-CiMaoNII.css 200 before the fallback is consulted)
/ /aiui/ /health 200
Hash collision between the two builds is not a concern: Vite hashes are
content-derived, and any main-UI asset that exists is served by try_files
before the fallback runs.
Noted while doing this, not fixed here: the node's own
/etc/nginx/sites-enabled/archipelago is 378 lines BEHIND this repo file
(984 vs 1362) — it predates the IPv6 listener and the @asset_missing
no-store handling, among others. The node was patched minimally in its
own shape rather than overwritten, since a wholesale copy of a config
this diverged is not a safe unattended action.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deployed 6b3693dc to archi-dev-box off a clean tree and exercised the
real RPC surface. lnd.getinfo returns a real identity_pubkey through the
field that did not exist before this plan. mesh.lightning-peers returns
an empty array with success — the FED-05 empty edge proven on hardware,
not just in a unit test. Both refusal paths of mesh.send-lightning-info
observed live, including T-01-13's "no broadcast form".
The finding worth carrying to 01-06: this node's LND advertises no URI
(uris: []), so send-lightning-info correctly refuses rather than
shipping an empty advertisement a peer would store as an undialable
target. The receive and list halves work; the SEND half is inert on any
node whose LND has no externally reachable address. The picker must not
assume the local node always has something to share.
Corrects this summary's own earlier claim that nothing was exercised on
hardware — that was true when written and is not now. The mesh leg
proper (an advertisement crossing real RF into a peer's lightning_uri)
remains unproven and is still called out as such.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 3, completing 01-04.
mesh.lightning-peers returns the peers that have advertised a Lightning
URI: filtered, deduplicated, deterministically ordered, and an empty
array rather than an error when nobody has — "nobody yet" is a normal
state on a fresh node, not a fault.
mesh.send-lightning-info advertises this node's own URI to ONE chosen
peer. There is deliberately no broadcast form: this discloses the node's
payment endpoint, and who learns it is the operator's choice rather than
a side effect of being in radio range (T-01-13). It refuses to send when
LND advertises no URI, instead of sending an empty one a peer would
store as an undialable target.
The list-building and target-parsing logic is extracted into pure
functions because this file has no handler test harness and the
handlers need a live mesh service. That keeps the three contracts that
actually matter provable rather than merely readable:
- dedup is keyed on identity_pubkey_hex() — the AUTHENTICATING key,
lowercased — never the firmware routing key, so a radio contact and
its federation twin collapse to one entry (T-01-11)
- "newest advertisement wins" compares PARSED RFC3339 timestamps, not
strings: 09:30-01:00 is later than 10:00Z while sorting earlier as
text, and there is a test that fails if that is ever string-compared
- ordering is name-then-contact_id and asserted byte-identical across
eight rotations of the input, because a HashMap's iteration order is
not stable and a picker that reshuffles between reads means an
operator can click a different node than the one they aimed at
The peer allow-list is untouched: server.rs has an empty diff and
is_peer_allowed_path still occurs 13 times (T-01-15).
Verified: cargo test -p archipelago 1087 passed / 0 failed; clippy
--all-targets clean in every touched module (two useless_format lints in
the new test code fixed, not waived).
The SUMMARY records one deviation honestly: Task 1's tests were written
alongside its implementation rather than before, so no pre-implementation
failing output exists. A mutation test was run in its place — disabling
the pubkey validation fails 3 of the 5 tests — which proves the
assertions bind, and the mutation was reverted and verified gone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Tasks 1 and 2 of 01-04. This node's own shareable URI, and a mesh
message a peer uses to advertise theirs.
lnd.getinfo now deserializes identity_pubkey and uris, which its
response struct simply did not declare before (RESEARCH.md Pitfall 5).
The identity mapping is split into a pure map_identity() so it is
testable without a live LND. A pubkey that is not 66 hex characters maps
to None rather than being forwarded: the same rule lnd.openchannel
enforces, applied where the operator is reading their own node's
identity instead of at the moment they try to open a channel. An absent
field yields an honest absence — never a fabricated or placeholder
identity.
MeshMessageType::LightningInfo = 26 is additive on a wire format shared
with every fleet node: 26 was unused, so a peer that predates this fails
to decode it rather than mis-decoding it as something else. Its payload
is deliberately two fields — this rides LoRa, where every byte is paid
for on air, and the optional alias is skip_serializing_if so an absent
one costs nothing (asserted, not assumed).
is_valid_lightning_uri() validates before anything is stored, because
this is unauthenticated RF input: 66-hex pubkey, non-empty host, optional
numeric :port, exactly one '@'. It deliberately does NOT resolve or dial
the host — that would turn a received advertisement into an outbound
connection an attacker chose.
Two preservation hazards found while wiring MeshPeer.lightning_uri, both
of which would have silently emptied the picker:
- decode.rs's identity-advert path does a WHOLESALE insert, preserving
only advert_name and lat/lon by hand. Reticulum re-emits identity
adverts every announce tick, so a stored URI would have been wiped
about once a minute. Now preserved, alongside the same guard the name
and position already had.
- session.rs's refresh_contacts and mod.rs's federation seeding rebuild
the peer record wholesale too. Neither carries a Lightning datum, so
both now carry the previous value forward rather than nulling it.
A malformed inbound URI is rejected before the write, leaving any
previously stored good URI intact — otherwise anyone in range could
blank out a real peer's picker entry (T-01-12).
Verified: 5/5 new lnd::info tests, 18/18 mesh::message_types (5 new),
cargo check --all-targets clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Controlled test on archi-dev-box with operator approval. The daemon was
stopped first so the reconciler could not repair the state before the
re-exposure was confirmed — without a confirmed 200, the later 401 would
be consistent with the state never having been broken at all.
1. stale conf installed + container restarted -> POST /bitcoin-rpc/
returned 200 with a real block height and Allow-Origin: *
2. daemon started 20:00:36, nothing else touched
3. 20:02:19 reconcile rendered the conf and logged the expected warn
line naming bitcoin-ui/archy-bitcoin-ui, then restarted it
4. POST -> 401, Allow-Origin origin-scoped
5. conf byte-identical to the pre-test known-good, container healthy
Both halves are now proven on real hardware: a05956c4's template (the
gate works) and f6b5245b's delivery path (the gate reaches a container
the reconciler had been skipping).
Also records the operator's decision AGAINST credential rotation — no
macaroon, no Bitcoin RPC password — with the trade it accepts stated
plainly, so it is not silently re-litigated later.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Second copy of the wiring fixed in aaa89789. apps/lnd-ui/manifest.yml still
declared network_policy: bridge with a 18083 -> 80 port mapping, while
docker/lnd-ui/nginx.conf listens on 18083 directly — it has to, so it can
proxy the backend on 127.0.0.1:5678 same-origin; the cross-origin fallback
is what broke this app on http-only nodes.
Publishing a host port to a container port where nothing listens is exactly
the failure reproduced on archi-dev-box when recreating from the matching
container-specs.sh entry: :18083 refusing connections, HTTP 000. Fixing
only the spec would have left the manifest as a live footgun for any code
path that provisions this app from its manifest instead.
Now host networking with an empty ports list, matching both what actually
runs and apps/bitcoin-ui/manifest.yml, which had it right all along.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two things needed for the new UI to actually reach users.
The rendered nginx.conf served index.html with only ETag/Last-Modified and
no Cache-Control, so browsers applied heuristic caching to it. Confirmed on
archi-dev-box: after rebuilding and recreating the container, :8334 and
/app/bitcoin-ui/ both served the new markup immediately, but the app iframe
in the main UI kept showing the previous UI until a hard refresh.
docker/lnd-ui/nginx.conf has always carried this header, which is why only
bitcoin-ui showed the stale copy. Using "no-cache" (revalidate) rather than
"no-store" keeps the ETag doing its job when nothing has changed.
Validated by mounting the rendered config into a throwaway container from
the built image and running nginx -t. (An earlier attempt to test it inside
the running container was meaningless — conf.d/default.conf is a read-only
bind mount, so the copy failed and nginx -t just re-checked the original.)
The 8 container::bitcoin_ui tests still pass; their assertions cover the
placeholder, the 8332 proxy_pass and the listen directive, none of which
this touches.
BITCOIN_UI_IMAGE was still pinned to 1.7.84-alpha, so a fresh install would
pull a bitcoin-ui from many releases ago regardless of what the OTA ships —
first-boot-containers.sh tries the registry image before building from
source. Bumped to 1.7.119-alpha, matching the current release, and the
image is pushed under that tag.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All four found by verifying on archi-dev-box rather than assuming.
container-specs.sh: archy-lnd-ui was specified as a BRIDGE container with
SPEC_PORTS="18083:80", but docker/lnd-ui/nginx.conf listens on 18083
directly (it must, to proxy the backend on 127.0.0.1:5678 same-origin).
Recreating from that spec publishes host 18083 to container port 80, where
nothing listens. Reproduced on the node: the app came back with :18083
refusing connections, HTTP 000. This never fired before because the running
containers are created by first-boot-containers.sh, which is host-networked
and never reads this file; the spec is only consulted when self-update.sh
rebuilds a UI image, and that only happens when a file under docker/lnd-ui/
changes — which is exactly what the previous two commits did. So the next
OTA would have taken lnd-ui down on every node. Now SPEC_NETWORK="host"
with no port mapping, matching what actually runs. NET_BIND_SERVICE dropped
with it: 18083 is unprivileged.
lnd-ui channels link: pointed at /apps/lnd/channels, but that route is a
CHILD of the /dashboard record in neode-ui's router, so the real path is
/dashboard/apps/lnd/channels. nginx's SPA fallback returns 200 for the
wrong path, so it failed as vue-router's NotFound view rather than an HTTP
404 — both the Payment Channels card and the Manage Channels button.
Both apps, copy buttons: navigator.clipboard only exists in a secure
context, and nodes serve these apps over plain http; the main UI also
embeds them in an iframe, where the async Clipboard API is separately gated
by the clipboard-write permission policy. Every copy button silently did
nothing there. Added an execCommand('copy') fallback behind a copyText()
helper and routed all six call sites through it.
lnd-ui Node ID: showed the bare pubkey whenever getinfo.uris was empty,
which is the common case — LND only populates uris once it is advertising
an external address. The bare pubkey is not what a peer pastes to open a
channel. The full pubkey@host:9735 URI is now built from the Tor onion
where available, falling back to this node's address, with a hint saying
which and what its reachability is. The QR encodes the URI too.
Verified on archi-dev-box: both images rebuilt and containers recreated
from the specs; lnd-ui and bitcoin-ui both serve 200 with the new assets;
and the RPCs the new tabs depend on all answer on the live node —
getblockstats returns every field the charts read, getpeerinfo returns 11
peers carrying relaytxes and network values the classifier handles.
Note for whoever tests bitcoin-ui's Insights/Peers tabs: /bitcoin-rpc/ now
sits behind auth_request /_session_check (a05956c4 et al), so it answers
401 to an unauthenticated curl by design. A logged-in browser sends the
session cookie same-origin, which is how those tabs get their data.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Names the four open items explicitly, including the one that is easy to
lose: the reconcile fix is deployed but unexercised, so the node's 401
proves the template and not the delivery path.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The node is closed and verified 401 with origin-scoped CORS. But an
unrelated bitcoin-ui rebuild at 18:36 cleared the stale conf before the
reconcile fix was deployed at 19:06, so the 401 proves a05956c4's
template and NOT the delivery path f6b5245b adds.
Window 14 closed (exposure gone, verified). Window 15 opened for the
delivery path, which is deployed but never exercised — bitcoin-ui is
still in the uninstall marker, so this node depends on that untested
path the next time its config has to change.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
system.stats on archi-dev-box returns host_secrets with verdict 'per-node'
and three evidence lines (machine-id anchor 2026-04-09; every SSH host key
and the TLS key newer than the anchor). Previously proven against the file
contract in unit tests only.
Honest limitation: this is one node, not the dev pair — archy-x250-dev has
been offline for two days, so the second node is unreachable, not skipped.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Five review fixes across both node UIs.
1. LND now uses the app-store icon. It was shipping its own 182KB lnd.svg
while the app store, My Apps and the signed catalog all render
neode-ui/public/assets/img/app-icons/lnd.png (catalog.json points at
/assets/img/app-icons/lnd.png). Same file is now vendored into the
image, so the app header, the launcher and the store agree. That icon is
a full-bleed square with an opaque white background rather than a
transparent glyph, so it fills the frame and is clipped to the inner
radius — exactly how bitcoin-ui frames its own icon — instead of being
inset with padding on a dark plate.
2. Header no longer squishes at tablet widths. Both headers had a single
768px breakpoint, so between 768 and 1024 the title and description got
crushed against the controls on the right (four status cards on
bitcoin-ui) and overlapped. Both now use three breakpoints: fully
stacked and centred below 768, logo + title on one row with the controls
wrapped underneath below 1024, single row above. The app name and
description are centred on mobile.
3. QR codes stay square. .conn-layout is a flex row on desktop and flex
items stretch by default, so the white QR plate was being pulled to the
height of the fields column and the square QR sat letterboxed in it. The
plate is now a fixed square inside a black glass panel that absorbs the
extra height, so the panel matches the fields and the QR stays square.
4. Buttons read as one family. The Settings button and both modal dismiss
buttons used the flat .glass-button while every other button on the page
used .info-card-button; they now all use the latter, via new .compact
(inline) and .icon-only (square) variants so the shared style works at
button size rather than only as a full-width card.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The half that landed correctly (LND, clean 401) made the half that did
not harder to notice, because the first check an operator would run
returns a pass. Records the probes, the three-fact root cause, and the
pass condition for re-probing a node.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Verified live on archi-dev-box, code fix committed in f6b5245b but not
deployed. Deliberately logged as open rather than fixed: the defect that
matters to an operator is the running node, not the source tree.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while VERIFYING a05956c4 on archi-dev-box rather than assuming it.
GET /lnd-connect-info is correctly 401 with no cookies over the LAN
address. But POST /bitcoin-rpc/ on :8334 still answered an
unauthenticated caller with a real block height, and still carried
`Access-Control-Allow-Origin: *`. The node looked patched. Half of it
was not.
The rendered /var/lib/archipelago/bitcoin-ui/nginx.conf was dated
2026-06-30 — the pre-fix version — even though the running binary
carries the new template. a05956c4's commit message claimed the
template "is re-rendered on every reconcile pass, so this ships
atomically with the binary". That is false in one specific state, and
this node was in it:
1. bitcoin-ui sits in the durable user-uninstalled marker.
2. reconcile returns on that marker BEFORE run_pre_start_hooks, which
is what renders the config.
3. The container keeps running regardless, because it is owned by
systemd via a Quadlet unit (archy-bitcoin-ui.service, active,
restarted 17:25 after the daemon restart) — not by this reconciler.
So a container systemd keeps alive, that the orchestrator has stopped
reconciling, never receives a config fix shipped inside the binary. An
OTA carrying a05956c4 would have silently failed to close this on every
node in that state, while the LND half closed correctly — the most
misleading possible outcome. archy-electrs-ui is in the same state on
this node, so it is not a one-app accident.
A container that is actually running is a live attack surface whatever a
marker says about it. Its security-relevant config is now reconciled
even behind the marker, and it is restarted so nginx actually loads it.
Deliberately narrow:
- Nothing is created, pulled, built, started or resurrected. The "must
stay removed" contract only ever gets weaker if a container is ALREADY
running, which by definition means it was never removed.
- A hook error is swallowed, not propagated: an app the user uninstalled
must not be able to fail the reconcile pass for everything after it.
- The pre-existing marker test still passes unchanged, which is what
proves the removal contract survived.
Verified: 11/11 reconcile tests and 9/9 bitcoin_ui tests pass, including
a new regression test that pins the whole chain — stale conf in, gate
present out, container restarted, nothing created.
No node has been touched. The live exposure on archi-dev-box stands
until this is deployed and the operator restarts archy-bitcoin-ui.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The Bitcoin UI was one long scroll: sync card, RPC/ZMQ cards and the
relay-sharing panel stacked on a single page, with node details hidden in a
modal. umbrelOS's rebuilt Bitcoin Node app splits the same surface across a
Home/Insights/Settings dock and shows considerably more.
Added, all of it additive — the sync state machine, the status-snapshot
staleness logic and the relay-sharing panel are untouched and just move
inside a tab panel:
- Five tabs (Node / Insights / Peers / Connect / Sharing) using the same
segmented control as lnd-ui, which becomes a fixed bottom dock under
768px with safe-area padding.
- Insights: the four stats umbrel's StatSummary shows (Connections,
Mempool, Blockchain Size, Node Uptime), a Latest Blocks strip, and
Block Size / Fee Rate / Block Rewards charts — the same three umbrel
plots, drawn as CSS bars so nothing has to load a chart library past
the CSP's script-src 'self'.
- Peers: sortable, filterable table with umbrel's exact columns (Peer,
Network, Relay TXNs, In/Out, Connected) plus ping. Network is derived
from getpeerinfo's own `network` field, falling back to address
matching, so Tor/I2P/CJDNS/Local/Clearnet are labelled correctly.
- Connect: RPC and P2P details with a Local/Tor selector, QR codes and
per-field copy buttons (umbrel's ConnectionDetails), including its
unencrypted-LAN warning on Local.
Block statistics come from getblockstats, one call per block for the last
ten, cached by height so only the new tip is re-fetched. The live tabs poll
only while visible rather than adding a third unconditional 5s timer.
Also removed the hardcoded "archipelago123" from copyRPCInfo. That string
was never the node's actual RPC password — the real one is a
manifest-declared generated secret rendered into this app's nginx upstream
and deliberately never sent to the browser — so copying it could only ever
mislead. The Connect tab says where the password actually lives instead.
qrcode.js is vendored from docker/lnd-ui (same file, already CSP-clean) and
added to the Dockerfile's COPY set.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The LND UI was a single scrolling page with a flat gradient instead of a
background, a permanently-disabled "Wallet" card, and four tabs buried
inside a Settings modal. Everything a node runner actually wants to see —
routing revenue, peers, liquidity, activity — was absent.
Rebuilt against the feature set of umbrelOS's Lightning Node and Bitcoin
Node apps (getumbrel/umbrel-lightning, getumbrel/umbrel-bitcoin), rendered
in Archipelago's own idiom rather than copying their visual design:
- Real background. bg-web5.jpg was already being COPYd into the image by
the Dockerfile and simply never referenced; it now drives the same
perspective-layer + 0.8 overlay treatment bitcoin-ui uses. Paths stay
relative because the app is served at / on :18083 but under /app/lnd/
when proxied by the host nginx, where absolute /assets 404s.
- Glass cards with the masked gradient border, matching bitcoin-ui exactly.
- Six top-level tabs (Overview / Channels / Activity / Insights / Connect /
Settings) replacing the one-page scroll — umbrelOS's Home/Insights/
Settings dock, widened for Lightning. On mobile the bar becomes a fixed
bottom dock with safe-area padding; on desktop it is a segmented control.
- Overview: total/lightning/on-chain balances, a Max Send vs Max Receive
liquidity bar (Umbrel's framing), and peers/channels/capacity/routing
stat tiles.
- Insights: routing revenue over 24h/7d/30d from /v1/fees, channel totals,
network graph stats, and a sortable+filterable peers table with Umbrel's
Tor/I2P/Local/Clearnet classification.
- Activity: merged Lightning payments, settled invoices and on-chain
transactions on one timeline, filterable by rail.
- Connect: the existing lndconnect QR flow, plus a Node ID panel with the
pubkey and advertised URI (umbrel's NodeIdModal).
- sats/BTC unit switch persisted to localStorage (Umbrel's SatsBtcSwitch).
Channel management deliberately links out to the existing Archipelago
channels view at /apps/lnd/channels rather than being reimplemented here;
this app only shows a read-only channel overview.
Sync progress tracks the synced_to_chain/synced_to_graph booleans rather
than inventing a block-based percentage, because LND exposes no IBD ratio.
All data comes through the existing authenticated GET proxy at
/proxy/lnd, so no backend, manifest or nginx change is required.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
All five KEY-05 layers landed. Records the two things a reader would
otherwise get wrong:
- the 2 boot_reconciler test failures in the full-suite run are
parallel-load flakes (4/4 pass in isolation), not regressions;
- layer (b)'s clippy gate is LIVE but not yet EFFECTIVE, because 42
pre-existing warnings already fail the same -D warnings step.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Layer (b) — core/clippy.toml bans rand::random and rand::thread_rng
crate-wide, each with a reason naming KEY-05 and pointing at the
evidence doc. No CI change was needed: the Rust job already runs
`cargo clippy --all-targets --all-features -- -D warnings` from core/,
so a disallowed_methods hit is already a build failure. --all-targets
covers tests deliberately — a fixture keeping the default is a template
for the next production call site.
Ordering was asserted before the file was written, not after: the
residual count of unmigrated call sites is 0, so this cannot turn CI red
for other agents on this shared tree.
Layer (c) — core/deny.toml makes the rand major split change-detecting:
global multiple-versions = "allow", a per-crate deny-multiple-versions
for rand, and a dated grandfather skip pinning =0.9.2 exactly. The tree
as it stands passes; a third version or a change to either member fails.
Both gates were OBSERVED working, not assumed:
- Reintroducing one banned call produced the disallowed_methods error
with the reason text reaching the developer at the failure point;
reverting returned the residual count to 0.
- `cargo deny check bans` exits 0 as-is. Removing the grandfather entry
made it exit 2 and print both dependency trees, independently
confirming F-07's account of where each rand version comes from.
Restored, it exits 0 again.
Policy (checkpoint Task 5, human-approved): bans-only. The advisories
gate is NOT enabled — it fails builds when a new CVE is published
against an existing dep with no local change, which on a tree where
several agents push continuously would block everyone at an arbitrary
hour, with remediation often meaning a bump to an exactly-pinned crypto
dependency. No break-glass procedure exists. F-07's advisory half stays
OPEN and is recorded as such.
cargo-deny is pinned to 0.20.2 and installed from crates.io rather than
via EmbarkStudios/cargo-deny-action, because that action exposes no
input to pin the tool version — an unpinned supply-chain checker would
reintroduce, at the CI layer, the exact "backend fixed by configuration
rather than stated" shape this plan exists to remove. crates.io is also
the source vetted at the Task 5 legitimacy gate (EmbarkStudios, repo
resolves, ~4.79M downloads).
RECORDED HONESTLY: layer (b)'s gate is live but not yet EFFECTIVE. The
tree carries 42 pre-existing clippy warnings — unused imports, dead
code, ~39 style lints — that are already errors under -D warnings, so
that CI step cannot pass today for reasons unrelated to KEY-05. Until a
dedicated lint-clearing pass lands, a new banned RNG call would be one
error among many rather than a distinctive build-stopper. Pre-existing
and out of scope; clearing it right before an OTA would be poor
sequencing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Found while checking whether the /lnd-connect-info leak (a05956c4) was
also reachable over Tor. It was not — but only by accident, and the
accident was one app id away from failing.
auto_add_tor_service() creates a hidden service for a freshly installed
app, mapping onion:80 -> 127.0.0.1:<the app's host port>. It skips the
node's own service and is_protocol_service() — which names the DAEMONS
(bitcoin, bitcoin-knots, electrs, electrumx, lnd) but NOT their UI
sidecars. lnd-ui and bitcoin-ui are real, installable app ids
(apps/lnd-ui, apps/bitcoin-ui) whose host ports are 18083 and 8334:
exactly the two ports that served the admin macaroon and the
credential-injecting Bitcoin RPC proxy.
So nothing structural prevented either from acquiring a GLOBAL onion as
a silent side effect of being installed — re-exposing worldwide, and
persistently, what a05956c4 had just closed to mesh/LAN peers. Verified
on a live node that this has not fired (services.json maps lnd to 8080
and holds no *-ui entry, and the running torrc contains neither port),
so this closes a latent hole rather than an active one.
The gate gets its own named predicate rather than an addition to
is_protocol_service, because the two express different things:
is_protocol_service says "this speaks a wire protocol, not HTTP", while
never_auto_onioned says "this fronts the node's money and must not be
published unasked". Conflating them would have made the fix look like a
classification tweak.
This gates only the AUTOMATIC path. An operator who deliberately enables
Tor for one of these apps still can — that is an informed choice, not a
silent default. Both endpoints are session-gated at the backend as of
a05956c4 either way.
Compile-checked clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Operator tool for the fix in a05956c4. GET /lnd-connect-info answered
unauthenticated callers with the LND ADMIN MACAROON, the TLS cert, the
gRPC/REST ports and the node's onion address. Every macaroon on an
affected node must be treated as known to an attacker.
Rotation removes the macaroon root key from macaroons.db plus the issued
macaroon files; LND mints a fresh root key on unlock, so every
previously issued macaroon — including any the attacker holds — stops
verifying. Coins live in wallet.db and channel state in channel.db, and
macaroons are bearer tokens rather than keys, so neither database is
touched, opened or moved.
Safety properties, in the order they matter:
- Detect-only by DEFAULT. --apply additionally requires --yes.
- An ORDERING GUARD refuses to rotate on a node whose binary lacks the
fix, because the new macaroon would leak through the same door within
seconds and the operator would believe they were safe. Overridable
only via an explicit --force-unpatched.
- It backs up the old material to a 0700 dir OUTSIDE the dir LND
rescans, verifies the backup file count matches, and refuses to delete
anything if it does not.
- It never reads, prints or copies a macaroon's CONTENT. Everything it
reports is a SHA-256 digest, so the material is proven changed without
disclosing it to the terminal or scrollback. lncli runs INSIDE the
container and reads the macaroon off its own disk, so the secret never
crosses into this script's output.
- It records the node identity pubkey and channel census BEFORE, and
aborts loudly if the identity or the OPEN channel total changed.
Two deliberate non-assertions, both of which would otherwise produce
frightening false alarms on a completely healthy rotation:
- wallet.db is NOT asserted byte-identical. btcwallet records chain sync
progress inside it, so it legitimately changes on every start.
- num_active_channels alone is NOT asserted. It counts channels whose
peer is currently online and so legitimately dips after any restart
while peers reconnect. The safety invariant is the ACTIVE+INACTIVE
total, which is what gets asserted; a changed active count is reported
as normal post-restart behaviour.
Enumeration runs under sudo rather than as a shell glob: the LND data
dir is 0700 owned by the container's mapped uid, so "$LND_DIR"/*.macaroon
does not expand in an unprivileged shell — it stays literal, which would
have made both the backup and the removal loop silent no-ops. Verified
on a live node: sudo find returns 9 files where the glob returns 0.
Exercised in detect mode against a real LND on a live node: correctly
reported the binary as unpatched, live-probed :18083 as LEAKING (200),
read both digests and the node identity, and exited 2 without changing
anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes F-10a. Nothing here fixes a present defect: on the pinned rand
0.8.5, rand::random() and thread_rng() both resolve to a ChaCha12 CSPRNG
seeded from getrandom(2). What they lack is a STATED backend — it is
fixed by dependency and build configuration rather than by the calling
code, with no compile error if that changes. That is the structural
shape behind the 2026-07-30 COLDCARD entropy defect, and here the blast
radius includes Cashu blinded-key-exchange values, X3DH prekey material,
session bearer tokens and a ChaCha20-Poly1305 nonce.
Layer (a) — every production key, nonce and token draw now names
rand::rngs::OsRng at its own call site. The mnemonic seam is bound to
entropy::KeyGenRng, a SEALED allowlist whose supertrait lives in a
private module, so the set of RNGs that can drive the master key
hierarchy is exactly what one file says it is. This retires the false
promise at seed.rs:656: rand::CryptoRng is a marker with no
compiler-checked content, and the crate now contains zero impls of it.
Layer (d) — key material and AEAD nonces of >=12 bytes run a
degenerate-entropy predicate that refuses all-zero, all-identical and
wrapping +/-1 counter draws. Nothing heuristic: no entropy estimator, no
chi-squared. Each of the three shapes has a false-positive probability
computable in closed form (3 * 2^-88 at 12 bytes, 3 * 2^-248 at 32), and
a predicate whose false-positive rate cannot be computed cannot be
argued safe on a key-generation path. There is deliberately no retry — a
retry would paper over the broken RNG this exists to surface.
Layer (e) — the kernel-CSPRNG readiness verdict at master-seed
generation is now durable (backlog R-09). It was previously computed,
logged and thrown away, so a node could never answer after the fact
whether its keys were born from a seeded pool. The record holds a schema
version, timestamp, verdict and event name — no entropy, no key bytes.
Formats and wire shapes are proven unchanged rather than asserted:
storage_crypto and the credential store each open a HARDCODED
pre-migration ciphertext vector (a same-process round trip would pass
even if the envelope had changed), the vector was produced by an
independent RFC 8439 implementation so it pins the documented
nonce||ciphertext format rather than this implementation's output, and
the x3dh prekey bundle and bdhke values keep their field set and order.
totp.rs migrates its SOURCE only: the % charset.len() reduction and the
32-char charset are untouched. The bias there is presently zero (32
divides 256) and fixing the latent bias is R-12, which stays deferred.
Verified: cargo build clean; cargo test -p archipelago 1068 passed,
2 failed. Both failures are container::boot_reconciler timing tests
(second_pass_fires_after_interval, shutdown_terminates_loop) in a file
this change does not touch — pre-existing, not caused here.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CRITICAL. Two app-UI ports handed unauthenticated callers full control of
the node's money. Both verified live on archi-dev-box 2026-08-02 over the
fips0 mesh ULA with no cookies.
GET /lnd-connect-info returned 200 with the LND ADMIN MACAROON, the TLS
cert, the gRPC/REST ports and the node's onion address — a complete remote
wallet-drain package, and the onion means an attacker keeps that ability
after losing network access. POST /bitcoin-rpc/ reached Bitcoin Core RPC
with credentials the proxy injected on the caller's behalf, with a wallet
loaded, so wallet methods were reachable too.
Both were reachable because ports 18083 (lnd-ui) and 8334 (bitcoin-ui)
bind 0.0.0.0 AND sit on the fips0 mesh allowlist in fips/app_ports.rs. Any
mesh peer, LAN host or Tailscale peer could take either path.
The root cause is one mistaken idea in two places: that a check performed
by a reverse proxy is an auth check. It is not — it only holds for traffic
that arrived through that proxy. /lnd-connect-info's comment said "nginx
validates session cookie (presence check), backend is bound to 127.0.0.1
so only nginx can reach it". Both clauses were false in production: the
lnd-ui container runs its OWN nginx on :18083 that proxies straight to the
backend forwarding whatever cookies arrived, including none, and that
second front door never performed the check the premise named.
So authorisation moves to the resource:
- /lnd-connect-info now requires a session, like /proxy/lnd/ beside it.
The 401 carries CORS headers so the wallet UI shows a readable error
rather than an opaque CORS failure.
- New GET /auth/session-check returns 204/401 and nothing else, giving
container nginx an auth_request gate it can actually use.
- bitcoin-ui's /bitcoin-rpc/ is gated by that auth_request. Its
`Access-Control-Allow-Origin *` is also gone: on a proxy that injects
credentials, it let any page a user visited drive the node's RPC.
Preflight is answered before the gate, since OPTIONS carries no cookies.
The nginx template is include_str!'d and re-rendered on every reconcile
pass, so this ships atomically with the binary.
Operators must treat the LND admin macaroon and the Bitcoin RPC password
on every affected node as compromised and rotate them AFTER this is
deployed — rotating first just re-leaks through the same hole.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SUMMARY for 10-04, plus three WINDOWS.md entries (11-13) so the unverified
items stay visible at ship time: the rotation never exercised on real
hardware, host_secrets never observed in a live system.stats, and the C-3
finding itself — three live nodes still on shared SSH host keys, two of them
also sharing a TLS private key, none of them rotated.
STATE.md and ROADMAP.md deliberately not touched: both carry other agents'
uncommitted work in this shared tree and the orchestrator owns them for this
wave.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Audit checklist item C-3 ("the highest-value check here") is no longer
UNVERIFIED. It failed, and the failure is a live F-03 instance rather than a
theoretical one.
Three distinct fleet nodes — archipelago-1, archy-x250-beta and archipelago —
present byte-identical ECDSA, ED25519 and RSA host key fingerprints. Two of
them (archipelago-1, archy-x250-beta) also present the same TLS certificate,
so they share the TLS private key as well.
Gathered read-only and remotely: ssh-keyscan plus an anonymous TLS handshake.
No node was logged into, nothing was written, nothing was rotated. A weaker
instrument than the checklist's on-node commands, chosen because it needs no
access and therefore covers the reachable fleet rather than two nodes — and it
is sufficient for the FAIL condition, which is any fingerprint appearing twice.
Ruled out the obvious alternative (one machine registered three times on the
tailnet): all three answered live TCP within the same minute, and tailscale
ping resolves them to different physical endpoints on different continents
under different tailnet accounts.
One finding worth more than the count: `archipelago` has a UNIQUE TLS cert
(CN=austin-sapien) and SHARED SSH host keys, because it was renamed and
server.set-name re-mints the cert via regenerate_tls_cert() while touching
nothing else. So TLS uniqueness is not evidence that a node's key material is
per-node — any renamed node gets a unique certificate for free. Checked on TLS
alone, that node would have looked clean. Recorded because it justifies the
audit script reporting the two key classes separately instead of issuing one
node-level verdict.
All three are listed under "shared verdict, deliberately not rotated" with the
reason and the next step. A verification task that remediates is a
verification task that takes a node offline.
Also records what this does NOT establish, each with the evidence still
needed: same-ISO provenance, the script's own verdict on those nodes, that a
rotation preserves the operator's live session on real hardware, that
host_secrets reaches system.stats on a real node, and the four nodes that were
unreachable at scan time.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
[Rule 3 — blocking] bootstrap.rs installs systemd units from the runtime
payload at image-recipe/configs/, but create-release-manifest.sh copies only
archipelago-doctor.service and .timer into that directory. The new
archipelago-host-secrets-audit.service would therefore never exist on any
node: bootstrap looks for it, `src.exists()` is false, and it silently
installs nothing. No error, no log line — the whole deployed-node half of
10-04 would have been inert on arrival.
Two enumerations of the same list in two languages in two files is the drift
that caused it, so the loop now carries a KEEP IN SYNC pointer naming the
array in bootstrap.rs, and the redundant `if [ -f doctor.service ] || [ -f
doctor.timer ]` wrapper is gone — the per-unit `-f` test inside the loop
already does that job, and the wrapper would have skipped the whole block on
a tree that had the new unit but not the doctor ones.
Outside 10-04's declared files_modified. Taken because the alternative was to
ship a deliverable that cannot reach its target and file the gap as a
follow-up. Staged by path; no other agent had uncommitted work in this file.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
10-03 closed the build half of F-03: the ISO no longer bakes SSH host keys or
a TLS keypair into the shared rootfs, and first-boot regeneration fails closed.
Nodes already in the field receive none of that — the first-boot script is
installed by the installer, not shipped by OTA — so a node that hit the old
fail-open path is still running key material that every downloader of its ISO
also holds, and its completion marker guarantees it will never try again.
scripts/security/host-secrets-audit.sh decides, from the node's own disk alone,
which of those it is. Four signals in a fixed precedence: missing material can
never be shared material; the fail-open fingerprint (marker present plus the
literal `WARNING: TLS regeneration failed` / `WARNING: ssh-keygen -A failed`
lines the old script emitted) is direct evidence and outranks timestamps and
also names WHICH class survived; then key mtime against a first-boot anchor
(.secrets-regenerated, falling back to the installer's LUKS key then
machine-id). Verdicts are per-node / shared / fail-closed-missing / unknown,
and every one of them carries the evidence strings that produced it, each
naming the file it was read from.
per-node is never claimed from an absent signal. No anchor means `unknown`, and
a standing first-boot-secrets.failed record also means `unknown` — a clean
mtime is not evidence that generation succeeded. That is T-10-37: a false
per-node verdict leaves an exposed node looking clean, which is worse than no
verdict at all.
Rotation (D-06: detect-report-then-apply, recorded in
docs/security/KEY-02-FLEET-ROTATION.md):
- --detect is the default and is read-only; it always exits 0, because
detection is informational and must never fail a boot.
- --apply without --yes writes nothing at all, not even its own verdict file.
"Touches nothing" is worth being able to say without a footnote.
- --apply --yes refuses unless the verdict is `shared`, so the wrong node
cannot be rotated even deliberately.
- It stages the full replacement TLS pair AND host-key set before touching
anything live and aborts if either fails; records the OLD fingerprints
before the swap; does TLS first (a dead web UI is recoverable over SSH, the
converse is not); replaces host keys by mv-onto-the-existing-path rather
than rm-then-mv, so the directory is never momentarily empty; and RELOADS
sshd, never restarts it, so the operator's own session survives its own
rotation.
bootstrap.rs ships the boot unit through the existing run_runtime_assets
promotion and enables it --now, so the verdict lands with the OTA rather than
at the next reboot. handle_system_stats gains a host_secrets object read from
the on-disk verdict — cheap, never an error however malformed the file, and
deliberately carrying no fingerprints, because a payload polled every few
seconds does not need digests an operator on the node can already read.
tests/first-boot-secrets/rotation-tests.sh: 8 cases against temp roots through
the HOST_SECRETS_ROOT seam. Negative controls run and reverted, each reddening
exactly one case: dry run writing its verdict file (STATE-DIR-CHANGED); the
old fingerprints recorded after the swap instead of before (caught by an
ordering observation, not a content comparison — the systemctl stub records
whether the file existed at the moment of the first reload); a tolerated
generation failure leaving a half-rotated node; and `per-node` claimed with no
anchor.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Deployed the frontend to archi-dev-box only (--frontend-only, no fleet,
no alpha-tester, no Tailscale, no OTA, no release) and proved all six
UIFIX fixes are in the bundle the node actually serves.
- Resolved the live chunk set from sw.js first: /opt/archipelago/web-ui/
assets keeps every prior deploy's hashed chunks, so a naive disk grep
returns hits from dead chunks and would have produced a false pass
(threat T-01-83, and it was a real trap here).
- Fetched each live chunk over HTTP from http://archi-dev-box and grepped
it: all eight probe strings for UIFIX-01..06 PRESENT.
- Real Chromium boot check on the node at 1440x900 and 390x740: app
mounts, 0 console errors, 0 page errors, 0 failed requests.
- archy-x250-dev recorded as an explicit gap: offline, last seen 2d ago,
no MagicDNS record; still has neither this plan set's nor phase 2's
frontend.
Task 2's six numbered checks are all recorded NOT VERIFIED. They need an
authenticated session on the node (UI returns 401 / redirects to /login,
and no credential was guessed against a node holding real funds), and two
of them are not testable as the node stands: it owns exactly one
purchased item (image/jpeg) and has zero video and zero audio content
anywhere, so the purchased-video, purchased-music and picture-in-picture
checks have nothing to open.
No source file modified, no fix applied inline, and STATE/ROADMAP/
REQUIREMENTS deliberately left untouched - UIFIX-01..06 are NOT closed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 done. Tasks 2 and 3 are blocked on unmet preconditions and were
NOT auto-approved: no fleet node runs 10-01's gate (installed binary
predates 879de59e and lacks the refusal string), and no second machine
was available to probe from. Nothing moved UNVERIFIED -> VERIFIED.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
C-6 is NOT closed by this commit and is not marked verified.
Measured (read-only, on-node):
- loopback and self-LAN-IP: auth.isOnboardingComplete 200 (EXPOSED),
seed.status 401 (session enforcement intact) — no stop-the-plan finding.
- /rpc/ returns 404: nginx's second proxy block is not a second door, so
the unauthenticated surface is reachable through /rpc/v1 only.
NOT measured — needs a second machine: LAN, Tor, FIPS mesh ULA.
NOT performed — the KEY-01 refusal check and the fresh-node onboarding
walkthrough. No node runs 10-01's gate yet: the installed binary was built
at 06:37 and 879de59e landed at 13:05, and the refusal string is absent
from it. A --destructive run against the dev-box would replace its identity
rather than be refused, so it was not made.
Also carries 10-01's pre-OTA fleet check (onboarding.json complete-true
without user.json): dev-box safe, rest of fleet unchecked.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
F-10a recorded raw grep counts and deliberately declined to classify them.
This resolves that: every one of the 43 matches under core/archipelago/src
now carries a production/test verdict (evidenced by its file's
`#[cfg(test)] mod tests` line), what the drawn value becomes, whether the
degenerate-entropy guard applies, and a disposition.
Tally: 41 migrate, 2 comment, 0 allow. No site needed an exemption, so the
crate-wide ban will have no holes to audit.
Two corrections to F-10a, each derived independently with its evidence line:
session.rs is 4 production sites not 16 (mod tests begins :471), and
mesh/x3dh.rs:100/:114 are u32 prekey identifiers, not key material -- the
X25519 secrets come from crypto::generate_x25519_ephemeral() at :99/:113.
The enforcement blast radius is pinned with `cargo metadata` output rather
than asserted: models, helpers and js-engine are not workspace members, so
the two core/models matches are outside the clippy build graph and are
recorded as a stated limitation rather than omitted.
Requirement: KEY-05. Supersedes R-13, absorbs R-05 and R-09.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 1 of 10-04 is a blocking decision checkpoint, rated one-way: rotating a
node's SSH host key invalidates every known_hosts entry for it fleet-wide and
the old private key is destroyed by the swap.
Chosen: detect-report-then-apply. auto-on-boot would fire simultaneous
known_hosts breakage across the fleet during an OTA with no operator holding
the new fingerprints, and a rotation that fails partway on a remote node (.228
is at a remote site and in real use) needs physical console access. It also
cannot be dev-paired, which contradicts the standing verify-on-the-dev-pair-
first policy — by the time it has been observed on the dev pair it has already
run everywhere.
The cost of the chosen option — exposure persists on any node nobody revisits
— is bounded by making the verdict visible in system.stats rather than by
automation, and by keeping a list in this document of every node that reported
`shared` and was deliberately not rotated.
Records what the decision binds: detect-only default, --apply inert without
--yes, the boot unit carries no apply path, and --apply --yes refuses on any
node whose verdict is not `shared`.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Measures EXPOSURE (auth.isOnboardingComplete) and SESSION ENFORCEMENT
(seed.status) separately; the audit's C-6 probed with seed.status alone,
which is not allowlisted and returns 401 by design, so its "Fail: 200"
criterion could never fire.
- Read-only by construction: methods come from a fixed READONLY_METHODS
array, never from an argument; the one mutating request is behind
--destructive with a red disposable-nodes-only banner.
- The refusal check uses the published BIP-39 all-abandon/art test vector,
so no real key material is ever generated, handled or printed.
- No node address, onion address or credential embedded.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written by the previous session's executors for 01-17, 10-01, 10-03 and
10-05, all of which are complete and whose code is already committed. The
session was cut off by a dropped SSH connection before these were staged,
so they were sitting untracked. Recording them so the phase history is not
lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The on-disk format was detected by sniffing the first byte for `[` or `{`.
Encrypted blobs begin with a random 12-byte nonce, so roughly 1 in 128
saves produced a valid encrypted file whose first byte was 0x5B or 0x7B;
those were misread as plaintext JSON, failed `String::from_utf8`, and the
store became permanently unreadable. This was surfacing as a flaky
`test_list_credentials_no_filter`, but it is a real data-loss bug: a node
whose ciphertext happened to start with one of those bytes could not load
its credentials.
Writes now carry a fixed `ARCHYCRED1` marker, which cannot collide with a
random nonce, so detection of the current format is exact.
Legacy unmarked files are detected by SUCCESSFUL AEAD DECRYPTION rather
than by another byte sniff. A verifying Poly1305 tag under the node key is
a cryptographic discriminator (~2^-128 false-positive rate), strictly
stronger than any structural guess — which is why the deferred item's
suggested "keep the first-byte sniff as the legacy fallback" was not the
shape adopted. Plaintext JSON remains the last resort, and is still
reachable on a node that has no node key at all.
An undecodable file now errors instead of returning an empty store, so a
transiently unreadable file is never silently replaced by an empty one
that the next save would commit to disk (CLAUDE.md: migrations never
destroy data). Legacy files upgrade on write, never on read.
Tests drive the collision deterministically via an explicit nonce rather
than waiting on the 1-in-128 draw, and cover all three on-disk
populations, the read-path-does-not-rewrite guarantee, and tamper
rejection. 28 passed, 0 failed.
Closes the 10-01 deferred item.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two near-identical periodic federation sync loops were running side by
side. git history shows the overlap was accidental, not load-bearing: the
30-minute loop landed first (8dd57bcb, 2026-04-19), and the 90s loop
landed later (837cc028, 2026-06-19) describing itself as "new 90s
periodic federation auto-sync (none existed)" — its author simply hadn't
seen the existing one. Running both doubled the write-race exposure
against nodes.json that plan 01-01 locked down.
The 90s loop survives; it already did strictly more (per-peer sync-result
recording, asymmetry self-heal). The deleted loop's one unique behavior —
refresh_federation_mesh_peers() after a completed pass (#42), which pushes
newly-learned names/roster into the live mesh peer table so chat contacts
refresh without a restart — is preserved at the tail of the survivor. That
call is a local, idempotent re-seed from nodes.json with no network I/O,
so running it per-pass rather than per-half-hour is cheap.
Also carried over: MissedTickBehavior::Delay, so a pass delayed by suspend
or heavy load resumes the cadence instead of firing a burst of catch-up
ticks. And node-load errors are now logged and skipped explicitly rather
than swallowed by a catch-all, so an empty roster and an unreadable one
are no longer indistinguishable.
Not carried over: the deleted loop's 5s per-peer stagger. Its stated
reason was avoiding concurrent connects against the Tor SOCKS proxy, but
both loops iterate peers sequentially and await each sync, so there were
never concurrent connects to stagger; keeping it would only push a
multi-peer pass past the 90s cadence.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Parsing each half back proves each is well-formed; it never proves they
belong together. A key from one generation beside a cert from another
passes both individual parse checks, gets blessed, and then nginx refuses
to start at the exact moment the marker claims first boot succeeded.
gen_tls() now extracts the public key from each half and compares them
before the swap, and needs_tls() applies the same check to what is already
installed, so a mismatched pair that reached disk some other way (an older
build, a half-finished manual edit) is repaired instead of quietly
breaking nginx. Extraction subsumes parsing, so this replaces the separate
-noout parse checks rather than adding to them.
Kept deliberately in step with regenerate_tls_cert() in
core/archipelago/src/api/rpc/system/handlers.rs, which does the same
comparison on the running node after a rename.
Test harness: the openssl stub keypair now carries the generation it came
from, and STUB_OPENSSL_MISMATCH emits a cert from a different one — the
pair that passes both parse checks and still breaks nginx. New case 9
covers both directions: fail closed when the mismatch arises during
generation, repair exactly once when found already on disk, and no spin
on the run after either. 9/9 passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
regenerate_tls_cert() passed -keyout /etc/archipelago/ssl/archipelago.key
and -out .../archipelago.crt, so openssl wrote straight into the files nginx
is serving from. If openssl died partway, was killed, or the disk filled,
the live key and cert were already truncated — a routine `server.set-name`
could take HTTPS down with no way back. Reproduced: the live key goes from a
valid 2048-bit PEM to 33 unparseable bytes.
Mirror the discipline gen_tls() already uses in the ISO builder: generate
into .new siblings of the destinations (same directory, so the final mv is a
rename(2) and therefore atomic), parse both halves back with `openssl pkey`
and `openssl x509` and compare the extracted public keys to prove they are
valid and belong together, and only then swap them in. On any failure the
existing key and cert are left byte-for-byte untouched and the error is
returned. Staging files are cleared before the attempt and on every exit
path, success or failure.
Permissions: the staging key is created by `install -m` carrying the live
key's own mode and owner *before* openssl writes into it (openssl truncates
an existing -keyout file rather than recreating it), so the new private key
is never group- or world-readable, not even between generation and a chmod.
A live mode that grants group/other any access is not reproduced — the key
falls back to 0600 — so the swap can never widen permissions.
Cert content and parameters are unchanged: same subject, same SAN
construction, same rsa:2048, same 3650 days. This is an atomicity and
validation fix, not a crypto change.
Testing seam: the hardcoded sudo prefix and absolute paths made this
untestable, so the logic moved into a small TlsMaterial struct holding the
ssl dir, the openssl binary path and a privileged flag. Production is
TlsMaterial::production(); tests point it at a temp dir, drop sudo, and
substitute a stub openssl. Against the pre-fix shape the two atomicity tests
fail (live key modified; garbage accepted); against this change all five
pass.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes F-01 (Critical) of docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md.
seed.generate/seed.restore/seed.save-encrypted/backup.restore-identity/
auth.setup are all in UNAUTHENTICATED_METHODS, and several reach
NodeIdentity::from_seed or restore_encrypted_backup, which overwrite
identity/node_key unconditionally. One unauthenticated POST from the LAN or
from any FIPS mesh peer hijacked a live node's Ed25519 identity, Nostr node
key and FIPS transport key.
- new api::rpc::onboarding_gate::ensure_onboarding_open: refuses once ANY of
is_setup() / is_onboarding_complete() / seed_exists() says provisioned,
failing safe on I/O errors. NodeIdentity::key_exists is deliberately NOT a
signal — server.rs:63-71 writes a temporary key on every boot, so a gate
keyed on it would refuse seed.generate on a never-onboarded node. Pinned by
allows_on_fresh_temp_dir_even_though_node_key_exists.
- ensure_user_account_exists: the inverse guard for auth.onboardingComplete,
which is unauthenticated and sets the flag the gate reads — without it, one
call locks a fresh node out of its own onboarding.
- seed.restore body extracted to restore_node_identity_from_words so the
regression suite drives the real path; seed.verify left open with a written
verdict (non-mutating).
- refusal text begins "Not supported:" so it survives sanitize_error_message
and names the authenticated system.factory-reset recovery path.
- per-method rate limits for the four onboarding mutators, sized ~6x the
measured client retry budget so a 429 cannot reintroduce the error at the
DID-creation screen.
First-boot onboarding is untouched: all three signals are false throughout the
seed steps, and auth.setup runs last (Login.vue:405-425).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI (.github/workflows/ci.yml) already runs
`cargo clippy --all-targets --all-features -- -D warnings`, but
archipelago-openwrt emitted 4 warnings on a clean checkout, so the gate
was red by default and enforced nothing. Fixed each lint at the source;
no #[allow] added.
- clippy::cmp_owned (wan.rs:146) — dropped the .to_string() that built an
owned String purely to compare against "1"; &str == &str compares the
same content.
- clippy::unnecessary_sort_by (wifi_scan.rs:75, :177) — replaced
sort_by(|a, b| b.signal.cmp(&a.signal)) with
sort_by_key(|n| std::cmp::Reverse(n.signal)). Both are stable descending
sorts on signal, so tie order is unchanged. Deliberately NOT -n.signal,
which would misorder i32::MIN.
- clippy::trim_split_whitespace (wifi_scan.rs:156) — removed the .trim()
before .split_whitespace(); the latter already skips leading/trailing
whitespace and never yields empty items, so parsing is unchanged.
All three are semantics-preserving rewrites: no change to comparison
results, sort ordering, or channel parsing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Backend already exists: server.set-name runs hostnamectl set-hostname and
regenerates the TLS cert with a SAN for the new name. This is a UI step.
Records the hazard that decides the design: renaming changes both the mDNS
.local name and the TLS cert, so a rename mid-flow can drop the user's session
in the middle of onboarding — potentially between seed generation and seed
verification. Placement is therefore a design decision, with three options laid
out (last-before-Done, first, or collect-early-apply-late).
Also flags RFC-1123 slugification (users will type "Dorian's Node"), whether
the rename propagates to the Reticulum display name and mesh surfaces, the
reconnection UX, and whether the step is skippable.
Sequenced after the in-flight regenerate_tls_cert atomicity fix, since renaming
is exactly the path that fix protects.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 3 of plan 10-05, run by the operator over Tailscale on 2026-08-02 using the
read-only procedure in KEY-03-SIGNING-POSTURE.md. No escalation: nothing found.
Examined and CLEAR (4): archi-dev-box, shorty-s/.228, archy-x250-beta,
archy-x250-pa. On every one there is no wallet named `archipelago` — the deleted
handler's default wallet_name — `listwallets` returns only the unnamed default,
and that default reports blank=true, keypoolsize=0, txcount=0, balance=0. The
only named wallets are Fedimint gatewayd-*. The result holds across two
container vintages (bitcoin-knots and bitcoin-core), so it is not four copies of
one image behaving identically.
Not examined (6), recorded with reasons rather than omitted: framework-pt,
archipelago-1, archipelago and archy-dev-pa (SSH permission denied — password
rotated/not held), archipelago-5 (timed out during banner exchange), and
archy-x250-dev (offline). Password auth was deliberately not attempted: several
fleet nodes lock PAM quickly on a wrong password, and locking out an in-use
production node is a worse outcome than an incomplete census.
The conclusion is stated at the strength the evidence supports — no *examined*
node holds a wallet the deleted handler created, and no examined node holds any
wallet with keys or funds. It is deliberately NOT generalised to "the fleet is
clear" while six nodes are unknown. F-13 is closed by deletion regardless: the
code that could create such a wallet is gone from every future build.
No key material appeared in any output and `listdescriptors true` was never run.
Also corrects the now-stale R-04/F-13 entry in UNIFIED-TASK-TRACKER.md, which
still described `handle_bitcoin_init_wallet_from_seed` and a watch-only
migration as pending work — that code no longer exists. Marks it done-by-
deletion and adds the six unchecked nodes as a standing item, flagged as a
natural fold-in for KEY-04's on-node work but tracked independently so it does
not vanish if KEY-04 is re-scoped.
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>
With Bitcoin Core's wallet deleted, LND's PSBT round trip is the only
external-signer path Archipelago has, and D-09's key-origin protection moves
from Core descriptors (of which none remain) to the PSBT itself.
Adds `psbt_key_origin_report(&str) -> Result<PsbtKeyOriginReport>` to
lnd/wallet.rs, reporting `input_count`, `inputs_with_key_origin` and
`all_inputs_have_key_origin`. An input counts as carrying key origin when
either its `bip32_derivation` or `tap_key_origins` map is non-empty. A PSBT
with zero inputs reports false rather than vacuous truth. Parsed with the
already-present `bitcoin` and `base64` crates; no dependency added.
`lnd.create-psbt` gains an additive `key_origin` object on its response and a
`tracing::warn!` with the counts when key origin is missing, because that is
the exact condition under which a hardware signer refuses the PSBT. Computed
best-effort: a decode failure degrades to `null`, never to an error, so a
user's send cannot fail because an inspection helper could not parse
something. `handle_lnd_finalize_psbt` and `handle_lnd_create_raw_tx` (which
deliberately auto-signs with LND's hot keys) are untouched.
Three tests, with fixtures built programmatically from the `bitcoin` crate
rather than pasted as opaque base64: with-derivations, without-derivations,
and malformed-is-an-error-not-a-panic.
KEY-03-SIGNING-POSTURE.md gains an honest per-step coverage map of the
fund -> export -> sign offline -> import -> finalize -> broadcast round trip.
Of six steps, only the new inspection has automated coverage; steps 1, 4, 5
and 6 have none, and there is no air-gap transport (no animated QR, no .psbt
file exchange) — export/import is copy-paste of base64. Untested paths are
named as untested.
Records the verdict that decides whether any of this is an air gap: on a
default node an external signer CANNOT meaningfully sign a PSBT from
`lnd.create-psbt`, because LND holds the keys for every input it selects.
Evidence: the PSBT is funded from LND's own wallet; `ensure_wallet_initialized`
creates a full key-holding wallet via /v1/initwallet; the generated lnd.conf
carries no `remotesigner.*` block; and a search of apps/, scripts/,
core/archipelago/src and image-recipe/ for remotesigner/createwatchonly/
nochainbackend returns zero matches. No fleet node is provisioned watch-only.
What ships is PSBT transport, not air-gapped custody — the gap is
provisioning, not plumbing.
Adds the standing honesty statement in its own subsection: Lightning channel,
revocation and HTLC keys are NOT air-gappable at all. They must sign in real
time to answer counterparty commitments; remote signing relocates them to a
hardened host, it does not cool them.
Also adds a status banner to PSBT-SIGNING-ARCHITECTURE.md recording that its
Phase 1 was superseded by deletion rather than delivered, so §0's "single
highest-value change" and §2.1's invariant now read against a code path that
no longer exists. Banner only; §5.4's honesty table is byte-identical.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The failure fail-closed cannot catch, because generation SUCCEEDS.
This unit runs very early (DefaultDependencies=no, Before=ssh/nginx), long
before time has synced. `openssl req -x509` stamps notBefore from whatever the
clock says, so on a node with a dead RTC or a flat CMOS battery the cert can be
years out: clock ahead -> clients reject it as "not yet valid", a harder failure
than the usual self-signed warning; clock behind -> notAfter is already in the
past once time syncs. The completion marker was then set and never revisited —
a node permanently serving a cert nothing accepts.
Finding 1, reported rather than assumed: this image does NOT use
systemd-timesyncd. It installs and enables chrony, and chrony-wait.service —
the unit that is Before=time-sync.target — is not enabled. So time-sync.target
is inert here and ordering After= it would buy nothing. Enabling chrony-wait to
make it meaningful would stall boot behind NTP on a node with no network, and
these nodes are routinely offline at first boot. Not deadlocking boot outranks
cert-date elegance, so the ordering is deliberately left alone.
Fixed locally instead, in two parts:
1. Backdate notBefore by 24h so ordinary skew between node and client cannot
invalidate a fresh cert. -not_before/-not_after arrived in OpenSSL 3.5 and
the rootfs is debian:trixie which ships it, but the capability is PROBED,
not assumed — guessing wrong would fail every attempt and brick the node,
the exact outcome all of this exists to prevent. Without the flags we simply
do not backdate and rule 2 still covers the dangerous case.
2. Refuse to bless a cert dated by a clock outside a plausible window
(2026-01-01 .. 2056-01-01). The material stays installed so the node is
usable and sshd comes up, but the bad dates are recorded as
failed=cert-dates and the cert is regenerated automatically once time syncs.
Generation is now driven by need rather than by "is the marker absent", and
ConditionPathExists=! is removed from the unit so a node that already completed
can still be re-examined — skipping the unit is precisely how such a node stays
broken forever. The script exits in milliseconds when everything is fine.
Anti-spin is one condition: a date-driven regeneration happens ONLY when the
clock is currently plausible. A node whose clock is still wrong re-checks and
mints nothing.
Regression caught while writing this: driving generation purely by content made
needs_ssh() false whenever any host key existed, which would have left an
image-baked fleet-shared key in place forever — F-03 reopened. The marker check
is back in both needs_ functions and case 1 (which prestages a baked key and
asserts it was replaced) is what caught it.
Case 8 covers mint-under-wrong-clock, repair-after-sync, and both spin
directions. Controls: blessing regardless of clock reddens only case 8
(run1-BAD-DATES-NOT-RECORDED); removing the anti-spin guard reddens only case 8
(SPINNING-reminted-while-clock-still-wrong(1->2)). The second control initially
passed against a broken guard because the assertion compared certificate dates,
and a re-mint under a frozen clock produces a byte-identical notBefore — the
assertion now counts mints, which is the only thing that distinguishes "left
alone" from "regenerated again".
Not covered here: nodes already deployed from earlier ISOs never receive this
script (it is installed by the installer, not by OTA), so fleet remediation for
them remains 10-04/OTA work in core/**, which is held by other executors.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`handle_bitcoin_init_wallet_from_seed` derived the BIP-84 account extended
*private* key, stringified it, and imported `wpkh(xprv/0/*)` / `wpkh(xprv/1/*)`
into a Bitcoin Core descriptor wallet created with `disable_private_keys=false`
and an empty passphrase. That put a second copy of the node's spending key in
Core's `wallet.dat`, outside the daemon's Argon2 + ChaCha20-Poly1305 envelope.
That duplication into weaker protection was audit finding F-13 (High).
Deleted rather than rewritten watch-only (D-07b supersedes D-07/D-07a):
- No caller anywhere. Repo-wide search leaves exactly one occurrence of the
method name (its own dispatcher registration) and two of the symbol in code
(definition + dispatch call); every other hit is prose in docs.
- LND is the wallet the product drives. Across neode-ui/src every `bitcoin.*`
call is read-only status (getinfo/prune-status/onion); the wallet UI sends
via `lnd.sendcoins`.
- It never ran on archi-dev-box: no wallet named `archipelago` exists there,
and the one loaded wallet reports blank=true, keypoolsize=0, txcount=0.
- It was authenticated AND password-gated, so F-13 was key-at-rest
duplication, not an exposed endpoint.
No migration is performed and none is planned. This removes code, not wallets:
nothing on disk is touched, no funds move, no wallet.dat is modified. If a node
is ever found holding a wallet this handler created, that is a finding to
surface and stop on, not a trigger to auto-migrate.
`seed::derive_bitcoin_xprv` loses its only non-test caller and is retained
deliberately with `#[allow(dead_code)]` and a stated reason: it keeps its
existing test coverage and it is the derivation D-07c's deferred BDK cold vault
will need.
Records the evidence, the D-08/D-09 consequences and the D-07c deferral in
docs/security/KEY-03-SIGNING-POSTURE.md.
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>
`cat > "$WORK_DIR/Dockerfile.rootfs" <<DOCKERFILE` was unquoted, so the build
shell performed command substitution on the Dockerfile body. Any backtick in a
Dockerfile COMMENT was executed on the build host and its output spliced into
the generated file. Six comments did this. One of them ran
`systemctl start archipelago-fips.service` against the build machine on every
ISO build; the others were harmless only by accident of being command-not-found.
Fixes the class, not the six instances. The delimiter is now quoted, so the
body is emitted verbatim and a future backticked comment is inert. Verified the
boundary by line range first: the other backticked comments in this file
(:264, :809, :1188, :1289, :1506, :1605, :3597, :3651) are ordinary shell
comments outside any unquoted heredoc and were never at risk — they are
untouched.
The body needs exactly four build-time values and they are all package names
(LINUX_IMAGE_PKG, GRUB_EFI_PKG, GRUB_EFI_SIGNED_PKG, GRUB_PC_PKG), on four
consecutive lines. So quoting was practical: the heredoc is split into
DOCKERFILE_HEAD and DOCKERFILE_TAIL, both quoted, with a single explicit printf
interpolating those four names between them. Escapes that existed only because
the heredoc was unquoted are undone in the same pass: six trailing `\\` become
`\` (Docker line continuations) and four `\$` become `$` (RUN arguments reach
the shell verbatim — Docker does not substitute variables in RUN).
Verified by rendering the generated Dockerfile before and after with the same
inputs and diffing them normalised (continuations joined, whitespace
collapsed). Both are 190 normalised lines and the ONLY differences are the six
comments regaining their text — every instruction is byte-identical. Before:
"# the archipelago backend calls" / after: "# the archipelago backend calls
`systemctl start archipelago-fips.service`".
Test: case 7 asserts every heredoc writing Dockerfile.rootfs has a quoted
delimiter, and when one is not, reports which body lines would execute. The
assertion is on the delimiter, not on backticks — with quoting a backticked
comment is legal and six of them are back in the body on purpose, so flagging
backticks would flag a non-bug and fail on the very comments this restored.
This bug is invisible to `bash -n`; an instance of it introduced earlier in
this plan hung a syntactically-clean build for two minutes before being caught.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Unify rather than delete. The defect in F-03 was never "a second attempt to
create a key exists" — it was that failure was silent and the completion marker
lied about it. A second attempt is only dangerous when it is an unaudited second
PRODUCER carrying its own idea of success, its own absent retry policy and its
own absent failure record.
Single producer. gen_tls() is now the only code in the ISO build that creates
/etc/archipelago/ssl/archipelago.{key,crt}; gen_ssh() the only code that creates
/etc/ssh/ssh_host_*. Two secondary producers are gone:
- the Dockerfile's `openssl req` layer, which baked a keypair the strip layer
deleted moments later in the same build;
- the installer's "ensure SSL cert exists for nginx HTTPS" block, which before
the strip almost never fired and after it would have fired on every install.
Proof is mechanical, not a claim: every executable `openssl req` / `ssh-keygen
-A` invocation in the builder now lives inside the generator heredoc, and the
test suite fails if one appears outside it.
Build-time assertion. The one realistic total failure is a missing generator
binary, which is deterministic — no retry or reboot fixes it. A rootfs RUN layer
now fails the build if openssl or ssh-keygen is missing or non-executable.
openssl and openssh-server are both already in the package list (and
openssh-server hard-depends openssh-client, which ships ssh-keygen), so today
this is cheap insurance; it earns its place the first time someone edits that
list.
Self-heal, never dead-end. Fail-closed governs SERVING; retry governs
RECOVERING, and they are different things. Adds
archipelago-first-boot-secrets.timer (OnBootSec=5min, OnUnitActiveSec=15min),
installed and enabled with a hand-written symlink fallback because chroot
systemctl enable can fail silently. The service's own ConditionPathExists=!
makes every trigger a no-op once the marker exists, so a healthy node pays
nothing. On success the script now restarts consumers that are in `failed` —
try-reload-or-restart is a no-op on a failed unit, so without this a recovered
node would have valid keys on disk and nginx still down.
Never serve a bogus key. gen_tls parses both halves back with `openssl pkey`
and `openssl x509` before the swap, so a truncated or half-written artefact is
never what nginx reads.
Tests: 6 cases, each with an isolated negative control (transcripts in SUMMARY).
- case 4, TLS fails every attempt on a stripped root -> no key from any source.
Control: reintroduce a fallback key creation -> only case 4 red.
- case 5, self-heal: a failed run then a later successful run -> key present,
marker set, failed units restarted. Control: dead-end on a node that already
failed -> only case 5 red.
- case 6, single-producer invariant. Control: reintroduce the installer block
-> only case 6 red, naming the line.
Residual risk, stated plainly: a machine where generation can never succeed
still ends up with no SSH and no TLS. Build-time assertion removes the
deterministic cause, retry plus timer removes the transient ones, so what
remains is genuinely broken hardware — and it says so on the console and in
/var/lib/archipelago/first-boot-secrets.failed rather than quietly serving a
key nobody audited.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A guide a third-party security auditor can use to verify Phase 10's claims
without trusting our test harness — and that we use ourselves.
Every claim carries four parts, all required: the claim stated falsifiably;
how to REPRODUCE THE DEFECT on the parent commit; how to verify the fix; and a
negative control that must go red on exactly that defect and nothing else. A
test passing on both fixed and unfixed code proves nothing, and reproduce-first
is the step most often omitted in security theatre.
Prefers external checks (curl from another host, tar listing, cross-node file
comparison) over our own tests wherever a claim can be checked from outside.
Tiered by hardware needed: Tier 0 any checkout, Tier 1 running node, Tier 2 ISO
build host, Tier 3 two physical nodes, Tier 4 pre-release gate. Status marked
per claim — verifiable now, pending a plan, or hardware-gated — so an unmarked
absence is never read as a pass.
States what is explicitly NOT claimed (Lightning custody is not air-gappable;
no claim against a compromised kernel CSPRNG or supply chain; KEY-05 is
structural not exploitable), the known-accepted risks with where each was
decided, and carries the C-6 warning that probing with seed.status reports the
surface closed while the real door stands open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Task 3 of 10-03 is a blocking checkpoint: proving the shipped rootfs tar is
identity-free needs a real ISO build host with podman/docker and disk for a
full rootfs rebuild. This commits the prepared evidence document with the exact
command sequence, marked UNVERIFIED, rather than claiming the check passed.
The document states the inverted expectation explicitly. The audit's C-4 entry
expected SSH host keys and the TLS key to be PRESENT — that described the
broken state it was measuring. After the strip layer those must be ABSENT, so
the audit's stated expectation is now the failure condition. A future reader
comparing the two would otherwise conclude the check regressed.
Also records two things the operator would otherwise get wrong:
- RECIPE_HASH must be read from the stamp file, not computed from the repo
file. build-debian-iso.sh rewrites the builder's relative paths into a temp
copy before exec, and the hash covers "$0"; the hashed region has 35 such
rewritten expressions plus an absolutised SCRIPT_DIR, so the value is
specific to the build host and checkout path.
- C-4 is a build-host check only. Two-node key divergence is C-3 and stays
separately UNVERIFIED; the note explains why SSH host keys are the sharper
signal there than TLS, given the installer's per-install TLS fallback.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The rootfs is a container image exported to a tar and extracted verbatim onto
every disk flashed from the ISO, and the ISO is published. It baked two things
nobody asked for: Debian's openssh-server postinst generates /etc/ssh/ssh_host_*
during the container build, and the `openssl req` layer writes the TLS keypair.
Both were therefore identical on every node and known to every downloader.
Add a final RUN layer to Dockerfile.rootfs that removes /etc/ssh/ssh_host_*,
removes the archipelago TLS keypair (keeping the ssl directory so the first-boot
staging swap has somewhere to land), truncates /etc/machine-id to systemd's
documented "regenerate on next boot" state, and drops a non-shared
/var/lib/dbus/machine-id if one exists as a real file rather than a symlink.
It also writes /opt/archipelago/rootfs-identity-stripped so a node can answer
after the fact whether its rootfs came from a stripped build; no timestamp,
so the RECIPE_HASH cache stays reproducible.
This is what makes 10-03's fail-closed regeneration structural instead of
procedural: with the material gone, a regeneration failure degrades to
"no key, service refuses to start" rather than "fleet-shared key, silently".
The `openssl req` layer is deliberately left in place — it keeps proving
openssl is present and keeps the SAN template next to its consumer; the strip
layer is what makes the output non-shared.
Two comment corrections that follow from the strip:
- The installer's TLS block is no longer a rarely-taken safety net; it now
fires on every install. It is per-install and never image-wide, so it does
not reopen F-03, but it does mean a first-boot failure still leaves the web
UI with a cert while SSH has nothing. Comment updated to say so.
- The first-boot script header overstated the fail-closed cost for TLS for the
same reason; corrected to claim certainty only for SSH.
This edit is inside the RECIPE_HASH region, so the next build is forced to
rebuild the rootfs tar — required for the C-4 evidence to mean anything.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The first-boot per-device secret regeneration was fail-open: both branches
logged a warning and continued, and `touch "$MARKER"` ran unconditionally
outside both `if` blocks. Combined with the unit's ConditionPathExists=! and
the script's own marker fast-path, one transient failure left that node on the
image-wide shared SSH host key and TLS private key permanently and silently —
and the ISO is a published artefact, so every downloader holds those keys.
- Retry each generator 3 times with backoff (D-05), so a transient first-boot
condition recovers inside the same boot instead of being terminal.
- Write the completion marker ONLY when both TLS and SSH succeeded, so a
failed boot leaves the unit eligible to run again on the next boot.
- On terminal failure: durable record at
/var/lib/archipelago/first-boot-secrets.failed naming which generator
failed, plus console + logger + stderr, and exit 1 so the unit lands in
`failed` rather than `active`. The record is cleared on a later success.
- Add FIRST_BOOT_SECRETS_ROOT / FIRST_BOOT_SECRETS_BACKOFF seams. Unset in
production the behaviour is byte-identical; set, they let the fail-closed
property be asserted rather than claimed.
- Order the unit After=systemd-random-seed.service (no-op today, correct if a
seed file is ever baked).
- State the operational trade in the script header: after the rootfs strip, a
terminal failure means no SSH and no TLS and needs the physical console.
That was chosen deliberately over running on fleet-shared keys.
tests/first-boot-secrets/run-tests.sh extracts the shipped heredoc body from
the builder and drives it against a temp root with stubbed generators: both
succeed, openssl fails every attempt, ssh-keygen fails twice then succeeds.
Moving the marker touch back outside the success branch makes case 2 fail with
MARKER-SET-ON-FAILURE, which is the regression this pins.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
98 operational files still carry 146.59.87.168 — the domain was only adopted
for the git remote, not for container registry references. Bulk is app
manifests' image: lines, plus .gitmodules, both CI workflows, the signed
catalog.json, and two Android companion files with compiled constants. The 117
hits in .planning/ are historical records and stay.
Not a find-and-replace: the domain serves Gitea over HTTPS:443 while images are
pulled from :3000 over plain HTTP, and podman treats host:3000 and domain as
different registries — so every node re-pulls under the new name and any node
that can't resolve or trust the new host fails to pull. It also invalidates the
signed catalog (needs a re-sign ceremony) and the APK ships compiled constants.
Rollout order: registry serving on the domain → manifests → catalog re-sign →
APK rebuild. Steps 2-4 are actively breaking until step 1 holds.
Analysis from the concurrent agent's session before it ended; recorded so it is
not lost.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Companion build 0.5.27 (versionCode 47) shims navigator.clipboard natively, so
in-app copy/paste is fixed with zero web changes — but the contract must not be
clobbered (no unconditional re-define, no Object.freeze).
Still open web-side: main.ts's fake readText() makes SendBitcoinModal's Paste
button render and silently no-op in plain-HTTP browsers; 30 writeText call
sites across three inconsistent patterns, ~10 of which toast 'Copied!'
regardless of success; scanner prewarm/torch/constraints/no-reinit.
Also records three factual corrections to docs/qr-scanner-snappiness-handover.md
(ZXing not ML Kit; FORMAT_QR_CODE + KEEP_ONLY_LATEST already in place; do NOT
drop to 720p — 1080p is a deliberate 0.5.22 fix for dense bolt11 QRs).
Routed at Phase 11: the signed-PSBT paste affordance and the scanner items are
the same surface as WALLET-05.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Clicking "Open a channel" or "Setup Guide" navigated correctly but left the
wallet's send/receive modal floating over the destination. The Lightning modal
itself did close — the parent did not. Tab views are KeepAlive'd, so
navigating deactivates the owner rather than unmounting it, and its Teleported
modal keeps rendering.
BaseModal now emits close on any route change while shown, fixing the class in
one place rather than per button. Every modal here is a transient dialog; none
should survive navigation. Two tests pin it, including that a hidden modal
stays quiet.
Also fixes a test-only regression from fa26c5fc: useLightningRequired()
resolved the Pinia store at composable-call time, so merely having the gate in
SendBitcoinModal made it unmountable without an active Pinia (PaidTick mounts
it bare). The store is now resolved lazily inside the function that needs it —
a gate should never be what breaks a component's ability to mount. That one
shipped because I verified fa26c5fc with targeted tests and a build but had
not re-run the full suite since 5718179e.
Verified: full suite 103 files / 827 tests green; npm run build clean.
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>
Icons: each node choice now shows its app icon (lnd.png; Core Lightning's is
vendored from the Umbrel gallery as core-lightning.svg). Vendored rather than
hotlinked on purpose — these nodes run offline/airgapped, and a remote image
would both break there and leak a request to a third-party host on every
render. A missing asset falls back to a neutral bolt glyph so a row can never
render a broken-image box.
Mobile: the choice row keeps icon + name + blurb together and drops the action
to its own full-width line under 26rem, instead of squeezing the description
into a two-word column next to a button.
Funding mode: a node that is running but has no funds / no inbound liquidity
is neither "install one" nor "start it", so the same modal gains a third mode
that explains it and routes to the run-lightning-node goal, where funding and
channel-opening already live — reusing that flow rather than duplicating it.
It fires where the user actually meets the problem: on a failed attempt.
handleLightningFailure() maps a running node's send/receive failure onto the
funding modal, matched on message text because LND surfaces "no route", "no
channels" and "insufficient balance" as plain strings with no distinct code —
and all three mean the same thing to a user: fund me.
Verified: 5 gate tests; npm run build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Three defects from testing the previous commit on archi-dev-box:
1. The gate keyed on `id in packages`, which is not "installed and usable" —
package-data carries an entry for a Lightning app that is known but not
running. On a box with no lnd container at all the gate passed and the raw
error came through as "Operation failed. Check server logs for details."
Now keyed on PackageState.Running.
2. Because installed-but-stopped is a real and different situation, the modal
has two modes: absent offers the install choices, stopped says the node
isn't running and offers "Open My Apps". Neither dead-ends in an error.
3. Lightning SEND let you walk all the way to confirm-send with no node. The
gate now runs in review(), before the confirm step — failing at submit
after a review screen is the defect, not a smaller version of it.
Also adds CopyButton, the start of one consistent copy affordance: icon +
label, an emerald tick held 1.6s, a fixed box so the width never jumps, and a
document.execCommand fallback so copy still works over plain http on a LAN IP
(navigator.clipboard rejects on insecure origins, which is how a lot of nodes
are reached). Converted the wallet's own copies — the lightning invoice the
user reported, plus the on-chain/Ark addresses and the payment hash/txid.
20 of 25 copy sites across 15 other files still use ad-hoc markup; converting
them is mechanical but was not attempted here rather than half-done.
Verified: 5 gate tests; npm run build clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The F-10a scope correction committed hours earlier asserted semantics its
evidence did not support. The KEY-05 planner caught it against the code:
- mesh/x3dh.rs:100/:114 are u32 prekey IDENTIFIERS (spk_id, otk_id), not key
agreement material. The X25519 secrets come from
crypto::generate_x25519_ephemeral() at :99/:113 and were never in scope.
- session.rs's 16 raw matches read as 16 production token sites; #[cfg(test)]
begins at :470, so it is 4 production + 12 test.
- wallet/bdhke.rs is 2 production of 4 (#[cfg(test)] at :143) — and those two
ARE genuine key material: generate_secret() :133 and
random_blinding_factor() :139.
The Medium rating still holds, on narrower grounds: bdhke's two production
sites plus storage_crypto.rs:39's AEAD nonce. It no longer rests on x3dh.
Struck rather than silently rewritten. F-10 was corrected on the grounds that
understatement misleads the next reader; overstatement does the same, and this
table managed both within a day.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Adds 10-06-PLAN.md covering KEY-05 (F-10a / R-16). 10-01..10-05 untouched.
Six tasks, sequenced so CI stays green at every intermediate commit:
classify all 43 call sites with file:line evidence; a tracer that wires the
sealed KeyGenRng allowlist, the degenerate-entropy predicate and the CSPRNG
readiness ledger end-to-end through the mnemonic seam; two migration tasks;
a blocking human checkpoint for cargo-deny scope and legitimacy; then the
gates are enabled last and observed failing a real build.
The clippy ban is a compile failure under the existing -D warnings CI step,
so core/clippy.toml is deliberately not committed until every site --
including test code, since --all-targets counts it -- has migrated.
Wave 2: shares seed.rs with 10-05 and api/rpc/auth.rs with 10-01.
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>
The checkpoint on archi-dev-box proved rotation alone doesn't close FED-07:
the credential file went unique while the RUNNING container kept serving the
compromised one, because the Quadlet path rewrites a unit without restarting
it and fedimint-gateway is classified restart-sensitive, so drift was detected
and deliberately ignored on every tick.
Rotation now records the app id, and the drift check consumes that flag to
recreate even a restart-sensitive app, with a WARN naming the reason. This
mirrors the published-port carve-out a few lines above, which already makes
the same trade for the same reason: a container that is already broken (there)
or already compromised (here) is not protected by leaving it running.
Restart-sensitivity protects working services. A gateway answering to a
credential published in this repository is not working, it is compromised, and
gateway admin can drain Lightning liquidity — indefinite exposure loses to a
few seconds of restart. Rotating-but-only-alerting was rejected: the
monitoring system fires on metric thresholds only, so it would have needed new
event-alert plumbing to deliver something strictly weaker.
Re-verified on the same node, same scenario: rotation at 06:39:23, recreate at
06:39:27, PID 3923125 -> 148426, running credential now matches the file,
container healthy with the same name and ports, gatewayd.db intact at 18 files
with IDENTITY present, 32 containers untouched, no repeat rotation.
3 new tests. Also lands the missing 01-19 and 01-20 SUMMARYs: both had code
committed 2026-07-31 but no summary and no roadmap tick, so they read as
unstarted. Phase 1 is 11/20. FED-09 carries 15h of Tor uptime and 0
permission-fixes across 542 doctor runs on archi-dev-box.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The audit recorded F-10 as two call sites in container/secrets.rs. The real
defaulted-RNG surface is 41 sites across 15 files: session.rs (16),
pine_ha.rs (6), wallet/bdhke.rs (4 — ecash key material), mesh/x3dh.rs (2 —
key-agreement material), storage_crypto.rs (1 — AEAD nonce), +10 more.
Nothing is broken today: rand::random()/thread_rng() are ChaCha12 seeded from
getrandom(2). What changes is blast radius — F-10's Low rating rested on
'per-app credentials rather than the master key hierarchy', which does not
survive the true scope. Re-rated Medium as F-10a.
Records why the original audit missed it: F-10 was reached by tracing the
manifest-secrets path, and no step enumerated defaulted-RNG use across the
crate independently of the traced paths.
F-10's original text is left unedited so the correction is auditable rather
than retroactive. R-13 superseded by R-16; tracker item replaced.
Adds KEY-05 to Phase 10: sealed allowlist trait at key-gen seams, clippy
disallowed-methods ban (compile-time, CI-enforced), cargo-deny on duplicate
rand majors, degenerate-entropy runtime check, persisted CSPRNG-readiness
verdict. Also retires the false 'impl CryptoRng for CountingRng' at
seed.rs:656.
Records the user's execution gate: Phase 10 does not start until the
concurrent Phase 1 agent is finished and their changes are synced. KEY-05 is
unplanned — the existing 5 plans predate it and a 6th is required.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Handoff for the reactive demo-day session that followed 09-06/09-07
(both already complete). Covers: security audit (6 IDOR fixes across
the botfight repo), Cashu payout claim UI, existing-bot AI-config UI,
botfights 1.2.11 built+deployed to both demo nodes, catalog
signed+published.
Also: discovered and fixed 4 botfight-repo commits that were local-only
and never pushed to origin — pushed as part of this handoff step
(botfight @ d00e792..10d4209 -> origin/main).
Co-Authored-By: Claude <noreply@anthropic.com>
The executor was instructed to leave docs artifacts to the orchestrator; this
commits them: the research that drove the audit, the task summary, and the
archi-dev-box test-node todo raised during the same session.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ran Task 2's blocking checkpoint on a real node. The rotation works and was
proven end to end: it fired ~15s after restart, wrote a fresh unique
credential (0600, service-owned), logged exactly one line naming the .pw path
with no value in it, left every other secret and the gateway's data untouched,
and kept the container's name and ports.
But the assumption the plan rests on is WRONG, and the checkpoint is what
caught it. 25 minutes after rotating, /proc/<pid>/environ showed the running
gatewayd still using the PRE-ROTATION credential while the file and podman
secret held the new one. The orchestrator explains itself in its own logs:
Quadlet unit drift-synced — file rewritten, .service NOT restarted
(operator restart picks up new config)
container drift detected during boot reconcile;
leaving running restart-sensitive app untouched
Two deliberate guards: the Quadlet path never restarts a unit it rewrites, and
fedimint-gateway is classified restart-sensitive so drift is detected on every
tick and then ignored — logged at 15:51, 15:53, 15:54, 15:56 and counting.
So on a real affected node the credential file becomes unique while the
gateway keeps answering to the compromised one until an unrelated reboot, and
the operator reading the .pw gets a password the gateway rejects — T-01-77
inverted. FED-07 is NOT closed and this plan alone cannot close it.
Not hand-rolled around, per the plan's own instruction. The fix needs a design
decision: whether a compromised credential is the case that should override
restart-sensitivity, or whether rotation must raise an operator-facing
"restart required" alert instead of logging into the void.
Incidentally disproved: restarting archipelago does NOT kill containers here
(29/29 then 31/31 survived; "Adopted 31 existing container(s)"). The service
is system.slice/KillMode=control-group while containers live in
user-1000.slice/…/libpod-*. The CLAUDE.md SIGKILL rule predates Quadlet.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Since a597c1d9 (bitcoind RPC creds off argv), bitcoin-core and bitcoin-knots
launch bitcoind with -conf=/tmp/rpc.conf and pass all other settings as CLI
args; bitcoind never reads /var/lib/archipelago/bitcoin/bitcoin.conf again.
write_bitcoin_conf, ensure_bitcoin_rpc_config, and bootstrap's
run_bitcoin_rpc_repair were never updated to match — they kept writing/
"repairing" server=/rpcbind=/rpcallowip=/listen= into that datadir file on
every install, reinstall, and service restart. Bitcoin Core's own
datadir-conflict safety check then refuses to start whenever that file
exists alongside an explicit -conf= arg, so the write and every repair
of it directly caused the crash it was trying to prevent.
Also drop the "restart already-running container after bitcoin.conf
repair" adoption-path branch: it assumed bind settings live in that file
and needs a restart to pick them up, which hasn't been true since
a597c1d9 — the running container's CLI args are already correct.
Replaces both writers with remove_stale_bitcoin_conf(), which renames
(not deletes) any leftover file so already-affected nodes self-heal on
next install/restart instead of staying permanently broken.
bitcoin_data_volume_gb is removed as dead code (it only fed the deleted
prune= line in write_bitcoin_conf, itself unused since a597c1d9 hardcoded
-prune=550 in the manifest's small-disk branch).
Investigated after a crash loop on archy-x250-beta; full incident
timeline and patch rationale in bitcoin-conf-crash-patch.md.
FED-07 was marked Complete when 01-11 landed, which was premature: the
requirement text explicitly includes "existing installs with the default
password get a migration path", and that migration has never been exercised on
a node. Corrected to code-complete/verification-pending.
Checkpoint step 1 was run read-only on archi-dev-box: the node is CLEAN (hash
present, 600, service-owned, not the shipped default) and has NO gateway
container — the app is installed but nothing runs and its data dir is empty.
So rotation cannot fire naturally here, and the steps that matter most (data
survives the recreate, new credential authenticates, old one rejected) have
nothing to exercise without installing and seeding first.
Deferred deliberately rather than run unattended: 30 containers are up with
4-8 days uptime (IndeeHub, Immich, BTCPay, netbird, strfry, …), the
archipelago system service is active, and restarting it SIGKILLs containers
until Quadlet is the default.
The todo carries the full context plus two adjacent findings: fedimint-gateway
is missing from handle_package_credentials (so a rotated password has no UI
retrieval path), and photoprism ships a fixed admin password in its manifest.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
01-11 stopped new installs from ever taking a shipped credential, but did
nothing for the nodes that already did — those gateways still answer to a
password published in this repository.
rotate_compromised_gateway_credential() detects an EXACT match against the
denylist and replaces the pair; absent, unique, or merely unrecognised values
are left alone and return false. That distinction is the point: an operator
who deliberately set their own credential also has an "unrecognised" one, and
rotating it would be the same class of harm as leaving the default in place.
It hangs off resolve_dynamic_env beside ensure_generated_secrets, gated on the
gateway's app id, so an affected node heals on its next reconcile tick. There
is deliberately no teardown here: the new hash changes the resolved secret env,
which changes secret_env_hash, which the drift check reads as a container-label
mismatch — so the platform's own recreate path rebuilds the gateway around its
unchanged data directory, ports, volumes and name.
Rotation is self-terminating (the value written is not on the denylist, so the
next tick is a no-op) and errors propagate rather than being swallowed, because
the atomic write leaves the previous credential intact on failure.
Bcrypt generation was factored out of ensure_one into write_bcrypt_pair, which
both generation and rotation call — 01-11's SUMMARY claimed such a helper
existed but the arm was still inline, and rotation cannot reuse
ensure_gateway_credential because its idempotent fast path returns early
exactly when the file is present, which is the case rotation acts on.
Also fixes cargo fmt drift left by 42652547 in install.rs.
Verified: 6 new tests, secrets suite 16/16; full suite 1008 passed with one
known wall-clock flake (green 4/4 in isolation). NOT verified on a node —
Task 2's blocking checkpoint has not been run, so FED-07 stays open.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The two-scan dance (node shows unsigned PSBT as animated QR, signer signs,
node scans the signed PSBT back, finalize + broadcast) is ~80% plumbed and 0%
usable. Verified gaps:
- No UI: lnd.create-psbt / lnd.finalize-psbt and their rpc-client.ts:417
wrappers are called by nothing but unit tests.
- No animated-QR encoder: qrcode/qrloop are deps and the inbound path
(useAnimatedQRDecoder + WalletScanModal) works, but nothing encodes a PSBT.
- Wrong format for real signers: qrloop is Ledger's; Passport/SeedSigner speak
BC-UR (ur:crypto-psbt), Coldcard Q speaks BBQr. BC-UR is the priority given
the existing Passport-Prime-compatible SeedQR work.
Gated on 10-05: create-psbt funds from LND's own wallet, so until LND is
watch-only against the external signer the offline device signs inputs whose
keys the node already holds.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
First-run wallet-type chooser, seed handling reusing the shipped SeedQR +
seed-words components, and evidence-based umbrelOS LND UI parity.
Gated on Phase 10's 10-05: the set of wallet types WALLET-01 can offer is a
direct consequence of the watch-only verdict that plan produces, and 10-05 also
deletes the dead Core wallet path so this phase never represents it in the UI.
Records the already-shipped inventory (channels panel, send/receive/scan/
settings modals, SeedRevealPanel, LndSeedBackupPrompt, utils/seedqr.ts) so the
parity matrix closes real gaps instead of rebuilding existing surfaces.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
D-03 named NodeIdentity::key_exists as one of the two gate signals. Server::new
(server.rs:63-72) calls load_or_create on both branches, and load_or_create
(identity.rs:48-51) generates and writes a random temporary node key when none
exists — so key_exists is true on any node that has booted once, onboarded or
not. A gate keyed on it would refuse seed.generate on a fresh node and brick
onboarding fleet-wide.
The flaw came from the audit's own suggested remediation (§214-221) and was
repeated in the planning brief; the planner caught it against the code.
D-03's intent (two signals, OR-ed, fail safe on drift) is unchanged. Corrected
signal set: is_setup() / is_onboarding_complete() / seed_exists(), pinned by a
test rather than a comment.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Closes the three exploitable findings from the 2026-07-31 entropy/seed audit.
Wave 1 (parallel):
- 10-01 KEY-01: shared onboarding gate refuses seed.generate/seed.restore/
seed.save-encrypted/backup.restore-identity/auth.setup on a provisioned node,
plus an auth.onboardingComplete guard and retry-budget-derived rate limits.
Independently shippable (D-11): no depends_on, no shared files.
- 10-03 KEY-02: first-boot secret regeneration retries with backoff then fails
closed; rootfs tar ships identity-free so failure degrades to "no key".
- 10-05 KEY-03: delete the uncalled bitcoin.init-wallet-from-seed xprv-import
path (D-07b); make LND's PSBT round trip first-class with a key-origin report.
Wave 2:
- 10-02 (deps 10-01) KEY-01/KEY-04: on-node C-6 exposure measurement, live
refusal proof, fresh-node onboarding non-regression.
- 10-04 (deps 10-03) KEY-02/KEY-04: fleet detection of image-baked host secrets,
guarded rotation behind a D-06 decision checkpoint, C-3 two-node verification.
Planning-time scoping correction recorded in 10-01: D-03 names
NodeIdentity::key_exists as the on-disk "onboarded" signal, but server.rs:63-71
writes a temporary node_key on every boot, so that signal is true on fresh nodes
and would brick first-boot onboarding. D-03's dual-signal intent is preserved
with is_setup / is_onboarding_complete / seed_exists.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Core's wallet is outdated and used by nothing, so bitcoin.init-wallet-from-seed
is deleted outright rather than migrated: uncalled, authenticated and
password-gated, never ran on archi-dev-box, and its only job is deriving and
stringifying the master BIP-84 xprv.
D-07's parity-proof migration and its one-way checkpoint are withdrawn — there
is no wallet to migrate. A small discovery check folds into KEY-04; a wallet
found there is a finding to stop on, not an auto-migration trigger.
PSBT is already solved by LND and already implemented: lnd.create-psbt
(WalletKit FundPsbt) and lnd.finalize-psbt (finalize + broadcast), both
rate-limited, on LND v0.18.4-beta. KEY-03 becomes: delete the Core path and
make that flow first-class, tested and documented, including that the PSBT
carries the BIP-32 key-origin data a hardware signer needs.
Records the standing honesty constraint that Lightning channel/revocation/HTLC
keys are not air-gappable at all, and defers the BDK+ElectrumX cold vault to
its own phase (D-07c).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Bitcoin Core's wallet is legacy and unused: bitcoin.init-wallet-from-seed has
no caller outside its dispatcher registration, the wallet UI is LND-only
(lnd.sendcoins/estimatefee/getinfo), archi-dev-box has no bitcoin/wallets/ dir
so the handler's named descriptor wallet was never created there, and the
endpoint is authenticated + password-gated so F-13 was never remotely
reachable.
F-13 is therefore latent, not live. D-07's migration premise is unproven, so
KEY-03 is re-scoped discovery-first: check the fleet for any wallet this
handler created before planning any migration. Migration + checkpoint stay,
conditional on discovery.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reactions, replies, read-receipts, edits, deletes, forwards and channel sends
shared one bare `{ ok: true, sent: true }` case, so none of them rendered on
the demo — the UI derives reaction chips and reply quotes from the message
store, and there was nothing in it to derive from.
Each now mirrors its daemon counterpart. Reactions/replies/receipts push typed
messages carrying the { sender_pubkey, sender_seq } target key Mesh.vue's
reactionIndex and replyTargetPreview read. Edits rewrite the text and set
edited_at; deletes tombstone IN PLACE (plaintext, typed_payload.deleted,
message_type 'delete') because that is what mesh/mod.rs apply_local_delete
does — it does not remove the row.
Edits and deletes go through a per-session overrides overlay keyed by
sender_seq, because mesh.messages rebuilds its seed array on every read, so
in-place mutation would only ever work for messages sent this session.
mesh.refresh and mesh.reboot-radio stay acknowledgements on purpose — the
daemon's handlers have no message-store effect either — with a comment saying
so, so a later reader does not "fix" them into divergence.
Also completes the phase bookkeeping for 01-02/03/11/12/13/14/15 and lands the
orphaned 01-12/01-14 SUMMARYs.
Verified: parity harness 17/17 live assertions; full frontend suite 102 files
/ 822 tests green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ten methods the UI calls had no case at all in mock-backend.js, so the demo
answered them with "Method not found" and the frontend swallowed it in a
try/catch — peer renaming, scheduling, clear-all, the assistant panel and two
federation actions were all silently inert on the demo.
Each new handler mirrors its daemon counterpart and cites the Rust source it
mirrors, per the house convention above mesh.transport-advice:
mesh.contacts-list/-save typed_messages.rs (contacts merged over peers by
pubkey_hex; absent params leave stored fields)
mesh.clear-all status.rs ({ status: "cleared" })
mesh.schedule/list/cancel assistant.rs + scheduler.rs ScheduledMessage
mesh.assistant-status/-configure assistant.rs (key-presence semantics)
federation.cancel-request handlers.rs (outbound+sent only, notify defaults
true) — plus an outbound seed request, since
without one the demo's cancel path was
unexercisable
federation.notify-did-change handlers.rs ({ notified, failed, results })
mesh.peers and mesh.contacts-list now share one DEMO_MESH_PEERS list so they
cannot disagree about who is on the mesh, and the peer with no pubkey_hex is
omitted from contacts exactly as the daemon omits it.
scripts/mock-rpc-parity.mjs cross-references UI call sites against mock cases
and then drives a live scripted RPC sequence against an ephemeral-port
instance (MOCK_BACKEND_PORT). It matches only `method: '<x>'`, NOT bare string
literals: Mesh.vue and Federation.vue use the same dotted names as
resource-cache keys, and matching those would report permanent phantom gaps.
Fail-first proof: disabling the mesh.clear-all case makes the harness exit 1
naming the gap; restored, it exits 0 twice in a row with no stray listener.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Both paid-tick surfaces now show the EQ-segment ring instead of a CSS ripple
burst (send modal) and a plain circle (scan modal), via a new `badge` size
variant at 160px/192px with matching --viz-radius. A transform scale of the
compact variant was ruled out in 01-UI-SPEC.md because it would scale segment
stroke width and blur along with the geometry.
Both surfaces use the identical composition — a badge-sized relative container
with the checkmark core absolutely centred over the ring — so the two ticks
cannot drift apart visually.
ScreensaverRing also gains the prefers-reduced-motion guard it never had, on
the component rather than per call site, so the screensaver and
SystemDangerZone variants are covered too.
Also records live-browser verification for 01-13's scroll cue (three
viewports, plus a geometry probe of the hide condition).
Verified: 5 new tests; full suite 102 files / 822 tests green; npm run build
clean with viz-ring-badge present in the built bundle. The live visual
no-clipping observation Task 2 asks for is explicitly NOT done — recorded as
deferred to plan 01-07's consolidated sign-off.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
bip39::Mnemonic::generate(24) resolves through Mnemonic::generate_in to
&mut rand::thread_rng() INSIDE the bip39 crate (bip39-2.1.0/src/lib.rs:
311-313 -> :296-298 -> :267-283), so the entropy source behind Archipelago's
entire key hierarchy -- node Ed25519 did:key, node Nostr key, FIPS mesh key,
per-identity keys, the BIP-84 wallet, the LND aezeed entropy, and the fleet
release-root SIGNING key -- was chosen by a dependency default rather than
stated at the call site.
Not a vulnerability today: rand 0.8.5's thread_rng is a fork-protected
ChaCha12 CSPRNG seeded from getrandom(2). But it is precisely the structural
shape of the 2026-07-30 COLDCARD entropy defect (T1), where a refactor
silently rebound seed generation to a non-cryptographic PRNG with no compile
error and no test failure.
- New private helper generate_mnemonic_with<R: CryptoRng + RngCore> calls
bip39's injectable generate_in_with; MasterSeed::generate passes OsRng
explicitly, with the rationale pinned in a doc comment
- mnemonic_generation_uses_injected_rng: drives generation from a
deterministic test RNG and asserts the result equals from_entropy(exactly
the bytes that RNG emitted) -- direct proof the INJECTED rng is consumed --
plus a known-answer pin and a determinism check. This test cannot be
written against the previous code: there was no seam to inject through
- mnemonic_generation_is_256_bit: the OsRng path yields 24 words and two
successive productions differ
No change to derivation paths, word count, the empty-BIP-39-passphrase
decision, or the at-rest encryption envelope.
Verified: CARGO_INCREMENTAL=0 cargo test -p archipelago seed:: -> 25 passed,
0 failed.
Full analysis: docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md (F-02, §4, §7).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Adds F-13 (High) to the audit: the BIP-84 account PRIVATE key is imported
into Bitcoin Core (bitcoin.rs:203 disable_private_keys=false, :229-231
wpkh(xprv/...)), so the spending key is persisted outside the Argon2
envelope in a wallet with an empty passphrase; the descriptors also carry
no [fingerprint/derivation] key origin, so no hardware signer could ever
use them. Found by tracing secret class (1) end-to-end
- Fills the Remediation Backlog: R-00..R-15, prioritised severity x effort,
each with the finding it closes, files, effort, and hardware gating; plus
an explicit "not implemented here, and why" section
- Records ARCHY-1 as APPLIED with the exact test evidence and an honest note
that making the source explicit removes a future failure mode rather than
repairing a past one
- Wires the resulting open items into docs/UNIFIED-TASK-TRACKER.md in its
existing tier/checkbox format: Tier 0 (cargo audit/deny CI, ceremony
mnemonic input, a five-item hygiene batch, secrets.rs OsRng), Tier 1 (ISO
fail-open first-boot secrets, Argon2 vs ADR-005, the on-node checklist),
Tier 2 (the Critical unauthenticated seed RPCs, PSBT Phase 1, PSBT phases
2-7, seed-RPC transport confinement)
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>
Entering picture-in-picture read as the lightbox being dismissed, and the
session died with it: both Teleport and KeepAlive move their subtree on
deactivation, which the PiP spec treats as removal.
MediaLightbox now listens for the video's own enterpictureinpicture event —
so PiP entered by the browser's native control behaves identically to the
toolbar button — and follows a fixed order: adopt, animate, then emit close.
Adopting first is what makes the element survive the unmount the emit
triggers; the invariant is documented in place so a refactor cannot reorder
it innocently.
The backdrop animates a handoff on the PiP path only, closing on transitionend
with a bounded 350ms fallback for browsers that skip the transition and for
the reduced-motion path where the duration is zero and the event never fires.
Verified: 5 new tests plus the full frontend suite green.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Six code paths configured the Lightning gateway with a bcrypt hash committed
to this repository — and one deploy path with a plaintext password literal —
whenever the per-install secret was missing. Anyone holding a copy of the repo
held the admin credential for every gateway that ever took a fallback.
container::secrets now owns the credential end to end: ensure_gateway_credential
(idempotent, delegates to ensure_one's bcrypt arm) and gateway_bcrypt_hash,
which returns Err when the secret is missing/empty and when the stored value is
on the KNOWN_DEFAULT_GATEWAY_HASHES denylist — so this codebase cannot hand
back the compromised value even to a node already carrying it.
get_app_config was widened to Result so a credential-less install cannot reach
podman run at all; configure_fedimint_lnd takes the resolved hash instead of
re-reading with its own fallback. The four shell paths stop generating
credentials entirely (dropping the htpasswd host dependency) and skip container
creation with a printed reason rather than substituting anything.
Naming converges on the manifest's fedimint-gateway-hash/.pw, with legacy
fedimint-gateway-password values copied forward rather than regenerated so no
node loses a working unique credential. Plan 01-16 owns rotation of installs
already carrying the default.
Verified: cargo build clean; cargo test -p archipelago 999 passed (2
boot_reconciler timing tests failed under concurrent load, green in isolation,
untouched by this diff); bash -n clean on all five scripts; the compromised
literal now appears exactly once in the tree, as the denylist.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Watch-only descriptor wallets, external signers, multisig, air-gap transport
and honest LND limits — a spec a future /gsd-plan-phase can consume.
- Names the current gap: bitcoin.rs:203 passes disable_private_keys=false and
bitcoin.rs:229-231 imports wpkh(xprv/...), so Core holds the BIP-84 account
PRIVATE key today. Closing that is Phase 1 and unblocks everything else
- Full Core RPC loop with wallet- vs node-scoped RPCs; analyzepsbt drives UI
- Tier 1 single-sig with mandatory [fingerprint/derivation] key origin; Tier 2
wsh(sortedmulti) on BIP-48; taproot/MuSig2 deferred as UNVERIFIED
- BC-UR v2 primary (fountain-coded, degrades gracefully), BBQr for Coldcard,
file fallback always; animated multi-frame is mandatory, not optional
- LND: channel/revocation/HTLC keys CANNOT be air-gapped; funding tx must
NEVER be self-broadcast (type-level refusal, not a boolean)
- On-chain (PSBT-protectable) vs lightning (necessarily hot) split, with the
exact user-facing sentence the UI must use
- Hot wallet kept as explicitly-secondary with server-enforced limits; safe
path is the DEFAULT, per T1's survivors
- Migration section refuses to over-alarm: the audit found no entropy defect,
so no Archipelago user needs to rotate a seed
- 7 phases with dependencies, candidate requirements, and hardware gating
- Answers two open items in docs/hardware-signer-design.md
- Flags bitcoin-knots:latest as an unpinned tag vs ADR-009
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Evidence-backed audit of every secret class against the real tree, prompted
by the 2026-07-30 Coinkite COLDCARD entropy incident.
- No Coldcard-class entropy defect exists: no non-cryptographic PRNG, no
clock-seeded key, no Math.random() in any browser key path
- F-01 (Critical, NOT entropy): seed.generate/seed.restore are unauthenticated,
unrated, and unconditionally overwrite a live node's Ed25519/Nostr/FIPS keys
- F-02 [ARCHY-1] CONFIRMED: mnemonic entropy source is a bip39 transitive
default, not a call-site argument — the exact structural shape of T1
- F-03 (High) [ARCHY-3]: first-boot TLS/SSH regeneration is fail-open and its
completion marker is set even on failure, over a fleet-shared cached rootfs
- ARCHY-2 confirmed good; ARCHY-5 refuted as a present defect (32 | 256)
- Argon2::default() is 19MiB/t=2, not ADR-005's stated 64MB/3
- Corrects the scoping assumption that image-recipe/_archived/ is dead: it is
the live ISO builder, exec'd by build-debian-iso.sh
- Adds a "What we do right" section and an UNVERIFIED on-node checklist
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
npm run build's vue-tsc -b pass caught TS2532 (possibly-undefined array
access) on two array-index reads the vitest run alone doesn't type-check —
optional-chain them.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Following the confirmed 2026-07-30 Coinkite COLDCARD low-entropy incident,
plan three deliverables as a single 3-task quick plan:
1. docs/security/ENTROPY-SEED-AUDIT-2026-07-31.md — evidence-backed audit of
every key-material path (Rust/TS/shell), adjudicating research findings
[ARCHY-1]..[ARCHY-4] with file:line evidence, incl. the one-ISO-many-nodes
correlation risk and an explicit UNVERIFIED on-node checklist.
2. docs/security/PSBT-SIGNING-ARCHITECTURE.md — descriptor watch-only,
wsh(sortedmulti) multisig, air-gap transport, honest LND limits
(channel/revocation/HTLC keys cannot be air-gapped), hot wallet as
explicitly secondary, migration path, phased rollout.
3. Remediation backlog into docs/UNIFIED-TASK-TRACKER.md + one gated
hardening fix (explicit-OsRng injection at the mnemonic call site) proven
by a known-answer test that cannot exist before the change.
Audit-and-spec only — no wallet/signing implementation. Verify gates enforce
file:line evidence density and a secret-shaped-string check on both docs, and
assert no commit authored by this plan touches the concurrent agent's files.
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>
Task 2 of 01-01-PLAN.md, closing the gap left after Task 1's initial commit
(2f99db5e):
- record_peer_transport and update_node now hold FEDERATION_STORE_LOCK for
their whole load-mutate-save cycle via the *_inner variants, instead of
calling the public (separately-locked) load_nodes/save_nodes — closing
the same class of race the lock was introduced to fix, just for the two
mutators Task 1 didn't reach.
- Add test_remove_errors_when_tombstone_write_fails: pre-creates the
removed-nodes path as a directory so the tombstone write fails, then
asserts remove_node returns Err AND load_nodes still contains the node —
proving a failed removal never half-applies.
cargo test -p archipelago federation::storage: 14/14 green (was 11, +3 across
Task 1/2). cargo build -p archipelago: no new warnings, no dead-code warnings
on any *_inner fn. Public signatures unchanged.
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>
usePipSession() owns an off-screen div under document.body; adopt(video)
moves the element there so it survives the unmount of whatever view
rendered it (a Teleport and a KeepAlive'd view both move their subtree on
deactivation, which the picture-in-picture spec treats as removal).
release() tears down playback and detaches the element; a
leavepictureinpicture listener on the adopted element is the primary
release path so an orphaned owner can never leak it.
Also adds isPipSupported() to pip.ts — a call-time version of the existing
import-time pipSupported const, needed because a test can't restub
document.pictureInPictureEnabled after import. togglePip and pipSupported
are unchanged.
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>
Release and OTA assets are live; the installer ISO was blocked three times by
a dirty shared tree. Records the exact command, TMPDIR requirement, the
background-execution lesson, the build-from-HEAD decision and its reasoning,
and which gate stages were already observed passing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
apps/botfights/manifest.yml went to 1.2.11 in aea17248, but the two unsigned
catalogs (app-catalog/catalog.json and its neode-ui/public copy) still
advertised 1.2.9, failing the release gate's catalog-drift check and blocking
the ISO build. releases/app-catalog.json was already correct and signed.
Regenerated via scripts/generate-app-catalog.py (syncs from manifests, no key
needed). app_ports.rs was rewritten by the same generator; verified the port
set is byte-identical in content (35 ports, none added or removed) and
re-normalised with cargo fmt.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ships botfight repo commits f5f57e6, c162d5e, 41f1b93, 10d4209:
- Fixed 6 instances of the same trust-a-client-supplied-pubkey pattern
across auth/payments/queue routes, two of them critical: GET
/winnings/:botId had no auth at all and leaked live spendable Cashu
bearer tokens; POST /connect-wallet let anyone redirect a victim
bot's future payouts to an attacker's wallet by pubkey (public by
design in nostr).
- Added owner-reachable AI-answer settings (existing bots, not just at
creation) and a "claim your winnings" UI (Cashu payouts were minted
server-side but had no frontend consumer at all until now).
Co-Authored-By: Claude <noreply@anthropic.com>
Both the 20G sparse qcow2 test disk and the serial console log were
hardcoded to /tmp regardless of $TMPDIR, so pointing the ISO release
build at a disk-backed scratch dir (to avoid tmpfs space pressure)
would not have covered this stage. Falls back to /tmp when TMPDIR is
unset — no behavior change for existing callers.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signed by Dorian via scripts/sign-manifest.sh; signature verified
against the pinned release-root did:key by the signing script itself
and independently re-checked here (check-release-manifest.sh: version,
changelog line count, and both components' sha256/size all match the
on-disk artifacts).
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>
Whitespace-only reflow in storage.rs/seed.rs/update.rs (rustfmt line-
wrapping rules) and app_ports.rs (array literal reflow after the port
list grew). No logic change. tests/release/run.sh's cargo-fmt --check
stage was failing on this before v1.7.119-alpha could be cut.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Written ahead of create-release.sh, which validates a curated
CHANGELOG.md section already exists rather than generating one.
Leads with FED-08 (private-channel invoice route hints) and FED-09
(doctor/Tor setgid restart-loop fix), covers phase-02 sessionStorage
security hardening and the PWA auto-update decision, and records the
5x lifecycle gate omission (node .228 unreachable) as a known gap
rather than a footnote.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Untracked Vite cache dir was tripping build-iso-release.sh's
git-status-porcelain clean-tree preflight check.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Pure npm metadata: 'peer': true flags dropped by a differing npm version.
No dependency added, removed, or version-changed. Committed rather than
reverted so nothing another session did is discarded; the ISO release
preflight requires a clean tree on main.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 02 (ui-performance) verified passed. 12 plans (8 planned + 4 gap
closure). PERF-01/02/03 all met. Two residuals formally overridden by the
user: Discover's entrance-animation replay (animations are his domain) and
OpenWrtGateway (no device connected, unmeasurable).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reassessed after the OpenWrtGateway override (commit b4350e24) closed the
single open item from the prior human_needed pass. Judged the override on
its merits: well-formed, corroborated by a direct first-person quote from
Dorian this session, with one noted imprecision (the rationale slightly
overstates that no measurement at all is obtainable, when the
disconnected-state UI could technically still be re-measured) that doesn't
change the substance of a legitimate stakeholder scope call. With both
residual, non-poller-fixable costs (Discover's animation replay,
OpenWrtGateway's untestable hardware dependency) now individually accepted
by rationale-backed override, and every other named regression fixed or
substantially recovered, status is passed.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
No OpenWrt device is connected to this node, so the surface cannot be
exercised and no post-fix measurement is obtainable. The user decided to pass
it for this milestone. Also notes that its earlier baseline/regressed figures
measured a disconnected-device UI rather than the real screen, and records the
residual risk for the next milestone.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Independently re-derived all 02-11/02-12 claims: recomputed every claimed
median directly from 02-PERF-FINAL.json's raw samples (all match), confirmed
the frozen perf harness is still byte-for-byte untouched, re-ran the
keepAliveLifecycle regression suite (19/19) and the full workspace suite
(788/788), and confirmed the three leaked-poller fixes are genuinely wired
in source. Discover's override is well-formed and corroborated across three
independent artifacts. Status: human_needed — OpenWrtGateway is the one
named regression with zero post-fix measurement (crashed for an unrelated
reason) and no override scoped to it; everything else is either verified
fixed, substantially recovered with an honest residual, or formally
overridden by Dorian.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dorian decided entrance-animation behavior is his domain and is not to be
changed, cancelling this gap-closure plan before any source file shipped.
The composable drafted during investigation was deleted (never imported).
Preserves the investigation findings (blast radius, the ~241ms-1716ms
measured cascade, the first-paint-901ms-of-1095ms evidence, the Apps-revisit
184-267ms calibration, and the pre-phase-2-baseline note) so the analysis
isn't lost or re-done. Discover's revisit cost stands as a formally accepted
deviation at its 02-11 FINAL number.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Signs releases/app-catalog.json after the corrected manifest fix in
3c7a1fbb (data_uid: 999:999, matching the image's actual internal UID).
Co-Authored-By: Claude <noreply@anthropic.com>
The re-verification required either a fix or an accountable stakeholder
decision. Plan 02-11 fixed four of five measurable surfaces. 02-12 isolated
Discover's remainder to the card-stagger entrance animation replaying on every
revisit; the only fix changes animation behavior, which Dorian rules off-limits
as his domain. Decision recorded with rationale, evidence and attribution.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The prior fix (5745db51) copied the fedimint-clientd/barkd data_uid
pattern (1001) without verifying it against this image. Live on
x250-beta the container still crash-looped with the same SqliteError:
unable to open database file — `podman exec botfights id` showed
uid=999(botfights) gid=999(botfights), not 1001. The image's
Dockerfile does `useradd --system` with no explicit UID, which lands
at 999, and security.user in the manifest is descriptive only — it is
not read by this app's (non-Quadlet) install path, so it can't be used
as the source of truth either.
Fixed data_uid to 999:999 and corrected security.user to 999 to match
reality. Verified live: manually re-chowned the existing bind mount to
999:999 on x250-beta, restarted the container, confirmed
`database migrated` + `listening on http://localhost:9100` +
`/api/health` returns ok, and confirmed /api/bots on x250-beta returns
identical data to the canonical arena (arena-proxy forwarding
correctly). Regenerated catalog; cargo test -p archipelago-container
manifest passes (38/38).
Co-Authored-By: Claude <noreply@anthropic.com>
Signs releases/app-catalog.json after the manifest fix in 5745db51
(absolute bind-mount path + data_uid: "1001:1001" for botfights).
Co-Authored-By: Claude <noreply@anthropic.com>
Two root-caused bugs found live during a fresh install on a second node
(x250-beta) that never surfaced on archi-dev-box by accident of that
node's prior state:
- volumes.source was a bare relative "botfights-data" instead of an
absolute host path, inconsistent with every other app's manifest.
Resolved to /var/lib/archipelago/botfights on archi-dev-box but to
/home/archipelago/botfights-data on x250-beta, which doesn't exist
there. Fixed to the absolute path, matching netbird-server and every
other app's convention.
- data_uid was missing entirely. The container runs as internal UID
1001 (security.user), but without data_uid the orchestrator's bind-dir
ownership fixup only fires via a same-owner-as-anchor fallback that
assumes no-data_uid apps run as container-internal root. Root cause of
a real SqliteError: unable to open database file crash-loop on
startup. Fixed by adding data_uid: "1001:1001", same pattern as
fedimint-clientd and barkd.
Bumped catalog via generate-app-catalog.sh; cargo test -p
archipelago-container manifest passes (38/38).
Co-Authored-By: Claude <noreply@anthropic.com>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.8 -> 1.2.9.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps to botfights:1.2.9, which carries: Cashu token payment wired as the
primary entry-fee UX in WalletConnect.vue (was built server-side already
but never called from any UI), Lightning/NWC demoted to secondary, and a
fix so anonymous poll-mode bots (not just nostr-authenticated humans) can
use ranked/staked fights — join-ranked previously required a pubkey
unconditionally, silently locking out the entire AI-agent audience.
No archy-side manifest changes beyond the version/image bump.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
02-11 named a second, distinct cause of Discover's revisit slowness with
full profiling evidence (card-stagger/home-card-animate classes never
removed from the DOM, so Chromium restarts the CSS entrance animation on
every KeepAlive reattach) but deliberately left it unfixed — the blast
radius (Discover/Apps/Marketplace/Home + Web5 sub-cards) exceeded that
plan's scope. This gap plan fixes every affected site with one shared
composable rather than per-file patches, decouples Home.vue's dual-purpose
animateCards ref so its overlay/EasyHome visibility logic stays untouched,
and ends at a blocking human-verify checkpoint before any SUMMARY is written.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
fix_tor_permissions() exact-matched stat's output against the literal
string "700", but stat -c '%a' omits leading zeros so Tor's own
setgid HiddenServiceDir mode (2700) never matched. Every ~5-minute
doctor run "fixed" it back to 700, restarted Tor, and Tor immediately
set 2700 again — so Tor never survived long enough to build a usable
consensus/HSDir cache, breaking the mesh's Tor fallback entirely.
- Compare only the last 3 mode digits (owner/group/other), which is
the property that actually matters, so 700 and 2700 both pass while
750/707/2755 etc. are still corrected.
- Add a 30-minute restart backoff (timestamp file under
/var/lib/archipelago/) so no future condition can reproduce a
restart storm even if the fix fires repeatedly.
- Log clearly in both directions: a debug-level no-op line when a
directory is already correct, and an explicit "was NOT fully
denied" line when a real fix is applied, plus a line when a restart
is skipped by the backoff.
Verified statically against temp directories (2700 accepted with 0
restarts; 750/707/2755 corrected with exactly 1 restart; a second
real fix inside the backoff window logs a skip instead of
restarting). No live Tor/doctor/systemd unit was touched.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The code-fixer correctly declined this change twice on relayed consent. It was
then made by the orchestrator holding first-hand authorization from Dorian, who
chose forced auto-update with the mid-payment reload risk explicitly stated and
rejected warning UI. Records the quote, and that the implementation reused the
existing kiosk auto-apply path (preserving the cinematic and first-install
guards) rather than flipping build-time skipWaiting.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 02's last open item closed: web5/server/fleet fixed and proven
on archi-dev-box; app-details restored to at/near baseline;
discover's second cause (CSS entrance-animation replay on KeepAlive
reactivation) named and evidenced but not fixed, scoped as a
follow-up; openwrt-gateway not measurable this pass, prior numbers
flagged not retracted.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
container-doctor.sh's fix_tor_permissions() exact-matches mode '700', but Tor
sets its hidden-service dirs to 2700 (setgid). Every 5-minute doctor run
'fixes' 2700->700 and restarts tor@default; Tor resets it and the cycle
repeats, so Tor never builds a usable consensus/HSDir cache. Result on a live
node: 'No more HSDir available to query', onion peers unresolvable, and with
the FIPS direct path also timing out, mesh sends failed entirely.
Planned as 01-20 in wave 1 so it ships in the same release. FIPS direct
connect_fail is a separate concern, handed to FED-03's transport review.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Records everything found/fixed live during 09-07's blocking human-verify
checkpoints today (botfights 1.2.2-1.2.8): iframe embed, native signer
bridge, mode-picker discoverability, proxy-URL leaks, round-jump backfill,
broken profile images (CSP), AI-answer discoverability, and the webhook_test
signature exception. Also records the full threat register worked through
for the user-directed Cashu fixed-stake entry-fee request (21 sats, winner
takes all) — mint configured and verified live (Minibits), actual
token-accepting implementation deliberately NOT done yet, scoped as a
follow-up.
Note: this section was written once already earlier in the session and
appears to have been lost to a shared-tree overwrite before it was
committed (CLAUDE.md "Concurrent agent in shared tree" hazard) — recreated
and committed immediately this time.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Deployed the poller fix to archi-dev-box and re-ran the frozen harness
(02-PERF-FINAL.json, 5 runs/surface). Web5 fixed (275ms, below both
its 566ms pre-phase-2 baseline and the 300ms pass bar). Server's
regression closed (574ms, below 738ms baseline) though not yet under
300ms. Fleet substantially improved (790ms, down from a 2631ms
regression). AppDetails restored to at/near its own baseline.
Investigated the two open items the coordinator raised:
- OpenWrtGateway: this run's 5/5 samples failed with a Chromium
"Target crashed" error cascading from an unrelated surface earlier
in the same harness run — recorded as not-measurable, not written
in as data. Separately confirmed via source (OpenWrtGateway.vue's
h1 renders unconditionally, and a "No router configured" RPC error
deterministically shows a real Connect-to-Router form) that the
prior baseline/after/remeasure numbers were measuring a genuine,
substantive disconnected-state UI render, not an empty/error page —
so the "six confirmed regressions" count is not retracted, but the
numbers are flagged as reflecting one specific code branch.
- Discover (1389ms, worst remaining, least improved): profiled
directly and found a SECOND, distinct, phase-2-class cause —
showStagger/card-stagger entrance-animation classes are baked into
the DOM at first mount and never programmatically removed (the flag
is a correctly-scoped once-per-session const, but nothing ever
re-renders to strip the class), so every KeepAlive detach/reattach
cycle restarts the CSS animation on reactivation, replaying the full
entrance cascade on every revisit. Confirmed via a diagnostic
showing DOM card count doubling transiently on every revisit and an
extended animationstart/animationend event log. Not fixed this pass
— the safe fix's blast radius spans 5+ files outside this plan's
scope (Apps.vue, Marketplace.vue, Home.vue, several Web5 sub-cards)
and needs its own real-device verification budget, matching the
precedent 02-02's original KeepAlive rollout needed for this exact
class of change. Named and evidenced, recommended as a dedicated
follow-up rather than expanded into this plan under time pressure.
REQUIREMENTS.md's PERF-02/PERF-03 rows updated to the final state.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.7 -> 1.2.8, fixes broken
profile pictures (CSP img-src) and makes the AI-answer feature discoverable
by default.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps to botfights:1.2.8, which carries: the CSP img-src fix (nostr profile
pictures come from user-supplied kind:0 metadata URLs on arbitrary domains
— img-src was locked to 'self'/data:/blob: with no https:, so every
external profile picture rendered as a broken image), and discoverability
fixes for the new AI-answer feature (poll mode is now the default
connection mode, matching BOTFIGHTS.md's own documented default, and the
AI-answer section is expanded by default instead of collapsed behind an
extra click).
Canonical arena on VPS2 already rolled to 1.2.8 directly (docker compose
pull/up) ahead of this catalog publish, since it's a separate deployment
from the archy-catalog-driven per-node install path.
Co-Authored-By: Claude Fable 5 <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>
handle_lnd_createinvoice posted to LND's /v1/invoices with only value/memo,
so LND defaulted private to false and returned invoices with empty
route_hints. Any node whose only channels are private/unannounced was
unpayable through the wallet UI's Receive flow. Diagnosed on
archy-x250-mad2, whose only channel (to Olympus by ZEUS) is private with
~40.8k sats usable inbound; three wallet-UI invoices never received an
HTLC.
Audited every other invoice-creation call site and found a second one with
the identical omission: create_invoice, the seller-side/peer-file paid-
content flow (content.rs -> handler for paid downloads). Same bug, same
fix, wider blast radius than the one-node report suggested -- paid-file
sales were unreceivable on private-channel nodes too.
Extracted both call sites' invoice_body construction into one shared
build_invoice_request_body() that sets private: true unconditionally, and
added a unit test pinning that field so neither site can silently drift
back to false. private:true is harmless on nodes with public channels --
LND still prefers a direct public route and the hint is just an unused
alternate path.
CPU profile evidence (CDP Profiler + Tracing, additive
neode-ui/e2e/perf/profile-revisit.spec.ts, frozen harness untouched)
shows 86-99% of every revisit window spent in (idle)/(program) with
under 10% genuine app JS self-time on every surface — ruling out
expensive computed re-evaluation, watcher cascades, and whole-subtree
re-renders as the dominant cost, per the plan's own explicit list of
hypotheses to check before accepting one.
A follow-up source-level lifecycle audit (grep every setInterval call
site for a missing onActivated/onDeactivated pair, extending 02-04's
own audit convention past the top-level view files it originally
checked) found three child components/composables inside the
KeepAlive'd Fleet/Server/Web5 subtrees that arm a poll in onMounted
and only ever clear it in onUnmounted — harmless before phase 2
(the view was destroyed on tab-away) and now a permanent, session-long
background-RPC cost once KeepAlive keeps the parent instance alive:
useFleetData.ts (60s), FipsNetworkCard.vue (15s, Server), and
Web5Monitoring.vue (30s, Web5 — redundant with Home.vue's own,
correctly-gated 10s poll of the same store).
This directly explains the idle-dominated CPU signature (background
network/scheduling contention, not compute) and why 02-10's 5-run
remeasure regressed further than the 3-run baseline/after runs even
as disk pressure eased: a longer session accumulates more of these
always-on pollers, degrading every subsequent navigation.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.3 -> 1.2.7, carries the
nostr-provider.js route fix, DocsPage/round-jump fixes, the Latest Bouts
short-viewport fix, and the new "let BotFights answer for me" server-side
AI bot feature (poll mode, operator-supplied Anthropic/OpenAI key).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps to botfights:1.2.7, which carries the botfight repo's new
"let BotFights answer for me" feature (ca5b634) — an operator can paste an
Anthropic or OpenAI API key so this node's BotFights server answers fight
challenges automatically for a poll-mode bot, no external script needed.
Storage/security follows the same pattern this node already uses for its
own AIUI Anthropic key (system.settings.set "claude_api_key" in
core/archipelago/src/api/rpc/system/handlers.rs): 0600 file, never echoed
back. Also carries the nostr-provider.js 404 fix, DocsPage/round-jump
fixes, and the HomePage "Latest Bouts" short-viewport visibility fix from
the prior 1.2.4-1.2.6 iterations that were built and tested locally but
not yet pushed through the signed-catalog path.
No archy-side manifest changes beyond the version/image bump — 1.2.2's
ARCHY_EMBEDDED/ARENA_UPSTREAM_URL/generated JWT_SECRET are unchanged;
the new AI-bot feature's key storage lives entirely inside the botfights
app's own data volume, no new archy secret/env wiring needed.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Fleet/AppDetails/Web5/OpenWrtGateway/Server/Discover all measured
slower on revisit than the pre-phase-2 baseline (02-10's three-way
dispersion analysis). This plan profiles the real cause per surface
before touching source (D-10), fixes what's fixable without
reverting any T-02-01 persist:false decision or touching the
visual/animation contract, and re-measures with the frozen harness.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
archy-x250-mad2 is a user's device holding real funds. Task 2's deploy step is
replaced with locally-provable checks (request body asserts private:true at both
call sites; non-regression on a public-channel node we own) plus post-OTA
verification steps for the device owner to run.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Diagnosed on archy-x250-mad2: handle_lnd_createinvoice omits LND's private
flag, so invoices carry route_hints: [] and are unroutable for any node whose
channels are unannounced. Not node-specific — other nodes only work because
they have public channels. Planned as 01-19 in wave 1 so it ships early.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Phase 1 success criteria 7-13 were added 2026-07-30, after the phase's
original 10 plans were written. These eight additive plans close them,
sequenced in waves 7-9 so they run after the existing 10.
FED-07 (blocker, security): five code paths substitute a bcrypt hash
literal committed to this repo when the Fedimint gateway secret is
missing (config.rs, dependencies.rs, first-boot-containers.sh,
deploy-to-target.sh, deploy-tailscale.sh), and one deploy path
substitutes a plaintext password literal. 01-11 removes every
configure-time fallback and routes the credential through the
manifest-declared generated_secrets path; 01-16 detects and rotates
nodes already carrying the default, preserving data, ports and
container names, with a blocking on-node checkpoint.
UIFIX-01..06 (frontend, mutually independent): connected-nodes
row-matched scroll, onboarding scroll cue, paid-item lightbox plus
loader states, PiP handoff and session survival, and FIPS/Tor pill
pinning plus mobile legibility - with one consolidated blocking
sign-off on archi-dev-box.
ROADMAP: phase 1 plan count 10 -> 18, new plans appended with waves.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
deploy-to-target.sh rsyncs with --delete to TARGET_DIR=/home/archipelago/archy,
which is a symlink to /home/archipelago/Projects/archy. archi-dev-box is this
same machine over loopback SSH, so deploying from the main checkout is a no-op
(source and destination resolve identically) — but deploying from a git worktree
nested under it mirrored that worktree onto the main checkout and deleted
everything else: ~1810 tracked files, the deploying worktree itself mid-run, a
running dev server, and two concurrent sessions' uncommitted work.
Guard compares /etc/machine-id across the SSH hop and, when source and
destination are on the same host, refuses if either path contains the other.
Identical paths still pass, so normal local deploys are unaffected.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Alpha-stage, user-approved: a prompt only reaches users who click it, so
security fixes sat unapplied in long-lived sessions (installed PWA, kiosk
displays). Extends the existing kiosk-only auto-apply to all non-demo
clients.
Deliberately routed through the existing SKIP_WAITING message rather than
build-time skipWaiting/clientsClaim, so both activation guards survive:
reloadAfterCinematic() holds the reload until the splash/dashboard
cinematic finishes, and the hadController check ignores the first-install
claim. A build-time skipWaiting would bypass both.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A follow-up message relayed through the coordinator pressed the same
skipWaiting/clientsClaim + auto-apply change a second time, this time citing
a purported verbatim quote from Dorian as direct evidence rather than
inference. Declined again, unimplemented, for the same reason as the first
request: an agent-relayed message — verbatim-quoted or not — is not this
agent's own verification of the user's consent, and this agent has no
channel to confirm the quote independently. vite.config.ts and
PWAUpdatePrompt.vue remain unmodified.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- New `## Re-measurement (gap closure)` section in 02-FINDINGS.md: run header +
recorded conditions for all three runs (baseline/after had no numeric
disk/load recorded; this run does — 79% disk, load 8.70-11.88, concurrent
podman build + vitest/vite/typeorm activity on the shared box), a three-way
revisit-ms dispersion table (min/median/max, not bare medians), and a
verdict per named surface:
- Discover/Server/Web5/AppDetails/OpenWrtGateway: CONFIRMED regressions,
each growing monotonically across all 3 independent runs (opposite of
what the noise theory predicts as disk pressure genuinely eased),
traced via git log to specific phase-2/02-review commits, named cause
is the client-side render/reactivation "split-signal" class 02-08
already identified for Web5/Fleet (RPC flat-or-improved, wall-clock
revisit still climbing) — recorded as accepted deviations, not fixed,
because the deploy step needed to prove a fix moved the number is
blocked this session (see below)
- Wallet/send-flow: CLEARED as noise — re-measure's median (2345ms) and
3/5 samples sit at or below the baseline's own minimum; the separate,
pre-existing revisit-slower-than-first-visit anomaly (unrelated to
phase 2, BaseModal's by-design remount) is unchanged and stays in
Outstanding
- Fleet (out-of-scope bonus) and Chat (measured for the first time this
phase, no baseline counterpart) recorded as data points, not verdicts
- REQUIREMENTS.md: PERF-02/PERF-03 traceability rows updated to point at
this section (scope deviation from files_modified, per plan checker note
— the coverage-table update this plan's own Task 2 text calls for)
Deploy note: Task 1 deployed archi-dev-box to 3e3159fa (frontend-only,
clean tree, dirty=false) before measuring, since 4 of 6 named surfaces are
touched by the 02-review commits the previously-deployed 8fe6217b predates.
Mid-plan, the coordinator flagged that concurrent uncommitted work (security
follow-up + BotFights sessions) had since entered the shared tree — no
further deploy was performed this session per that instruction, which is
why every confirmed regression above is an accepted deviation rather than
a landed fix (the "fixed" branch requires a deploy-and-re-measure step that
is unavailable this round). `neode-ui/e2e/perf/02-REVIEW.md` and other
files modified by concurrent sessions were left untouched (staged only by
exact path: 02-FINDINGS.md, REQUIREMENTS.md).
Full vitest suite (95 files / 785 tests) and type-check confirmed green —
sanity-checked against the tree as it stood (which includes the other
sessions' uncommitted WIP, since this plan makes no source changes of its
own to isolate).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Appends a dated addendum to 02-REVIEW.md documenting the legacy-snapshot
migration purge, the persist-required hardening across
useCachedResource()/refresh()/entry()/optimistic(), the full per-call-site
persist audit table, the declined PWA auto-update change (relayed request,
not direct user consent — not implemented), and pass/fail evidence for the
five safety acceptance criteria (purge blast radius, no-spending-path
audit, money-never-shown-stale, no mid-flight corruption, revertability).
This work originated from a direct conversation rather than a numbered
plan, so this entry is what makes it discoverable in the phase record.
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>
CR-01 fixed web5.lnd-info/web5.networking-profits to persist:false, stopping
FUTURE writes to sessionStorage, but a tab already open before the update
ships reloads in-place onto the new bundle and keeps whatever the OLD
bundle already wrote under the old decision — indefinitely, since nothing
but clearAll() (logout) ever purges a resource: snapshot. Long-lived tabs
(installed PWA, kiosk display) are normal here, so this left updating users
exposed to exactly the T-02-01 exposure CR-01 was meant to close.
- Add a schema-version marker (resource:__schema) checked once at store
setup: absent or stale marker purges every resource:-prefixed
sessionStorage key, then writes the current version. One-time per tab
session (a matching marker no-ops), not per navigation/reload, so this
doesn't defeat the instant-paint-from-snapshot benefit the cache exists
for. CURRENT_SCHEMA_VERSION must be bumped whenever a key's persist
decision changes, documented inline as the contract for future changes.
- Extract clearAll()'s purge loop into purgeAllSnapshots(), reused by both
clearAll() (logout, T-02-02) and the new migration, so there's one place
that enumerates/removes resource: keys.
- Close the residual refresh()/useCachedResource() default: opts.persist
?? true was the exact footgun that caused CR-01 (a call site silently
opting into persistence by omission). persist is now a required
parameter on refresh() and useCachedResource()'s options, matching the
entry()/optimistic() hardening WR-04 already applied.
- Tests: legacy snapshot (no/stale marker) is purged on init; a snapshot
under the current marker survives a later init (proves one-time, not
every-boot); persist:false never writes a snapshot; marker is written
after purge; purge is strictly bounded to the resource: prefix (seeded
non-resource: sessionStorage keys and a localStorage auth flag survive
byte-for-byte); migration cannot race an in-flight fetch (runs
synchronously at store setup, before entries/inflight can hold anything).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
- Frontend-only deploy to archi-dev-box first (commit 3e3159fa) — last deploy
was 02-08's fix (8fe6217b), predating the 02-review commits that touch
Web5/Discover/Server/OpenWrtGateway and 02-09's investigation; deployed
clean (dirty=false) before any concurrent uncommitted work landed in the tree
- Harness confirmed byte-for-byte frozen: git diff --stat 3ee20430 -- neode-ui/e2e/perf/
shows only the new keepalive-remount-probe.spec.ts (02-09), zero changes to
surfaces.ts/measure.ts/surface-perf.spec.ts
- First attempt (ARCHY_PERF_RUNS=5) hit the harness's own hardcoded 20-min
test.setTimeout under concurrent node load (a botfights podman build +
other sessions), aborting after 9/15 surfaces with the rest failing
"browser has been closed" (not a real measurement, not hand-edited into
the artifact) — re-ran once per the plan's own contingency, completed
clean in 7.9 min once the concurrent build finished
- 15/15 rows recorded; runs: 5, baseUrl: http://archi-dev-box; Mesh unmeasured
again (device not reporting connected, same reason as both prior runs);
Chat measured this time (bonus data point, no baseline-comparable
counterpart — findings doc will say so plainly)
- Conditions recorded: pre-run 06:01 EDT, df 79% (1.4T/1.8T, down from
baseline's 85%), load 8.70/7.99/7.94 with a concurrent podman build
running; post-run 06:42 EDT, df 79%, load 6.88/11.88/9.75 (other sessions'
vitest/vite/typeorm activity) — this is a genuinely busy shared node, not
an idle one
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.2 -> 1.2.3, carries the
JoinBoutPage.vue setup-guide mode-picker UX fix on top of 1.2.2's
ARCHY_EMBEDDED iframe fix and 1.2.1's JWT_SECRET/arena-federation fixes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Bumps to botfights:1.2.3, which carries the JoinBoutPage.vue fix from the
botfight repo (commit 603e09b): the poll/webhook mode picker on the bot
setup step now visibly reacts when clicked — a colored banner in the guide
viewer and a prepended line in the copied prompt point at "Option A:
Polling Bot" or "Option B: Webhook Bot" within the single unified doc
(BOT-02), instead of silently refetching the same file with no visible
change. No manifest/env changes beyond the version/image bump — 1.2.2's
ARCHY_EMBEDDED/ARENA_UPSTREAM_URL/generated JWT_SECRET are unchanged.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 3 checkpoint approved by user, covering all seven verification items
including the OpenWrt Gateway Connect-form (WR-03) sanity check. Writes
02-09-SUMMARY.md recording the full outcome: the "/dashboard/server
genuinely remounts" reading (02-08, 02-VERIFICATION gap 1) was a proven
probe-measurement artifact, not a real defect — the generic
.view-container [data-controller-container] selector shared by every
KeepAlive-cached main tab couldn't disambiguate the foreground tab from
another still-connected cached tab, confirmed via an authoritative
document.elementFromPoint() hit-test that repeatedly contradicted the
naive "remounted" verdict across independent device runs for both Server
and Web5 (the latter a mid-investigation discovery). The committed
keepalive-remount-probe.spec.ts replaces the ad-hoc 02-08 probe, and four
new vm.$.uid-based regression tests pin real instance survival immune to
the same selector ambiguity.
Updates STATE.md (metrics, decisions, resolved blocker, session) and
ROADMAP.md (02-09 checked off, 9/10 plans executed) for phase 02's gap
closure. No code changes; 02-FINDINGS.md is owned by the concurrently
running 02-10 executor and was not touched here (already committed by
this plan's earlier Task 1/2 commits).
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>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). botfights is
the only content change vs the previously-published catalog (verified
structurally — all other 65 apps unchanged): 1.2.1 -> 1.2.2, carries the
X-Frame-Options fix (ARCHY_EMBEDDED=1 disables SAMEORIGIN for the
node-dashboard iframe) on top of 09-06's JWT_SECRET/arena-federation fixes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1: built keepalive-remount-probe.spec.ts, a committed, re-runnable
Playwright spec covering every KEEP_ALIVE_PATHS tab with three added
instruments (instance-uid, session-wide console/pageerror capture, DOM
population/pathname logging per hop) beyond 02-08's ad-hoc probe.
Eliminated suspects 2 (runtime error), 3 (LRU eviction), 4 (route.path
mismatch) and 5 (include-name matching) by direct measurement. Confirmed
suspect 1 with positive proof: an authoritative document.elementFromPoint()
hit-test signal contradicted the naive selector-match method's "remounted"
verdict for both Server and Web5 across independent runs, and a companion
diagnostic found the original stamped root still connected+visible under
a different (unpicked) match. Root cause: Server, Web5 and Fleet share the
fully generic .view-container [data-controller-container] selector every
KeepAlive-cached main tab's root carries via fallthrough, which cannot
disambiguate "the foreground tab" from "another cached tab still connected
to the document" once more than one tab has been visited — the normal,
intended KeepAlive state. Settings (the away tab every round trip uses)
independently renders matching content too (AccountInfoSection/
KioskDisplaySection), compounding the ambiguity.
Server.vue and Web5.vue's instances survive tab round-trips exactly like
every other registered tab — no defect in DashboardRouterView.vue,
dashboardViewWrappers.ts, keepAliveRoutes.ts or Server.vue's KeepAlive
wiring. 02-FINDINGS.md records the full method, eliminated suspects and
verdict per D-10 (this commit lands before any src change, per gate).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1.2.x's auth-hardening work added Hono secureHeaders() with a default
X-Frame-Options: SAMEORIGIN, which unconditionally blocked the Archipelago
node dashboard's iframe (different origin by port) — a real regression
versus 1.1.0, which never sent this header. Fixed upstream in the botfight
repo (commit 8eb27ed): X-Frame-Options is now conditional on ARCHY_EMBEDDED,
disabled only for the first-party node-embedded instance.
apps/botfights/manifest.yml: image/version -> 1.2.2, adds
ARCHY_EMBEDDED=1 to environment, drops the interim
metadata.launch.open_in_new_tab workaround (no longer needed — the app can
now be framed). app-catalog/catalog.json, scripts/image-versions.sh,
neode-ui/public/catalog.json bumped in lockstep via
scripts/generate-app-catalog.py. core/archipelago/src/fips/app_ports.rs
regenerated (formatting only, same port set).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
archi-dev-box BotFights 1.2.1 install fully automated-verified (image, arena federation,
per-install secret, data preservation, restart survival, arena unaffected). Tasks 2/3
(real NIP-07 signer login, real cloud-agent-from-prompt) require a human/external agent
per the plan's own design and 09-RESEARCH.md Pitfall 5 — stopped here rather than
simulated. STATE/ROADMAP/REQUIREMENTS intentionally left untouched per this run's
explicit instruction.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Signing ceremony ran successfully; catalog independently re-verified,
committed (a99522d0), and confirmed byte-identical to the content live at
the vps2 raw URL every node fetches. Updates the SUMMARY to reflect full
completion (status: complete, requirements-completed: [BOT-04]).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
releases/app-catalog.json regenerated and signed by the release-root key
(did:key:z6MkkidEnEpo6qHMCNSZoNKWtvQvxq3whnaME9wGgEFhq7ur). The botfights
entry is the only content change vs the previously-published catalog
(verified structurally — all other 65 apps unchanged): version 1.1.0 ->
1.2.1, embeds the generated_secrets/secret_env JWT_SECRET fix (D-04/BOT-04,
prevents the 1.2.x image's crash-loop on missing JWT_SECRET) and default-on
ARENA_UPSTREAM_URL federation (D-03/BOT-03).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rebasing the review-fix branch onto main (which had advanced with
unrelated 09-06/botfights commits since this branch was created) rewrote
every commit hash. Update 02-REVIEW.md's Status lines and summary table to
reference the post-rebase hashes.
Co-Authored-By: Claude <noreply@anthropic.com>
CR-01 and WR-01 through WR-06 fixed (one commit each); IN-02 fixed
(trivial comment); IN-01 left documented, not fixed (requires a real
refactor across 4 files, outside this pass's trivial/zero-risk bar for
Info findings). Full test suite, vue-tsc --noEmit, and npm run build are
all green after every fix.
Co-Authored-By: Claude <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>
armMapVisibility() unconditionally scheduled a setTimeout(initMap, 300) on
every reactivation, not just the first mount. initMap()'s own guard made
this harmless (a no-op once the map exists), but it scheduled a throwaway
timer on every tab-switch back into Mesh. Guard the scheduling itself so
intent ("fallback init for the very first mount") matches behavior.
Co-Authored-By: Claude <noreply@anthropic.com>
onDeactivated only tore down the resize listener/ResizeObserver, not the
navigator.geolocation.watchPosition watch started by "Share Location" —
leaving GPS polling running in the background (battery drain, active
location indicator) for as long as the KeepAlive'd component survives,
instead of only while the Mesh tab is visible like every other resource
this phase added only-while-visible handling for in this file.
onDeactivated now stops an active watch (tracked via a flag rather than
losing the user's toggle state), and onActivated transparently resumes it
on return to the tab.
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>
Manifest/catalog prep for BotFights 1.2.1 is done and verified; the run
stopped intentionally before the human-only release-signing ceremony per
explicit instruction. releases/app-catalog.json is regenerated on disk but
deliberately not committed until it's signed (Task 3 continuation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
apps/botfights/manifest.yml: image tag 1.1.0 -> 1.2.1, adds container.generated_secrets
(botfights-jwt-secret, kind hex32) + secret_env (JWT_SECRET) so a fresh install no longer
crash-loops on the 1.2.x image's jwt.ts import-time throw when JWT_SECRET is unset under
NODE_ENV=production (D-04/BOT-04). Adds ARENA_UPSTREAM_URL=https://botfights.archipelago-foundation.org
to turn on shared public arena federation by default (BOT-03/D-03), with an in-manifest comment
documenting the per-node opt-out (remove the line to run standalone).
app-catalog/catalog.json + scripts/image-versions.sh bumped in lockstep to keep the legacy
catalog and the image-version drift checker consistent with the manifest.
releases/app-catalog.json intentionally NOT committed in this change — it has been
regenerated locally (scripts/generate-app-catalog.sh) with the new manifest embedded, but is
unsigned. It is committed separately (09-06 Task 3) only after the release-root signing
ceremony, so no unsigned catalog state ever lands even transiently on a branch this repo's
mirrors could serve to nodes.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Two plans closing the gaps 02-VERIFICATION.md found (6/8 must-have truths
verified):
- 02-09 (wave 6): Server.vue is registered in KEEP_ALIVE_PATHS since 02-04 but
02-08's corrected, twice-reproduced probe shows it fully remounting on every
revisit. Names the measured cause first (D-10), commits the ad-hoc probe as a
re-runnable spec, lands a targeted fix pinned by a regression test in
keepAliveLifecycle.test.ts, deploys frontend-only to archi-dev-box (D-15),
and closes with the D-11 human pass bar.
- 02-10 (wave 7): the six surfaces measured unimproved/regressed vs baseline had
a flagged-but-untested environmental-noise confound. Re-runs the frozen 02-01
harness under recorded conditions, compares three-way using per-run sample
spread rather than bare medians, and gives every named surface a verdict —
cleared with data, fixed, or an explicit accepted deviation for the verifier.
Co-Authored-By: Claude <noreply@anthropic.com>
Image built + pushed (digest sha256:854ea299...26e144), canonical VPS2
arena rolled to 1.2.0 and verified end-to-end over the public HTTPS
URL (health, unified prompt, registration, bot auth, live SSE), and
BOT-03's cross-instance fighter visibility proven on real hosts in
both directions with a throwaway proxy-mode instance. Two pre-existing
bugs found and fixed in the botfight repo along the way: a pnpm
overrides config drift blocking the docker build, and a route-order
bug that made GET /api/fights/poll (the entire polling protocol)
always 404.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Updates the Task 3 checkpoint follow-up addendum: the content.browse-peer
concurrency-cap fix (8fe6217b) resolved what the fresh-mount guard
(e1a3f31a) alone did not. Instrumentation showed 13 of 14 concurrent
browse-peer calls never settling; capping the fan-out at 3 concurrent
with a 10s per-call timeout eliminated the hang. Verified 5/5 fresh
sessions navigate cleanly on first folder click against the redeployed
build, zero in-flight requests after 15s on Cloud, and a previously-
hung route chunk import now resolves in 17ms.
Co-Authored-By: Claude Fable 5 <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>
Adds a Task 3 checkpoint follow-up addendum to FINDINGS.md documenting
the investigation behind the fix(02-08) commit (Cloud.vue fresh-mount
guard for the first-visit connection-pool stall — confirmed real,
partially fixed, not yet fully resolved) plus classification of four
other user-reported items, all traced via git history against the
a75b6709 pre-phase-2 baseline and confirmed pre-existing (not phase 2):
Paid Files opening images in a new tab instead of the lightbox, PiP not
closing the lightbox, missing loader state on Paid Files' item-open RPC,
and PiP not surviving tab changes/buffering pauses. No fixes applied
for the pre-existing items per standing direction (captured separately
as phase-1 UX work).
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>
- SUMMARY for 09-03 (botfight repo commits bbc3c7a, a080956, 2dd9947)
- WINDOWS.md: record e2e suite as unrun-verify (local dev backend port
9100 is occupied by the live archi-dev-box demo container)
Per explicit user instruction this run, STATE.md/ROADMAP.md/REQUIREMENTS.md
are intentionally left untouched (shared coordination files across
concurrent phase-09 agents).
GET /api/auth/me (JWT-only session restore) shipped, POST /login reduced
to a read-only deprecated lookup, client auto-restore moved off the bare
pubkey path. Includes a fixed pre-existing migrate.ts/schema.ts drift
(15 pre-existing auth/tournament test failures resolved as a deviation).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Re-ran the plan 02-01 harness unmodified against archi-dev-box (same
target, same runs:3 contract) producing 02-PERF-AFTER.json (15/15 rows,
matching the baseline row count). Appended FINDINGS.md's Results table
(per-surface baseline/after comparison, Verdict) and an Outstanding
list of every regression, unmeasured surface, and unsettled gap.
Discovered and cross-verified (via a corrected, harness-independent
remount probe, reproduced twice) that the harness's own remount-probe
selector ('.view-container', generic and shared across every main tab)
became ambiguous once real KeepAlive caching keeps multiple instances
alive simultaneously — the raw artifact's 'remounted' field is
confounded for main tabs whose instance actually survives. Corrected
readings show Home/Apps/Marketplace/Cloud/Web5/Fleet genuinely survive
a round-trip; Server genuinely does not (a real, confirmed gap against
this phase's own must-have truth, flagged in Outstanding, not fixed
here — out of this task's measurement-only scope).
Also recorded real timing regressions (Discover, Server, Web5, Fleet,
AppDetails, OpenWrtGateway) honestly rather than averaging them away,
per the plan's own prohibition, alongside a timing-variance caveat
(baseline and after-run were taken at different times of day on the
same multi-service node).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Implements 09-01-PLAN.md (BOT-03 reverse-proxy tracer) end-to-end in the
botfight repo (pushed to origin main): server/src/middleware/arena-proxy.ts,
its 9-test end-to-end suite, app.ts mount, fights.ts SSE fix, docker-compose.yml
env-var docs. Records the T-09-02 threat-model correction (plain HTTP is a
supported upstream scheme, not https-only, per user decision) and a
pre-existing migrate.ts/schema.ts drift found out-of-scope during
verification.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Records the VPS2 arena deployment (http://146.59.87.168:9100, plain HTTP
per user decision, full-copy data seed of archi-dev-box's botfights.db).
Note: this phase (09-botfights-platform-upgrade) is an out-of-band
user-directed express path, not tracked in this project's main
STATE.md/ROADMAP.md/REQUIREMENTS.md (currently on milestone v1.8.0 phase
02) — those files are intentionally left untouched to avoid clobbering
concurrent phase-02 execution tracking.
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>
BOT-01 native nostr signer login (GET /api/auth/me, bare-pubkey path retired),
BOT-02 one self-contained AI bot-setup prompt at /api/docs/prompt,
BOT-03 shared public arena on VPS2 with a node-side reverse-proxy tracer,
BOT-04 manifest 1.2.0 with a generated JWT secret + republished signed catalog.
Wave 1 runs 09-01..09-04 in parallel (app work plus the DNS/TLS human gate,
started early for the 2026-07-31 demo deadline); waves 2-4 build the image,
publish the catalog, and land the demo rehearsal on archi-dev-box.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Belt-and-suspenders fix on top of AIUI's own root-cause fix (the
archyBridge origin bug, fixed in the AIUI repo): the archy-side loading
overlay now gets pointer-events:none (it has no interactive content of
its own, so it should never have blocked clicks reaching the iframe
underneath) and a bounded 8s timeout that unconditionally hides it if
no 'ready' message ever arrives — regardless of AIUI/backend state.
The timeout only dismisses the overlay; it does not fabricate a
successful connection, so the connected indicator still reflects
reality.
Two new tests in chatAiuiEmbed.test.ts cover the timeout firing at
exactly 8s and not firing prematurely.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Seed entropy comes from bip39 -> rand::thread_rng -> getrandom(2), which
blocks until the kernel pool is initialized -- but that ordering was
invisible in logs on first-boot ISO flows where the seed is generated
early. MasterSeed::generate() now probes getrandom(GRND_NONBLOCK) and
logs whether the pool was already seeded (warn if it would block).
Also adds a regression test that 64 generated mnemonics are all unique
with sane word diversity, guarding against a fixed/seeded RNG ever
being wired into seed generation.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
aiuiUrl now appends chatExpanded=true and mobileChat=true alongside
the pre-existing embedded=true and hideClose=true. Both are static
strings with no reactive dependency, so the computed's value never
changes after first evaluation — preserving the URL-stability
contract 02-04 relies on to keep the AIUI iframe from reloading on a
tab switch.
AIUI reads these two flags (commit 900c0b9 in the AIUI checkout,
recorded in 02-AIUI-D14.md) to start the chat expanded and, on
mobile, on the chat view rather than the context view. The deployed
AIUI build on any node does not yet carry that commit (anonymous push
to the AIUI remote was rejected — see 02-AIUI-D14.md's Deployment
Impact), so neode-ui's two new query params are inert no-ops against
today's deployed AIUI until that commit is merged and shipped; both
flags are additive and harmless in the meantime.
New test file chatAiuiEmbed.test.ts covers: both D-14 flags plus the
pre-existing embedded/hideClose params present in the URL; URL
string-equality across a simulated viewport resize and across a
KeepAlive deactivate/reactivate cycle; onAiuiMessage still rejecting
a foreign-origin message; and aiuiConnected surviving a
deactivate/reactivate cycle.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
AIUI is now cloned at /home/archipelago/Projects/AIUI
(git.tx1138.com/lfg2025/AIUI, development branch — 17 commits ahead
of main with zero unique main commits). Replaces the exhausted-search
conclusion with the real embed-parameter contract read from source
and records the D-14a/D-14b implementation: chatCollapsed's initial
state now honors a new ?chatExpanded param (chat.ts), and ChatPage.vue
re-asserts mobileTab='chat' on mount when ?mobileChat is present,
guarding against carry-over from AIUI's own module-singleton content-
panel state on an internal remount. Both are static, unconditional
query params — no runtime-varying URL input.
AIUI-side commit lives on feat/d14-embed-defaults (900c0b9) in the
local clone only — anonymous push to origin returned 403 Forbidden.
Needs a maintainer with push rights to land it before any deployed
AIUI build can honor these two parameters.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 of plan 02-07. Searched the expected sibling AIUI/ checkout
(both relative-path forms neode-ui/package.json and
scripts/setup-aiui-server.sh reference), a broader filesystem sweep
for any directory named AIUI holding source rather than a prebuilt
dist/, and the ThinkPad build server (.116) noted in project memory
— all three came up empty (no sibling checkout, no other source dir,
.116 unreachable via ping/ssh right now).
Recorded the existing embed-parameter contract from Chat.vue plus
diligence findings from the currently-deployed AIUI build's static
bundle (embedded/mockArchy/hideClose support), and marked both D-14
defaults as needing a new AIUI-side parameter or postMessage control
that cannot be added without source access. Deployment impact section
covers the prebuilt-image rebuild path per apps/aiui/manifest.yml and
D-15's dev-pair-only constraint.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- System stats (homeStatus.refresh), update status and cloud storage usage
are now every-entry, TTL-gated useCachedResource entries (10s/300s/30s),
hosted in Home.vue rather than inside the homeStatus Pinia store — a
store's own defineStore(id, setup) runs in a bare effectScope where
onActivated() silently no-ops (same finding as 02-05's Mesh.vue)
- Wallet is the deliberate exception (T-02-13): a new home.wallet-status
resource wraps the existing loadWeb5Status() composite fetch and
revalidates UNCONDITIONALLY on every activation rather than TTL-gated,
keeps the prior figure rendered throughout, and persist:false (never
written to sessionStorage). hydrateWalletSnapshot()'s separate localStorage
path is untouched.
- Read Web5.vue's two existing resources (web5.networking-profits,
web5.lnd-info) and did NOT share either key: profits is an unrelated
dataset, and lnd-info's own default persist:true (Web5.vue out of this
plan's file scope) would leak balance data via its own independent
refresh cycle regardless of what Home declares, and Home's wallet fetch
is a strictly broader 7-call composite (not the same single-call dataset)
- dedup:true added to all 12 underlying rpcClient calls (Home.vue's wallet
composite + checkUpdateStatus, homeStatus.ts's 5 status calls)
- RefreshIndicator wired next to the Home header, bound to the wallet
resource's loadState
- The websocket wallet-push path and hydrateWalletSnapshot's pre-network
paint are both left exactly as 02-04 placed them
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Server.vue already had five of the seven load groups (network summary, FIPS
summary, VPN peers, interfaces, Tor services) on useCachedResource from a
pre-phase commit, but none declared an explicit ttlMs/persist (relying on
the composable's 30s/persist:true defaults) and dedup:true was missing from
several underlying RPC calls. loadDiskStatus was the one remaining plain
uncached fetch, forced on every activation.
- Explicit TTL per group (10s fast tier: network-summary/interfaces/
disk-status; 30s near-default: vpn-peers/tor-services; 60s near-static:
fips-summary)
- Explicit persist:false for server.vpn-peers (npub/peer identity) and
server.tor-services (onion addresses) per T-02-01; other groups persist
- New server.disk-status cached resource replaces the plain fetch; it now
self-heals via the composable's own onActivated instead of an explicit
every-entry call
- dedup:true added to all underlying rpcClient calls, including the
parameterless vpnStatus()/dnsStatus()/diskStatus() convenience methods
- RefreshIndicator wired into a new minimal header row, driven by whether
any of the six cache entries is refreshing
- RESEARCH assumption A3 settled: read all seven loader bodies; none
consumes another's result — the concurrent fan-out is correct as-is
- Fixed a keepAliveLifecycle.test.ts assertion invalidated by the new 10s
network-summary TTL (reactivation now also revalidates that resource,
adding one more vpnStatus() call the test didn't previously account for)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
MeshMap.vue's onMounted setup (window resize listener + ResizeObserver)
is refactored into an idempotent armMapVisibility()/disarmMapVisibility()
pair, dual-registered on both onMounted and onActivated (onActivated is a
documented no-op outside a KeepAlive boundary) and torn down on
onDeactivated, matching the arm/disarm idiom Mesh.vue itself already uses.
The Leaflet instance is never destroyed or recreated by this — initMap()'s
own `if (!mapContainer.value || map) return` guard already makes
construction idempotent, so exactly one map is built per session. On
reactivation the map's size is invalidated via nextTick so a map laid out
while off screen re-tiles at its real size instead of showing an unsized
or partially tiled canvas.
FLAGGED: RESEARCH.md's premise that Mesh.vue owns a live D3 force
simulation does not hold for this codebase — a grep for
d3/forceSimulation/simulation across neode-ui/src found nothing in
Mesh.vue's or MeshMap.vue's tree; the only D3 force simulation belongs to
NetworkMap.vue (Federation.vue's graph, out of this plan's scope). The
plan's D3-specific truths are therefore vacuously satisfied — see
02-05-SUMMARY.md for detail. Only the real Leaflet-map lifecycle work
landed here.
meshMapLifecycle.test.ts is a new, separate file (not appended to
meshTabCache.test.ts) because its vi.mock('@/stores/mesh')/vi.mock('leaflet')
hoist file-wide and would otherwise clobber meshTabCache.test.ts's need for
the real mesh/transport stores — mirrors the MarketplaceRefresh.test.ts
precedent from 02-02 for the same class of vi.mock-hoisting conflict.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Mesh.vue: six useCachedResource entries wrap mesh.refreshAll(),
transport.fetchStatus(), refreshFederationNodes(), refreshSelfOnion(),
refreshSelfDid() and refreshContacts(), each with an explicit TTL
(10s reachability/transport, 30s federation/contacts, 300s self
DID/onion) and persist decision (T-02-01: every group carrying peer
or self identity data is persist:false; only aggregate transport
status may persist)
- armMeshLive's Promise.all fan-out becomes a single Promise.allSettled
over refreshMeshGroupIfStale() per group, so a revisit inside TTL
issues zero RPC, a stale revisit revalidates concurrently (not a
serial chain, T-02-16), and one rejected group never blocks the rest
- RefreshIndicator wired into the Mesh header, driven by whether any of
the six groups is refreshing — visible while peer reachability
revalidates on re-entry so a resumed tab never shows a frozen
reachability state as current (T-02-13)
- dedup: true added to every underlying rpc-client.ts/mesh.ts/
transport.ts fetcher call backing the six groups
- useCachedResource() calls live in Mesh.vue rather than inside
stores/mesh.ts or stores/transport.ts: Pinia's defineStore(id, setup)
runs in a bare effectScope, not a component instance, so the
composable's internal onActivated() would silently no-op there; the
fetchers still wrap the stores' own actions unchanged, so those
actions' other callers (clearAllMesh, setMeshOnly, the pre-send
balance check) keep their guaranteed-fresh, uncached reads
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 2 of 02-04 — finishes the remaining tabs' lifecycle audit (Apps.vue,
Discover.vue) and widens registration from the 02-02 tracer's single seed
path to every main tab the profiling pass showed remounting.
- Apps.vue: the 15s "unable to connect" timer follows activate/deactivate
(idempotent re-arm, cleared on exit) and resets connectionError on entry so
a since-reconnected node doesn't show a stale error instantly; the intro
flag stays once-per-session.
- Discover.vue: loadCommunityMarketplace/loadBitcoinPruneStatus now route
through the same shared 'app-catalog'/'bitcoin.prune-status' cache keys
Marketplace.vue introduced in 02-02, rather than duplicating the fetch;
RefreshIndicator wired to the catalog resource's loadState. Discover's own
dynamic-catalog-first fetcher (fetchAppCatalog with a curated-list
fallback) is preserved as this key's fetcher for this view — both views
are valid producers of the same shared cache entry.
- Fleet.vue: confirmed no lifecycle side effects (grep for the five tokens
found none) — left unchanged, registered as-is.
- keepAliveRoutes.ts: KEEP_ALIVE_PATHS now derives from TAB_ORDER (single
source of truth) plus /dashboard/discover, deliberately withholding
/dashboard/settings even though it's in TAB_ORDER — Settings.vue's child
sections (SystemDangerZone's reboot poll interval,
VpnStatusSection/KioskDisplaySection/TransportPrefsCard/ClaudeAuthSection's
one-shot onMounted fetches) were never in this plan's file scope and would
misbehave under KeepAlive exactly as this plan exists to prevent. Every
other TAB_ORDER path measured Remounted:true or was unmeasured in
02-FINDINGS.md, so per the plan's literal exclusion rule (only a measured
Remounted:false excludes) they all stay registered, including Mesh and
Chat.
- keepAliveLifecycle.test.ts extended (in the prior commit) with
shouldKeepAlive true/false assertions across every registered path and six
secondary-screen paths, plus the KEEP_ALIVE_MAX+2 eviction test against the
real DashboardRouterView + KEEP_ALIVE_PATHS.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Task 1 of 02-04 — audits every side effect owned by Home.vue, web5/Web5.vue,
Chat.vue, Cloud.vue, Server.vue and Mesh.vue and places each into one of
three buckets (once-per-session, every-entry, only-while-visible) so their
instances are safe to keep alive once KEEP_ALIVE_PATHS widens in Task 2.
- Home.vue: systemStats/wallet polling, the wsClient wallet-push
subscription and its debounce timer follow activate/deactivate with an
immediate re-sync on entry; hydrateWalletSnapshot/checkUpdateStatus/cloud
usage stay once-per-session.
- Chat.vue: the window `message` listener and ContextBroker follow
activate/deactivate; aiuiConnected is never reset on deactivate since the
iframe's one-time 'ready' message won't resend on re-entry.
- Web5.vue: the six child-component data loaders (none use
useCachedResource internally) and the 30s LND poll move to
activate/deactivate; the DID lookup and intro flag stay once-per-session.
- Cloud.vue: the per-peer transport/reachability warm-cache
(loadPeerFiles/loadCounts/loadPeers) re-runs every entry — the one path
here that bypasses useCachedResource and would otherwise render stale peer
reachability (T-02-13).
- Server.vue: the previously module-scope-armed 15s VPN poll interval now
follows activate/deactivate (it used to run forever regardless of
visibility); loadDiskStatus becomes every-entry.
- Mesh.vue: the entire live-communications surface (window/document
listeners, the 5s/15s poll intervals, the ws peer-push subscription, and
the six-way federation/self/contacts refresh) follows activate/deactivate;
a share-to-mesh handoff via direct navigation is now correctly picked up
on every activation, not just the first mount.
- useCachedResource.ts: onActivated's staleness check now skips an
`immediate: false` resource that has never been explicitly fetched, so a
tab-gated lazy resource (Cloud.vue's Paid Files / My Files walk) isn't
eagerly force-loaded the moment its owning view is kept alive.
- Every arm/disarm pair is idempotent and duplicated into both onMounted and
onActivated, since onActivated is a no-op outside a KeepAlive boundary
(caught by CloudPeersRefresh.test.ts, which mounts Cloud.vue bare) —
fresh-mount guard flags avoid double-firing the heavier loaders
(Home/Mesh/Web5/Server) on a KeepAlive-wrapped first mount.
- New neode-ui/src/views/dashboard/__tests__/keepAliveLifecycle.test.ts
covers the six lifecycle behaviors plus a real-view assertion
(Server.vue's VPN poll, mounted inside a real KeepAlive).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Task 3 checkpoint failed on the real preview: outer page margins broke
and the up/down main-tab slide animations stopped playing. Root cause: the
02-02 restructure moved view-wrapper (absolute inset-0) onto each view's
root inside the padded wrapper, and split navigation across two sibling
Transitions behind a stable intermediate div — but dashboard-styles.css
scopes every transition as a compound selector on .view-wrapper, which must
be the keyed direct child of .perspective-container.
- Restore the pre-02-02 rendered DOM exactly: single Transition whose child
is a keyed div.view-wrapper containing the per-route wrapper shape
- Re-integrate KeepAlive via statically-named per-route wrapper components
(dashboardViewWrappers.ts) so the keyed div.view-wrapper is the cached
component's own root; cache membership gated by :include on wrapper names
derived from KEEP_ALIVE_PATHS
- Drop the scroll-retention Map: scroll containers now live inside keyed/
cached wrappers, restoring the old reset-to-top behavior for non-kept routes
- Pin the visual contract structurally in keepAliveTabs.test.ts (padded
wrapper classes must render INSIDE div.view-wrapper)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- OpenWrtGateway.vue: manual resources-store usage (no TTL gating — every
mount force-refetched) replaced with useCachedResource (key
server.openwrt-status, no item id — one gateway per node, fixed route
with no :id param). load() keeps its explicit force-refresh semantics
for connect/tollgate actions via a pendingParams closure; onMounted now
only force-fetches when the cache is missing or past its 30s TTL.
- Fixed a real bug this conversion exposed (Rule 1): `loading` conflated
'refreshing' with 'loading', hiding the already-rendered status panels
behind the full skeleton on every background revalidation. Every mount
used to force a fetch, so this was previously masked — cached content
never got a chance to render before the skeleton took over. Now only a
true first-load (no data yet) blocks on the skeleton (D-07).
- secondaryScreenCache.test.ts: repeat-open call-count coverage for
OpenWrtGateway (within-TTL: one fetch; after TTL: cached paint + one
more fetch).
- CloudFolder.vue: left unchanged. See 02-03-SUMMARY.md for the
cache-placement decision and why a clean useCachedResource conversion
needs a cloud.ts change outside this plan's files_modified scope.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- AppDetails.vue: bitcoin-sync and credentials converted to keyed
useCachedResource (app-details:bitcoin-sync:<id>, app-details:credentials:<id>),
each keyed by the route app id so two items never collide. Credentials
is persist:false (credential material, D-08/T-02-01). Both loaders stay
fire-and-forget from onMounted (already parallel — not touched). Stop/
restart/uninstall now invalidate() the credentials resource so a stale
healthy state can't outlive a destructive action (T-02-12).
- MarketplaceAppDetails.vue: the one RPC call in this view that isn't a
measurement artifact of the Home-tab-transit confound (package.versions)
is now a keyed useCachedResource (app-details:versions:<id>, 120s TTL —
near-static catalog metadata). getCurrentApp() is a sync store read and
the bitcoin-prune check is a plain fetch(), neither need conversion.
- secondaryScreenCache.test.ts: covers per-item isolation (rendered
content, not just call counts), TTL-gated no-refetch, TTL-lapse
revalidation, and keep-last-value on a rejected refresh.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- resources.ts: clearAll() drops memory entries, in-flight/revalidator/
invalidate-timer bookkeeping, and every resource:-prefixed sessionStorage
key; a generation counter stops an in-flight fetch that resolves after
clearAll from repopulating memory or sessionStorage (T-02-02)
- auth.ts: logout() calls clearAll() in the finally path so a failed
server-side logout still leaves no cached payload behind locally
- resourcesClear.test.ts: covers all five required behaviors plus the
generation-guard fix
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Puts Marketplace.vue's data on the cache and adds the subtle background-
refresh indicator D-05 calls for:
- RefreshIndicator.vue: presentational, state-driven (ResourceLoadState).
Renders nothing for ready/idle/loading — a first load is the view's own
skeleton's job, not this component's — and a role="status"
aria-live="polite" spinner only while refreshing. A fixed-size outer slot
keeps its appearance/disappearance from ever shifting layout.
- Marketplace.vue: loadCommunityMarketplace()/loadBitcoinPruneStatus() move
onto useCachedResource behind shared keys app-catalog (300s TTL, persist —
near-static catalog, D-06 discretion) and bitcoin.prune-status (default
30s TTL, persist — both non-sensitive/small per T-02-01). app-catalog is a
shared key so Discover.vue's identical loader picks up the same entry
without its own conversion in 02-04. Error handling is keep-last-value
(D-07): a rejected refresh sets the existing communityError banner ref,
never a toast, and prior app cards stay on screen.
- Side-effect audit (the precedent 02-04 repeats across remaining tabs):
marketplaceAnimationDone is genuinely once-per-session intro state, stays
in onMounted; the two data loads needed no onMounted/onActivated hook of
their own at all — useCachedResource's internal onActivated (wired in
Task 1) already revalidates them on every kept-alive tab re-entry,
staleness-gated so a quick revisit issues no fetch. No interval,
subscription or window listener exists in this view, so no onDeactivated
teardown was needed either.
- Tests: RefreshIndicator's full render-nothing/render-something matrix
added to keepAliveTabs.test.ts (no router dependency, safe to colocate).
The D-07 rejected-refresh-keeps-content-and-no-toast test mounts
Marketplace.vue itself but lives in a new file,
views/__tests__/MarketplaceRefresh.test.ts, following the in-repo
CloudPeersRefresh.test.ts mount-the-view pattern — vi.mock('vue-router')
is hoisted file-wide, so colocating it in keepAliveTabs.test.ts would
clobber that file's real createRouter/createMemoryHistory used by the
DashboardRouterView tests (Rule 3 auto-fix; deviation from the plan's
literal single-test-file file list, documented here for the SUMMARY).
Marketplace.vue gains a defineExpose({ loadCommunityMarketplace,
loadBitcoinPruneStatus }) for tests, mirroring Cloud.vue's existing
defineExpose({ loadPeers }).
Full suite (87 files / 709 tests), type-check and build all green; the
built Marketplace chunk carries refresh-indicator.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Wires the phase's shared architecture end to end through one main tab
(Marketplace, the worst-measured revisit per 02-FINDINGS.md):
- keepAliveRoutes.ts: exact-match route classifier (shouldKeepAlive,
KEEP_ALIVE_PATHS, KEEP_ALIVE_MAX=6), seeded with only the tracer tab's
path. Deliberately not KeepAlive `include` name-matching (async
components under `<script setup>` have no inferable name).
- DashboardRouterView.vue: extracts Dashboard.vue's nested RouterView into
a host where <KeepAlive> is a permanent element (never torn down by
v-if) with its child conditionally present via shouldKeepAlive(route);
a sibling Transition renders non-cached routes. Both original wrapper
shapes (full-bleed chat/mesh vs. padded/scrollable default) are
preserved via computed helpers on one stable, unkeyed wrapper div; the
:key moves onto <component> itself. Adds per-route scroll retention
since the scroll container is now stable across navigations.
- useCachedResource.ts: registers onActivated(() => refreshIfStale())
alongside the existing onScopeDispose block, closing the gap where a
kept-alive tab would otherwise never revalidate on reactivation
(onScopeDispose doesn't fire on deactivate; window focus doesn't fire
on an in-SPA tab switch). No-ops safely for all 8 existing consumers
outside a KeepAlive boundary.
- useRouteTransitions.ts: exports TAB_ORDER so 02-04 can widen
KEEP_ALIVE_PATHS from the same source of truth.
- Dashboard.vue: renders DashboardRouterView in place of the inline
block; removes the now-superseded detail-route scroll save/restore
(querySelector target no longer exists post-restructure — the new
per-route Map in DashboardRouterView.vue is a strict superset).
Tests: keepAliveTabs.test.ts proves an included path's instance survives
a round trip (1 mount, 2 activations) while a detail path remounts (2
mounts); useCachedResource.test.ts proves no refetch inside the TTL,
exactly one refetch after it lapses, safe use outside KeepAlive, and
keep-last-value + sticky-ready semantics on a rejected refresh.
Full suite (706 tests), type-check, and build all green; built bundle
carries the new KeepAlive wiring (web/dist/neode-ui/assets).
Every claim cites 02-PERF-BASELINE.json (archi-dev-box, real hardware).
13/15 surfaces measured cleanly; Mesh and Chat recorded as unmeasured with
reasons, never as already-fast.
Key findings:
- 4 main tabs (Home, Apps, Cloud, Fleet) measure already fast — left alone
per D-02, KeepAlive wrapping only, no data-cache conversion.
- Marketplace is the worst main tab (2033ms revisit) and the tracer pick
for 02-02, matching the user's own complaint ("often app store").
- No surface shows a serial-RPC-waterfall signature (maxConcurrentRpc is
at or near each surface's total call count everywhere multiple calls
were observed) — corrects RESEARCH.md's one "confirmed" waterfall
target, ContainerAppDetails.vue, which this session's grep confirms is
fully unreachable dead code (no importer, no route entry). D-13's
parallelization pattern has no live target in this phase's D-09 set.
- Flagged a measurement confound: 3 rows (Marketplace, MarketplaceApp-
Details, OpenWrtGateway) reach their target via a navSteps chain that
transits another main tab first, so some of their captured RPC calls are
that intermediate tab's own onMounted burst, not the destination's.
Classified conservatively (remount storm, not uncached fetch) with the
confound spelled out for 02-02/02-03 to re-verify before assuming.
- Wallet-send's revisit (2607ms) is consistently slower than its first
visit (735ms) across all 3 runs despite zero RPC either time — flagged
as an anomaly for 02-03 to profile directly, not hidden.
This is the last commit in this plan — the gate 02-02 through 02-08 depend
on before any neode-ui/src change lands (git diff --name-only HEAD~2..HEAD
-- neode-ui/src is empty across all three of this plan's commits).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- 02-PERF-BASELINE.json: on-device baseline recorded against archi-dev-box
(real hardware, D-11's verification target). 13/15 surfaces measured
cleanly across 3 runs each; Mesh and Chat are recorded as unmeasured with
their reasons (Mesh: no connected mesh device on this node within the
wait window; Chat: AIUI's own loading overlay outlives the close-button
click budget) rather than silently marked already-fast, per plan rule.
- measure.ts: goHome() now falls back to a hard `page.goto('/dashboard')`
when the Chat surface's own close button is unreachable (blocked behind
AIUI's connecting overlay on real hardware) — without this, every
surface after Chat inherited a permanently hidden sidebar
(`v-show="!chatFullscreen"`) and cascaded to unmeasured. This recovery
path is exempted from the "no page.goto between surfaces" rule because
it exists only to break out of a stuck state, not to measure one.
- surface-perf.spec.ts: currentCommit() now shells out with `process.cwd()`
instead of `__dirname`, which is unavailable under this package's
`"type": "module"` runtime and was silently recording every run header's
`commit` field as 'unknown'.
No file under neode-ui/src/ was modified by this task (git diff --name-only
HEAD -- neode-ui/src is empty).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- surfaces.ts: SURFACES table covering all D-09 surfaces (home/wallet,
apps, marketplace, discover, cloud, mesh, server, web5, fleet, chat) plus
four secondary screens (AppDetails, MarketplaceAppDetails, CloudFolder,
OpenWrtGateway) and the located wallet-send modal. Navigation is via real
RouterLink/button clicks (never page.goto) so revisit measures actual
Vue Router client-side transitions.
- measure.ts: measureSurface() records first-visit vs revisit timing, an
RPC method+timing trace (no bodies), a dataset-stamp remount probe, and
derives maxConcurrentRpc/rpcWallClockMs via sweep-line so serial
waterfalls are distinguishable from parallel fan-outs. Includes a
dismiss-and-retry click guard for stray modals (Companion app intro) that
would otherwise cascade failures across surfaces.
- surface-perf.spec.ts: logs in via the existing app-launch flow, walks
every SURFACES row, writes results + a run header to ARCHY_PERF_OUT.
Verified end-to-end against the local mock-backend + vite dev server
(14/15 surfaces measured cleanly across 3 runs each; the 15th, Mesh, times
out only because the mock backend never reports a connected mesh device —
expected to resolve against a real node in Task 2).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Codebase-grounded research for KeepAlive/SWR caching, serial-waterfall fixes,
and the AIUI D-14 ride-along dependency risk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- Add FEDERATION_STORE_LOCK (tokio::sync::Mutex<()>) guarding every
load-mutate-save cycle in federation/storage.rs, closing the race where
the 90s auto-sync loop's stale pre-removal snapshot could silently
re-save a peer the operator just removed (no error logged anywhere).
- Split load_nodes/save_nodes/tombstone_did/untombstone_did into thin
locked outer wrappers + lock-free *_inner bodies so remove_node and
add_node can hold the guard across their whole tombstone+save critical
section without self-deadlocking (Mutex is not re-entrant).
- Route load_nodes, save_nodes, remove_node, add_node, tombstone_did,
untombstone_did, set_trust_level, and update_node_state through the
lock (set_trust_level pulled forward from Task 2's scope — required for
test_concurrent_writes_do_not_lose_updates, part of Task 1's own
required-green test suite, to pass; documented in SUMMARY).
- Convert save_nodes_inner to an atomic write: serialize to a sibling
nodes.json.tmp in the same directory, then tokio::fs::rename onto the
real path, so a crash mid-write never leaves a partially-written
nodes.json for a concurrent reader.
- Add 3 new regression tests, two of which are fail-first proven: heavy
tokio::spawn-based concurrency (not just tokio::join!, since
remove_node's extra tombstone I/O hop structurally biased a simple
2-task race toward the safe ordering) reliably reproduced both the lost
concurrent write and the removed-node-reappears bug pre-fix; both are
green post-fix along with the existing suite (13/13).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
New fips/endpoints.rs: an npub-keyed store (<data_dir>/fips-endpoints.json)
of every endpoint a peer was last seen connected at (fipsctl show peers
transport_addr/transport_type — covers LAN, Tailscale, and WAN alike),
refreshed each anchor tick, 30-day retention.
The anchor tick now escalates LAN → last-known-good → anchor tree: any
federation peer with a fips npub that is neither currently connected nor
covered by a live LAN direct entry gets its last-known-good endpoint
re-dialed (idempotent fipsctl connect, bounded by apply()'s per-connect
cap). This productizes the hand-applied .116↔.198 Tailscale fix of
2026-07-20 and closes RC2's "no endpoint fallback" gap.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-28 07:33:32 -04:00
498 changed files with 35187 additions and 20568 deletions
[INFO] Overlapping locked ADRs on Nostr marketplace discovery — consistent, not contradictory
Found: docs/adr/003-nostr-for-discovery.md and docs/adr/006-nostr-marketplace-discovery.md are both locked and both decide "Nostr relays (NIP-78, kind 30078) for app manifest discovery" over the same scope
Note: The decisions agree; ADR-006 refines ADR-003 with concrete trust tiers (Verified/Community/Unverified), curated built-in app list, and pre-install signature verification. Both preserved as separate entries in intel/decisions.md; no resolution needed. Consider marking one as superseding/refining the other in the docs for hygiene.
[INFO] SPEC security validation list narrower than ADR-009 mandatory defaults
Found: docs/adr/009-manifest-container-security.md (locked) mandates non-root UID (> 1000), pinned image tags (no `latest`), and a default seccomp profile as non-negotiable defaults; docs/app-manifest-spec.md's documented SecurityPolicy schema and AppManifest::validate() list do not mention these three (SecurityPolicy has apparmor_profile but no seccomp field)
Note: This is SPEC silence, not contradiction — no auto-resolution applied. ADR-009 governs by precedence (ADR > SPEC) and lock status. The SPEC itself declares `core/container/src/manifest.rs` canonical over the doc, so the gap may be documentation drift rather than implementation drift. Flagged for downstream verification, recorded as absent in intel/constraints.md.
[INFO] ADR numbering gap — ADR-010 absent from ingest set
Found: Classified ADRs run 001–009 and 011; no classification exists for an ADR-010
Note: Either ADR-010 does not exist, was withdrawn, or was not included in the ingest. No action required for synthesis; noted for completeness of the decision record.
Note: DFS cycle detection found no cycles; all 11 docs were synthesized. Several SPEC cross-refs point to documents not in the ingest set — they were not followed.
**Core Value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
No PRDs existed in the ingest set; these requirements are derived from the master plan's
`docs/UNIFIED-TASK-TRACKER.md`, and the user-chosen success metric. Constraints from
`docs/app-manifest-spec.md` and the locked ADRs (see PROJECT.md) bound how each is built.
## v1 Requirements
### Federation & Mesh Hardening (FED)
- [ ]**FED-01**: Removing a federation node sticks — it disappears from every UI surface, tombstones propagate, it never reappears via later sync cycles, and a failed removal surfaces an error (never a silent no-op)
- [ ]**FED-02**: Federation sync converges and is observable — after sync settles, fleet nodes agree on the node list with fresh status; stale entries, duplicates, and silent sync failures are eliminated and sync errors are operator-visible
- [ ]**FED-03**: A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and mesh area (`core/archipelago/src/mesh`, mesh RPC surface) is completed, with every finding fixed or explicitly deferred with a reason
- [ ]**FED-04**: Mesh messaging parity — attachment send (and the rest of the mesh chat surface) behaves identically on the demo and on real nodes: the demo backend implements the same RPC surface the UI calls, transport decisions mirror the real size-based tier logic, and no demo-only modals exist
- [ ]**FED-05**: Inter-node Lightning channel opening UX — the UI shows the node's shareable Lightning URI; lists trusted (federated) nodes by hostname for one-click channel opening; and lets the user browse/request channels with public nodes — using the existing design system and components, verified on the :8100 dev preview against archi-dev before deploy
- [ ]**FED-06**: On-brand payment success animation — the invoice "paid" tick's circle uses the screensaver-style ring with outer EQ-segment lines (reuse `ScreensaverRing.vue`'s compact size) in place of the current success burst, applied consistently everywhere the paid tick shows
### UI Performance (PERF)
- [ ]**PERF-01**: The slowest tab switches and secondary-screen opens are profiled with causes named (remount storms, serial RPC waterfalls, uncached fetches) — fixes are targeted, not guessed
- [ ]**PERF-02**: Main-tab switches render immediately from cached state with background refresh — no blank screens or long spinners on tabs already visited this session
- [ ]**PERF-03**: Secondary screens (screens reached from a tab's main page) open without a blocking full reload and are instant on repeat visits — verified on real node hardware, not just the dev box
### Multinode Verification (MNODE)
- [ ]**MNODE-01**: The 5× destructive lifecycle gate passes on a second fleet node (archy-x250-beta) with 0 failures, run on-node per gate policy
- [ ]**MNODE-02**: Cross-node federation/mesh/transport suites (`tests/multinode/smoke.sh`, `meshtastic.sh`) pass between fleet nodes, with all harness RPC calls time-bounded (no indefinite curl hangs)
- [ ]**MNODE-03**: Removing a federation peer sticks — tombstone-write failures are surfaced (not swallowed) and a removed peer never silently reappears after subsequent sync cycles
### Lifecycle Perfection (LIFE)
- [ ]**LIFE-01**: Quadlet backends are the default — restarting `archipelago.service` leaves every app container running (no SIGKILL-the-world, no multi-minute rebuild storm)
- [ ]**LIFE-02**: The reconciler self-heals failed Quadlet units — a `.service` in `failed` state (and not user-stopped) is reset-failed + started automatically, with backoff against busy-looping
- [ ]**LIFE-03**: Per-app restart/flap observability — restart counters, a threshold log line when an app restarts >N times in M minutes, and restart counts surfaced in health/status RPC output
- [ ]**LIFE-04**: Cascade uninstall→reinstall is gate-verified for multi-container stacks and installed apps — no ghost entries, no orphan containers, data preserved per policy, reinstall returns healthy
- [ ]**LIFE-05**: Install and uninstall report real, monotonic progress driven by backend progress events, always reaching a terminal success/failure state — asserted in the gate, never a fake or stuck bar
### Registry-Distributed Manifests (REG)
- [ ]**REG-01**: The published signed catalog embeds full app manifests; nodes install/update from signature-verified catalog manifests (disk manifests remain the fallback for build-source apps); tampered catalogs are rejected with safe fallback
- [ ]**REG-02**: The fleet is flipped to registry-distributed manifests — adding or bumping an image-only app requires only a re-signed catalog publish, no binary OTA or disk rsync
### Security Enforcement (SEC)
- [ ]**SEC-01**: `AppManifest::validate()` enforces the full ADR-009 mandate set — non-root UID, pinned image tags (no `latest`), capability allow-list, seccomp — with explicit, documented, auditable overrides
- [ ]**SEC-02**: Generated AppArmor/seccomp security profiles are actually applied at container creation (`--security-opt`) and verified effective on running apps
### Developer Tooling (DEV)
- [ ]**DEV-01**: `archy app validate` checks a manifest locally and returns the same pass/fail verdict the node enforces (schema + security rules)
- [ ]**DEV-02**: `archy app render` previews the exact Quadlet/podman configuration a manifest produces
- [ ]**DEV-03**: A developer can local-install and lifecycle-test an app against a dev node from the CLI (`archy app local-install` / `lifecycle-test`)
- [ ]**DEV-04**: The developer guide walks a new third-party developer from an empty directory to an installed, running app using only the CLI and docs
### Decentralized Marketplace (MKT)
- [ ]**MKT-01**: A third-party developer can publish a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) via the tooling
- [ ]**MKT-02**: A node discovers marketplace apps from multiple relays and displays each app's trust tier (Verified / Community / Unverified) per ADR-006 trust scoring
- [ ]**MKT-03**: Manifest signatures are verified before installation; tampered or invalid marketplace manifests cannot be installed
- [ ]**MKT-04**: End-to-end north star: a user installs a third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees
## v2 Requirements
Deferred to a future milestone. Tracked but not in the current roadmap.
### Distribution Backbone (DIST)
- **DIST-01**: BLAKE3 content-addressed catalog distribution via iroh swarm, origin-always-wins (workstream D — design-only today, tracker-marked backlog)
### Fleet & Hardening (FLEET)
- **FLEET-01**: Bitcoin multi-version fleet-wide OTA rollout (user-gated on timing per `docs/bitcoin-version-bulletproof-rollout.md`)
- **FLEET-02**: App-specific health assertions for the ~34 apps with only baseline lifecycle coverage
- **FLEET-03**: LUKS2 full-partition encryption for `/var/lib/archipelago/`
**Goal**: Federation and mesh are tight — a structured review of the fleet/federation and mesh code feeds fixes so node removal sticks, sync converges, and mesh messaging (including attachments) behaves identically everywhere it runs
1. A structured code review of the federation/fleet area (`core/archipelago/src/federation`, node sync, FIPS/transport dial layer) and the mesh area (`core/archipelago/src/mesh`, mesh RPC surface) produces a findings list, and every finding is fixed or explicitly deferred with a reason
2. Removing a federation node removes it everywhere — it disappears from all UI surfaces, tombstones propagate, and it never reappears after later sync cycles; a failed removal surfaces an error instead of silently no-opping
3. Federation sync converges: after sync settles, fleet nodes agree on the node list and node status is fresh — stale entries, duplicates, and silent sync failures are gone, and sync errors are visible to the operator
4. Mesh attachment send works identically on the demo and on real nodes — same modals, same transport decisions, same success — with the demo backend implementing the same RPC surface the UI calls (no "Method not found", no demo-only chooser modal)
5. Channel-opening between nodes is first-class UI: a user can copy/share their node's Lightning URI; sees a list of trusted (federated) nodes by hostname to open a channel with in one flow; and can browse/request channels with public nodes — built with the existing design system (Teleport-to-body modals, house style), tested live on the :8100 dev preview against archi-dev, and fixed there before any deploy
6. The invoice/payment "paid" success animation is on-brand: the tick's circle is the screensaver-style ring with the outer EQ-segment lines (reuse `neode-ui/src/components/ScreensaverRing.vue`, which already ships a `compact` overlay size), replacing the current burst in the payment success pane (`neode-ui/src/components/SendBitcoinModal.vue`) and matching wherever else the paid tick appears
**Plans**: 10 plans
Plans:
- [ ] 01-01-PLAN.md — Serialize the federation node store and make removal stick (FED-01)
- [ ] 01-10-PLAN.md — Consolidated phase verification on the dev pair (FED-01/02/05/06)
**UI hint**: yes
### Phase 2: UI Performance
**Goal**: The UI feels fast — switching tabs and opening secondary screens (screens reached from a tab's main page) renders promptly instead of stalling on refetches and remounts
**Depends on**: Nothing (frontend-focused; parallelizable with Phase 1)
**Requirements**: PERF-01, PERF-02, PERF-03
**Success Criteria** (what must be TRUE):
1. The slowest tab switches and secondary-screen opens are profiled and the causes named (remount storms, serial RPC waterfalls, uncached fetches) before fixes land
2. Switching between main tabs renders the target view immediately from cached state, refreshing data in the background — no blank screens or long spinners on tabs already visited this session
3. Secondary screens open without a blocking full reload; repeat visits are instant
4. The fixes are verified on real node hardware (not just the dev box) — the sluggishness the user reported is gone on-device
**Plans**: TBD
**UI hint**: yes
### Phase 3: Multinode Verification Pass
**Goal**: The platform's lifecycle and federation guarantees are proven across the fleet, not just on .228 — the declared next exit criterion
**Depends on**: Phase 1 (proves the federation/mesh fixes hold fleet-wide)
**Requirements**: MNODE-01, MNODE-02, MNODE-03
**Success Criteria** (what must be TRUE):
1. The 5× destructive lifecycle gate reports 0 failures on a second fleet node (archy-x250-beta), run on-node
2. The cross-node smoke suite (federation pairing both directions, FIPS anchors, peer content browse) passes between two fleet nodes with every harness RPC time-bounded — a slow node produces a test failure, never an indefinite hang
3. An operator who removes a federation peer never sees it reappear in the peer list after later sync cycles; a tombstone-write failure is surfaced as an error instead of silently swallowed
4. The on-air mesh suite passes between two radio-equipped nodes over real RF
**Goal**: An insanely-reliable container environment — every app installs, runs, restarts, uninstalls, and reinstalls cleanly with honest progress, no ghosts, and automatic recovery
**Depends on**: Phase 3 (Quadlet default-flip is gated on the second-node gate reporting clean)
1. Restarting `archipelago.service` on a fleet node leaves every app container running — no SIGKILL-the-world, no multi-minute reconciler rebuild
2. An app whose Quadlet unit enters `failed` state (and was not user-stopped) comes back automatically within a bounded window, with backoff on persistent failure — no operator intervention
3. An operator can see per-app restart counts in status output, and a flapping app (>N restarts in M minutes) is flagged in logs instead of being invisible
4. Uninstalling then reinstalling any gated app — including multi-container stacks like immich/btcpay — leaves no ghost My-Apps entries or orphan containers, preserves data per policy, and returns the app healthy, verified by the cascade gate tier
5. Install and uninstall progress bars move monotonically from real backend progress events and always land on a terminal success/failure state — asserted in the gate, and the single-node gate stays green after all orchestrator changes
**Plans**: TBD
**UI hint**: yes
### Phase 5: Registry-Distributed Manifests
**Goal**: Manifests ship via the signed registry, not OTA disk files — bumping or adding an app becomes a signed catalog change
**Depends on**: Phase 4 (fleet lifecycle stable under Quadlet default before changing the distribution channel)
**Requirements**: REG-01, REG-02
**Success Criteria** (what must be TRUE):
1. A fleet node installs and updates an image-only app from the full manifest embedded in the signed catalog, verified against the pinned release-root key, with no corresponding OTA disk file present (disk remains the fallback for build-source apps)
2. A tampered or unsigned catalog manifest is rejected and the node falls back safely — it never installs from an unverified manifest
3. Bumping an app version fleet-wide requires only regenerating, re-signing, and publishing the catalog — no binary OTA, no disk rsync — proven live on the fleet
**Plans**: TBD
### Phase 6: Manifest Security Enforcement
**Goal**: A third-party manifest cannot weaken node security — declared security policy is fully validated and actually enforced at runtime
**Depends on**: Phase 5 (enforcement guards the registry channel third-party manifests will arrive through)
**Requirements**: SEC-01, SEC-02
**Success Criteria** (what must be TRUE):
1. A manifest violating ADR-009 mandates (root user, unpinned `latest` tag, capability outside the allow-list, disabled seccomp) is rejected at validation with a clear error naming the violation
2. Security overrides (`readonly_root: false`, extra capabilities) work only when explicitly listed in the manifest and leave an audit trail
3. Generated AppArmor/seccomp profiles are applied to containers at creation and verifiably effective on a running app — not just generated and ignored
4. The single-node lifecycle gate stays green with enforcement on — existing catalog apps all pass the strengthened validation (or carry documented overrides)
**Plans**: TBD
### Phase 7: Developer Tooling CLI
**Goal**: A third-party developer can build, validate, and test an Archipelago app locally without reading platform internals
**Depends on**: Phase 6 (CLI validation must mirror the final enforced rule set)
**Requirements**: DEV-01, DEV-02, DEV-03, DEV-04
**Success Criteria** (what must be TRUE):
1. A developer runs `archy app validate` on a manifest directory and gets the same pass/fail verdict — including security rules — that a node would enforce at install
2. A developer runs `archy app render` and sees the exact Quadlet/podman configuration their manifest produces before ever touching a node
3. A developer can install their app onto a dev node and run its lifecycle test (install/UI/stop/start/restart/uninstall) from the CLI
4. A new developer following only the developer guide goes from an empty directory to a running app on a node — no tribal knowledge required
**Plans**: TBD
### Phase 8: Decentralized Marketplace
**Goal**: The north star — third-party developers publish apps via the decentralized registry and users install them on their nodes
**Depends on**: Phase 7 (publish rides the CLI; installs ride registry distribution from Phase 5 and enforcement from Phase 6)
**Requirements**: MKT-01, MKT-02, MKT-03, MKT-04
**Success Criteria** (what must be TRUE):
1. A third-party developer publishes a DID-signed app manifest to public Nostr relays (NIP-78, kind 30078) using the tooling
2. A node discovers the published app from multiple relays and the app store UI shows its trust tier (Verified / Community / Unverified) per ADR-006 scoring
3. The node verifies the manifest signature before installation; a tampered or invalid marketplace manifest cannot be installed
4. A user installs the third-party marketplace-published app on their node and it runs healthy under the standard lifecycle guarantees — the user-chosen success metric, demonstrated end-to-end
last_activity_desc:"Completed quick task 260729-fw7: mesh hop graphic redesign (branded HopVizModal, vertical mobile layout)"
progress:
total_phases:8
completed_phases:0
total_plans:10
completed_plans:0
percent:0
---
# Project State
## Project Reference
See: .planning/PROJECT.md (updated 2026-07-29)
**Core value:** A third-party developer can publish an app via the signed/decentralized registry and a user can install it on their node — manifest-driven, rootless, secure, robust.
- Phase 2 added (2026-07-29): UI Performance — slow tab switches and secondary screens; prior phases shifted down
- FED-05 added to Phase 1 (2026-07-29): inter-node Lightning channel-opening UX (share node URI, pick trusted/federated nodes by hostname, request channels with public nodes); UI tested on :8100 dev preview against archi-dev before deploy
- FED-06 added to Phase 1 (2026-07-29): on-brand paid-tick animation — screensaver ring + EQ segments (reuse ScreensaverRing.vue compact) replacing the success burst in SendBitcoinModal.vue
### Decisions
Decisions are logged in PROJECT.md (10 locked ADRs in the `<decisions>` block + milestone decisions table). Recent decisions affecting current work:
- Milestone version = 1.8.0-alpha (decided 2026-07-08)
- Phase-3 Quadlet default-flip is gated on the second-node gate reporting clean (do fresh, never stage uncommitted)
- Workstream D (DHT distribution) deferred to v2 — design-only backlog
- [Phase 6] Strengthened ADR-009 validation may reject existing catalog apps — audit manifests before enforcement lands
- [Global] Live OTA fleet: deploy to the dev pair before any OTA; gate re-runs required after orchestrator changes; some verification is user/hardware-gated (radios, on-device tests)
- Webhook Relay: Listens for inbound webhooks, broadcasts to subscribers
- WebSocket Listener: Upgraded HTTP connections → broadcast state subscriber
- See: `core/archipelago/src/main.rs` (lines ~400-450 show the spawned tasks)
## Architectural Constraints
- **Single event loop** — All I/O-bound work runs on a single Tokio multi-threaded runtime; no worker threads by default (some container ops are blocking, run in tokio::task::spawn_blocking)
- **Global state via broadcast** — StateManager broadcasts to all WebSocket clients; no request-response for state changes (async by design)
- **Container state mutability** — Podman state can drift from manifest (manual edits, crashes); reconciler runs periodically to converge
- **No in-process data consistency** — Multiple services can mutate StateManager concurrently; last write wins (fine for UI; critical ops use locks)
- **Shared blob store** — All services that need to share content use the same BlobStore instance (single cap_key, single root directory)
- **Rate limiting per IP + method** — Prevents brute-force login, but shared IPs see shared limits (edge case: family users, proxies)
- **Session cookie same-site** — WebSocket + HTTP POST must be same-origin; CORS headers controlled by ApiHandler
## Anti-Patterns
### Circular RPC Dispatches
**What happens:** An RPC method calls back into another RPC method, forming a cycle (e.g., auth.login → container.list → auth.check_permission → auth.login)
**Why it's wrong:** Deadlocks on RwLocks, infinite loops on state broadcasts, unclear error messages, hard to debug
**Do this instead:** Pass check result as a side-effect from the outer method; compute permissions once at the start. Use composable patterns in frontend instead (e.g., `useCanInstall()` checks perms once per component mount).
### Synchronous blocking in RPC handlers
**What happens:** RPC method calls `.unwrap()` on Podman command result, blocking the entire event loop
**Why it's wrong:** One slow container op (e.g., large image pull) blocks all concurrent users
**Do this instead:** Use `tokio::task::spawn_blocking()` for I/O that may take >100ms. See `core/archipelago/src/container/docker_packages.rs` for examples.
### Hardcoding paths in app RPC modules
**What happens:**`bitcoin.rs` hardcodes `/opt/archipelago/data/bitcoin.conf` instead of using `config.data_dir`
**Why it's wrong:** Dev mode, tests, and alternate installs all fail with "not found"
**Do this instead:** Read from `Config` struct, which is passed to every RPC method. See `core/archipelago/src/api/rpc/bitcoin.rs:status()` for correct pattern.
### Frontend state outside Pinia stores
**What happens:** Components use component-local ref<> for app list, duplicate the StateManager's data
**Why it's wrong:** Stale data after OTA updates, inconsistent with other users on the same node, race conditions on install/uninstall
**Do this instead:** Always derive from Pinia stores (e.g., `useAppStore().apps`). Stores subscribe to WebSocket updates. See `neode-ui/src/stores/appStore.ts`.
### Not handling WebSocket reconnection
**What happens:** Frontend goes offline for 10s (network glitch), WebSocket closes, frontend doesn't re-sync state
**Why it's wrong:** UI shows stale data (app still "installing" when actually done), user clicks again, double-action happens
**Do this instead:** WebSocket reconnect handler should re-fetch full state (`node.status`, etc.), re-subscribe. See `neode-ui/src/api/rpc-client.ts` for the reconnect loop.
## Error Handling
**Strategy:** Defensive layering — errors are caught at each tier, logged, and converted to user-facing messages.
**Patterns:**
- HTTP layer: 4xx/5xx with JSON error (no 500s for logic errors; only for crashes)
- RPC layer: Serialize error as `{ error: { code: N, message: "...", data: {...} } }` per JSON-RPC spec
- Service layer: Use `anyhow::Result<T>` + `?` operator for early exit; convert to `RpcError` at handler boundary
- Frontend: Catch RPC errors, show toast/modal, log to console (never crash the app)
**Critical paths:**
- Auth failure: 401 Unauthorized + "Invalid password" (no "user not found" to leak usernames)
- Container ops: If reconciler sees drift, logs it but continues (never crashes the daemon)
- Image pull failure: Fallback to last-cached version if network timeout (user is never blocked on external registries)
- Podman socket unavailable: Return 503 Service Unavailable (user sees "Archipelago is starting")
- Issue: `federation::remove_node()` (`core/archipelago/src/federation/storage.rs:180-197`) calls `tombstone_did()` at line 193 but explicitly drops the error with `let _ = …`. If tombstone write fails (disk I/O, permission, transient), the peer is removed from `nodes.json` but never actually recorded as removed, so the next background sync/notify-join silently re-adds it.
- Impact: Federation peers marked for removal can reappear after the next sync cycle, confusing the operator and potentially re-establishing unwanted connections.
- Fix approach: Surface the tombstone-write failure instead of swallowing it; consider retry logic with backoff; add integration test via `tests/multinode/smoke.sh` to verify removal sticks across sync cycles.
**Container reconciler observability gap:**
- Issue: No metrics distinguish "settling after restart" from "flapping" — container thrashing is invisible until anecdotal reports. No per-app restart counter or log line when an app restarts >N times in M minutes.
- Impact: Silent restart storms go unnoticed; users see frequent service interruptions without diagnostics; operator can't distinguish normal convergence from a crash loop.
- Fix approach: Add per-app restart counter + log line when threshold exceeded; emit metric on each restart; wire restart count into health/status RPC output.
**Failed systemd unit self-healing gap:**
- Issue: When a Quadlet-backed app's `.service` unit enters `failed` state (e.g., exit 255), the reconciler does not automatically `reset-failed` + `start` it. The unit sits failed until the operator manually intervenes or the service restarts.
- Impact: Apps with transient failures go down and stay down; no automatic recovery; operator must manually reset or restart the orchestrator.
- Fix approach: Add reconcile step: quadlet-backed app whose `.service` is `failed` and not user-stopped → call `systemctl --user reset-failed <unit>` + `start`; add backoff to avoid busy-loop on persistent failures.
**Bitcoin RPC credentials not retrieved from config/secrets:**
- Issue: `core/container/src/bitcoin_simulator.rs:158` has a TODO marking hardcoded (or missing) RPC credentials in the Bitcoin simulator real-mode path. Credentials should be fetched from the secret store.
- Impact: Bitcoin simulator in real mode (Testnet/Mainnet) cannot authenticate to the node; RPC calls fail.
- Fix approach: Inject `SecretsProvider` into `BitcoinSimulator::new()` or pass credentials as constructor args; fetch via `config/secrets` at runtime; handle credential rotation.
**Container security policies not wired in:**
- Issue: `core/security/src/container_policies.rs` generates AppArmor/SELinux profiles but the `apply_profile()` function has a TODO at line 71: "Configure Podman to use the profile" — the profiles are generated but never applied to running containers.
- Impact: Security profiles exist but provide zero protection; containers run without the intended isolation constraints.
- Fix approach: Pass `--security-opt apparmor=<profile>` (or SELinux equivalent) to Podman at container creation; verify profile loads via `apparmor_status`; add CI check that profiles compile cleanly.
**Dynamic resource adjustment not implemented:**
- Issue: `core/performance/src/resource_manager.rs:86` has a TODO for dynamic resource adjustment based on usage. The allocator is static; no adaptive rebalancing when load patterns shift.
- Impact: Resource allocation is rigid; a node with skewed usage (e.g., one app consuming all memory) has no mechanism to rebalance dynamically.
- Fix approach: Monitor per-app resource usage via cgroup stats; implement feedback loop to adjust limits; gate on production deployment (likely Phase 3+).
## Known Bugs
**Multinode RPC robustness gap:**
- Symptoms: The `node_rpc()` function in `tests/multinode/lib/multinode.bash` lacks `--max-time` on curl calls — a slow server-side RPC can hang the test suite indefinitely with zero feedback.
- Files: `tests/multinode/lib/multinode.bash` (exact line TBD; see grep for `node_rpc`)
- Trigger: Run multinode federation/mesh test against a slow or overloaded node; curl will block forever.
- Workaround: Manually kill the test process and diagnose the hanging RPC manually; no automatic timeout recovery.
- Fix approach: Add `--max-time 30` to all curl calls in `node_rpc()`; re-run `tests/multinode/smoke.sh` to verify.
## Security Considerations
**Secrets environment variable exposure risk:**
- Risk: Bitcoin and other service credentials are materialized as env vars in `ARCHIPELAGO_*` (e.g., `BITCOIN_RPC_PASSWORD`). Env vars are visible via `/proc/<pid>/environ` and potentially logged.
- Current mitigation: DIDs are stored locally; transitive federation discovery uses the tombstone list to block removed peers.
- Recommendations: Add DID-ownership proof (e.g., signed proof-of-identity) before accepting a peer's advertised DID; document the trust model; consider user warnings when adding peers.
**AppArmor profiles overly permissive:**
- Risk: Generated AppArmor profiles use blanket `network,` instead of per-port/protocol rules. Readonly flag is checkbox only, not enforced per actual app needs.
- Current mitigation: None (profiles not applied).
- Recommendations: Refine per-app capabilities based on manifest's declared needs; add integration test verifying readonly mounts are enforced; apply profiles in development before prod.
## Performance Bottlenecks
**Container thrashing during reconcile:**
- Problem: Restarting `archipelago.service` SIGKILLs every container, forcing a full rebuild over several minutes. Uninstall + reinstall loops can cascade-trigger restarts.
- Files: `core/archipelago/src/container/prod_orchestrator.rs` (the reconciler's desired-state machine)
- Cause: Pre-Phase-3 architecture: containers run in systemd cgroup, not as independent Quadlet units.
- Improvement path: Phase-3 Quadlet default-flip (`config.rs:256`) — each app becomes an independent `.container` unit; restart only the affected app, not the entire cgroup.
**Reconciler churn on boot:**
- Problem: Boot reconciler makes multiple passes reconciling drift; during each pass, containers may be recreated. Post-OTA health checks deliberately skip per-app container assertions because of restart-storm unpredictability.
- Cause: Multi-pass reconciliation + no incremental diff detection.
- Improvement path: Consolidate reconciler into single pass for boot; cache manifest/config diffs to avoid redundant comparisons; add boot-only fast-path.
**Bitcoin IBD on .198 stalled (disk I/O):**
- Problem: .198 bitcoin is mid-IBD with only 21% progress; disk is 448GB (below 1TB archival threshold); load is high (~3–5).
- Improvement path: User decision required: swap in a different node (already done for gate run, using .5 instead) or add storage + wait for sync. Not a code issue.
- Why fragile: Pre-2026-07-26, `quadlet::disable_remove()` called systemd + podman with no timeouts, causing hangs. Fixed by commit `71cc9ac4` (added `QUADLET_STOP_TIMEOUT`, SIGKILL escalation, reset-failed). AppCard was hardcoding uninstall bar to "stuck full-red" (fixed `9f17ba68`). Tests for reinstall/cascade are still opt-in.
- Safe modification: Any changes to the uninstall path must be tested via `cascade-uninstall.bats` (7/7 on .228); extend coverage to multi-container stacks (immich, btcpay). Verify on .228 before fleet roll.
- Test coverage: `tests/lifecycle/bats/cascade-uninstall.bats` exists but not in canonical gate; must opt-in with `ARCHY_GATE_CASCADE=1`.
- Why fragile: Largest file in the codebase; owns install/start/stop/restart/remove/upgrade for every app; per-app mutex + RwLock concurrency model; complex dependency resolution, adoption scan, Quadlet rendering, and host-port-wait logic interleaved.
- Safe modification: Understand the per-app mutex protocol before touching state mutation; test all changes via the lifecycle gate on .228; use the adoption scan + manifest merge logic for any new manifest evolution.
- Test coverage: 667 unit tests green (2026-07-01); lifecycle gate covers ~8 core apps; ~30 apps untested in gate.
**Mesh radio configuration + boot race:**
- Files: `core/archipelago/src/mesh/meshtastic.rs`, `core/archipelago/src/mesh/mod.rs`, tests at `tests/lifecycle/bats/meshtastic.bats`
- Why fragile: Radio boot-race fixed (2026-07-28, `a8c4694c`/`3f76b496`); on-air config apply must finish before device is used. Earlier versions had probe-boot-race + live config propagation issues. Must verify on real hardware.
- Safe modification: Any mesh changes require E2E test on real LoRa radios (dev-box ↔ x250-dev, or fleet broadcast); unit tests alone won't catch RF timing issues.
- Test coverage: 8-stage on-air smoke test in `tests/multinode/meshtastic.sh` (run manually; not in canonical gate).
- Why fragile: Slow multi-hop payments (>15s) previously surfaced as "failed" while settling in background; client-side 15s timeout was aborting the wait. Fixed by commit `614a0f5a` (120s wait, pending status, lnd.paymentstatus poll). Must verify on Framework PT with real multi-hop.
- Safe modification: Any lnd state changes must test full payment lifecycle: invoice creation, encoding, send, multi-hop wait, settlement confirmation. Verify on Framework PT before release.
- Test coverage: Local LND payinvoice smoke test; no multinode lightning routing test in gate.
## Scaling Limits
**Uninstall progress bar truthfulness:**
- Current capacity: Uninstall now has timeouts (fixed 2026-07-26) but progress-bar still reports fake stages (full-red full-opacity).
- Limit: Long uninstalls (>30s) show no real progress; bar claims "uninstalling" for the full duration.
- Scaling path: Backend must emit real progress events (% complete, stage name); UI must poll + display truthfully; integrate into all 5 gate iterations (not just 1 throw-away app).
**Federation node list deduplication on disk bloat:**
- Current capacity: `federation/storage.rs:dedup_nodes_by_onion()` reads entire nodes.json into memory each time a node is added/synced. At N federated peers, O(N) memory + O(N²) comparisons per operation.
- Limit: No hard limit measured; scales fine up to hundreds of peers. Beyond 1000+ peers, memory/time may become visible.
- Scaling path: Switch to a disk-backed database (e.g., rocksdb) for federation state if peer count grows; or implement incremental dedup on disk writes (preserve dedup state, only recompute on load).
**Lifecycle gate iteration count:**
- Current capacity: `ARCHY_ITERATIONS=5` runs 5 full cycles (stop/start/restart/survive per app). Entire run takes ~8–12 hours on .228.
- Limit: Cannot easily scale to 10+ iterations without timeout risks; per-app timeout tuning is manual.
- Scaling path: Add per-app timeout tuning (manifest field); parallelize per-app tests where safe (currently serial to avoid contention).
## Dependencies at Risk
**Reticulum transport daemon process group:**
- Risk: Pre-fix (before `be50c886`), process group wasn't cleaned up on drop. Fork-bombs or dangling processes possible under error conditions.
- Impact: Stale reticulum processes accumulating over time; resource leaks on node.
- Migration plan: Code fix already deployed (commit `7a7fec21`); no active risk. Monitor fleet for stale python processes post-deployment.
**Podman socket mount security model:**
- Risk: Apps mounting `/run/podman/podman.sock` get full container-management access. Not restricted by the security policy (AppArmor profiles not applied).
- Files: `core/archipelago/src/container/prod_orchestrator.rs:135-137` (detection), manifests for apps with podman mounts (e.g., portainer)
- Impact: A compromised app with podman socket access can start/stop/delete any container on the node.
- Recommendation: Restrict podman socket mounts to admin-only apps (portainer, docker-api tools); document risk; consider socket filtering layer (selinux context, etc.) once AppArmor is wired.
**Bitcoin version multi-version branch not fleet-wide:**
- Risk: Branch `bitcoin-version-bulletproof` (base `095a76cd`) carries multi-version support but hasn't been deployed fleet-wide yet. .228 carries it; others still run single version.
- Impact: Users on single-version nodes can't switch versions; version mismatch across fleet breaks federation.
- Migration plan: Coordinated OTA + catalog publish + `:latest` repoint sequencing per `docs/bitcoin-version-bulletproof-rollout.md`. Awaiting user decision on timing.
## Missing Critical Features
**Developer tooling CLI suite:**
- Problem: Third-party developers need `archy app validate/render/local-install/lifecycle-test` tooling before external registry launches.
- Status: Not yet built; documented in APP-PACKAGING-MIGRATION-PLAN.md step 5.
**Manifest-distributed registry flip:**
- Problem: Manifests still travel via OTA disk rsync. The signed catalog currently distributes only image overrides, not full manifests. Workstream B phases 1+2 done; not yet fleet-deployed.
- Blocks: Cannot confidently add/bump apps without re-signing the catalog.
- Status: Code validated on .228/.198 (commit pending); ready to flip when multinode gate passes.
## Test Coverage Gaps
**~30 apps with zero app-specific assertions:**
- What's not tested: Apps like grafana, jellyfin, vaultwarden, penpot, nextcloud, photoprism, uptime-kuma, homeassistant, etc. have no app-specific health checks beyond "container running."
- Risk: Silent hangs or fake progress bars are invisible to the gate.
- Priority: High — immich/grafana uninstall was stuck full-red (fixed); progress truthfulness is part of definition of done for workstream F.
- Approach: Backend must emit real progress events; UI must display & test them; integrate into canonical gate (currently opt-in).
**All-apps matrix in cascade gate:**
- What's not tested: `ARCHY_GATE_CASCADE=1` runs ONE throwaway app's uninstall/reinstall. Must extend to multi-container stacks (immich, btcpay, mempool) and all ~40 installed apps.
- Priority: High — part of workstream F definition of done.
- Approach: Parametrize cascade test over all manifest IDs; run 5 cascades total (not 5 per app to save time); gate-pass requires zero ghost containers post-uninstall.
---
*Analysis based on codebase state 2026-07-29. Issues tracked in `docs/UNIFIED-TASK-TRACKER.md` (day-to-day) and `docs/PRODUCTION-MASTER-PLAN.md` (historical narrative).*
- TypeScript: indentation is 2 spaces (visible in `vitest.config.ts`, Vue components, test files)
- Line width: no strict enforcement observed; pragmatic wrapping around 80–100 characters
- Arrow functions preferred for short callbacks: `(x) => x * 2`
- Template strings for multi-line formatting
**Linting:**
- No `.eslintrc` detected at repo root or `neode-ui/` level
- Rust: Clippy allowances declared at crate level in `main.rs` (`#![allow(...)]`) to suppress stylistic lints and focus CI on correctness issues
- Examples of suppressed Clippy lints: `too_many_arguments`, `type_complexity`, `enum_variant_names`, `unused_io_amount`
## Import Organization
**Order:**
1. Vue framework imports (`import { computed, ref, type Ref } from 'vue'`)
2. Library imports (`import { defineStore } from 'pinia'`, `import { format } from 'date-fns'`)
3. Local module imports (`import { useFileType } from '../useFileType'`, `import { rpcClient } from '../api/rpc-client'`)
4. Types/interfaces (inline in import statements via `type` keyword when needed)
5. No blank lines required between groups in practice
**Path Aliases:**
- TypeScript: `@` alias maps to `src/` (configured in `vitest.config.ts` and `tsconfig.json`)
- Usage: `import { displayVersion } from '@/utils/version'`
- Rust: crate-relative paths (`use crate::module::submodule`) and external crate paths
## Error Handling
**Patterns:**
- TypeScript: explicit try-catch with error type narrowing (e.g., `if (error instanceof Error) { ... }`)
- RPC client (`rpc-client.ts`): catches fetch errors, AbortError, and HTTP errors; distinguishes retryable (502, 503) from permanent (401, 403)
- Rust: `anyhow::Result<T>` for fallible operations; `?` operator for error propagation; `.context("message")` for adding context
- Backend error responses: JSON-RPC format with `error: { code, message, data? }` structure; UI catches and displays via toast system
- Network errors: automatic retry with exponential backoff (600ms × (attempt + 1) with jitter); configurable `maxRetries` per call
## Logging
**Framework:** console object for frontend, `tracing` crate for Rust backend
**Patterns:**
- Frontend: `console.warn`, `console.error` used selectively (e.g., `[RPC]` prefixed logs in `rpc-client.ts` for session/CSRF events)
- Rust: `tracing::info!`, `tracing::warn!` for structured logging; `println!` avoided in production code
- No log levels enforced or documented; pragmatic use based on severity
## Comments
**When to Comment:**
- Explain non-obvious retry logic, timeout decisions, CSRF handling (see `rpc-client.ts` lines 138–178 for example)
- Clarify why a workaround exists (e.g., "Already on the login page: redirecting = a full reload")
- Document integration points with backend RPC methods and their expected response shapes
- Avoid redundant comments restating what the code obviously does
**JSDoc/TSDoc:**
- Function parameter types documented inline via TypeScript type annotations (e.g., `ext: Ref<string>`)
- Minimal use of explicit JSDoc blocks; type signature is the primary documentation
- Comments above exports explain purpose in one sentence (e.g., "// RPC Client for connecting to Archipelago backend")
- Optional fields in interfaces documented via property-level comments (e.g., `/** Abort the call from the outside … */`)
## Function Design
**Size:** Functions are typically 5–50 lines; error-handling paths in `callInner<T>` (`rpc-client.ts`) stretch to 120 lines but remain single-responsibility (retry logic + error classification)
**Parameters:**
- Prefer object parameters for 3+ arguments (e.g., `RPCOptions` object over separate `method, params, timeout`)
"title":"ADR-002: DID Key Method for Node Identity",
"summary":"Chose did:key (Ed25519) as the primary DID method for node identity; self-contained and offline-capable, with federation trust lists mitigating rotation/revocation gaps.",
"summary":"Chose bilateral DID-based verification with single-use invite codes over Tor for establishing federation trust between nodes, with Trusted/Observer/Untrusted levels.",
"summary":"Maintain two key pairs per node identity: Ed25519 for DID/Web5 operations, secp256k1 for Nostr/Bitcoin/Lightning, both derived from one master seed.",
"summary":"Deprioritizes Web5 DWN spec compliance after TBD's shutdown; keeps existing custom DWN store code and prioritizes Nostr plus Tor federation for peer sync.",
"scope":["DWN (Decentralized Web Node)","Web5","dwn_store.rs","Nostr","federation","peer discovery","peer data sync"],
Extracted from 1 classified SPEC: `docs/app-manifest-spec.md` (accurate as of 2026-07-08). The SPEC self-declares that the canonical schema is the Rust parser in `core/container/src/manifest.rs` — if doc and code disagree, the code wins.
## App manifest top-level schema (`app:` block)
- source: docs/app-manifest-spec.md
- type: schema
- content: Every app is a directory `apps/<id>/` with a `manifest.yml` containing a single top-level `app:` block. Apps are purely declarative — the orchestrator owns the entire lifecycle; no per-app installer code. Required fields: `id` (lowercase alphanumeric + `-`/`_`, must match directory name), `name`, `version`. Optional fields: `description`, `container` (ContainerConfig), `dependencies` (storage/app_id+version/bare string), `resources` (cpu_limit, memory_limit, disk_limit), `security` (SecurityPolicy), `ports` (host/container/protocol), `volumes`, `files` (GeneratedFile: path/content/overwrite; path must sit under a declared bind mount), `environment` (static KEY=value), `health_check` (type/endpoint/path/interval/timeout/retries; `http` is what the monitor exercises), `devices` (must start with `/dev/`), `interfaces` (launch surfaces keyed by name), `hooks` (LifecycleHooks). Unknown keys are absorbed into an `extensions` map (serde flatten) as transitional metadata — not typed schema, not validated.
## ContainerConfig schema
- source: docs/app-manifest-spec.md
- type: schema
- content: Exactly one of `image` or `build` must be present (image XOR build). Fields: `image` (registry reference), `image_signature` (optional), `pull_policy` (default `if-not-present`), `build` ({context, dockerfile default "Dockerfile", tag, build_args}), `network` (literal podman `--network` value; omitted = rootless default isolated network), `network_aliases` (extra DNS names on the network), `entrypoint`, `custom_args`, `derived_env` ({key, template} rendered against host facts at apply time; allowed placeholders only: {{HOST_IP}}, {{HOST_MDNS}}, {{DISK_GB}} plus dependency-resolved facts — never hard-code host specifics), `secret_env` ({key, secret_file} read from /var/lib/archipelago/secrets/<secret_file>, injected as a podman secret so it never appears in `podman inspect` or unit files; secret_file must be a bare filename, no `/` or `..`), `generated_secrets` ({name, kind} materialised by the orchestrator on first use, 0600, rootless service user, idempotent + self-healing; kind ∈ hex16|hex32|base64|bcrypt; bcrypt writes <name>=hash and <name>.pw=plaintext), `generated_certs` ({crt, key, common_name?, sans?} self-signed TLS materialised before create), `data_uid` ("UID:GID" applied to the app's bind-mounted data dir before create).
## SecurityPolicy schema and validation rules
- source: docs/app-manifest-spec.md
- type: schema
- content: Security block defaults: `readonly_root: true`, `no_new_privileges: true`, `capabilities: []` (cap-drop ALL, add back only listed), `network_policy: isolated` (isolated | bridge | host), `apparmor_profile: null` (optional). Validation enforced at `AppManifest::validate()`: capabilities must come from the reviewed allow-list (CHOWN, DAC_OVERRIDE, FOWNER, NET_ADMIN, NET_BIND_SERVICE, NET_RAW, SETGID, SETUID, SYS_ADMIN); `network_policy` must be exactly isolated/bridge/host; no `container:`/`ns:` network modes; devices must be `/dev/*`; bind-mount sources confined to `/var/lib/archipelago` (reviewed exceptions: rootless podman socket and dbus); `derived_env` templates limited to the placeholder allow-list; `secret_env`/`generated_secrets` names must be bare filenames; hook steps validated against the hook allow-list. (Note: the SPEC's documented validation list does not mention ADR-009's non-root UID, pinned-image-tag, or seccomp mandates — see INGEST-CONFLICTS.md INFO entry.)
## Volumes schema
- source: docs/app-manifest-spec.md
- type: schema
- content: Volume entries: `type` ∈ bind | volume | tmpfs; bind entries take `source` (confined to /var/lib/archipelago per validation), `target`, `options` from an allow-list (rw, ro, z, Z, shared, …); tmpfs entries take `target` and `tmpfs_options` (e.g. "rw,noexec,nosuid,size=256m").
## Lifecycle hooks contract
- source: docs/app-manifest-spec.md
- type: api-contract
- content: Hooks are declarative, allow-listed operations that run against the app's own container — never the host (design: manifest-hooks-design.md). `post_install` runs once after install with the container running; supported steps: `copy_from_host` (src relative to an allow-listed root — data dir / web-ui; no absolute paths, no '..') and `exec` (podman exec inside the container). `pre_start` is reserved in the schema; its executor is not yet wired.
## Installation semantics (Quadlet + reconciler)
- source: docs/app-manifest-spec.md
- type: protocol
- content: The orchestrator compiles the manifest into a rootless Podman Quadlet unit under `user.slice` — the container survives backend restarts and reboots. A level-triggered reconciler converges drift every 30 seconds. Multi-container apps are sets of per-member manifests installed together via the stack orchestrator (`api/rpc/package/stacks.rs`) on an app-local network.
## Manifest distribution channels
- source: docs/app-manifest-spec.md
- type: protocol
- content: Manifests ship two ways. (1) Signed catalog (primary): `releases/app-catalog.json` embeds the full manifest per app with an Ed25519 detached signature verified against the pinned release-root anchor; nodes overlay catalog manifests over disk files — catalog wins for image-only apps; `apps/<id>/manifest.yml` on disk remains the fallback and is still required for build-source apps. (2) Decentralized marketplace: Nostr NIP-78 discovery with DID-signed manifests (marketplace-protocol.md); the marketplace uses its own flatter manifest schema, not this one. Tooling: validate with `scripts/validate-app-manifest.sh`, regenerate catalog with `scripts/generate-app-catalog.py`, drift-checked in CI by `scripts/check-app-catalog-drift.py`.
Extracted from 10 classified ADRs. All are `locked: true` (Status: Accepted) and cannot be auto-overridden by any lower-precedence source.
## ADR-001: Podman Over Docker
- source: docs/adr/001-podman-over-docker.md
- status: locked (Accepted)
- decision: Use Podman as the container runtime instead of Docker. Rootless by default, daemonless, Docker-compatible, native systemd integration, OCI-compliant. Use `archy-net` custom network for inter-container DNS.
- decision: Use `did:key` (Ed25519) as the primary DID method for node identity. Self-contained, offline-capable, local resolution. Known gaps (no rotation, no service endpoints, no revocation) mitigated via federation trust lists and separately-stored service endpoints; future migration to did:peer/did:web possible if rotation is needed.
## ADR-003: Nostr Relays for Node and App Discovery
- source: docs/adr/003-nostr-for-discovery.md
- status: locked (Accepted)
- decision: Use Nostr relays (NIP-78, kind 30078) for both node discovery and marketplace app manifests. Query multiple relays in parallel with dedupe; local cache with 15-minute TTL; trust scoring (DID verification, relay consensus, federation trust); hashtag filtering (`archipelago-marketplace`); NIP-33 replaceable events for updates; Tor-compatible via SOCKS proxy.
- decision: Use Tor hidden services (.onion addresses) for all inter-node communication. Scoped to RPC/control plane only — bulk data (container images) pulled from registries. Retry with backoff; `archy-tor` container runs automatically with host networking; federation sync interval (5 min) tolerates occasional failures.
- decision: Use ChaCha20-Poly1305 (AEAD) with Argon2id key derivation for backup encryption at rest, chosen over AES-256-GCM and XChaCha20-Poly1305. Random nonce per backup stored alongside ciphertext; Argon2id with 64MB memory cost and 3 iterations for password-to-key derivation.
- decision: Use bilateral DID-based verification with single-use invite codes for federation trust establishment. Invite code carries DID, .onion address, and shared secret; exchanged out-of-band; both nodes verify DIDs via signed challenges over Tor; ongoing communication is DID-authenticated over Tor hidden services. Trust levels: Trusted (full access), Observer (read-only), Untrusted (blocked). Discovery (ADR-003) finds nodes; federation trusts them.
- scope: federation, DID verification, invite codes, trust levels, Tor hidden services, Ed25519 keys
- decision: Maintain two key pairs per node identity, both derived from one master seed: Ed25519 as canonical identity (DID documents, verifiable credentials, federation auth, backup encryption via X25519 DH) and secp256k1 for Nostr/Bitcoin/Lightning (event publishing, node discovery, Lightning channel auth). Secp256k1 key linked to the DID via Nostr profile (NIP-05). Backup captures the master seed; DID document includes both verification methods.
- decision: Deprioritize Web5 DWN spec compliance following TBD's November 2024 shutdown. Keep existing custom DWN store code (`core/archipelago/src/network/dwn_store.rs`) for peer file catalogs and federation state; stop calling it "Web5 DWN" in user-facing text; do not invest in DWN spec compliance; prioritize Nostr + Tor federation for peer discovery and data exchange; re-evaluate only if DIF produces a viable Rust SDK or the spec regains maintainers.
No PRD documents were present in the ingest set (10 ADRs + 1 SPEC). No requirements extracted.
Downstream note: requirements for the roadmap must be derived elsewhere (e.g. from user input or a future PRD ingest); this file intentionally records absence rather than inferring requirements from ADR/SPEC content.
- "A federation removal issued while an auto-sync pass is in flight leaves the peer removed — a sync's pre-removal node-list snapshot can no longer re-save the removed peer (FED-01 adjacency edge)"
- "Two concurrent federation node writes both persist — neither silently loses the other's update (the lost-update class behind the reported 'removed nodes reappear' symptom)"
- "Removing the last remaining federated node succeeds, leaves an empty node list, and returns Ok with an empty Vec (FED-01 empty edge)"
- "A removal whose tombstone write fails returns Err to the caller instead of reporting success (FED-01 failure-surfacing)"
- "The tombstone is durably written before the filtered node list is saved, so an interruption between the two never resurrects the removed peer (FED-01 ordering edge)"
- "A partially-written federation node list can never be observed by a concurrent reader — the list is written to a sibling temp file and renamed into place"
prohibitions:
- statement:"Removing a federation node MUST NOT delete or destroy that peer's local data — no app data directory under /var/lib/archipelago, no mesh message history, no credential store is erased by unfederating; removal revokes trust, it never destroys operator data"
-`grep -Eq 'flavor = "multi_thread"' core/archipelago/src/federation/storage.rs` succeeds (the
race tests are useless on the single-threaded default runtime).
- The SUMMARY records the captured pre-fix failure output for at least one of the three tests.
</acceptance_criteria>
<done>The removal-vs-sync race is closed at the storage layer and proven by a test that fails without the lock; the node list is written atomically.</done>
</task>
<task type="auto">
<name>Task 2: Bring every remaining federation mutator under the lock + surface tombstone-write failure</name>
-`grep -c 'FEDERATION_STORE_LOCK.lock().await' core/archipelago/src/federation/storage.rs` is at least 9.
-`cd core && cargo build -p archipelago` exits 0 with no new warnings in `federation::storage`
(dead-code warnings on unused `*_inner` fns mean a mutator was missed).
-`cd core && cargo test -p archipelago` exits 0 — no caller in `sync.rs`, `handlers.rs`,
`server.rs`, or `mesh/mod.rs` was broken by the refactor.
</acceptance_criteria>
<done>Every federation node-store mutator is serialized; a tombstone-write failure is proven to surface as an error with no half-applied removal.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| federated peer → `federation::sync` → node store | A remote peer's state snapshot crosses into local persisted trust state |
| operator RPC (`federation.remove-node`) → node store | An authenticated local operator action mutates trust membership |
| process → `federation/nodes.json` on disk | Multiple concurrent async tasks write one file; a crash can leave it partial |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-01 | Tampering | `federation::storage` concurrent read-modify-write | high | mitigate | `FEDERATION_STORE_LOCK` held across the whole load-mutate-save cycle in every mutator (Tasks 1-2); regression test proves a removal survives a concurrent sync |
| T-01-02 | Elevation of Privilege | a removed (untrusted) peer regaining federation membership via the race | high | mitigate | Same lock; plus the pre-existing tombstone check in `merge_transitive_peers` and `handle_federation_peer_joined` is left intact and re-verified by `cargo test -p archipelago federation` |
| T-01-03 | Denial of Service | a partial `nodes.json` write on crash making the node list unreadable | medium | mitigate | Atomic temp-file + same-directory `fs::rename` in `save_nodes_inner` (Task 1) |
| T-01-04 | Denial of Service | lock contention stalling the federation RPC surface | low | accept | Federation writes are infrequent (90s loop + operator actions); `.lock().await` queues rather than rejects, and every critical section is a bounded file read+write |
</threat_model>
<verification>
-`cd core && cargo test -p archipelago federation` — green.
-`cd core && cargo test -p archipelago` — green (no caller regressions).
- The pre-fix failure of `test_remove_survives_concurrent_state_sync` is recorded in the SUMMARY as
evidence the test is fail-first and not vacuous.
</verification>
<success_criteria>
- Every read-modify-write in `federation/storage.rs` is serialized behind one module-level async mutex with no re-entrancy path.
- The node list is written atomically (temp file + same-directory rename).
- Four new tests exist and pass; at least one is demonstrated to fail without the lock.
- Public signatures unchanged; the full `archipelago` test suite is green.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-01-SUMMARY.md` when done.
Commit with `git add` by explicit path (another agent shares this tree — never `git add -A`), then
- "Every mesh.* and federation.* RPC method the neode-ui frontend calls has a matching handler in mock-backend.js — the demo never answers a UI call with 'Method not found'"
- "Renaming a mesh peer on the demo persists: mesh.contacts-save then mesh.contacts-list returns the saved alias, mirroring the daemon's handle_mesh_contacts_save/list behavior"
- "A reaction, reply, edit, delete, or forward performed on the demo mutates the demo message store and is visible on the next mesh.messages read — it is not a bare ok acknowledgement"
- "The demo's transport decision for an attachment matches the daemon's size tiers (auto under 1024 bytes, chooser in the 1024..2300 band, tor-only above 2300) — no demo-only chooser modal"
- "An automated parity check fails when a UI-called mesh.*/federation.* method has no mock-backend handler, so the gap class is caught before manual demo testing"
prohibitions:
- statement:"The demo/mock backend MUST NOT gain behavior that diverges from the real daemon — it must never invent a demo-only modal, a demo-only response shape, or a success path a real node does not produce; every mirrored handler cites the daemon source file and line range it mirrors"
category:transparency
artifacts:
- path:neode-ui/scripts/mock-rpc-parity.mjs
provides:"Static UI-call vs mock-handler cross-reference plus a live RPC smoke sequence"
min_lines:60
- path:neode-ui/mock-backend.js
provides:"mesh.contacts-list/save, stateful message-mutation handlers, and the 10 previously-missing UI-called methods"
contains:"mesh.contacts-list"
key_links:
- from:neode-ui/scripts/mock-rpc-parity.mjs
to:neode-ui/mock-backend.js
via:"spawns mock-backend.js on MOCK_BACKEND_PORT and posts a scripted JSON-RPC sequence"
pattern:"MOCK_BACKEND_PORT"
---
<objective>
Finish demo/real mesh parity: the demo backend answers every mesh and federation RPC the UI calls,
and the message-mutation calls actually mutate demo state instead of returning a bare acknowledgement.
Purpose: FED-04. Attachment-send parity already landed on main (`c2ce71c6`) — `mesh.send-content-inline`
/ `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` now mirror the daemon's tier
logic. RESEARCH.md and a fresh cross-reference of `neode-ui/src/**` against `mock-backend.js` show
what remains: **12 methods the UI calls that have no case at all** (they fall through to a
`Method not found` error the frontend swallows in `try/catch`), and **six ack-only stubs** that
never touch the demo message store, so reactions/edits/deletes silently do not render on the demo.
Output: those gaps closed, plus a repeatable parity harness so this class of drift is caught by a
command instead of by squinting at the browser console.
| T-01-05 | Spoofing | mock backend impersonating real daemon behavior in a way that hides a real-node bug | medium | mitigate | Every mirrored handler cites the daemon file and line range it mirrors; the parity harness asserts observable state transitions, not acknowledgements |
| T-01-06 | Information Disclosure | mock backend binding a non-loopback interface on a developer machine | low | accept | Pre-existing `0.0.0.0` bind is unchanged by this plan; the mock serves only synthetic demo data and ships in no release artifact |
| T-01-07 | Tampering | the parity harness leaving an orphaned server process holding a port | low | mitigate | The child is killed in a `finally` block and the acceptance criteria require two consecutive clean runs |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No packages are added by this plan — the harness uses only Node built-ins (`node:child_process`, `fetch`, `node:fs`). If any dependency becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
-`cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
-`cd neode-ui && npm run build` — green.
- Fail-first proof recorded: deleting a `case` line makes the harness exit non-zero.
</verification>
<success_criteria>
- Zero mesh.*/federation.* methods called by the UI lack a mock handler.
- Peer aliasing, reactions, replies, edits, deletes, and forwards all change demo state and render.
- A single command reproduces the parity verdict and is proven fail-first.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-02-SUMMARY.md` when done.
Commit staged by explicit path only (a second agent shares this tree), then `git push gitea-ai main`.
- "The payment-success tick in SendBitcoinModal renders the screensaver EQ-segment ring, not a CSS ripple burst"
- "The payment-success tick in WalletScanModal renders the same EQ-segment ring, so the paid tick is identical on every surface it appears"
- "ScreensaverRing exposes a third badge size variant sized 160px on mobile and 192px from 768px up, with --viz-radius 80px/96px, alongside the untouched default and compact variants"
- "The badge ring fits inside the modal card without clipping — the success pane's ring container is no larger than the badge diameter at either breakpoint"
- "The success amount numerals and SENT / Done copy are unchanged — only the ring geometry behind the checkmark changes"
- "SystemDangerZone and Screensaver continue to render the compact and default variants unchanged"
- statement:"ScreensaverRing's segment animation is disabled under prefers-reduced-motion for every size variant including the new badge, matching the site-wide reduced-motion convention"
verification:backstop
prohibitions:
- statement:"The paid-tick change MUST NOT alter what the success pane asserts about the payment — the ring is decoration; it must never render a success state for a payment that has not actually settled, and no success-gating condition may be relaxed to make the animation easier to trigger"
-`neode-ui/src/components/__tests__/BaseModal.test.ts` — the house vitest + `@vue/test-utils`
conventions for mounting a component in this repo.
-`.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the "FED-06 Sizing Decision"
table (exact diameters and radii) and the "UI Considerations" rows for the paid-tick ring.
</read_first>
<behavior>
-`ScreensaverRing.test.ts`: mounting with `size="badge"` puts `viz-ring-badge` on the root
element; mounting with `size="compact"` still yields `viz-ring-compact`; the default mount
still yields `viz-ring-default`; the rendered segment count matches the `segmentCount` prop.
-`PaidTick.test.ts`: SendBitcoinModal driven into its payment-success state renders exactly one
`ScreensaverRing` with `size="badge"`, renders the checkmark core, and renders zero ripple
elements; the success amount text is unchanged.
</behavior>
<action>
Write both test files first and confirm they fail before implementing.
In `ScreensaverRing.vue`: widen the `size` prop union with a third member `'badge'`, extend
`sizeClass` to map it to `viz-ring-badge`, and add a `.viz-ring-badge` CSS rule following the
exact shape of the existing two — `width`/`height` 160px and `--viz-radius: 80px` at mobile,
then a `@media (min-width: 768px)` block with 192px and `--viz-radius: 96px`. Do not touch
`.viz-ring-default` or `.viz-ring-compact`; `Screensaver.vue` and `SystemDangerZone.vue` must
keep their current rendering.
Also inside `ScreensaverRing.vue`, add the missing motion guard so it applies to every variant:
a `@media (prefers-reduced-motion: reduce)` block that sets `animation: none` and a static
reduced opacity on `.viz-segment`. Use the same media-query syntax as the guard already present
in `SendBitcoinModal.vue` so the two read identically.
In `SendBitcoinModal.vue`'s payment-success pane: import `ScreensaverRing`, replace the three
ripple span elements with `<ScreensaverRing size="badge" />`, keep the existing core circle and
checkmark markup untouched, and wrap the pair in the Screensaver-style layering (a
`position: relative` container sized to the badge diameter, with the core absolutely centred over
the ring). Remove the ripple elements' now-dead CSS rules and their keyframes; keep the core and
checkmark rules, and keep the existing reduced-motion rule but drop the clause that referenced
the removed elements. Do not change the success amount numerals, the SENT copy, the Done button,
or any condition that decides when the success pane is shown.
</action>
<verify>
<automated>cd neode-ui && test -f src/components/__tests__/ScreensaverRing.test.ts && test -f src/components/__tests__/PaidTick.test.ts && npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts</automated>
</verify>
<acceptance_criteria>
- Both test files exist and `npx vitest run src/components/__tests__/ScreensaverRing.test.ts src/components/__tests__/PaidTick.test.ts` exits 0 (the explicit `test -f` guards are required — `vitest.config.ts` sets `passWithNoTests: true`, so a missing file would otherwise pass vacuously).
-`grep -c 'viz-ring-badge' neode-ui/src/components/ScreensaverRing.vue` is at least 2 (computed mapping + CSS rule).
-`cd neode-ui && npx vitest run` exits 0 — no existing suite regressed.
-`cd neode-ui && npm run build` exits 0 and `grep -rq 'viz-ring-badge' ../web/dist/neode-ui/assets/` succeeds (per CLAUDE.md: the build can silently no-op, so grep the built bundle for the new string).
</acceptance_criteria>
<done>The badge variant exists, the send-payment success tick renders it, and both are pinned by tests that failed before the change.</done>
</task>
<task type="auto">
<name>Task 2: Bring the scan-modal paid tick to the same ring</name>
-`neode-ui/src/components/WalletScanModal.vue` around line 232 (the success circle markup — a
fixed 24-unit inline-flex circle with the success-ring class) and around line 861 (its CSS
rule). Note it has no ripple animation at all today, unlike the send modal.
-`neode-ui/src/components/SendBitcoinModal.vue` as left by Task 1 — the layering wrapper to
copy verbatim.
-`.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the FED-06 sizing table row
confirming this call site also uses the badge variant.
</read_first>
<action>
Replace WalletScanModal's fixed success circle with the same composition Task 1 established:
a `position: relative` container sized to the badge diameter holding `<ScreensaverRing size="badge" />`
with the existing checkmark content absolutely centred over it. Import `ScreensaverRing`. Drop
the now-unused fixed-size utility classes and the plain-circle CSS rule; keep the checkmark
glyph, its colour, and the surrounding copy exactly as they are.
Extend `PaidTick.test.ts` with a WalletScanModal case asserting its success state renders one
`ScreensaverRing` with `size="badge"` and still renders the checkmark.
Verify on the dev preview before considering this done, per the user requirement recorded in
CONTEXT.md: run the dev preview and confirm neither ring is clipped by the modal card's
scrolling container at a narrow viewport and at desktop width. Record the observation in the
SUMMARY. The blocking human sign-off for this is consolidated into plan 01-07.
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts && npm run build</automated>
</verify>
<acceptance_criteria>
-`cd neode-ui && npx vitest run src/components/__tests__/PaidTick.test.ts` exits 0 and the suite contains both a SendBitcoinModal case and a WalletScanModal case.
-`grep -c 'ScreensaverRing' neode-ui/src/components/WalletScanModal.vue` is at least 2.
| T-01-08 | Spoofing | success pane rendered for a payment that has not settled | high | mitigate | This plan changes decoration only; the acceptance criteria forbid touching any condition that gates the success pane, and `npx vitest run` on the existing suites must stay green |
| T-01-09 | Denial of Service | 48 animated segments rendered inside a modal degrading low-power devices | low | mitigate | The badge variant is the smallest of the three; the new `prefers-reduced-motion` guard disables the animation entirely for users who ask for it |
| T-01-10 | Repudiation | the success amount or recipient text changing as a side effect of the swap | medium | mitigate | Tests assert the success amount text is unchanged; the action forbids touching the numerals and copy |
</threat_model>
<verification>
-`cd neode-ui && npx vitest run` — green.
-`cd neode-ui && npm run build` — green, and the built bundle contains the new class name.
- Dev-preview observation recorded for both modals at narrow and desktop widths.
</verification>
<success_criteria>
- A third `badge` size variant exists on the shared ring component; existing variants and their consumers are untouched.
- Both paid-tick surfaces render the branded ring with the checkmark layered centred.
- A reduced-motion guard covers every variant.
- Component tests pin all of the above and were proven to fail before the change.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-03-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "lnd.getinfo returns this node's Lightning identity_pubkey and its advertised connection URIs, so the UI has something real to copy and share"
- "A node with no reachable LND, or an LND that advertises no URI, yields an absent identity rather than a fabricated one — the caller can tell 'not available' from 'available'"
- "A meshed peer that advertises Lightning is recorded with its URI on the mesh peer record and is listed by mesh.lightning-peers"
- "mesh.lightning-peers returns an empty list, not an error, when no meshed peer has advertised Lightning (FED-05 empty edge, mesh half)"
- "A peer that advertises Lightning twice appears once in mesh.lightning-peers, with the most recent URI (FED-05 adjacency edge, mesh half)"
- "mesh.lightning-peers returns peers in a deterministic order so the picker list does not reshuffle between reads (FED-05 ordering edge, mesh half)"
- "An inbound Lightning advertisement whose URI is not well-formed is rejected and does not overwrite a previously known good URI for that peer"
prohibitions:
- statement:"A node's Lightning URI MUST NOT be advertised to parties the operator has not chosen to reach — the advertisement is sent on an explicit send, never auto-broadcast to every radio contact in range, and a received URI is never re-broadcast onward to third parties"
category:privacy
artifacts:
- path:core/archipelago/src/api/rpc/lnd/info.rs
provides:"identity_pubkey + uris on the lnd.getinfo response"
- `grep -c 'handle_mesh_lightning_peers' core/archipelago/src/api/rpc/mesh/typed_messages.rs` is at least 1.
- `grep -c 'is_peer_allowed_path' core/archipelago/src/server.rs` is unchanged from before this plan (the peer allow-list is not widened).
- `cd core && cargo test -p archipelago` exits 0.
- `cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `api::rpc::mesh` or `mesh::message_types`.
</acceptance_criteria>
<done>The picker's meshed-Lightning-peer list has a real, deterministic, deduplicated data source, and a node can advertise its own URI to a chosen peer.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| radio peer → typed-envelope decode → `MeshPeer` | Untrusted, unauthenticated-by-default RF input mutates local peer state |
| LND REST (`/v1/getinfo`) → daemon | Local service response parsed into an RPC payload the UI displays and copies |
| operator RPC → outbound mesh send | An operator action that discloses this node's payment endpoint to a chosen peer |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-11 | Spoofing | a radio peer advertising someone else's Lightning URI to redirect a channel open | high | mitigate | The advertisement is stored against the peer's authenticating key (the verified archipelago identity key, never the firmware routing key — see `MeshPeer`'s auth-key accessor doc); the UI in plan 01-06 labels these peers as *request* targets, not trusted opens |
| T-01-12 | Tampering | a malformed or oversized URI corrupting stored peer state | high | mitigate | URI shape validated before store (66-hex pubkey part); invalid input leaves any previously stored value untouched; test asserts this |
| T-01-13 | Information Disclosure | this node's payment endpoint leaking to every radio contact in range | high | mitigate | `mesh.send-lightning-info` requires an explicit target and errors without one; there is no broadcast path, and a received URI is never re-advertised onward |
| T-01-14 | Denial of Service | advertisement flooding growing the peer map unboundedly | medium | accept | The advertisement writes a field on an existing peer record rather than creating records; peer-map growth is governed by the pre-existing contact-discovery limits, unchanged here |
| T-01-15 | Elevation of Privilege | a new RPC becoming peer-reachable and letting a remote peer enumerate Lightning peers | high | mitigate | Both RPCs ride the existing `/rpc/v1` operator surface; the acceptance criteria assert `is_peer_allowed_path` is not widened |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates are introduced. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
- Fixture-test failure output captured before the Task 1 implementation.
</verification>
<success_criteria>
- `lnd.getinfo` exposes a real identity pubkey and URI list, or an honest absence.
- A `LightningInfo` mesh message exists, is validated on receipt, and populates `MeshPeer.lightning_uri`.
- `mesh.lightning-peers` returns a deduplicated, deterministically ordered list and an empty array when there are none.
- `mesh.send-lightning-info` requires an explicit target and refuses to send an empty advertisement.
- The peer HTTP allow-list is unchanged.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-04-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "A federation sync failure is recorded on the peer's node record and surfaced through federation.list-nodes, so the operator sees it in the UI instead of it existing only as a debug log line"
- "A successful sync clears a previously recorded sync error for that peer — the badge does not persist after the peer recovers (FED-02 adjacency edge)"
- "A periodic sync pass over zero federated nodes is a clean no-op: no error is recorded, nothing is written, and no error surfaces in the UI (FED-02 empty edge)"
- "A state snapshot older than the one already stored for a peer does not overwrite the newer one — out-of-order sync responses cannot move a peer's status backwards (FED-02 ordering edge)"
- "Exactly one periodic federation sync loop runs in the daemon; the redundant second loop is gone and every behavior unique to it is preserved in the surviving loop"
- "Duplicate node entries do not accumulate across sync cycles — after sync settles the node list has one entry per federated node"
prohibitions:
- statement:"Making sync errors visible MUST NOT expose a peer's onion address, DID, or any transport secret in an error string rendered to a surface wider than the operator's own dashboard — a sync error message names what failed, never credential material"
category:privacy
artifacts:
- path:core/archipelago/src/federation/types.rs
provides:"last_sync_error / last_sync_error_at on FederatedNode"
contains:"last_sync_error"
- path:neode-ui/src/views/federation/NodeList.vue
provides:"Operator-visible sync-error badge on a node row"
contains:"last_sync_error"
key_links:
- from:core/archipelago/src/server.rs
to:core/archipelago/src/federation/storage.rs
via:"the periodic sync loop calls record_sync_result after each peer attempt instead of only debug-logging"
| `FederatedNode.last_sync_error: Option<String>` | new optional field | `core/archipelago/src/federation/types.rs` |
| `FederatedNode.last_sync_error_at: Option<String>` | new optional field | same |
| `record_sync_result` | new pub async fn (records or clears a peer's sync error under the store lock) | `core/archipelago/src/federation/storage.rs` |
| `last_sync_error`, `last_sync_error_at` on `federation.list-nodes` | new response fields | `core/archipelago/src/api/rpc/federation/handlers.rs` |
to `storage.rs`, acquiring `FEDERATION_STORE_LOCK` and using the `*_inner` load/save functions
established in 01-01. Missing DID is a silent Ok. Never create a node entry.
In `server.rs`'s 90s loop, replace the debug-only failure arm with a call to `record_sync_result`
carrying the error's display string, and call it with a success outcome on the success arm.
Truncate the recorded message to a bounded length (256 characters) so a pathological error
cannot bloat the node file. Keep the existing `debug!` line as well — persisting is additive,
not a replacement for logs.
In `handle_federation_list_nodes`, emit the two fields onto the node object using the same
`if let Some(...)` conditional-insert pattern the existing optional fields use. Add the matching
optional fields to the TS `FederatedNode` interface.
In `NodeList.vue`, add a badge on the node row shown only when `last_sync_error` is set: red
family (`alert-error`-adjacent classes already in the house style), short label, and the full
message plus the timestamp in the element's `title` attribute — the row must stay single-line, so
apply the same `truncate` + `:title` treatment the node-name span already uses. Place it beside
the existing transport badge, not in place of it. Do not add a new nav entry, card, or view —
only this badge inside the existing row.
</action>
<verify>
<automated>cd core && cargo test -p archipelago federation && cd ../neode-ui && test -f src/views/federation/__tests__/NodeList.test.ts && npx vitest run src/views/federation/__tests__/NodeList.test.ts</automated>
</verify>
<acceptance_criteria>
-`cd core && cargo test -p archipelago federation` exits 0 and includes a test named for the clear-on-success behavior and one for the missing-DID no-op.
-`grep -c 'last_sync_error' core/archipelago/src/federation/types.rs` is at least 2.
-`grep -c 'record_sync_result' core/archipelago/src/federation/storage.rs` is at least 1.
-`grep -c 'record_sync_result' core/archipelago/src/server.rs` is at least 2 (the failure arm and the success arm).
-`grep -c 'last_sync_error' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
-`grep -c 'last_sync_error' neode-ui/src/views/federation/types.ts` is at least 1.
-`grep -c 'last_sync_error' neode-ui/src/views/federation/NodeList.vue` is at least 1.
-`cd neode-ui && npx vitest run src/views/federation/__tests__/NodeList.test.ts` exits 0 with a case asserting the badge is absent when the field is unset (the guard against a badge that always renders).
-`cd neode-ui && npm run build` exits 0.
- The SUMMARY records the pre-implementation failing output for both the Rust and the component test.
</acceptance_criteria>
<done>A sync failure is persisted per peer, travels through the RPC, and renders as a badge the operator can see — and clears when the peer recovers.</done>
</task>
<task type="auto">
<name>Task 2: Collapse the two periodic sync loops into one</name>
<reversibility rating="costly">Deleting a background loop changes daemon runtime behavior across
the whole fleet on the next OTA; restoring it means re-deriving code that is gone from the tree
rather than flipping a flag. Mitigated by moving — not discarding — the loop's unique tail call
and by the required git-history check below.</reversibility>
<files>core/archipelago/src/server.rs</files>
<read_first>
-`core/archipelago/src/server.rs` lines 497-600 (the 90s loop, including its asymmetry
self-heal `notify_join` re-assertion) and lines 840-910 (the 1800s loop, whose unique tail
call is `rpc.refresh_federation_mesh_peers()`).
- The output of `git log -p -L 840,910:core/archipelago/src/server.rs` and
| T-01-16 | Tampering | a peer replaying an old snapshot to roll a node's status backwards | high | mitigate | The `update_node_state` monotonicity guard rejects strictly-older snapshots (Task 3), with tests |
| T-01-17 | Information Disclosure | a sync error string carrying a peer onion address or transport credential into the UI | medium | mitigate | The recorded message is the error's display string truncated to 256 characters and rendered only on the operator's own dashboard; the prohibition above states the constraint and it is re-checked in the FED-03 review |
| T-01-18 | Denial of Service | an unbounded error message bloating `nodes.json` on every failed pass | medium | mitigate | 256-character truncation before persistence (Task 1) |
| T-01-19 | Repudiation | a silently-failing sync leaving no record of when a peer was last reachable | high | mitigate | `last_sync_error_at` is written on every attempt outcome; the badge makes staleness visible |
| T-01-20 | Denial of Service | deleting the 1800s loop dropping a behavior the fleet depends on | high | mitigate | Mandatory `git log -p -L` history check before deletion, the unique tail call moved rather than dropped, and an explicit STOP path if the history shows a documented reason |
</threat_model>
<verification>
-`cd core && cargo test -p archipelago` — green.
-`cd neode-ui && npx vitest run && npm run build` — green.
- The SUMMARY answers RESEARCH.md Open Question 1 with git evidence.
</verification>
<success_criteria>
- A sync failure is persisted, exposed over RPC, and rendered as an operator-visible badge that clears on recovery.
- Exactly one periodic federation sync loop remains, with the deleted loop's unique behavior preserved.
- Out-of-order snapshots cannot regress a peer's state; repeated cycles converge to one entry per node.
- A zero-node sync pass writes nothing and records no error.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-05-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "A trusted federated peer's Lightning URI is known locally after sync and is emitted by federation.list-nodes, so the picker can offer a one-click channel open by hostname"
- "The Lightning field on the federation sync payload is optional with a serde default, so a node running an older build syncs with a newer one in both directions without error"
- "A federated peer that advertises no Lightning URI is emitted without the field rather than with an empty string — the picker can tell 'no Lightning' from 'Lightning at an unknown address'"
- "An inbound Lightning URI that is not well-formed is rejected at sync time and never persisted or rendered"
- "A stale sync snapshot cannot clear a peer's previously known Lightning URI, consistent with the snapshot-ordering guard from plan 01-05"
prohibitions:
- statement:"A node's Lightning URI MUST NOT reach a party the operator has not federated with — it must never be re-exported in this node's own outbound peer hints on behalf of a third-party peer, so a peer-of-a-peer cannot harvest payment endpoints by federating one hop away"
category:privacy
- statement:"Lightning URI sharing MUST NOT be silently enabled in a way the operator cannot see or reverse — whatever default ships, the current sharing state is discoverable from the node's own settings surface and changing it takes effect on the next sync without a data migration"
category:transparency
artifacts:
- path:core/archipelago/src/federation/types.rs
provides:"Lightning identity field(s) on NodeStateSnapshot (and FederationPeerHint only if the decision selects it)"
contains:"lightning"
key_links:
- from:core/archipelago/src/federation/sync.rs
to:core/archipelago/src/federation/types.rs
via:"build_local_state populates the Lightning field from this node's lnd.getinfo identity"
| Lightning identity field(s) on `NodeStateSnapshot` | new optional serde-default field(s); exact names fixed by the Task 1 decision | `core/archipelago/src/federation/types.rs` |
| Lightning identity field(s) on `FederationPeerHint` | added **only if** the decision selects transitive sharing | same |
| `FederatedNode.lightning_uri: Option<String>` | new persisted field on the local node record | same |
| `build_local_state` Lightning parameter | changed fn signature in the federation sync builder | `core/archipelago/src/federation/sync.rs` |
| `share_lightning_uri` | server setting + its accessor, **only if** the decision selects opt-in gating | `core/archipelago/src/api/rpc/federation/handlers.rs` (+ server info) |
| `lightning_uri` on `federation.list-nodes` | new response field | `core/archipelago/src/api/rpc/federation/handlers.rs` |
<tasks>
<task type="checkpoint:decision" gate="blocking">
<name>Task 1: Decide the Lightning field shape and sharing default on the federation sync payload</name>
<decision>What shape does the Lightning identity take on the federation sync payload, and is sharing on by default or opt-in?</decision>
<context>
This writes a new field into `NodeStateSnapshot`, the payload every federated node exchanges on
every sync. Once a release carrying it reaches the fleet, deployed peers parse that shape — a
later change to the field name, the split, or the sharing scope needs a coordinated fleet
upgrade plus a cleanup of URIs already cached in every peer's `nodes.json`. That is a one-way
door, and the sources disagree about which way to walk through it:
-`01-CONTEXT.md` records "a federated peer's Lightning URI rides the federation sync payload
**by default** — federation trust is already bilateral and explicit", and marks this
**Claude's discretion, revisable** — not locked.
-`01-RESEARCH.md` Open Question 3 recommends the opposite: follow the `shared_location`
precedent (opt-in, default off) "since exposing a payment channel target more broadly than
intended has real-money implications."
A second, related question rides along: `NodeStateSnapshot.federated_peers` carries a
`FederationPeerHint` for each of this node's trusted peers, used for transitive discovery. If the
Lightning field goes on the hint too, then Alice syncing with Bob learns Bob's *peers'* Lightning
URIs — a payment endpoint reaching a party that node never federated with. Options B and C below
keep the field off the hint; only choose otherwise deliberately.
</context>
<options>
<option id="option-a">
<name>Single `lightning_uri` on the snapshot AND on the peer hint, shared by default</name>
<pros>Widest picker coverage — a peer-of-a-peer's URI is available without an extra sync hop; simplest single field; matches CONTEXT.md's default-on stance</pros>
<cons>Sends a payment endpoint to nodes the operator never federated with, which the plan's own privacy prohibition forbids; hardest to walk back once cached across the fleet</cons>
</option>
<option id="option-b">
<name>Single `lightning_uri` on the snapshot only, shared by default with direct federated peers (CONTEXT.md's stated default, narrowed)</name>
<pros>Implements CONTEXT.md's recorded discretion default; bilateral federation trust is already explicit, so no new consent surface is needed; one field, one hop, no transitive leak; ships the picker with real data on day one</pros>
<cons>Every existing federated pair starts sharing a payment endpoint on the OTA that carries it, with no per-operator prompt; reversing later means shipping an opt-out and waiting for peers to re-sync</cons>
</option>
<option id="option-c">
<name>Single `lightning_uri` on the snapshot only, gated behind an explicit opt-in setting defaulting off (RESEARCH.md Open Question 3)</name>
<pros>Mirrors the proven `shared_location` pattern exactly; no operator starts sharing a payment endpoint without acting; safest given real-money implications; the field itself stays additive so flipping the default later is a one-line change</pros>
<cons>The trusted-node picker is empty until both sides opt in, so the FED-05 flow needs a discoverable "turn on Lightning sharing" path or it looks broken; more surface to build in this plan</cons>
</option>
</options>
<resume-signal>Select: option-a, option-b, or option-c. If you pick option-c, also say where the toggle lives (a new row in the existing federation settings surface is the default assumption).</resume-signal>
</task>
<task type="tracer" tdd="true">
<name>Task 2: End-to-end — a trusted peer's Lightning URI reaches federation.list-nodes</name>
<reversibility rating="one-way">This adds a field to `NodeStateSnapshot`, the wire payload every
fleet node parses on every sync; after the OTA carrying it, changing the field's name, split, or
sharing scope requires a coordinated fleet upgrade and a cleanup of URIs already cached in peers'
node files.</reversibility>
<precondition>`lnd.getinfo` returns `identity_pubkey` and `uris` (delivered by plan 01-04, Task 1) — confirm by reading `core/archipelago/src/api/rpc/lnd/info.rs` for both field names before starting.</precondition>
and how it is threaded into `federation::build_local_state`.
-`core/archipelago/src/federation/sync.rs` around lines 225-265 — `build_local_state`'s
signature and where `shared_location` is mapped into the snapshot at construction time.
-`core/archipelago/src/federation/storage.rs``update_node_state` as left by plan 01-05,
including the monotonicity guard and the `fips_npub` exemption comment — the new field follows
the same "learn from the peer's snapshot" treatment.
-`core/archipelago/src/api/rpc/lnd/info.rs` as left by plan 01-04 — the `identity_pubkey` /
`uris` field names and the 66-hex validation helper to reuse.
-`core/archipelago/src/api/rpc/lnd/channels.rs``handle_lnd_openchannel` (from L238) — the
exact URI/pubkey/address parsing the picker will feed, so the persisted format matches what
that handler accepts.
</read_first>
<behavior>
-`build_local_state` called with a Lightning URI puts it on the produced snapshot; called
without one produces a snapshot with the field absent (not an empty string).
- A snapshot deserialized from a payload that has no Lightning field succeeds with the field
`None` — an older peer syncs fine.
-`update_node_state` with a snapshot carrying a well-formed URI persists it onto the
`FederatedNode`; with a malformed URI it leaves any previously stored value untouched.
- A snapshot rejected by the plan-01-05 monotonicity guard does not clear an already-known URI.
-`federation.list-nodes` emits `lightning_uri` for a node that has one and omits it otherwise.
</behavior>
<action>
Implement exactly the option selected in Task 1 — do not substitute a different shape, and do not
add the field to `FederationPeerHint` unless option-a was chosen. Record the chosen option id in
the SUMMARY.
Write the tests first and confirm they fail.
Add the Lightning field(s) to `NodeStateSnapshot` with `#[serde(default)]` and a doc comment that
states the sharing rule chosen in Task 1 and explicitly notes where it differs from the
`shared_location` analog directly above it. Add `#[serde(default)] pub lightning_uri: Option<String>`
to `FederatedNode` for the locally-persisted peer value, and update the `make_node` test helper
so the struct literal still compiles.
Thread the value into `build_local_state` the same way `shared_location` is threaded: an added
parameter, mapped into the snapshot at construction. At the `handlers.rs` call site, source it
from this node's own `lnd.getinfo` identity (prefer the first entry of `uris`; fall back to
composing `identity_pubkey` with the node's reachable host when `uris` is empty), gated per the
Task 1 decision. An LND that is down or has no URI yields `None`, never an empty string and never
a fabricated address.
In `update_node_state`, learn the peer's URI from the snapshot: validate the shape before storing
(the pubkey part is 66 hexadecimal characters; the `@host[:port]` remainder is optional, matching
what `handle_lnd_openchannel` accepts), and on a malformed value log at `debug!` and leave the
prior value alone.
In `handle_federation_list_nodes`, emit `lightning_uri` with the same `if let Some(...)`
conditional-insert pattern the other optional fields use.
</action>
<verify>
<automated>cd core && cargo test -p archipelago federation</automated>
</verify>
<acceptance_criteria>
-`cd core && cargo test -p archipelago federation` exits 0 with the five behaviors above present as named cases.
-`grep -c 'lightning' core/archipelago/src/federation/types.rs` is at least 3.
-`grep -c 'serde(default)' core/archipelago/src/federation/types.rs` increased by at least 2 relative to the pre-change file.
- A round-trip test proves back-compat in both directions: a snapshot JSON with no Lightning key deserializes to `None`, and a snapshot serialized with the field deserializes cleanly after being stripped of unknown keys.
-`grep -c 'lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 1.
- If and only if option-a was selected: `grep -c 'lightning' core/archipelago/src/federation/types.rs` includes an occurrence inside the `FederationPeerHint` struct. Otherwise `FederationPeerHint` has none — assert this either way and state which in the SUMMARY.
-`cd core && cargo test -p archipelago` exits 0.
</acceptance_criteria>
<done>A trusted federated peer's Lightning URI is synced, validated, persisted, and emitted — the picker's primary list now has real targets.</done>
</task>
<task type="auto">
<name>Task 3: Make the sharing state visible and reversible</name>
- The Task 1 decision as recorded in the Task 2 SUMMARY notes.
-`core/archipelago/src/api/rpc/federation/handlers.rs` — the `share_location` server-info flag
and the `server.set-location` RPC that toggles it, for the accessor + persistence pattern.
-`core/archipelago/src/federation/sync.rs` — `build_local_state`'s tests (from L335) for the
assertion style.
</read_first>
<action>
Under option-c: add the `share_lightning_uri` server setting with a default of off, an RPC to
read and set it following the `server.set-location` shape, and make `build_local_state`'s
Lightning parameter `None` whenever the flag is off. Add tests: flag off produces a snapshot with
no Lightning field even when LND has one; flag on produces it; toggling the flag off then
re-syncing produces a snapshot without it.
Under option-a or option-b: add a read-only surface reporting the current sharing state and the
URI actually being shared, so the operator can see what is going out; and make the outbound value
`None` whenever this node's own Lightning is not installed or not reachable. Add tests: no LND
produces no Lightning field; a present LND produces the URI; the reported state matches what
`build_local_state` actually emits.
In both cases, add a test asserting a third-party peer's Lightning URI is never re-exported in
this node's own outbound peer hints — build a local state while holding a peer whose URI is
known, serialize it, and assert that URI string does not appear in the outbound payload's peer
hint section. This test is the mechanical form of this plan's privacy prohibition and must exist
regardless of which option was chosen.
</action>
<verify>
<automated>cd core && cargo test -p archipelago federation::sync</automated>
</verify>
<acceptance_criteria>
-`cd core && cargo test -p archipelago federation::sync` exits 0.
- A test named for the third-party-URI-not-re-exported behavior exists and passes; temporarily injecting the peer URI into the outbound hint makes it fail (fail-first proof recorded in the SUMMARY).
- Under option-c only: `grep -c 'share_lightning_uri' core/archipelago/src/api/rpc/federation/handlers.rs` is at least 2.
-`cd core && cargo test -p archipelago` exits 0.
-`cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `federation`.
</acceptance_criteria>
<done>The operator can see, and change, what Lightning identity this node shares — and a peer's URI provably never travels one hop further.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| this node → federated peer (outbound snapshot) | This node's payment endpoint crosses to a remote party |
| federated peer → this node (inbound snapshot) | A remote party's claimed payment endpoint is persisted and later fed to `lnd.openchannel` |
| transitive peer hint | A third party's identity data can ride this node's outbound payload to a party it never federated with |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-21 | Information Disclosure | this node's Lightning payment endpoint reaching a non-federated party | high | mitigate | The Task 1 decision fixes the sharing scope explicitly; Task 3 adds the test proving a third-party URI is never re-exported in outbound peer hints, plus a fail-first proof |
| T-01-22 | Spoofing | a peer advertising a Lightning URI it does not control, redirecting a channel open and its funds | high | mitigate | The snapshot arrives over the existing ed25519-signature-verified federation path (unchanged); the URI is bound to that verified peer record and validated for shape before persistence. Not re-implemented here — the existing `identity::NodeIdentity::verify` path is reused, per RESEARCH.md V6 |
| T-01-23 | Tampering | a malformed or oversized URI corrupting the persisted node record | high | mitigate | 66-hex pubkey validation before persist; malformed input leaves the prior value untouched, with a test |
| T-01-24 | Tampering | a replayed older snapshot clearing a known Lightning URI | medium | mitigate | The plan-01-05 monotonicity guard rejects strictly-older snapshots; a test asserts a rejected snapshot does not clear the URI |
| T-01-25 | Repudiation | the operator unable to tell what identity their node is sharing | medium | mitigate | Task 3 adds the visible sharing state (a setting under option-c, a read-only report otherwise) |
</threat_model>
<verification>
-`cd core && cargo test -p archipelago` — green.
- Back-compat round-trip proven in both directions against a payload lacking the new field.
- The chosen option id is recorded in the SUMMARY, together with the fail-first proof for the no-re-export test.
</verification>
<success_criteria>
- The Lightning identity field exists on the federation sync payload in exactly the shape the operator chose, additively and back-compatibly.
- A trusted peer's URI is validated, persisted, and emitted by `federation.list-nodes`.
- A third party's URI provably never leaves this node in its own peer hints.
- The current sharing state is visible to the operator.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-06-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "A user can send a channel-open request to a meshed Lightning peer, carrying this node's own Lightning URI, an optional amount, and an optional message"
- "A received channel-open request appears in the recipient's mesh conversation as a typed message showing the requester's URI and note, using the existing typed-message rendering path"
- "Sending a channel-open request requires an explicit target peer — there is no broadcast form"
- "A channel-open request whose payload URI is malformed is rejected on receipt and never stored as a message"
- "Two channel-open requests sent to the same peer in quick succession produce two distinct messages with distinct sender sequence numbers, and neither is silently dropped (FED-05 concurrency edge, mesh half)"
- "A request is never rendered or reported as an opened or funded channel — it carries no channel state"
prohibitions:
- statement:"A channel-open request MUST NOT be presented anywhere as an accepted, open, or funded channel — a request that has not been acted on by the recipient must never appear in a channel list, a balance, or a connected-peer count"
category:transparency
- statement:"Receiving a channel-open request MUST NOT cause the node to open a channel, connect to the requester, or move funds on its own — acting on a request is always a separate, explicit human decision"
| `MeshMessageType::ChannelOpenRequest = 27` (label `channel_open_request`) | new wire message type | `core/archipelago/src/mesh/message_types.rs` |
| `ChannelOpenRequestPayload { uri, amount_sats, message }` | new CBOR payload struct | same |
| inbound `ChannelOpenRequest` arm | listener dispatch arm storing the request as a `MeshMessage` | `core/archipelago/src/mesh/listener/dispatch.rs` |
| `handle_mesh_request_channel` | new RPC handler (`mesh.request-channel`) | `core/archipelago/src/api/rpc/mesh/typed_messages.rs` |
| `mesh.request-channel` | dispatcher match arm | `core/archipelago/src/api/rpc/dispatcher.rs` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — a channel-open request is sent to one peer and lands in their conversation</name>
<reversibility rating="costly">`MeshMessageType` is a radio wire format read by every fleet node
after the next OTA; the discriminant and payload shape become externally visible, so a later change
needs a coordinated fleet upgrade. Kept additive on an unused discriminant with serde-default
optional payload fields, so older nodes ignore it rather than erroring.</reversibility>
<precondition>`MeshMessageType::LightningInfo = 26` exists (plan 01-04, Task 2) — confirm the highest current discriminant by reading `core/archipelago/src/mesh/message_types.rs` before choosing this type's number.</precondition>
(never use the firmware routing key for authentication).
</read_first>
<action>
Add a short duplicate-suppression window to `handle_mesh_request_channel`: a second request to the
same target peer within a bounded interval returns a distinct, non-error result reporting that a
request was already sent, rather than emitting a second envelope. The UI in plan 01-08 also
disables its button while a send is in flight, but a backend guard is what actually stops a
double-click or a retried RPC from spamming a peer over a slow radio link. Two requests separated
by more than the window must both go out — the window suppresses accidental duplicates, not
legitimate repeat requests. Add tests for both sides of the window.
Bound the inbound side too: reject an inbound payload whose message field exceeds the same
length bound the send side truncates at, and reject an `amount_sats` outside the range
`handle_lnd_openchannel` accepts (its existing 20,000..=16,777,215 sat bounds) so a request can
never carry an amount the recipient could not act on. Read `channels.rs` for those exact bounds
rather than restating them from memory.
Attribute the stored inbound message to the peer's authenticating identity key, not the firmware
routing key, following the `MeshPeer` accessor's documented rule — a request that claims to be
from a trusted peer must be attributable.
</action>
<verify>
<automated>cd core && cargo test -p archipelago mesh api::rpc::mesh</automated>
</verify>
<acceptance_criteria>
-`cd core && cargo test -p archipelago mesh api::rpc::mesh` exits 0, including a within-window suppression case and an outside-window pass-through case.
- A test asserts an inbound request with an out-of-range `amount_sats` is rejected, using the bounds read from `channels.rs` rather than hardcoded duplicates of them.
-`cd core && cargo test -p archipelago` exits 0.
-`cd core && cargo clippy -p archipelago --all-targets` produces no new warnings in `mesh` or `api::rpc::mesh`.
</acceptance_criteria>
<done>Accidental duplicate requests are suppressed, oversize and out-of-range requests are refused, and every stored request is attributable to a verified identity.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| radio peer → typed-envelope decode → stored message | Untrusted RF input becomes a conversation entry naming a payment endpoint |
| operator RPC → outbound request | An operator action discloses this node's payment endpoint to a chosen mesh peer |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-26 | Spoofing | a peer sending a request that appears to come from a trusted node, luring a channel open to an attacker's URI | high | mitigate | The stored message is attributed to the peer's verified archipelago identity key, never the firmware routing key (Task 2); the recipient's action on a request is always explicit and human |
| T-01-27 | Elevation of Privilege | a received request causing an automatic channel open or fund movement | high | mitigate | The inbound arm's only effect is storing a message; the acceptance criteria require reading the arm and recording that it contains no open/connect/send-funds call |
| T-01-28 | Denial of Service | request flooding filling a peer's conversation or saturating a LoRa link | high | mitigate | Send-side duplicate-suppression window plus inbound length and amount bounds (Task 2) |
| T-01-29 | Information Disclosure | broadcasting this node's payment endpoint to every contact in range | high | mitigate | A target peer is required; the handler errors without one and there is no broadcast form |
| T-01-30 | Tampering | an oversize payload fragmenting into unreassemblable LoRa chunks | medium | mitigate | The message field is truncated at the send side against the framing budget the `TypedEnvelope` doc comment describes; inbound oversize is rejected |
| T-01-SC | Tampering | npm/pip/cargo installs | high | mitigate | No new crates. If one becomes necessary, stop and run the Package Legitimacy Gate before installing |
</threat_model>
<verification>
-`cd core && cargo test -p archipelago` — green.
-`cd core && cargo clippy -p archipelago --all-targets` — no new warnings in the touched modules.
- The SUMMARY states, from a direct read, that the inbound arm performs no Lightning action.
</verification>
<success_criteria>
- A new typed mesh message carries a channel-open request to one named peer.
- Receiving one stores a conversation message and does nothing else.
- Duplicates within a short window are suppressed; legitimate repeats are not.
- Malformed URIs, oversize notes, and out-of-range amounts are refused.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-07-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "A user can copy their own node's Lightning URI from the channel-open surface; the copy button label flips to Copied! for about two seconds"
- "The displayed own-node URI truncates to its container with the full value in a title tooltip, and the full untruncated value is what reaches the clipboard"
- "Trusted federated nodes that advertise Lightning are listed by hostname with a one-click Open Channel action"
- "Meshed peers that have Lightning installed are listed separately with a Request Channel action, never a direct open — they are not bilaterally trusted"
- "A peer that is both a trusted federated node and a meshed Lightning peer appears exactly once, in the trusted list (FED-05 adjacency edge)"
- "Both picker lists render in a deterministic order that does not reshuffle between refreshes (FED-05 ordering edge)"
- "When both lists are empty a single shared empty state renders once — not one per column"
- "Both lists show the house loading treatment while fetching and the house error row on failure, matching the existing federation node list and Lightning channels panel conventions"
- "Each list row shows the node name with truncation and a title tooltip, its trust badge, and its transport badge, mirroring the existing federation node row"
- "Clicking Open Channel twice, or opening two channels to the same peer at once, results in one open attempt — the action is disabled while a request is in flight (FED-05 concurrency edge)"
- "A manually pasted pubkey with no host still works, falling back to the address-less open path the Lightning channels panel already relies on"
- "The manual-paste field is reached through a de-emphasised Paste URI Manually entry point below both lists, not as a third equal-weight column"
- "The modal renders through the house modal shell so its backdrop covers the full screen and a click outside closes it"
- "The request flow reuses the existing peer-request modal pattern — optional message field, Send Request submit, Sending… busy label"
- statement:"When both lists are empty the shared empty state renders exactly once rather than once per list"
verification:backstop
- statement:"A manually pasted URI that is not in pubkey@host:port form is rejected client-side with a format message before any open call is made"
verification:backstop
prohibitions:
- statement:"A channel-open request sent to a meshed peer MUST NOT be displayed as an open, pending-funding, or connected channel anywhere in the UI — until the recipient acts, it is a sent request and nothing more"
category:transparency
- statement:"The picker MUST NOT present a meshed peer's advertised URI with the same visual authority as a bilaterally-trusted federated node — the two lists stay visually distinct and the meshed action stays a request, so a user cannot mistake an unverified advertisement for a trusted target"
| `identity_pubkey` / `uris` on the mock `lnd.getinfo` result | extended mock response | same |
| `lightning_uri` on the mock `federation.list-nodes` nodes | extended mock response | same |
| `LightningChannelModal.test.ts` | new vitest suite | `neode-ui/src/components/__tests__/LightningChannelModal.test.ts` |
<tasks>
<task type="tracer" tdd="true">
<name>Task 1: End-to-end — open a channel with a trusted federated node in one click</name>
<precondition>`federation.list-nodes` emits `lightning_uri` (plan 01-06) and `mesh.lightning-peers` is registered in the dispatcher (plan 01-04) — confirm both by grepping `core/archipelago/src/api/rpc/federation/handlers.rs` and `core/archipelago/src/api/rpc/dispatcher.rs` before starting.</precondition>
Typography, Color, and the full Copywriting Contract. Every string in this modal comes from
that table verbatim.
</read_first>
<behavior>
- Mounted with a node list containing one trusted node that has a Lightning URI and one that does
not, the trusted list renders exactly one row.
- The row shows the node name (truncated, with a `title`), its trust badge, its transport badge,
and an Open Channel button.
- Clicking Open Channel calls the open RPC once with that node's URI; clicking it twice in
immediate succession still results in exactly one call, and the button is disabled while in
flight.
- While the node list is loading, the loading treatment renders and no empty state renders.
- When the node fetch rejects, the error row renders with the contract's error copy.
- The modal root renders through the house modal shell, so the backdrop is a full-screen sibling
of the card rather than a child of a transformed ancestor.
</behavior>
<action>
Write the test file first and confirm it fails.
Add rpc-client wrappers for `mesh.lightning-peers`, `mesh.send-lightning-info`, and
`mesh.request-channel` following the existing one-line convention. Extend the mock backend so the
demo answers all three, so `lnd.getinfo` returns an `identity_pubkey` and a `uris` array, and so
`federation.list-nodes` nodes carry `lightning_uri` — mirroring the real handlers per the mock's
established "cite the daemon source" comment convention. The plan-01-02 parity harness must stay
green.
Create `LightningChannelModal.vue` using `BaseModal` as its shell, title "Open Lightning Channel".
In this task implement the trusted-node section only: fetch the federated node list, keep nodes
whose trust level is trusted AND which have a Lightning URI, sort by display name with a stable
tiebreak so the order does not shuffle between refreshes, and render each as a row mirroring the
federation node row — truncated name with a `title` tooltip, the transport badge reusing
NodeList's existing FIPS/Tor logic, the trust badge, and an Open Channel button on the right.
Wire Open Channel to the existing open-channel RPC, reusing the panel's proven sequence: validate
before calling, keep the 20,000-sat minimum, split the URI into pubkey and optional address, and
reuse the `openError` ref plus the `isStartupNotice()` amber-vs-red distinction rather than
inventing a new error idiom. Guard against double submission with an in-flight flag keyed to the
target so the button is disabled and a second click is a no-op.
Point the Lightning panel's **existing** Open Channel button at this modal instead of its bespoke
one. Do not add a new nav entry, route, card, or dashboard tile — the user places new entry
points, this plan only upgrades the one that already exists. Leave the panel's channel list,
close-channel flow, and fee presets untouched.
Follow the UI-SPEC tables exactly: spacing on the 4px grid, the two-weight typography scale,
accent orange reserved for the primary action buttons, the bolt icon path already used elsewhere
in the app, and the copy strings verbatim.
</action>
<verify>
<automated>cd neode-ui && test -f src/components/__tests__/LightningChannelModal.test.ts && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && node scripts/mock-rpc-parity.mjs</automated>
</verify>
<acceptance_criteria>
- The test file exists and `npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with all six behaviors present as named cases (the `test -f` guard is required — `vitest.config.ts` sets `passWithNoTests: true`).
- A test asserts two immediate clicks produce exactly one open call.
-`grep -c 'BaseModal' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (import + usage).
-`grep -c 'Open Channel' neode-ui/src/components/LightningChannelModal.vue` is at least 1 and the copy matches the UI-SPEC Copywriting Contract verbatim.
-`grep -c 'LightningChannelModal' neode-ui/src/components/LightningChannelsPanel.vue` is at least 2.
-`cd neode-ui && node scripts/mock-rpc-parity.mjs` exits 0 with zero missing methods.
-`cd neode-ui && npx vitest run` exits 0 and `npm run build` exits 0.
- No new route, nav item, or dashboard card was added — confirmed by `git diff --stat` showing no change to the router or any layout/nav component, recorded in the SUMMARY.
</acceptance_criteria>
<done>A trusted federated node can be picked by hostname and a channel opened with one click, through the house modal shell, on the demo and against a real node.</done>
</task>
<task type="auto">
<name>Task 2: The meshed Lightning peers list and the Request Channel flow</name>
-`cd neode-ui && npx vitest run && npm run build` exits 0.
</acceptance_criteria>
<done>Meshed Lightning peers are listed and requestable, deduplicated against the trusted list, with a single shared empty state and no misleading channel wording.</done>
</task>
<task type="auto">
<name>Task 3: Share your own URI, and the manual-paste fallback</name>
-`neode-ui/src/components/SendBitcoinModal.vue` — its `copyDetail` / "Copied!" clipboard
feedback pattern (the label flips for about two seconds). Reuse it; do not invent a new
copy-feedback idiom.
-`neode-ui/src/components/LightningChannelsPanel.vue` lines 250-270 and 595-625 — the
`pubkey@host:port` placeholder, the `Format: pubkey@host:port` helper text, and the
validate-before-RPC sequence including the address-optional split.
-`.planning/phases/01-federation-mesh-hardening/01-UI-SPEC.md` — the Copywriting Contract rows
for "Primary CTA — own URI" and "Manual fallback entry point", and the UI Considerations rows
marked backstop for the manual-paste form.
</read_first>
<action>
Add the own-node URI block at the top of the modal: read this node's Lightning identity from
`lnd.getinfo`, display the URI truncated to its container with the full value in a `title`
tooltip, and add a copy button whose label flips to the confirmation string for about two seconds.
The clipboard receives the full untruncated value regardless of visual truncation. When the node
has no Lightning URI available, the block explains that instead of showing an empty field or a
fabricated address.
Add the manual-paste fallback below both lists as a de-emphasised disclosure, not a third
equal-weight column: the entry point reveals a peer URI input with the placeholder and helper text
reused verbatim from the Lightning panel. Validate client-side before calling the open RPC — a
value that is not in `pubkey@host:port` form is rejected with the format message and no RPC is
issued; a bare pubkey with no host is accepted and passes an undefined address through, which is
the behavior the open RPC already supports. Reuse the same error ref and startup-notice treatment.
Extend the test suite: the copy button places the full untruncated URI on the clipboard and its
label flips then reverts; the URI element carries a `title` with the full value; an invalid
pasted value shows the format message and issues no RPC call; a bare pubkey issues the open call
with an undefined address; the no-URI-available state renders its explanation rather than an
empty field.
Then verify on the dev preview against archi-dev before this plan is considered complete, per the
user requirement in CONTEXT.md: the preview at the dev port, the copy button, the trusted list,
the meshed list, the request flow, and the manual paste. Record what was exercised in the SUMMARY.
The blocking human sign-off is consolidated into plan 01-09.
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts && npm run build</automated>
</verify>
<acceptance_criteria>
-`cd neode-ui && npx vitest run src/components/__tests__/LightningChannelModal.test.ts` exits 0 with the five cases above present by name.
- A test asserts the clipboard receives the full untruncated URI even when the rendered element is truncated.
- A test asserts an invalid pasted value results in zero RPC calls (assert on the call count, not merely on the message being visible).
-`grep -c 'Copy Lightning URI' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
-`grep -c 'Paste URI Manually' neode-ui/src/components/LightningChannelModal.vue` is at least 1.
-`grep -c 'pubkey@host:port' neode-ui/src/components/LightningChannelModal.vue` is at least 2 (placeholder + helper text).
-`cd neode-ui && npx vitest run && npm run build` exits 0.
- The SUMMARY lists the dev-preview steps exercised against archi-dev and what was observed.
</acceptance_criteria>
<done>A user can share their own node's Lightning URI and fall back to a pasted URI with real client-side validation, verified on the dev preview against a real node.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| daemon RPC → browser | Peer-advertised Lightning URIs, some of them from unauthenticated radio peers, are rendered and offered as payment targets |
| browser → clipboard | This node's payment endpoint is copied for the user to share out of band |
| user click → `lnd.openchannel` | A UI action commits real funds to a channel |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-31 | Spoofing | a meshed peer's advertised URI presented with the same authority as a bilaterally-trusted federated node, luring funds to an attacker | high | mitigate | The two lists stay visually and semantically distinct; the meshed action is Request Channel, never a direct open; the prohibition above states this and a test asserts the meshed row's action wording |
| T-01-32 | Tampering | a peer-supplied node name or URI containing markup that renders as UI | high | mitigate | Vue's default text interpolation escapes; the plan uses no `v-html` anywhere. A test asserting a name containing angle brackets renders as text is required before this row can be dispositioned |
| T-01-33 | Repudiation | a sent request being read as an open channel, so a user believes they have inbound liquidity they do not | high | mitigate | The sent confirmation is worded as a request only; a test asserts the confirmation contains no open or connected wording |
| T-01-34 | Denial of Service | a double click or a fast repeat committing two channel opens to the same peer | high | mitigate | An in-flight flag keyed to the target disables the action and makes a second click a no-op, backed by the plan-01-07 backend suppression window; a test asserts exactly one call for two immediate clicks |
| T-01-35 | Information Disclosure | this node's Lightning URI being displayed to a shoulder-surfer or copied in a shared session | low | accept | A Lightning URI is a public payment endpoint by design; it is deliberately shareable and carries no spend authority |
| T-01-36 | Elevation of Privilege | the modal bypassing the open RPC's server-side validation by calling with unvalidated input | medium | mitigate | Client-side validation is additive only; the existing server-side pubkey-format and amount-bounds validation in the open handler is reused unchanged and is the authority |
</threat_model>
<verification>
-`cd neode-ui && npx vitest run` — green.
-`cd neode-ui && node scripts/mock-rpc-parity.mjs` — green, zero missing methods.
-`cd neode-ui && npm run build` — green.
- Dev-preview walkthrough against archi-dev recorded in the SUMMARY (own URI copy, trusted open, meshed request, manual paste), per CONTEXT.md's "verified on the dev preview before any deploy" requirement.
</verification>
<success_criteria>
- Own-node URI is displayed, truncated with a tooltip, and copied in full.
- Trusted federated nodes with Lightning are listed by hostname with a one-click open.
- Meshed Lightning peers are listed separately and requestable, deduplicated against the trusted list.
- Shared empty state renders once; loading and error states follow the house conventions.
- Manual paste validates client-side and supports a bare pubkey.
- Double-submission is impossible; a request is never shown as a channel.
- No new route, nav entry, or dashboard card was added.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-08-SUMMARY.md` when done.
Stage by explicit path, commit, and `git push gitea-ai main`.
- "A findings document exists listing every issue the structured review of the federation/fleet area and the mesh area produced, with file and line citations"
- "Every finding carries exactly one disposition — fixed, or deferred with a written reason — and no finding is left without one (FED-03 ordering edge)"
- "Every reviewed area appears in the document, including areas where the review produced no findings, recorded as reviewed with none rather than omitted (FED-03 empty edge)"
- "Every federation and mesh claim in the codebase concerns document is re-verified against current code and git history before being filed as a finding or dismissed, with the evidence cited (FED-03 adjacency edge)"
- "Every finding marked fixed cites the commit and the test or command that demonstrates the fix"
- "The known-fixed claims are recorded as already-fixed with their commit, not re-fixed"
prohibitions:
- statement: "A finding MUST NOT be closed as fixed without evidence a reader can re-run — a disposition of fixed always cites a commit and a verifying command or test name, never an assertion alone"
category: transparency
- statement: "A finding MUST NOT be dropped silently — an item judged out of scope is recorded as deferred with the reason and the phase or requirement that owns it, never deleted from the list"
- Every row in `01-REVIEW-FINDINGS.md` has a non-empty disposition cell — verify by counting rows and counting non-empty disposition cells and asserting the two numbers match; record both numbers in the SUMMARY.
- All six named areas appear in the document.
- The summary section's counts sum to the row count.
- Every `fixed` row cites a commit hash and a verifying command or test name.
- Every `deferred` row has a non-empty reason and names an owning phase or requirement.
</acceptance_criteria>
<done>Both areas are reviewed, every finding has exactly one evidenced disposition, and the contained fixes are committed.</done>
</task>
<task type="auto">
<name>Task 3: Build and deploy the phase to the dev pair, sha256-verified</name>
<precondition>archi-dev-box and x250-dev are reachable over the fleet network — confirm with a bounded connectivity probe to each before starting; if either is unreachable, halt rather than deploying to a partial pair.</precondition>
- `cd core && cargo build --release -p archipelago` exits 0 and `cd neode-ui && npm run build` exits 0.
- The built frontend bundle contains a string introduced by this phase — assert with a grep over `web/dist/neode-ui/assets/` for the badge ring class name added in plan 01-03.
- The deployment section records two target hostnames, two sha256 pairs that match, and a container-survival result per target.
- No release tag was created and no OTA manifest was published — confirmed by `git tag --points-at HEAD` producing no output, recorded in the SUMMARY.
</acceptance_criteria>
<done>The phase's code is running on both dev-pair nodes, provably the artifact that was built, with containers intact.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| developer workstation → fleet node (deploy) | A built binary crosses onto a live node over SSH |
| review process → codebase | A fix applied during review changes federation trust-adjacent code |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-01-37 | Tampering | a deployed binary differing from the one built and tested | high | mitigate | Per-target sha256 comparison against the local artifact, both hashes recorded (Task 3) |
| T-01-38 | Denial of Service | a deploy restarting the service and killing running app containers | high | mitigate | The established deploy script is read before use and container survival is checked and recorded per target; a container loss is filed as a finding |
| T-01-39 | Elevation of Privilege | a review fix loosening federation trust or join verification | high | mitigate | CONTEXT.md's scope fence is a required read; findings needing trust-code changes are deferred with the reason rather than patched here; the full test suite gates each fix |
| T-01-40 | Repudiation | a finding quietly dropped so a known issue leaves no trace | medium | mitigate | The row-count-equals-disposition-count check and the summing counts section make an omission detectable; the prohibitions above state the rule |
| T-01-41 | Information Disclosure | deploy credentials or node passwords committed while recording deployment evidence | high | mitigate | The deployment section records hostnames and hashes only; per CLAUDE.md, never commit secrets. Stage by explicit path and review the diff before committing |
</threat_model>
<verification>
- `cd core && cargo test -p archipelago` — green.
- `cd neode-ui && npx vitest run && node scripts/mock-rpc-parity.mjs && npm run build` — green.
- `01-REVIEW-FINDINGS.md` row count equals its disposition count, and the summary counts sum to it.
- Both dev-pair nodes report a matching sha256 and surviving containers.
</verification>
<success_criteria>
- A findings document covers six named areas, including those with no findings.
- Every finding has exactly one evidenced disposition; fixed rows cite commit and test, deferred rows cite reason and owner.
- Every codebase-concerns federation/mesh claim was re-verified against current code and git history before being filed or dismissed.
- The phase is deployed to the dev pair, sha256-verified, with containers intact and no release cut.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-09-SUMMARY.md` when done.
Stage by explicit path, commit each fix separately, and `git push gitea-ai main`.
- "An operator who removes a federated peer on a live node does not see it reappear after at least two subsequent sync cycles"
- "A peer whose sync is failing shows the operator-visible sync-error badge on the live node, and the badge clears once that peer syncs successfully"
- "The channel-open flow works against a real node on the dev preview: the own-node URI copies, a trusted federated node opens in one click, a meshed Lightning peer can be sent a request, and a manually pasted URI is accepted"
- "The paid tick renders the branded ring on both payment-success surfaces on the dev preview, at a narrow and a desktop viewport, without clipping"
- "The demo and a real node behave the same through the mesh chat surface — aliasing a peer, reacting, editing, deleting, and sending an attachment produce the same modals and the same outcome on both"
prohibitions:
- statement: "The phase MUST NOT be signed off on demo evidence alone — every criterion in this checkpoint that names a real node is exercised against a real node, because a demo-only pass is exactly the divergence class this phase exists to remove"
category: transparency
artifacts: []
key_links: []
---
<objective>
Consolidate every human-gated verification this phase owes into one sign-off, run against the dev
pair rather than the demo.
Purpose: `01-VALIDATION.md` lists three manual-only verifications (removal sticking across real sync
cycles, the channel-open flow end to end, and the paid-tick visual), and CONTEXT.md adds the user's
own requirement that FED-05 and FED-06 are verified on the dev preview against archi-dev **before any
deploy**. Rather than interrupting each implementation plan with its own checkpoint, they are gathered
here so the operator is asked once, after the code is on the dev pair.
Output: a recorded sign-off, or a list of issues that becomes the input to a gap-closure pass.
| T-01-42 | Repudiation | signing off on demo evidence while a real node still fails | high | mitigate | Each step names where it runs; the prohibition above forbids demo-only sign-off; step 5 explicitly compares the two |
| T-01-43 | Elevation of Privilege | a removed peer regaining federation membership unnoticed because the check was too short | high | mitigate | Step 1 requires waiting at least two sync cycles at the 90-second interval, stated as a wall-clock duration rather than "a while" |
| T-01-44 | Denial of Service | the verification itself taking a live node off the network and leaving it that way | medium | mitigate | Step 2 restores the peer as part of the step and requires observing the badge clear, so the node cannot be left isolated as a side effect |
| T-01-45 | Spoofing | a channel opened against a peer-advertised URI during verification sending funds to the wrong node | high | mitigate | Step 3's request path targets a meshed peer with a request, not an open; the one-click open is exercised only against a bilaterally-trusted federated node the operator already federated with |
</threat_model>
<verification>
The operator's response is the verification. An "approved" response completes the phase; any
described issue is captured verbatim in the SUMMARY as a gap for `/gsd-plan-phase 1 --gaps`.
</verification>
<success_criteria>
- All five numbered checks were exercised, each in the place it names.
- The operator either approved or produced a numbered issue list.
- The outcome is recorded in the SUMMARY, including which node each check ran against.
</success_criteria>
<output>
Create `.planning/phases/01-federation-mesh-hardening/01-10-SUMMARY.md` when done, recording the
verdict, the node each check ran against, and any issue text verbatim.
**Confidence:** HIGH (backend federation/mesh code — read directly, git-blamed); MEDIUM (FED-05 Lightning-URI UX — net-new surface, no prior art in repo); HIGH (FED-06 — both source components read directly)
<user_constraints>
## User Constraints (from CONTEXT.md)
No CONTEXT.md exists for this phase (not yet run through `/gsd-discuss-phase`). No locked decisions or discretion areas to honor beyond `REQUIREMENTS.md` and the phase description supplied by the orchestrator. Treat all implementation choices below as recommendations for the planner, not locked decisions — the planner should flag any of these that warrant a user check-in (see `## Assumptions Log`).
</user_constraints>
## Summary
The federation and mesh code is more mature than `CONCERNS.md` suggests — several concerns it lists (tombstone-write-swallowed, DID-join without signature verification) were already fixed in commit `01cbec27` (2026-07-02) and the `handle_federation_peer_joined` signature-verification path respectively. **Do not treat `CONCERNS.md` as current truth for this phase; the structured review (FED-03) must re-verify each claim against the code read in this research before acting on it.**
The real, currently-live bug class behind the user's "nodes reappear / sync issues" reports is almost certainly a **concurrency race on `federation/nodes.json`**: `federation/storage.rs` has zero locking (no `Mutex`, no atomic temp-file+rename) around `load_nodes()` → mutate → `save_nodes()`, yet the daemon runs **two independent, overlapping periodic federation-sync loops** (`server.rs` ~line 497, every 90s; `server.rs` ~line 840, every 1800s) plus the manual `federation.sync-state` RPC and `federation.remove-node` RPC — all of which do their own read-modify-write cycle against the same file with no coordination. A `remove_node()` call racing against an in-flight `sync_with_peer()`'s `update_node_state()` (which read the node list *before* the removal landed) will have the sync's stale read clobber the just-written removal when it saves — the removed node reappears with no error, exactly matching the reported symptom, and it is invisible to logs because both loops only `debug!()` on failure. This is the primary hypothesis to design a fix and a regression test around for FED-01/FED-02.
Mesh attachment-send parity (FED-04) was fixed just before this phase started (commit `c2ce71c6`, uncommitted → committed by another concurrent agent during this research session): `mock-backend.js` now implements `mesh.send-content-inline` / `mesh.send-content` / `mesh.fetch-content` / `mesh.transport-advice` mirroring the daemon's real tier logic. What remains for full "rest of the mesh chat surface" parity: `mesh.contacts-list` / `mesh.contacts-save` (peer aliasing, called live from `Mesh.vue` on mount and on rename) are **not implemented in mock-backend.js at all** and will 404 with "Method not found" on the demo; and `mesh.send-reaction` / `send-reply` / `edit-message` / `delete-message` / `forward-message` are stubbed as bare `{ok:true}` acks that never mutate `meshStore.dynamic`, so reactions/edits/deletes silently don't render on the demo even though the RPC call "succeeds."
FED-05 (Lightning channel-open UX) is greenfield: no RPC anywhere in the codebase currently returns this node's own Lightning `identity_pubkey`/`uris` (LND's own `/v1/getinfo` provides both, but `handle_lnd_getinfo` in `core/archipelago/src/api/rpc/lnd/info.rs` doesn't parse or forward them), and `NodeStateSnapshot` (the federation sync payload) carries no Lightning fields for a peer's pubkey/host, so there is no way today to look up a *federated* peer's channel-open target. `handle_lnd_openchannel` (channels.rs) already accepts `pubkey` + optional `address` + `amount`/fee params and does the connect-then-open sequence correctly — reuse it as-is. "Public nodes" browse/request has no existing data source in this codebase (no LN graph query, no curated list) and needs a scope decision from the user before planning task breakdown.
FED-06 is a straightforward swap: `ScreensaverRing.vue` (`compact` size = 240px/320px) renders only the radiating EQ segments (no circle of its own — the "circle" is the separately-layered content in the center, exactly as `Screensaver.vue` does with `ScreensaverLogo`). `SendBitcoinModal.vue`'s `.send-success-burst` is 112px (7rem) with 3 CSS-ripple `.burst-ring` elements plus a `.burst-core` circle+checkmark — swap the `.burst-ring` elements for `<ScreensaverRing size="compact" />`, keep `.burst-core`+checkmark centered on top, and reconcile the size mismatch (ring is 2-3x larger than the current burst container; either scale it down via CSS `transform: scale()` or accept the larger footprint since the modal is `max-w-2xl`). `WalletScanModal.vue` has a second, simpler "paid tick" (`.success-ring`, no ripple animation at all) that the phase's "wherever else the paid tick appears" clause covers — plan to update both.
**Primary recommendation:** Start FED-03's structured review by (1) auditing every `federation::storage` read-modify-write call site for the missing-lock race described above and design a fix (a `tokio::sync::Mutex` per data_dir, or collapsing the two periodic sync loops into one), (2) re-verifying every `CONCERNS.md` federation/mesh claim against current code before acting on it, (3) filling the two demo-parity gaps in `mock-backend.js` (contacts-list/save + stateful reaction/edit/delete), (4) treating FED-05 as new RPC surface (own-node Lightning URI, peer Lightning info propagation, and a scoped "public nodes" answer) before any UI work, and (5) the FED-06 CSS/component swap.
| Federation node list, tombstones, sync loops | API / Backend (`core/archipelago/src/federation/`) | — | Disk-persisted state; must be race-free at the storage layer, not patched in the RPC or UI layer |
| Federation removal propagation to mesh chat | API / Backend (`core/archipelago/src/mesh/mod.rs::purge_federation_peer`) | Frontend (Pinia `stores/mesh.ts`) | Backend already purges peer/messages/contacts server-side; frontend must not cache a stale contact after the WebSocket state bump |
| Mesh RPC surface (attachments, reactions, contacts) | API / Backend (`core/archipelago/src/api/rpc/mesh/`) | Dev tooling (`neode-ui/mock-backend.js`) | The demo backend is a parity shim over the same RPC surface — it must mirror backend behavior, never invent its own contract |
| FIPS/Tor transport dial + fallback | API / Backend (`core/archipelago/src/fips/dial.rs`, `transport/`) | — | Transport selection is a backend concern; UI only displays the resulting badge (`last_transport`) |
| Lightning node URI (own + peer) | API / Backend (new: `lnd.getinfo` extension, federation sync payload extension) | Frontend (new modal) | LND is the source of truth for `identity_pubkey`/`uris`; federation sync is the transport for sharing a peer's LN info |
| Channel-open UX (initiate) | Frontend (new modal, `Teleport`-to-body, house style) | API / Backend (`lnd.openchannel` — already exists) | Backend channel-open RPC is complete; only the UI (URI share, trusted-peer picker, public-node browse) is missing |
| Paid-tick success animation | Frontend (`SendBitcoinModal.vue`, `WalletScanModal.vue`, `ScreensaverRing.vue`) | — | Pure presentation; no backend involvement |
## Standard Stack
This phase does not introduce new external dependencies. It is a hardening + UI-surface pass over an existing Rust (Tokio/Hyper/reqwest/serde) backend and Vue 3 (Pinia, Vue Router, Teleport) frontend, plus a Node.js/Express demo backend (`neode-ui/mock-backend.js`). No new libraries are needed for any of FED-01 through FED-06 — `ScreensaverRing.vue` and `handle_lnd_openchannel` already exist and should be reused, not reimplemented.
### Core (existing, reused)
| Library | Version | Purpose | Why Standard |
|---------|---------|---------|--------------|
| tokio | (workspace pin) | Async runtime, `interval()`/`spawn()` for the periodic sync loops | Already the project's async foundation |
| reqwest | (workspace pin) | HTTP client for FIPS/Tor peer dial and LND REST calls | Already used throughout `fips/dial.rs` and `api/rpc/lnd/` |
| serde/serde_json | (workspace pin) | Wire format for `NodeStateSnapshot`, RPC params | Project-wide convention |
**Version verification:** No new packages are being added; skip registry verification per protocol (nothing to verify). If the planner introduces any new crate/npm package during execution, verify it then.
## Package Legitimacy Audit
No external packages are being introduced by this phase — this section is not applicable. If a later plan step decides to add a dependency (e.g., a curated public-LSP list requires a small crate), run the Package Legitimacy Gate at that time.
### Pattern 1: Federation removal is already "belt and suspenders" — reuse, don't rewrite
**What:** `handle_federation_remove_node` (handlers.rs:273) captures the peer's pubkey *before* calling `federation::remove_node`, then after removal calls `mesh::purge_federation_peer` to drop the synthetic mesh contact, its messages, presence, and persisted mesh-contacts entry. `federation::remove_node` (storage.rs:180) already tombstones the DID **before** saving the filtered node list and propagates a tombstone-write failure as an error (fixed in `01cbec27`).
**When to use:** This is the correct pattern for FED-01 already. Don't redesign it — the actual gap is the concurrency race in the storage layer underneath it (see Pitfall 1), not the removal logic itself.
**What:** `merge_transitive_peers` (sync.rs:120) loads `load_removed_dids()` and skips any hint whose DID is tombstoned, and `handle_federation_peer_joined` (handlers.rs:641) independently rejects a `peer-joined` callback for a tombstoned DID. Both paths that could resurrect a removed node already check the tombstone list.
**When to use:** The FED-03 review should write a test that concurrently exercises remove + an in-flight sync (see Pitfall 1) rather than re-deriving the (already-correct) tombstone-check logic.
### Pattern 3: Demo backend must be a byte-for-byte RPC mirror, not a "close enough" mock
**What:** `mock-backend.js`'s `mesh.transport-advice` case (line 4318) explicitly duplicates the daemon's size thresholds (`MESH_AUTO_MAX = 1024`, `MESH_HARD_MAX = 2300`) with a comment pointing at `typed_messages.rs handle_mesh_transport_advice` as the source of truth.
**When to use:** Apply the same pattern to `mesh.contacts-list`/`mesh.contacts-save` and to the reaction/edit/delete stubs — read the real handler in `core/archipelago/src/api/rpc/mesh/typed_messages.rs` (lines 1180-1371 for contacts/presence, 637-976 for reply/reaction/receipt/forward, 1065-1180 for edit/delete) and mirror its actual state transitions in `meshStore.dynamic`, not just an `{ok:true}` ack.
### Anti-Patterns to Avoid
- **Unlocked read-modify-write on shared JSON files:**`federation/storage.rs` has none of `load_nodes()`/`save_nodes()` behind a mutex, and writes are a direct `fs::write()` (not atomic temp+rename). Any new federation code must NOT add a third code path that does its own read-modify-write without going through a shared lock — that widens the race window instead of closing it.
- **Silent `debug!()` on periodic-loop errors:** Both sync loops in `server.rs` log sync failures at `debug!` level only (not surfaced to the state broadcast, not visible in the UI). FED-02 explicitly requires operator-visible sync errors — don't add a third silent loop; extend the existing ones to persist a `last_sync_error` alongside `last_seen`.
- **Reinventing `handle_lnd_openchannel`'s connect-then-open sequence:** It already does `perm=false` synchronous peer connect before opening (with a documented reason: `perm=true` races and fails with "peer is not online"). Reuse it; do not write a second Lightning-channel RPC.
- **Assuming `ScreensaverRing` is pre-sized for a 112px success badge:** its `compact` class is 240px (mobile) / 320px (≥768px) — 2-3x the current `.send-success-burst`. Naive drop-in will overflow the modal card; must be explicitly scaled or the surrounding layout redesigned.
## Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---------|-------------|-------------|-----|
| Connecting to + opening a channel with an LN peer | A new RPC | `lnd.openchannel` (`api/rpc/lnd/channels.rs:238`) | Already validates pubkey format, amount bounds, fee params, and does the connect-before-open sequence correctly with the documented `perm=false` fix |
| Paid/success radial visual | A new ring/particle component | `ScreensaverRing.vue` (`compact` size) | Explicitly required by FED-06; also already ships a reduced-motion-friendly animation pattern to copy for consistency |
| Peer transport badge (FIPS/Tor) | New transport-tracking logic | `FederatedNode.last_transport`/`last_transport_at` (already written by `record_peer_transport`) | Already ground-truth (records what was actually used, not predicted) — FED-05's peer picker can reuse this field to show reachability |
| Federated-peer list for the "trusted nodes" picker | A new RPC | `federation.list-nodes` (already returns `did`, `name`, `onion`, `trust_level`, `last_seen`) | FED-05 only needs to ADD Lightning fields to this payload/peer lookup, not build a parallel peer list |
**Key insight:** Almost every piece of infrastructure FED-01/02/04/05 need already exists in some form — the gaps are narrow (a missing lock, a missing mock method, a missing struct field) rather than missing subsystems. Resist the urge to redesign the federation sync architecture; patch the specific race and the specific parity/field gaps identified here.
## Common Pitfalls
### Pitfall 1: Unlocked concurrent read-modify-write on `federation/nodes.json` (PRIMARY SUSPECT for FED-01/FED-02)
**What goes wrong:** A federated node the operator removes reappears after "some sync cycles," with no error anywhere — exactly the symptom reported. `federation::remove_node()` and `federation::sync::update_node_state()` (called from `sync_with_peer()`) both do `load_nodes()` → mutate in memory → `save_nodes()` with zero mutex and a non-atomic `fs::write()`. Two async tasks (e.g., the 90s auto-sync loop mid-flight for peer X, and a `federation.remove-node` RPC for the same peer X arriving concurrently) can interleave: the sync task's `load_nodes()` snapshot (taken before the removal) still contains X; the removal completes and saves a list without X; the sync task then finishes and calls `save_nodes()` with its stale in-memory list, silently restoring X.
**Why it happens:** No `Mutex`/`RwLock` guards the federation JSON files, and there are TWO independent periodic sync loops (`server.rs` ~line 497, every 90s; ~line 840, every 1800s — the second loop's comment even says "every 30 min" while the interval literal is `Duration::from_secs(1800)`, i.e. that arithmetic is correct but the redundancy with the 90s loop is not otherwise explained or justified anywhere in the code) plus manual "Sync All" and remove/join/set-trust RPCs, all writing the same file.
**How to avoid:** Add a per-data-dir `tokio::sync::Mutex<()>` (or an `Arc<Mutex<Vec<FederatedNode>>>` cache) that every `federation::storage` read-modify-write function acquires for the duration of its load+mutate+save; consider switching `save_nodes` to atomic temp-file+rename to avoid partial-write corruption on crash. Separately, evaluate collapsing the two periodic sync loops into one (the 90s loop already does everything the 1800s loop does, plus asymmetry self-heal) — the 1800s loop appears vestigial/redundant and doubles the race exposure for no described benefit.
**Warning signs:** `tests/multinode/smoke.sh`'s "removed-node tombstone" section (already covers the transitive-reappear case) intermittently fails only under load/timing variance, or a removed node's `last_seen` timestamp updates *after* a `federation.remove-node` call succeeded — that's the race manifesting as reappearance without any logged error.
### Pitfall 2: Trusting `CONCERNS.md` as current state for this phase
**What goes wrong:** Re-fixing an already-fixed bug (tombstone-write-swallowed was fixed in `01cbec27`, 2026-07-02) wastes the FED-03 review's time and risks reintroducing a regression if the "fix" reverts working code.
**Why it happens:** `CONCERNS.md` was generated 2026-07-29 from a static codebase snapshot/analysis pass that in at least two documented cases (tombstone swallow, DID-join-without-verification) predates fixes already on `main`.
**How to avoid:** For every `CONCERNS.md` federation/mesh item, `git log -p` the referenced file/line range before deciding it's still open. Two items already verified fixed in this research: "Federation node removal tombstone gap" (fixed `01cbec27`) and part of "Federation DID validation incomplete" (the `peer-joined` RPC does require and verify an ed25519 signature — `handlers.rs:588-607`). The remaining un-verified part of that concern — no proof-of-ownership check on the *original* DID mint, i.e. can anyone claim any DID string on first contact — may still be valid; verify it during the review rather than assuming either way.
**Warning signs:** A "finding" in the FED-03 review that exactly matches a `CONCERNS.md` bullet without a fresh code read is a signal to re-verify before filing it.
### Pitfall 3: Demo mock silently no-ops instead of erroring on unmirrored RPCs
**What goes wrong:** `mesh.contacts-list`/`mesh.contacts-save` are called live from `Mesh.vue` (lines 113, 896) but have no case in `mock-backend.js`'s switch — they fall through to the `default` case which returns a proper JSON-RPC error (`Method not found`), but the frontend call sites wrap them in `try {} catch { /* non-fatal */ }`, so the failure is invisible during manual demo testing unless you watch the browser console or server log (`console.log('[RPC] Unknown method: ...')`).
**Why it happens:** New frontend RPC call sites get added over time; `mock-backend.js` parity is manual and easy to miss for methods that aren't on the "main" flow (aliasing a peer is a secondary action, not part of onboarding/attach-file).
**How to avoid:** Grep `neode-ui/src/api/rpc-client.ts` for every `mesh.*`/`federation.*` method string and cross-reference against `mock-backend.js`'s switch cases as an explicit FED-03/FED-04 checklist item, not just the attachment-send path already fixed.
**Warning signs:** Browser console shows `[RPC] Unknown method: mesh.contacts-list` while testing the demo at `:8100`.
### Pitfall 4: `ScreensaverRing`'s size classes don't have a "success-badge" variant
**What goes wrong:** Dropping `<ScreensaverRing size="compact" />` directly into `.send-success-burst` (currently 112px) either overflows the card or looks disproportionate at 240-320px without adjusting the surrounding layout.
**Why it happens:** `ScreensaverRing.vue`'s two size classes (`viz-ring-default`, `viz-ring-compact`) were designed for full-screen screensaver and settings-panel contexts (`SystemDangerZone.vue`), not for an inline modal success pane.
**How to avoid:** Either (a) wrap the component in a container with `transform: scale(0.5)` (112/240 ≈ 0.47) and compensate for the transform not affecting layout box size (use negative margins or a fixed wrapping box), or (b) add a third `compact-sm`/`badge` size variant to `ScreensaverRing.vue` sized for this use case (cleaner, and reusable for `WalletScanModal.vue`'s `.success-ring` too). Confirm the choice with a UI-spec/sketch before implementation given this affects two components.
**Warning signs:** Visual QA on `:8100` shows the ring clipped by the modal's `max-h-[90vh] overflow-y-auto` container or the checkmark badge floating disconnected from the ring's visual center.
### Pitfall 5: FED-05 has no backend field for a peer's Lightning identity
**What goes wrong:** Building the "trusted nodes by hostname, one-click channel open" UI before the backend can supply a federated peer's LN `pubkey`/`host:port` results in a UI that can list *names* but has nothing to pass to `lnd.openchannel`.
**Why it happens:** `NodeStateSnapshot` (the payload `federation.get-state`/sync exchanges) has no Lightning fields at all — it was designed for app/CPU/mem/tor status, not payment-channel metadata.
**How to avoid:** Plan FED-05 backend-first: (1) extend `handle_lnd_getinfo` to parse and return `identity_pubkey` + `uris` from LND's real `/v1/getinfo` response (both fields already exist in LND's REST API — the daemon's `LndGetInfoResponse` struct just doesn't deserialize them yet), (2) add optional `lightning_pubkey`/`lightning_uri` fields to `NodeStateSnapshot` so a synced peer's info includes it (defaulted via `#[serde(default)]` for backward compat, matching every other optional field in that struct), (3) decide and scope the "public nodes" browse/request feature — no existing data source; recommend a small curated static list (documented, versioned) rather than a live LN graph query (`DescribeGraph` is heavy and not currently proxied anywhere in this codebase) unless the user specifically wants live graph browsing.
**Warning signs:** A plan step that starts building `LightningChannelModal.vue` before a corresponding backend RPC/field change is scoped — check the plan's task ordering.
## Code Examples
### Reuse: opening a channel (backend already correct)
| Tombstone write silently dropped (`let _ = tombstone_did(...)`) | Tombstone write propagated as a hard error, written before the node-list save | 2026-07-02, `01cbec27` | A failed tombstone write now fails the whole remove — matches FED-01's "a failed removal surfaces an error" requirement already, at the single-call level (the remaining gap is the cross-call race in Pitfall 1) |
| Mesh attachment send: demo threw "Method not found" and force-opened a demo-only chooser modal | `mock-backend.js` implements the same RPC surface + mirrors the real size-tier logic | 2026-07-29, `c2ce71c6` | FED-04's core attachment-parity requirement is met; remaining gaps are contacts and reaction/edit/delete (see Pitfall 3) |
**Deprecated/outdated:** None specific to this phase's tech; no framework/library version churn involved.
## Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|-------|---------|---------------|
| A1 | The unlocked read-modify-write race on `federation/nodes.json` (Pitfall 1) is the primary cause of the user-reported "nodes reappear / sync issues" — this is a code-derived hypothesis, not confirmed via a reproduced failure in this research session | Summary, Pitfall 1 | If wrong, the planner should still fix the race (it's a real bug regardless) but should budget review time for other causes too — start FED-03 with a broader look, not a narrow patch-and-close on this one hypothesis |
| A2 | The 1800s periodic federation sync loop (`server.rs` ~line 840) is redundant given the 90s loop and safe to remove/collapse | Pitfall 1, Anti-Patterns | If a maintainer added it for a specific reason not documented in the surrounding comments (e.g. covering a case the 90s loop misses), removing it could regress that unstated behavior — confirm via `git log -p` / git blame on that block before deleting |
| A3 | "Public nodes" for channel-open browse/request (FED-05) should be a small curated static list rather than a live LN network graph query | Pitfall 5 | If the user actually wants live graph discovery, the curated-list approach under-delivers; this needs explicit user confirmation before FED-05 backend work starts |
| A4 | `ScreensaverRing`'s size mismatch with `.send-success-burst` should be solved with a CSS scale-down rather than a new component size variant | Pitfall 4 | Either approach works technically; scale-down is faster but may look slightly different under `prefers-reduced-motion`; a new size variant is cleaner but touches the shared component. Low risk either way — a UI sketch/spec pass can decide before implementation |
| A5 | The remaining "Federation DID validation incomplete" concern (no proof-of-ownership check on first DID mint) is still an open gap, not yet fixed like its sibling claims | Pitfall 2 | Not independently re-verified in this session (only the peer-joined signature check was confirmed); FED-03 should explicitly re-check this specific sub-claim before filing or dismissing it |
**If this table is empty:** N/A — see rows above.
## Open Questions
1. **Is the two-loop federation sync redundancy intentional?**
- What we know: Both loops call `sync_with_peer` over all `Trusted`/`Observer` nodes; only the 90s loop does the "asymmetry self-heal" `notify_join` re-assertion; the 1800s loop additionally calls `refresh_federation_mesh_peers()` after its full pass (the 90s loop does not).
- What's unclear: Whether the 1800s loop's `refresh_federation_mesh_peers()` call covers a gap the 90s loop leaves (e.g. name/roster propagation to mesh chat), which would mean simply deleting it regresses something.
- Recommendation: `git log -p` / blame both loop-insertion commits during FED-03; if the mesh-peer-refresh behavior is the only unique value of the 1800s loop, move that single call into the 90s loop's completion and delete the 1800s loop entirely, closing half the race window.
2. **What UI/UX should "browse/request channels with public nodes" (FED-05) actually look like?**
- What we know: No existing data source; `lnd.openchannel` supports a manual pubkey+address entry today (a user could theoretically paste a public node's URI already, just with no picker/browse UI).
- What's unclear: Whether "public nodes" means (a) a curated list Archipelago ships/updates, (b) a live query against some LSP directory API, or (c) simply a well-labeled manual-paste field with format help (lowest-effort, matches what the backend already supports).
- Recommendation: Flag for `/gsd-discuss-phase` or a direct user check-in before FED-05 planning — this is a scope decision, not a technical one.
3. **Does `federation.get-state`'s `federated_peers` hint list need a Lightning field, or should peer LN info be a separate on-demand RPC?**
- What we know: `NodeStateSnapshot.federated_peers` already carries a lightweight `FederationPeerHint` (did/pubkey/onion/name/fips_npub) shared during sync; adding `lightning_uri` there means every synced peer's LN info is cached locally without an extra round-trip.
- What's unclear: Whether peers want to opt out of advertising their LN URI transitively (privacy consideration, similar to the existing `shared_location` opt-in pattern for lat/lon).
- Recommendation: Follow the `shared_location` precedent (`Option<(f64,f64)>` only sent when the node opts in via `server.set-location`) — add an explicit opt-in setting for Lightning URI sharing rather than defaulting it on, since exposing a payment channel target more broadly than intended has real-money implications.
## Environment Availability
| Dependency | Required By | Available | Version | Fallback |
| Rust toolchain / cargo (from `core/`) | FED-01/02/03/04 backend work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
| Node.js / npm (`neode-ui/`) | FED-04/05/06 frontend + mock-backend.js work | Not probed this session (assume present per CLAUDE.md build instructions) | — | — |
| `:8100` dev preview proxying to archi-dev | FED-05/06 required verification step per phase description | Not probed this session — verify at execution time per `docs/../reference_neode_ui_dev_testing.md` (mock=5959, pw password123) | — | — |
| LND REST API (`LND_REST_BASE_URL`, local macaroon) | FED-05 `lnd.getinfo` extension + `lnd.openchannel` reuse | Assumed present on real nodes per existing `channels.rs`/`info.rs` code; demo backend has no real LND — FED-05 UI must be exercised against archi-dev (real LND) per phase description, not the pure-mock demo | — | Demo-only mock stub for `lnd.getinfo` identity fields if archi-dev is unavailable during a work session |
| `tests/multinode/smoke.sh` | Regression coverage for FED-01/02 fix | Present, already covers removed-node tombstone + transitive-reappear scenarios; does NOT currently exercise the concurrent-race scenario (Pitfall 1) | — | Extend smoke.sh with a concurrent remove+sync test, or add a Rust-level `#[tokio::test]` in `federation/storage.rs` that spawns concurrent remove/save calls |
**Missing dependencies with no fallback:** None identified — this phase is code-only, no new external services.
**Missing dependencies with fallback:** LND-backed FED-05 verification (see row above) — use archi-dev per phase instructions; demo-only stubbing is a fallback if archi-dev is temporarily unavailable, but the phase's own success criteria require verification against archi-dev before deploy, so this fallback should not be treated as sufficient sign-off.
## Validation Architecture
### Test Framework
| Property | Value |
|----------|-------|
| Framework (backend) | `cargo test` (Rust, workspace root `core/`) — federation/storage.rs and federation/sync.rs already have `#[tokio::test]` unit coverage |
| FED-01 | Removed node never reappears, incl. under concurrent sync | unit + integration | `cargo test -p archipelago federation::storage::tests` (existing) + NEW concurrent-race test | ✅ existing tests / ❌ Wave 0 for the new race test |
| FED-01 | Failed removal surfaces an error, not a silent no-op | unit | `cargo test -p archipelago federation::storage::tests::test_remove_nonexistent_node_errors` (existing, covers the "not found" case; add one for a simulated tombstone-write I/O failure) | ✅ existing / ❌ Wave 0 for I/O-failure case |
| FED-02 | Sync errors are operator-visible | manual / UI | No automated test yet — requires a UI element (e.g. per-node "last sync error" badge) that doesn't exist yet | ❌ Wave 0 (needs the field to exist first) |
| FED-03 | Structured review findings fixed or deferred with reason | N/A (process requirement) | N/A — tracked via the plan's findings list, not a single automated test | — |
| FED-04 | Attachment send parity demo vs real | manual (visual) + existing `mock-backend.js` logic mirrors daemon tier thresholds | Manual walk-through on `:8100` per phase description; consider a Vitest test asserting `mesh.transport-advice` tier boundaries match `MESH_AUTO_MAX`/`MESH_HARD_MAX` constants | ❌ Wave 0 (no existing frontend test pins these thresholds) |
| FED-04 | Contacts list/save + reaction/edit/delete parity | manual + NEW mock-backend.js stateful behavior | Manual on `:8100`; no existing automated coverage of `mock-backend.js` behavior (it's a dev tool, not covered by `npm run test`) | ❌ Wave 0 if automated coverage is wanted; otherwise manual-only is acceptable for a demo shim |
| FED-05 | Own node Lightning URI is shareable | unit (backend) + manual (UI) | NEW `cargo test` for `handle_lnd_getinfo`'s identity_pubkey/uris parsing (mock LND response fixture); manual UI check on archi-dev | ❌ Wave 0 |
| FED-05 | Trusted-node picker + channel open flow | manual (UI, requires archi-dev + a live peer) | Manual per phase description ("tested live on the :8100 dev preview against archi-dev") | ❌ Wave 0 — inherently a live/manual check per the phase's own success criteria |
| FED-06 | Paid-tick animation matches screensaver ring everywhere it appears | manual (visual) | Manual visual check of `SendBitcoinModal.vue` + `WalletScanModal.vue` on `:8100` | ❌ Wave 0 — visual-only requirement, no meaningful automated assertion beyond "component renders" |
### Sampling Rate
- **Per task commit:** Backend: `cargo test -p archipelago federation:: mesh::` (scoped). Frontend: `npx vitest run` scoped to touched component test files, or a full quick run if none exist yet for touched files.
- **Per wave merge:** Full `cargo test` (backend) + `npm run test` (frontend).
- **Phase gate:** Full backend + frontend suites green, plus `tests/multinode/smoke.sh` federation sections green on a real 2-node pair, plus a manual FED-05/FED-06 walkthrough on `:8100` against archi-dev before any deploy (per phase description, "fixed there before any deploy").
### Wave 0 Gaps
- [ ] New `#[tokio::test]` in `core/archipelago/src/federation/storage.rs` (or a new integration test) that spawns concurrent `remove_node()` + `update_node_state()`/`sync_with_peer`-equivalent calls against the same `data_dir` and asserts the removed node stays removed — this is the regression test for Pitfall 1 and does not exist today.
- [ ] Test/fixture for `handle_lnd_getinfo` parsing `identity_pubkey`/`uris` from a mocked LND `/v1/getinfo` JSON response (FED-05) — no existing test touches this handler's response shape.
- [ ] Decide whether `mock-backend.js` behavior warrants automated (Vitest/Playwright-against-mock) coverage, or manual-only is acceptable given it's a dev-preview tool, not shipped code — recommend manual-only unless the team already has a pattern for testing the mock backend elsewhere (none found in this research).
- [ ] Framework install: none — all frameworks (`cargo test`, Vitest) are already configured and running.
## Security Domain
### Applicable ASVS Categories
| ASVS Category | Applies | Standard Control |
|---------------|---------|-----------------|
| V2 Authentication | Partial | Federation peer identity is DID/ed25519-key-based, not password auth; `peer-joined`/`peer-did-changed`/`peer-address-changed` all require and verify an ed25519 signature over a canonical message before mutating state — already correct, verify no new RPC bypasses this |
| V3 Session Management | No | Not applicable — federation/mesh RPCs are peer-signed, not session-cookie based |
| V4 Access Control | Yes | `is_peer_allowed_path()` (`server.rs:1270`, tested at `server.rs:2075+`) restricts which HTTP paths a peer-only listener will serve — any new FED-05 RPC (e.g. "fetch peer's Lightning URI") that's meant to be peer-reachable must be added to this allow-list explicitly, not left to fall through |
| V5 Input Validation | Yes | `lnd.openchannel` already validates pubkey format (66-hex) and amount bounds server-side (`channels.rs:252-268`) — reuse, and apply the same rigor to any new Lightning-URI-sharing field (validate the URI format before persisting/displaying it) |
| V6 Cryptography | Yes | ed25519 signature verification via `identity::NodeIdentity::verify` — never hand-roll signature checks; reuse this existing verification path if FED-05 needs to authenticate a peer's advertised Lightning info |
### Known Threat Patterns for this stack
| Pattern | STRIDE | Standard Mitigation |
|---------|--------|---------------------|
| Federation peer spoofing a DID they don't control | Spoofing | ed25519 signature verification (already implemented for join/address-change/did-rotation — confirm any new FED-05 peer-info exchange follows the same pattern) |
| Concurrent-write race corrupting/reverting federation state (Pitfall 1) | Tampering (unintentional, but security-relevant since it undermines the "removal sticks" guarantee — a removed/untrusted peer regaining federation membership is a real access-control regression) | Add locking around the storage layer (see Pitfall 1's fix) |
| Unbounded transitive federation exposure (a Trusted peer's peer list auto-added as Observer) | Elevation of Privilege (bounded) | Already mitigated — `merge_transitive_peers` only runs for `Trusted`-level sources and only adds new peers as `Observer` (never auto-escalates to `Trusted`); this is intentional and correct, don't loosen it |
| Advertising this node's Lightning payment-channel target more broadly than intended (FED-05 new surface) | Information Disclosure | Follow the existing `shared_location` opt-in pattern — do not default Lightning URI sharing to "on" for all federated peers (see Open Question 3) |
## Sources
### Primary (HIGH confidence)
- `core/archipelago/src/federation/storage.rs`, `sync.rs`, `types.rs`, `invites.rs` — read directly, current `main`
- `core/archipelago/src/api/rpc/federation/handlers.rs` — read directly, current `main`
- `git log -p` on `core/archipelago/src/federation/storage.rs` (commit `01cbec27`) and `git show c2ce71c6` — verified fix history directly, not from documentation
- `tests/multinode/smoke.sh` — read directly for existing federation test coverage
- `.planning/REQUIREMENTS.md`, `.planning/STATE.md`, `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/CONCERNS.md` — project-provided context (CONCERNS.md's federation claims were then verified/refuted against live code per Pitfall 2)
### Secondary (MEDIUM confidence)
- None — this research relied entirely on direct codebase reads and git history, not external web sources, since the phase is about hardening this specific project's existing code rather than adopting new external technology.
### Tertiary (LOW confidence)
- None.
## Metadata
**Confidence breakdown:**
- Standard stack: HIGH — no new dependencies; existing stack confirmed by direct file reads
- Architecture (FED-01/02/03/04): HIGH — read the actual implementation, confirmed fix history via git log, identified a concrete unverified race condition with file:line citations
- Architecture (FED-05): MEDIUM — greenfield UI/RPC surface; confirmed what's missing (no existing Lightning-URI field/RPC) but the design (opt-in sharing, public-nodes scope) needs a user decision, not just engineering judgment
- Pitfalls: HIGH for Pitfalls 1-3 (backend/demo, code-verified); MEDIUM for Pitfalls 4-5 (frontend sizing and FED-05 scope, judgment calls flagged in Assumptions Log)
**Research date:** 2026-07-29
**Valid until:** 2026-08-12 (14 days — this is a fast-moving area of an actively-developed codebase; other agents were committing federation/mesh-adjacent changes during this very research session, per the mock-backend.js commit observed mid-session)
> Visual and interaction contract for the two UI-facing requirements in this phase:
> **FED-05** (inter-node Lightning channel-opening UX) and **FED-06** (on-brand paid-tick
> animation). The rest of Phase 1 (FED-01–04) is backend/parity work with no new UI surface.
> Generated by gsd-ui-researcher, verified by gsd-ui-checker.
---
## Design System
| Property | Value |
|----------|-------|
| Tool | none — no `components.json` found; project is **Vue 3**, and shadcn/ui does not support Vue (React-only), so the shadcn init gate does not apply here. Registry safety gate: not applicable. |
| Preset | not applicable |
| Component library | none — hand-authored Tailwind utilities + a custom "glass" CSS system (`glass-card`, `glass-button`, `glass-button-warning/danger/success`, `input-glass`, `alert-error/warning/info`, `BaseModal.vue`) defined in `neode-ui/src/style.css` and reused project-wide |
| Icon library | none — inline hand-authored SVG, 24×24 viewBox, `stroke-width="2"` outline style (heroicons-esque but not the package). The bolt path `M13 10V3L4 14h7v7l9-11h-7z` is already the house Lightning icon (used in `Server.vue`, `HomeWalletCard.vue`, `Web5Wallet.vue`) — reuse it verbatim for any new Lightning iconography in FED-05, do not source a new icon. |
| Font | Avenir Next (`font-sans`, body/UI text), Montserrat 700/800 (`font-archipelago`, headers only — not used in modals) |
**Modal contract (hard rule, repeated user complaint):** Every new modal in this phase MUST use `BaseModal.vue` (already wraps `Teleport to="body"` + full-screen `bg-black/60 backdrop-blur-md` backdrop + column layout with pinned header/footer and scrolling body) or, if a bespoke modal is unavoidable, MUST replicate that exact `<Teleport to="body">` + `fixed inset-0` + `@click.self="close"` pattern. Never nest a modal inside a `transform`-affected ancestor (glass-panel `translateZ` layers trap `position:fixed`).
---
## Spacing Scale
Declared values (must be multiples of 4) — matches `tailwind.config.js`'s existing 4px-grid `spacing` tokens (`1`=4px … `8`=32px) plus standard Tailwind rem multiples used throughout the codebase for larger gaps:
| 3xl | 64px | Not used by this phase's new elements |
Exceptions: 44px minimum touch target on all new interactive buttons (global rule already enforced in `style.css` for mobile — the "Copy URI" / "Open Channel" / "Request Channel" buttons inherit `min-height: 44px` from `.glass-button` automatically, no override needed).
---
## Typography
Scoped to this phase's new elements only (existing typography elsewhere is unchanged). Exactly two weights govern this phase's new elements — 400 and 600; Label and Body are differentiated from each other by size and color (not weight), matching how `NodeList.vue` already distinguishes node-name text from badge/hint text:
Heading is pinned to `text-xl` (20px), not a range — this matches `BaseModal.vue`'s own `<h3 class="text-xl font-semibold">` title (the component every new modal in this phase must use per the Modal contract above) and `WalletScanModal.vue`'s pane title, i.e. the size the existing house modals actually use most for their titles. Modal titles ("Open Lightning Channel", "Request Channel") use Heading; node names use Body (`text-white`); URI strings, badges, and helper/meta text use Label (`text-white/60`) per the existing `LightningChannelsPanel.vue`/`NodeList.vue` convention.
**Inherited — not governed by this contract:** The `SendBitcoinModal.vue`/`WalletScanModal.vue` success-amount numerals (e.g. `12,345 sats`, `text-5xl font-black` — 48px / weight 800) are pre-existing, unchanged display text. FED-06 only replaces the ring graphic behind/around that text, never the text itself, so this weight/size falls outside the phase's new-elements typography contract above and is not counted toward its weight budget.
| Destructive | `#ef4444` family (`.glass-button-danger`) | Not used by FED-05 v1 (no destructive action ships this phase — channel *close* is existing, out-of-scope UI in `LightningChannelsPanel.vue`); declared for consistency if a future "revoke URI sharing" action is added |
**Inherited semantic colors (pre-existing house convention, unchanged by this phase, NOT part of the 10% accent budget):**
- Success/paid emerald `#4ade80` text / `rgba(16,185,129,*)` fills — the paid-tick's center badge and "SENT"/amount numerals (FED-06 keeps this palette; only the surrounding ring geometry changes).
- Info blue `#60a5fa` — FIPS/Tor transport badges already shown next to trusted-node rows (`NodeList.vue`'s `transportBadge`); reused as-is in the FED-05 trusted-node picker rows, not introduced by this phase.
Accent reserved for: **primary Lightning-channel action buttons, the Lightning bolt icon, focus rings, and the active picker-tab indicator only** — never for body text, card backgrounds, or informational badges.
---
## Copywriting Contract
| Element | Copy |
|---------|------|
| Primary CTA — own URI | **"Copy Lightning URI"** (copy-to-clipboard button; on success the label flips to **"Copied!"** for ~2s, mirroring `SendBitcoinModal.vue`'s existing `copyDetail`/`Copied!` pattern — do not invent a new copy-feedback idiom) |
| Primary CTA — trusted federated node | **"Open Channel"** (one-click; matches the verb already used in `LightningChannelsPanel.vue`'s existing Open Channel button/modal) |
| Primary CTA — meshed Lightning peer | **"Request Channel"** (opens the request flow reusing `PeerRequestModal.vue`'s pattern — optional message field, "Send Request" submit button, `sending` → **"Sending…"** busy label — do not build a new request-modal component from scratch) |
| Manual fallback entry point | **"Paste URI Manually"** (reveals a `Peer URI` input, placeholder `pubkey@host:port`, helper text `Format: pubkey@host:port` — verbatim reuse of `LightningChannelsPanel.vue`'s existing field copy) |
| Empty state heading | **"No Lightning peers yet"** |
| Empty state body | **"Add a federated node or connect with a meshed peer running Lightning to open a channel directly — or paste a peer's URI manually below."** |
| Error state | **"Couldn't reach that peer — check they're online and try again."** (tone/placement mirrors the existing `openError`/`alert-error` treatment in `LightningChannelsPanel.vue`; LND "still starting up" transient errors reuse that same component's amber `isStartupNotice` treatment rather than the red error style) |
| Destructive confirmation | Not applicable — FED-05 v1 ships open/request flows only, no destructive action |
| FED-06 copy | Not applicable — pure visual swap. Existing "SENT" / success-amount / "Done" button copy in `SendBitcoinModal.vue` and `WalletScanModal.vue` is unchanged; only the ring graphic behind the checkmark changes. |
---
## UI Considerations
Applicable state considerations resolved: 13 covered, 3 backstop, 0 unresolved.
| long-text | own-node URI display | ✅ covered | The displayed `pubkey@host:port` string truncates (CSS `truncate` + `title` tooltip, the existing house pattern) to fit its container; the full untruncated value is what gets copied to clipboard regardless of visual truncation |
| empty | trusted-nodes picker list | ✅ covered | Empty state copy row above renders once when both the trusted and meshed-peer lists are empty (shared empty state, not duplicated per column) |
| empty | meshed-LN-peers picker list | 🧪 backstop | Same shared empty-state copy as above; no wired test yet asserting the "shared, not duplicated" rendering rule — flag for planner/executor to add a component test |
| populated | meshed-LN-peers picker list | ✅ covered | Same row layout, "Request Channel" button in place of "Open Channel" (peers are not bilaterally trusted, so the action is a request, never a direct open) |
| zero-one-many | trusted-nodes / meshed-peer lists | ✅ covered (dismissed) | No item-count copy is planned for either list (unlike e.g. the channel-status tabs' count badges) — singular/plural phrasing is not applicable |
| overflow | picker list rows (long node names) | ✅ covered | `truncate` class + `:title` tooltip on the node-name span, identical to the existing `NodeList.vue` convention |
| partial | manual-URI-paste form | ✅ covered | A pasted pubkey without a host falls back to `lnd.openchannel`'s existing address-less-pubkey handling (`address = parts[1] \|\| undefined`), already proven in `LightningChannelsPanel.vue` |
| error | manual-URI-paste form | 🧪 backstop | Invalid-format message ("Peer URI must be `pubkey@host:port`") is specified but no explicit format-validation test is scoped yet — planner should add one, do not silently skip client-side validation before calling `lnd.openchannel` |
| long-text | manual-URI-paste form | ✅ covered | Same truncation/tooltip treatment as the own-node URI display |
| unclassified | request-to-open-channel flow | ✅ covered (dismissed) | Reuses `PeerRequestModal.vue` verbatim (message field, Send Request/Sending states) — its own state coverage predates this phase and is not re-specified here |
| long-text | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered (dismissed) | The ring itself renders no text content (pure SVG/CSS segments); the 48px sats amount inside it is inherited text explicitly out of this contract (see Typography inherited note) |
| overflow | paid-tick ring (`SendBitcoinModal.vue` + `WalletScanModal.vue`) | ✅ covered | New `badge``ScreensaverRing` size variant (see below) is explicitly sized to fit inside the modal's `max-h-[90vh] overflow-y-auto` card without clipping — do not drop in the existing `compact` (240–320px) variant unscaled |
| static-content (motion) | paid-tick ring, all `ScreensaverRing` size variants | 🧪 backstop | `ScreensaverRing.vue`'s `segment-pulse` animation currently has **no**`prefers-reduced-motion` guard anywhere (a real gap — confirmed by reading the component; contrast with `SendBitcoinModal.vue`'s existing `.burst-ring`, which already has one). This phase must add `@media (prefers-reduced-motion: reduce) { .viz-segment { animation: none; opacity: 0.6; } }` inside `ScreensaverRing.vue` itself so the guard applies to every size variant (including the new `badge` one), matching the site-wide reduced-motion convention. No existing automated test covers this — flag for planner as a Wave 0 test gap. |
<!-- Status vocabulary (locked by probe-core projectTruths):
✅ covered → a plain truth string lifted into must_haves.truths
🧪 backstop → a flat scalar { statement, verification: backstop }; at verify time, no explicit
evidence → insufficient_spec → human_needed (never a silent pass, #1154)
⚠ unresolved → an explicit planner assumption (surfaced, never silently dropped)
Rows are REPLACED (not appended) on a probe re-run — idempotent. -->
---
## FED-05 Visual Anchor
Primary visual anchor: the trusted-nodes list (federation trust is the primary path); meshed-lightning-peers list second; manual-paste fallback visually de-emphasized below both (collapsed behind the "Paste URI Manually" entry point per the Copywriting Contract above, not rendered as a third equal-weight column).
RESEARCH.md flagged the `ScreensaverRing` size mismatch (`compact` = 240–320px vs. the current 96–112px paid-tick badges) as needing a UI-spec decision before implementation. **Decision: add a new `badge` size variant to `ScreensaverRing.vue`**, not a CSS `transform: scale()` wrapper — cleaner, reusable across both call sites, and avoids reduced-motion/layout-box mismatches that a transform hack would introduce.
| Variant | Diameter (mobile) | Diameter (≥768px) | `--viz-radius` | Used by |
Composition at both call sites: `<ScreensaverRing size="badge" />` renders the radiating EQ segments; the existing `.burst-core` (green circle + checkmark, `SendBitcoinModal.vue`) or `.success-ring` inner content (`WalletScanModal.vue`) is layered centered on top via `position: absolute; inset: 0` within a shared `position: relative` wrapper sized to the `badge` diameter — same layering pattern `Screensaver.vue` already uses for `ScreensaverLogo` inside `ScreensaverRing`. Do not resize or restyle the checkmark/core itself; only its container changes from a bespoke 96–112px circle to the `badge`-sized wrapper.
---
## Registry Safety
| Registry | Blocks Used | Safety Gate |
|----------|-------------|--------------|
| shadcn official | none | not applicable — shadcn/ui is React-only; this is a Vue 3 project with an established hand-rolled design system (see Design System table) |
| Removed peer never reappears across real fleet sync cycles | FED-01 | Needs two live nodes + wall-clock sync cycles | Remove a peer on archi-dev, watch peer list through ≥2 sync cycles (90s loop), confirm absent + error surfaced on induced failure |
| Channel-open UX end-to-end | FED-05 | Visual/UX judgment + live LND | Drive :8100 preview against archi-dev; share URI, open channel to trusted node, request public-node channel |
- "Clicking a mesh message's transport pill (or the ⋯ button) opens a hop-route modal that is visually balanced on desktop — the hop chain fills the modal width, endpoint nodes are prominent, and the modal is no longer a cramped 420px card with tiny emoji + dot row"
- "On narrow viewports (phone width) the hop chain renders VERTICALLY — sender at top, relays stacked, recipient at bottom — with the packet animation traveling top-to-bottom, nothing overflowing or wrapping awkwardly"
- "The visualization uses Archipelago's design language: glass panel, dark-only palette, per-transport accent colors (meshcore orange #fb923c, meshtastic mint #3eb489, reticulum blue #60a5fa, lora amber #f59e0b, fips violet #a78bfa, tor indigo #818cf8), EQ-segment-style bars echoing ScreensaverRing, node glow, staggered reveal, animated packet traveling the path"
- "prefers-reduced-motion disables all looping animations (existing behavior preserved)"
- "Tor / FIPS / unknown-transport cases still render their distinct shapes (3 anonymous relays / direct P2P / not recorded), and SNR/RSSI + E2E/delivery metadata still display"
artifacts:
- "neode-ui/src/views/mesh/HopVizModal.vue — new self-contained modal component (Teleport to body) with scoped styles"
- "neode-ui/src/views/Mesh.vue — inline hop-viz modal markup replaced by <HopVizModal>"
- "web/dist/neode-ui/ — rebuilt bundle containing the new component's class strings"
- "HopVizModal derives accent color from msg.transport, matching the pill colors in mesh-styles.css lines 168-173"
---
<objective>
Redesign the mesh-message hop visualization modal in neode-ui: properly sized and balanced on desktop, vertical stacked layout on mobile, and fully on-brand (glass, per-transport accents, EQ-segment motif from ScreensaverRing, animated packet travel, node glow, staggered reveal).
User feedback (verbatim): "please make the hop graphic and animation on mesh messages much better balanced, the desktop one is very small and it doesn't work on mobile where it should be vertical, and make it much more archipelago style and branded, make it beautiful."
Purpose: The current hop viz (inline in Mesh.vue lines ~2627-2678, styles in mesh-styles.css lines ~597-637) is a cramped horizontal flex row inside a 420px modal — tiny emoji endpoints, a dashed border with blinking `•` dots, no mobile handling. It reads as an afterthought, not an Archipelago feature.
Output: New `HopVizModal.vue` component wired into Mesh.vue, old inline markup and `.mesh-hopviz-*` CSS removed, rebuilt bundle in web/dist/neode-ui/ verified to contain the new strings.
</objective>
<context>
**Current implementation (read all of these first):**
- `neode-ui/src/views/Mesh.vue` lines ~1291-1310 (hopVizMsg / hopVizPeer / hopVizHops state), lines ~2340-2350 (transport pill + ⋯ button that set `hopVizMsg`), lines ~2627-2678 (the inline Teleported modal to replace). Also `transportLabel()` and `signalQualityLabel()` helpers used by the modal — grep for them in Mesh.vue.
- `neode-ui/src/views/mesh/mesh-styles.css` lines ~160-175 (transport pill accent colors + `.mesh-chat-e2e`), ~572-584 (`.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, title/sub/cancel — SHARED with the send-transport and image-quality modals, do not break them), ~597-637 (`.mesh-hopviz-*` rules to delete/migrate).
- `neode-ui/src/components/ScreensaverRing.vue` — the brand EQ-segment motif: thin 4px rounded bars, white gradient fill, staggered `scaleY` pulse keyframes. Echo this visual language for relay-node markers.
- Global focus glow + accent used app-wide: orange `rgba(251,146,60,…)` (#fb923c).
**Transport accent map (must match pill colors):** meshtastic `#3eb489`, meshcore `#fb923c`, reticulum `#60a5fa`, lora `#f59e0b`, fips `#a78bfa`, tor `#818cf8`.
**Data realities:** For LoRa transports only a hop COUNT is known (`peer.hops`, 0 or 0xff/null = direct), not per-relay identities — render count relays as anonymous branded markers. Tor = fixed "3 anonymous relays" shape. FIPS = direct P2P. `null` transport = not recorded. SNR/RSSI are current link readings (keep the existing disclaimer note).
**Project rules that apply:**
- Modals MUST `<Teleport to="body">` with full-screen backdrop (already true — preserve it).
- Frontend build can silently no-op: after `npm run build`, grep the built bundle in `web/dist/neode-ui/` for new strings before claiming done.
- Commit the code changes when they work; push via `git push gitea-ai main`. Stage explicitly by path (`git add <paths>`), never `git add -A` — other agents may share the tree.
- Do not deploy to any node; this rides the normal dev-pair → OTA pipeline later.
</context>
<tasks>
<task type="auto">
<name>Task 1: Build HopVizModal.vue — branded, balanced desktop hop visualization</name>
Create `neode-ui/src/views/mesh/HopVizModal.vue` (script setup, TypeScript) and move the hop-viz modal out of Mesh.vue into it.
Component contract:
- Props: `msg: MeshMessage` (import type from `../../types/api` or wherever Mesh.vue imports it), `peer: MeshPeer | null`, plus the two label strings Mesh.vue already computes (`transportLabel` result and `signalQualityLabel` result) OR import/reuse those helpers if they are importable; if they are local functions in Mesh.vue, pass computed strings as props — do NOT duplicate logic.
- Emits: `close`.
- Template: `<Teleport to="body">` wrapping a full-screen backdrop (reuse `.mesh-transport-modal-backdrop` class so backdrop behavior stays consistent) with `@click.self="emit('close')"`, containing a `glass-card` panel.
Visual redesign (all styles SCOPED in the component; delete the old `.mesh-hopviz-*` rules from mesh-styles.css lines ~597-637 — but leave `.mesh-transport-modal-backdrop`, `.mesh-transport-modal`, `.mesh-transport-title/sub/cancel` untouched since the send-transport and image-quality modals still use them; the new panel should use its own width class, wider than 420px — target `min(560px, 94vw)` on desktop):
1. **Header:** transport-colored title using the Montserrat brand font (`font-family: 'Montserrat', sans-serif` or the `font-archipelago` utility), e.g. "MeshCore route", with the existing You → peer subtitle. Derive an `--hop-accent` CSS custom property on the panel root from `msg.transport` using the exact pill color map (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback rgba(251,146,60) orange). All accents below use `var(--hop-accent)`.
2. **Endpoint nodes (You / peer):** substantial circular medallions (~64-72px) instead of bare emoji — island glyph 🏝️ centered inside a ring of 12-16 EQ-style segments (thin rounded bars radiating like a compact ScreensaverRing — reuse its technique: absolutely-positioned bars, `transform: rotate(deg) translateY(-radius)`, staggered `scaleY` pulse animation, white-to-transparent gradient tinted with the accent). Soft accent glow behind each medallion (`box-shadow: 0 0 24px color-mix(...)` or an rgba shadow). Node name below in white 600-weight, ellipsized.
3. **Path between endpoints:** replace the dashed-border + blinking `•` row with a proper track: a horizontal line/gradient in the accent color connecting the medallions, with:
- Relay markers for each hop (LoRa transports: `Math.min(hops, 6)` markers; Tor: exactly 3 with a 🧅/anonymous treatment; FIPS: no relays, a single direct link) rendered as small EQ-segment clusters or glowing accent dots (~10-14px) sitting ON the track, each with its own subtle pulse, staggered.
- An animated packet: a small bright dot/comet (accent color, blurred glow trail) traveling from sender to recipient along the track on an infinite ~2s loop. Use a CSS keyframe translating along the track container (like the existing `mesh-hopviz-travel` sweep but as a discrete glowing packet, not a background sheen).
- Label under/over the track: "direct radio link" / "N hops" / "3 anonymous relays" / "direct peer-to-peer" / "transport wasn't recorded" — preserve the existing per-transport template branches and copy.
- Staggered entrance: sender medallion, then track+relays, then recipient fade/slide in (keep the existing appear pattern, ~0.05/0.35/0.65s delays).
4. **Metadata footer:** keep the SNR/RSSI signal row (LoRa only, with the existing "current link readings" disclaimer note) and the E2E / delivered ✓✓ / timestamp row, restyled as small glass chips consistent with `.mesh-transport-meta` sizing. Keep the Close button (`.mesh-transport-cancel` class is fine).
5. **Reduced motion:** wrap ALL looping animations (packet, segment pulses, relay pulses) and entrance animations in the component's own `@media (prefers-reduced-motion: reduce)` block that disables them — the old CSS did this; the new component must too.
Wire-up in Mesh.vue: import HopVizModal, replace the inline `<Teleport>` block at lines ~2627-2678 with `<HopVizModal v-if="hopVizMsg" :msg="hopVizMsg" :peer="hopVizPeer" ... @close="hopVizMsg = null" />`. Keep `hopVizMsg`/`hopVizPeer`/`hopVizHops` state in Mesh.vue (or move `hopVizHops` into the component — it only needs `peer.hops`; prefer moving it in to shrink Mesh.vue). Do not touch the transport pill / ⋯ button triggers.
<done>HopVizModal.vue exists with the medallion + track + packet design, Mesh.vue renders it in place of the inline modal, old `.mesh-hopviz-*` rules removed from mesh-styles.css, other transport modals' shared classes untouched, type-check/build passes.</done>
Add responsive behavior inside HopVizModal.vue's scoped styles. At narrow widths (`@media (max-width: 560px)` — pick the breakpoint so a typical phone portrait always gets it):
- The chain flips to a COLUMN: sender medallion at top, vertical track with relay markers stacked below it, recipient medallion at bottom. Implement so the same DOM works in both orientations (flex-direction column + a track that switches from horizontal line to vertical line), rather than duplicating markup.
- The packet animation travels TOP-TO-BOTTOM along the vertical track (a second keyframe or a transform-based animation that follows the flex axis).
- Medallions may shrink slightly (~56px) but stay prominent; names and hop label must not truncate mid-word or overflow the panel; panel uses near-full width (`width: 94vw`) with comfortable vertical padding, and the whole modal scrolls (`max-height: 90vh; overflow-y: auto`) if metadata pushes it tall.
- Entrance stagger and reduced-motion handling apply identically in vertical mode.
Sanity-check both orientations in the browser via the dev preview (`npm run dev`, viewport toggling in devtools) if a display is available; otherwise rely on the CSS being purely breakpoint-driven and symmetric.
<done>Below the breakpoint the hop chain renders vertically (sender top → recipient bottom) with the packet traveling downward; no overflow; desktop layout unchanged above the breakpoint.</done>
1. `cd neode-ui && npm run build` (outputs to `web/dist/neode-ui/`).
2. Per CLAUDE.md, prove the build actually picked up the change: grep the built JS/CSS bundle for a new unique string from the component (e.g. a distinctive class name like `hopviz-medallion` or `hopviz-packet` — whatever class names Task 1 used; pick one that did not exist before): `grep -rl "hopviz-packet" web/dist/neode-ui/assets/` (adjust the token to the actual class name). It MUST match; if it doesn't, the build silently no-opped — investigate before proceeding.
3. Also confirm the OLD inline markup is gone from the bundle source of truth: `grep -c "mesh-hopviz-chain" neode-ui/src/views/Mesh.vue` returns 0.
4. Commit the code changes only (docs/planning files are committed by the orchestrator): `git add neode-ui/src/views/mesh/HopVizModal.vue neode-ui/src/views/Mesh.vue neode-ui/src/views/mesh/mesh-styles.css web/dist/neode-ui` — stage exactly these paths, never `git add -A`. Check `git status` first for other agents' unrelated changes and leave them alone. Commit message: `feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile` ending with the `Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>` trailer. Push: `git push gitea-ai main`.
</action>
<verify>
<automated>grep -rl "hopviz" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/ | head -1 && cd /home/archipelago/Projects/archy && git log --oneline -1 | grep -qi "hop"</automated>
</verify>
<done>Fresh build in web/dist/neode-ui/ contains the new component's class strings, Mesh.vue no longer contains the old inline hopviz markup, and the change is committed and pushed via gitea-ai.</done>
</task>
</tasks>
<verification>
- `npm run build` succeeds in neode-ui/; bundle in web/dist/neode-ui/ contains a new hopviz class string (silent-no-op guard).
- Old `.mesh-hopviz-*` rules removed; `.mesh-transport-modal-backdrop` / `.mesh-transport-option` / image-quality modal styles untouched (grep mesh-styles.css and open the send-transport modal path in code to confirm shared classes intact).
- Modal still Teleports to body with full-screen backdrop.
- All five transport branches render: meshcore/meshtastic/reticulum (hop count), tor (3 relays), fips (direct), null (not recorded).
- prefers-reduced-motion block present in the new component.
<human-check>On the dev preview (:8100 or `npm run dev`), open Mesh chat, click a message's transport pill: desktop shows the wide balanced medallion+packet layout; shrinking the window below the breakpoint flips it vertical. Confirm it "feels Archipelago" — glass, accent glow, EQ-segment motif.</human-check>
</verification>
<success_criteria>
- Desktop hop modal is visually balanced: ~560px panel, prominent glowing endpoint medallions with EQ-segment rings, accent-colored track with animated traveling packet and staggered relay markers.
- Mobile (< 560px) renders the chain vertically top-to-bottom with the packet traveling downward; nothing overflows.
- Per-transport accent colors match the existing transport pill colors exactly.
- Reduced-motion users get a static layout.
- Built bundle verified to contain the new strings; code committed and pushed via gitea-ai.
</success_criteria>
<output>
Executor commits code only. On completion, note results for the orchestrator; no SUMMARY.md required for quick mode unless the orchestrator asks.
- Montserrat transport-colored title; `--hop-accent` / `--hop-accent-soft` / `--hop-accent-faint` CSS vars derived from `msg.transport` matching the chat pill colors exactly (meshtastic #3eb489, meshcore #fb923c, reticulum #60a5fa, lora #f59e0b, fips #a78bfa, tor #818cf8, fallback orange).
- Endpoint medallions: 72px (56px mobile), island glyph on an accent-tinted disc with glow, ringed by 14 EQ segments using the ScreensaverRing rotate+translateY+scaleY-pulse technique.
- Track: accent gradient line, relay markers positioned fractionally along it — mini 3-bar EQ clusters for radio hops (`min(hops, 6)`), 🧅 ×3 for Tor, none for FIPS/unknown (unknown dims the line). Animated white/accent glowing packet travels sender→recipient on a 2.2s loop.
- `@media (max-width: 560px)`: chain flips to column, track becomes a vertical line, packet animates top→bottom (`hopviz-packet-y`), same DOM.
- `@media (prefers-reduced-motion: reduce)`: all loops and entrance animations disabled, packet hidden.
- **`Mesh.vue`**: inline 50-line Teleport block replaced by `<HopVizModal>`; label helpers passed as computed props; `hopVizHops()` removed (moved into component).
- **`mesh-styles.css`**: all `.mesh-hopviz-*` rules and their keyframes/reduced-motion block deleted; `.mesh-chat-transport-clickable`, `.mesh-transport-modal-*` (shared with send-transport + image-quality modals), and `.mesh-chat-more-btn` untouched (12 shared-class occurrences verified intact).
## Commits
| Task | Commit | Description |
| ---- | ------ | ----------- |
| 1–3 | `ac09fc5d` | feat(mesh): redesign hop-route visualization — branded, animated, vertical on mobile |
## Verification
- `npx vue-tsc --noEmit` → exit 0.
- `npm run build` → success; bundle guard (CLAUDE.md silent-no-op rule): `hopviz-packet` and `hopviz-medallion` found in `web/dist/neode-ui/assets/Mesh-D2ImmPoh.js` + `Mesh-Dy7zKwro.css`; old `mesh-hopviz-chain` string absent from both source and bundle.
- Submodule guard run before commit (no indeedhub paths staged); no file deletions in the commit.
## Deviations from Plan
**1. [Rule 3 - Blocking] `web/dist/neode-ui` not staged/committed**
- **Found during:** Task 3
- **Issue:** Plan said to stage `web/dist/neode-ui`, but `web/` is gitignored (`.gitignore:74`) and untracked — build output is intentionally excluded from the repo (release tarballs are built from it at ship time).
- **Fix:** Committed the three source files only; fresh build exists on disk in `web/dist/neode-ui/` and was grep-verified.
**2. Push deferred to orchestrator** — plan Task 3 said `git push gitea-ai main`, but the executor constraints state the orchestrator handles pushing; not pushed here.
## Known Stubs
None — no placeholder text, hardcoded-empty data paths, or unwired components introduced.
## Human Verification Pending
On the dev preview (`npm run dev` or :8100), open a mesh chat and click a message's transport pill: desktop shows the wide medallion+packet layout; shrinking below 560px flips it vertical. Confirm brand feel (glass, accent glow, EQ motif).
## Self-Check: PASSED
- FOUND: neode-ui/src/views/mesh/HopVizModal.vue
- FOUND: commit ac09fc5d on main
- FOUND: hopviz strings in web/dist/neode-ui/assets/ (fresh build)
- CONFIRMED: 0 occurrences of `mesh-hopviz` in Mesh.vue and mesh-styles.css
- "A fresh demo session (clean browser, no localStorage) shows IndeeHub as an installed, running app in My Apps"
- "Launching IndeeHub in the demo renders the real indee.tx1138.com site inside the in-app iframe session (no new tab, no external interstitial)"
- "The embedded IndeeHub boots signed-in (active demo account visible, no login wall) and no identity-picker modal blocks the demo visitor"
- "The non-demo (real node) build is byte-for-byte unaffected in behavior: indeedhub launch, identity picker, and NIP-07 bridge all work as before"
- "The served demo content contains no occurrence of the private release-server IP (existing Docker build guards still pass)"
artifacts:
- "neode-ui/docker/nginx-demo.conf — new whole-origin reverse-proxy server block (port 2101) for indee.tx1138.com with framing headers stripped and sign-in script injected"
- "neode-ui/docker/indee-demo-signin.js — demo-only localStorage seeding script with a labelled throwaway demo nsec"
- "docker-compose.demo.yml and demo-deploy/docker-compose.yml — publish the new 2101 port"
- "neode-ui/src/composables/useDemoIntro.ts — indeedhub moved from external-tab to iframe launch via the :2101 proxy origin"
- "neode-ui/mock-backend.js — indeedhub present in staticDevApps as installed/running"
in the nginx stage of Dockerfile.web; it lands only in the demo web image,
never in real-node artifacts). Publish the port in docker-compose.demo.yml
(`"2101:2101"` on neode-web) and demo-deploy/docker-compose.yml (use an
env-overridable mapping consistent with its existing `DEMO_WEB_PORT`
style, e.g. `"${DEMO_INDEE_PORT:-2101}:2101"`, and document it in that
file's header comment). Read docker-entrypoint.sh first and make sure the
new server block survives its template substitution exactly like the
existing blocks (same escaping convention for nginx `$` variables); touch
the entrypoint only if its substitution list needs it.
Do not put any host IP in any of these files; upstream hostname
indee.tx1138.com is fine.
</action>
<verify>
<automated>docker run --rm -v "$PWD/neode-ui/docker/nginx-demo.conf:/etc/nginx/nginx.conf:ro" nginx:alpine nginx -t (or, if docker unavailable locally, `nginx -t -c` via a podman run — config must parse). Plus: grep -c "2101" neode-ui/docker/nginx-demo.conf docker-compose.demo.yml demo-deploy/docker-compose.yml neode-ui/Dockerfile.web — each ≥1; grep -q "indee-demo-signin" neode-ui/docker/nginx-demo.conf && grep -qi "throwaway" neode-ui/docker/indee-demo-signin.js</automated>
</verify>
<done>nginx config parses with the new :2101 whole-origin proxy block (framing headers stripped, sub_filter injection, WS upgrade); seed script exists with labelled throwaway demo key and idempotent localStorage seeding; both compose files publish 2101; demo web image copies the script and exposes the port; no host IPs added anywhere.</done>
</task>
<task type="auto">
<name>Task 2: demo frontend — iframe launch via :2101 and no identity-picker wall</name>
<done>Demo build (VITE_DEMO=1) bundle contains the :2101 launch logic (grep hit proves the build didn't silently no-op — per CLAUDE.md); plain build still compiles and demo-gated branches do not alter non-demo behavior; unit tests updated and green; launching indeedhub in demo resolves to the same-host :2101 origin in the iframe session; identity picker suppressed only under IS_DEMO.</done>
Add an `indeedhub` entry to `staticDevApps` in mock-backend.js using the
existing `staticApp({...})` helper: id `indeedhub`, title `Indeehub`
(match the existing title map at ~line 537 and APP_TITLES), a short/long
description consistent with the marketplace copy ("Bitcoin documentary
streaming platform" per the existing entry), `state: 'running'`,
`lanPort: 8190` (matches the existing port map), icon
`/assets/img/app-icons/indeedhub.png`. Because per-session demo state is
`structuredClone(staticDevApps)`, this alone makes it installed+running on
every fresh session. Then reconcile the rest of the mock so nothing
contradicts installed status: check the marketplace/available-apps mock
responses and any install/uninstall handlers (~lines 540-740, 1900-1960,
4900+) for `indeedhub` entries that would render it as not-installed or
double-listed, and check `DEMO_APP_PAGES` does NOT grow an indeedhub
placeholder (the demo launch URL bypasses /app/indeedhub/ entirely — the
iframe goes to the :2101 origin). Keep the existing `node.nostr-pubkey`
mock as-is unless Task 1's fallback path was taken (in which case align
its pubkey with the throwaway demo key and add the sign handlers described
there).
</action>
<verify>
<automated>cd neode-ui && node -e "const s=require('fs').readFileSync('mock-backend.js','utf8'); if(!/staticDevApps[\s\S]*?indeedhub:\s*staticApp/.test(s)) process.exit(1)" && (DEMO=1 timeout 20 node mock-backend.js & sleep 4; curl -s -X POST localhost:5959/rpc/v1 -H 'content-type: application/json' -d '{"method":"server.data","id":1}' -H 'cookie: demo=fresh' | grep -o '"indeedhub"' | head -1; kill %1 2>/dev/null) — expect an indeedhub hit in fresh-session package-data (adapt the RPC method/auth to what the mock actually serves; a login with the demo password first is fine)</automated>
</verify>
<done>A fresh demo session's package-data includes indeedhub as installed and running with launchable UI; My Apps shows it without an install step; no duplicate/contradictory indeedhub listing in marketplace mocks; mock backend boots cleanly with DEMO=1.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| demo nginx :2101 → indee.tx1138.com | demo host proxies an external site; upstream content is served under the demo host |
| iframe (:2101 origin) ↔ parent (:2100 origin) | cross-origin; parent NIP-07 bridge only used in fallback path |
| public visitors → demo host | anyone can drive the proxy |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-gjd-01 | Spoofing | throwaway demo nostr key | low | accept | key is a labelled public demo identity by design; generated fresh, never a real user key; anyone extracting it can only impersonate "the demo visitor" |
| T-gjd-02 | Info disclosure | private release-server IP in served content | high | mitigate | no host IPs added in any changed file; iframe URL derived from window.location.hostname; existing Docker-build scrub+fail guards remain the backstop |
| T-gjd-03 | Tampering | open reverse proxy on :2101 | medium | mitigate | proxy is pinned to a single upstream host (proxy_pass fixed hostname + proxy_ssl_name), no dynamic upstreams, no request-driven destinations — it cannot be used as an open proxy |
| T-gjd-04 | Elevation | header stripping (X-Frame-Options/CSP) | low | accept | stripping applies only to the :2101 demo proxy of one known site, demo image only; real-node builds never carry this config |
| T-gjd-SC | Tampering | npm installs | low | accept | primary path adds no dependencies; fallback path adds only nostr-tools (well-known, verify on npmjs.com before install) |
- "localStorage seeding via injected classic script on the proxied origin (applesauce-accounts nsec account)"
key-files:
created:
- neode-ui/docker/indee-demo-signin.js
modified:
- neode-ui/docker/nginx-demo.conf
- neode-ui/Dockerfile.web
- docker-compose.demo.yml
- demo-deploy/docker-compose.yml
- neode-ui/src/composables/useDemoIntro.ts
- neode-ui/src/views/appSession/useAppIdentity.ts
- neode-ui/mock-backend.js
decisions:
- "Primary sign-in path used (seeded nsec account, self-signing) — NIP-07 bridge fallback NOT needed; verified against the live bundle"
- "Dropped `sub_filter_types text/html` (text/html is nginx's default sub_filter type; explicit listing produced a duplicate-MIME warning)"
metrics:
duration: "~50 min"
completed: 2026-07-29
status: complete
---
# Quick Task 260729-gjd: IndeeHub in the Demo Summary
**One-liner:** Whole-origin nginx proxy of indee.tx1138.com on :2101 with an injected throwaway-nsec sign-in seeder, demo iframe launch via same-host :2101, and IndeeHub pre-installed in every fresh mock-backend session.
| 3 | d00ca624 | mock-backend.js staticDevApps gains indeedhub (running, lanPort 8190) → installed on every fresh session |
## What was verified at exec time (live-bundle facts)
- Live site still serves `X-Frame-Options: SAMEORIGIN`, no CSP; bundle `assets/index-BMWtjRCn.js`.
- Account serialization confirmed by de-minifying the live bundle: private-key account class has `static type="nsec"`, `toJSON` → `{ signer: { key: <hex sk> }, id, pubkey, metadata, type }`; the manager registers the nsec type (`MM(Fe)` registers `mr`) and restores from `indeedhub-accounts` + activates by id from `indeedhub-active-account`. `Vn`/`je` confirmed hex decode/encode.
- Pubkey math independently validated against BIP340 test vectors (sk=1 → Gx, sk=3 → F9308A01…) before embedding the generated pair. Mismatch would trigger the bundle's "Account signer mismatch" guard, so this was load-bearing.
## Throwaway demo identity
Freshly generated 2026-07-29 for this task (generator ran in scratchpad, not committed):
- sk embedded in `neode-ui/docker/indee-demo-signin.js`, clearly labelled PUBLIC-DEMO-ONLY / not a secret (threat T-gjd-01: accepted by design). Never a real user key.
## Local verification results
1. **nginx parse:**`nginx -t` clean in `nginx:alpine` (podman, with `--add-host neode-backend:127.0.0.1` to satisfy the pre-existing upstream reference).
3. **Unit tests:** 195/195 green (`src/views/appSession` + `src/stores`), running with IS_DEMO=false — non-demo path exercised.
4. **Demo build:**`VITE_DEMO=1 npm run build` succeeded; bundle (`web/dist/neode-ui/assets/index-ChDwfLt5.js`) contains the 2101 launch logic. This is exactly what Dockerfile.web builds (ARG VITE_DEMO=1 default).
5. **Mock backend:** boots with DEMO=1; `/ws/db` initial dump of a fresh session contains `indeedhub` with `state=running`, ui=true, lan `http://localhost:8190`.
6. **No IP leaks:** none of the changed files contain the private release-server IP; pre-existing occurrences in dist (catalog.json/marketplace data) are scrubbed+gated by the existing Dockerfile.web guard.
7. **Submodule guard:** ran before all three commits; nothing under `indeedhub/` ever staged. No file deletions in any commit.
## Deviations from Plan
**1. [Minor] Dropped `sub_filter_types text/html` from the :2101 block**
- **Found during:** Task 1 nginx parse check
- **Issue:** nginx warns `duplicate MIME type "text/html"` — text/html is sub_filter's built-in default type
- **Fix:** removed the redundant directive (identical behavior), noted in a comment
- **Commit:** 69bc3d3f
**2. [Environment] Plain (non-demo) build + vue-tsc typecheck could not be run — permission denied**
- Three attempts (`npm run build`, `vite build --outDir <scratch>`, `vue-tsc --noEmit`) were denied by the permission system mid-execution.
- **Proxy coverage:** 195 unit tests ran and passed under IS_DEMO=false (compiles + non-demo runtime behavior), and the demo build compiled the same source. All changed TS is IS_DEMO-gated with types unchanged at call sites.
- **Residual risk:** low; a plain `npm run build` before the next real-node frontend ship will confirm (it runs vue-tsc).
**3. [Flag] `web/dist/neode-ui/` currently holds a VITE_DEMO=1 bundle**
- The demo verification build overwrote the (gitignored) `web/dist/neode-ui` output. **Rebuild with a plain `npm run build` before any real-node frontend deploy/ISO/OTA that rsyncs `web/dist`** — do not ship the demo bundle to real nodes.
## Fallback status
The plan's fallback (extension-type account + window.nostr postMessage shim + mock signer) was **not needed** — the primary seeded-nsec path matches the live bundle's restore contract exactly. If post-deploy testing shows a login wall anyway, the fallback is fully documented in the PLAN (Task 1 action block).
## Post-deploy checklist for orchestrator (vps2, after demo image rebuild + redeploy)
1. `curl -sI http://146.59.87.168:2101/` → 200, NO `X-Frame-Options`; `curl -s http://146.59.87.168:2101/ | grep indee-demo-signin` → hit. **If unreachable: open port 2101 in the vps2 firewall** (new requirement of this change).
2. Fresh private window → `http://146.59.87.168:2100` → login `entertoexit` → IndeeHub shows installed/running in My Apps with no install step.
3. Launch IndeeHub → renders inside the in-app iframe session (not a new tab), content browsable, **no identity-picker modal**.
4. Signed-in check: IndeeHub header shows an active account (avatar/profile, not a sign-in button). Note: the throwaway key has no published kind-0 profile, so expect a default avatar/truncated npub rather than a named profile — that still counts as signed in. If a login wall appears, execute the documented fallback and redeploy.
5. Spot-check served responses for the private release-server IP (should be none; build guard enforces).
6. Reload the iframe once — sign-in must persist (localStorage already seeded even if a service worker serves cached HTML without the injected tag).
7. Reminder: the `demo-deploy` thin stack now publishes `${DEMO_INDEE_PORT:-2101}:2101` — the public archy-demo repo copy of that compose file needs syncing when the images ship.
## Self-Check: PASSED
- neode-ui/docker/indee-demo-signin.js — FOUND
- neode-ui/docker/nginx-demo.conf :2101 block — FOUND
- Commits 69bc3d3f, 66d540f8, d00ca624 — FOUND in git log
- No paths under indeedhub/ in any commit — VERIFIED
- "Searching the Cloud files search for the Wavlake track titles returns them as peer-file results from at least one demo peer (A1, A2)"
- "The first Wavlake track is PAID; buying it via the demo ecash flow immediately autoplays it in the bottom GlobalAudioPlayer bar (A3)"
- "Cloud -> Paid Files tab shows pre-seeded purchase-history entries (sats paid + date); clicking an audio purchase plays it in the bottom bar (A4)"
- "The aggregated Peer Files tab shows each library item on ~1 peer, with only 2-3 items deliberately duplicated across peers (A5)"
- "Photo peer files show real photographs as card previews and in the full-screen viewer, not picsum placeholders (A6)"
- "In the PeerFiles gallery, clicking a FREE image opens the full-screen image viewer (A8); free audio -> bottom player; free video -> video modal; paid unowned items stay gated behind the pay modal (A7)"
- "Real-node build unaffected: all existing unit tests stay green, npm run build succeeds, and the frontend changes degrade gracefully when the real backend omits demo-only fields"
artifacts:
- "demo/content/music/: two downloaded Wavlake mp3s (real bytes, committed)"
- "demo/peer-media/: Wavlake artwork jpg(s) + photo-*.jpg replaced with real photographs"
- "content.download-peer-paid must return real bytes + correct mime_type -> confirmEcashPay's audio branch -> audioPlayer.play (this is the A3 autoplay chain; today the mock returns text/plain which breaks it)"
- "new /api/peer-content/:onion/:id mock route -> free-audio streaming AND the free-image viewer src (frontend already builds this URL at PeerFiles.vue:1011/1470; it 404s in the demo today)"
- "seeded content.owned-list entries must reference onions of the SAME session's demoFederationNodes() output and items actually present in that peer's peerCatalogFor slice, or Owned badges will not line up"
- "mock content.owned-list must include session purchases, otherwise loadOwned() (called after every purchase at PeerFiles.vue:1314) wipes the just-bought Owned state"
---
<objective>
Peer-files media batch for the public demo plus two shared-behavior fixes.
Demo/mock only (A1-A6): add two real Wavlake tracks (metadata + bytes fetched at
execution time), make the first one PAID with a working pay -> autoplay-in-bottom-bar
flow, seed visible purchase history, dedupe the peer catalog to ~2-3 intentional
duplicates, and replace picsum placeholder photos with real photographs.
Both builds (A7, A8): clicking a peer file opens the appropriate viewer (photos ->
full-screen image viewer, audio -> bottom music player, video -> video modal), and
fix the confirmed bug that FREE images are a click no-op in the PeerFiles gallery
(PeerFiles.vue line 91 ternary falls through to `undefined` for non-playable free items).
Purpose: the peer-files demo is a flagship "buy content over the mesh" showcase; today
paid purchases unlock a text placeholder, free images don't open, free streaming 404s,
ridge) or update the descriptions (including the "sourced via picsum.photos"
credit text and the stale PEER_LIBRARY header comment) to match reality. Keep the
photo-*.jpg filenames so PEER_MEDIA references don't change; update each entry's
size_bytes to the new actual file size. Verify each file with file(1) is a real
JPEG of reasonable resolution (>= ~1000px wide).
5. Owned/purchase state (A3, A4). Introduce a mock owned-content store (e.g.
mockState.ownedContent array of { onion, content_id, filename, mime_type,
size_bytes, paid_sats, purchased_at }). Lazily seed it on first access with 2-4
plausible past purchases dated days-to-weeks ago, referencing onions from the
CURRENT session's demoFederationNodes() output and content ids actually present in
that peer's peerCatalogFor slice (onions are random per session — compute, don't
hardcode). At least one seeded purchase must be an audio item backed by real disk
bytes so the Paid Files tab click plays music. content.owned-list returns this
store (all fields of Cloud.vue's PaidItem interface). Every successful purchase
path (content.download-peer-paid, and the -invoice/-onchain and onchain/invoice
status flows' download calls) appends an entry with the real price and
purchased_at=now, so the Owned badge survives the post-purchase loadOwned()
refresh and purchases show up in the Paid Files tab.
6. Real bytes for paid/owned downloads (A3). Rework the shared
content.owned-get / content.download-peer-paid / -invoice / -onchain case: look up
the PEER_LIBRARY entry by content_id; when entry.disk exists return the real file
bytes with mime_type entry.mime_type; keep the text placeholder only as fallback
for entries without disk bytes (the fictional 2GB films). Preserve the existing
ecash rail-deduction logic exactly. This makes buying the Wavlake track deliver a
real mp3 with mime_type audio/mpeg, which is what confirmEcashPay's existing audio
branch needs to autoplay in the bottom bar.
7. Streaming route. Add an Express GET route /api/peer-content/:onion/:content_id
to mock-backend.js that resolves the PEER_LIBRARY entry and serves entry.disk via
res.sendFile (Express handles Range/206 automatically — the frontend probes with
Range: bytes=0-0), 404 JSON { error } otherwise. This unbreaks the demo's existing
free-audio/video streaming and is required by Task 2's free-image viewer. Verify
the demo/dev proxy actually forwards /api/* to the mock (check vite.config.ts /
vite.preview.config.mts proxy config and the demo docker nginx config if present);
if /api is not proxied in dev, register the route on whatever path prefix reaches
the mock and keep the frontend URL unchanged (the frontend path is fixed — it must
work on real nodes too, where nginx proxies /api to the daemon).
Commit this task on its own (stage mock-backend.js + the demo/ media files
explicitly by path; git add demo/content/music demo/peer-media is fine, never
git add -A; nothing under indeedhub/).
</action>
<verify>
<automated>cd neode-ui && node --check mock-backend.js && node mock-backend.js & sleep 2; then RPC-probe localhost:5959: (a) content.browse-peer for each federation onion — every PEER_LIBRARY id appears on >=1 peer, total duplicate ids across all peers <= 3, both Wavlake ids present; (b) content.owned-list returns >=2 seeded items with paid_sats and purchased_at; (c) content.download-peer-paid for the paid Wavlake id returns mime_type audio/mpeg with data length > 1MB base64; (d) curl -H "Range: bytes=0-0" /api/peer-content/{onion}/{free-wavlake-id} returns 206; (e) file demo/peer-media/photo-*.jpg all report JPEG; then kill the mock. (If /rpc/v1 requires a session, log in first with the mock demo password password123.)</automated>
</verify>
<done>Both Wavlake tracks in the catalog with real committed bytes + real API metadata; first is paid and delivers real audio/mpeg bytes on purchase; owned-list seeded and purchase-persistent; catalog duplication reduced to <=3 intentional items; photos are real JPEGs; /api/peer-content serves Range requests. Committed.</done>
NON-playable (images, blurred) -> openPayModal(item) so the click gets the
appropriate "viewer" for locked content while preserving the paid gating (the
full image is never fetched or revealed pre-purchase — only the blurred thumbnail);
FREE image -> open the existing Teleport-to-body owned-content viewer modal
(template line 310) as a lightbox: set viewerItem/viewerMime and point viewerUrl
at the free stream URL /api/peer-content/{onion}/{id} directly (an img src streams
it; no base64 round-trip; works on real nodes via the Range proxy and in the demo
via Task 1's new route). Free audio/video already route through playMedia — keep.
Guard closeViewer's URL.revokeObjectURL so it only revokes blob: URLs.
2. Generalize the viewer footer (line 354): the hardcoded "Owned · unlocked" green
caption must only show for owned items; for free items show a neutral caption
(exact string "Free · shared by peer" — also used as the bundle-grep sentinel), and
make the footer Save button use the free download path for free items (streamDownload)
instead of content.owned-get.
3. Unify post-payment success handling (A3-adjacent, both builds). payWithLightning
(line 1364), pollInvoice (line 1417) and pollOnchain (line 1197) currently
triggerDownload on success; align them with confirmEcashPay (line 1277): mark the
item owned in ownedKeys, refresh loadOwned, and open in-app — audio ->
audioPlayer.play (bottom-bar autoplay), image/video -> the viewer modal — using the
mime_type from the download response with item.mime_type as fallback. Extract the
shared logic (e.g. an openPurchased(item, data, mime) helper) rather than
duplicating it four times. Keep triggerDownload available via the viewer's Save
button. Do not touch the payment/polling logic itself.
4. Do NOT extend the preview-thumbnail watcher (line 872) to audio — on real nodes
content.preview-peer returns audio bytes for audio items (see recon), which must
not be used as an img src. Audio cards keep the waveform icon.
5. Sanity-check the other previews-grid views for the same A8 class of bug:
CloudFolder.vue / Cloud.vue My Files use FileCard @preview -> handlePreview ->
MediaLightbox and should already open free images; verify by reading the handler
chain (no change expected — do not modify them if they work).
6. Keep src/views/__tests__/PeerFilesRefresh.test.ts green; if the click-dispatch
refactor is cheaply testable, extend that test file with a case asserting a free
image click sets viewerUrl (do not build new test infrastructure).
Commit this task separately (stage neode-ui/src paths explicitly).
</action>
<verify>
<automated>cd neode-ui && npx vitest run src/views/__tests__/PeerFilesRefresh.test.ts src/composables/__tests__/useAudioPlayer.test.ts</automated>
</verify>
<done>Clicking a free image in PeerFiles opens the full-screen viewer (Teleport-to-body, backdrop preserved); free audio -> bottom bar; free video -> video modal; paid unowned image click opens the pay modal and never reveals the image; all four payment-success paths open purchased content in-app with audio autoplaying in the bottom bar. Targeted tests green. Committed.</done>
</task>
<task type="auto">
<name>Task 3: Full test suite, production build, bundle verification, demo smoke</name>
<files>(no new files — verification only; fixes belong to the task that broke them)</files>
<action>
1. Run the full unit suite: cd neode-ui && npx vitest run — all tests (currently
195) must pass. Fix any regression in the file that caused it, amending or adding
a fixup commit to the responsible task's change.
2. Production build per CLAUDE.md: cd neode-ui && npm run build (vue-tsc must pass;
output lands in web/dist/neode-ui). Grep the built bundle for the Task 2 sentinel
string to prove the build did not silently no-op:
grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ must match at least
one js file.
3. End-to-end demo smoke against the mock: start node mock-backend.js plus the dev
frontend (or vite preview against the built dist) and exercise via curl/RPC: search
corpus contains the Wavlake titles (browse-peer aggregation), paid purchase of the
Wavlake track deducts ecash and returns audio/mpeg, owned-list grows by the
purchase, /api/peer-content serves the free track with 206. Confirm no occurrence
of 146.59.87.168 in mock-backend.js additions or demo-served data:
grep -rn "146.59.87.168" neode-ui/mock-backend.js demo/ must be empty.
4. Confirm git hygiene: git status shows nothing staged under indeedhub/; all work
is committed across the task commits (code only — the summary doc is the
orchestrator's commit). Leave deploy/push to the orchestrator.
5. Write into the task summary a post-deploy live checklist for
http://146.59.87.168:2100 (orchestrator deploys): search finds the Wavlake tracks;
buy the paid track with ecash -> bottom bar autoplays; Paid Files tab shows seeded
purchases; peer catalog shows <=3 duplicated files; photos are real and open in the
lightbox on click; free song click plays in bottom bar.
</action>
<verify>
<automated>cd neode-ui && npx vitest run && npm run build && grep -rl "Free · shared by peer" ../web/dist/neode-ui/assets/ | head -1 && ! grep -rn "146.59.87.168" mock-backend.js ../demo/</automated>
</verify>
<done>Full suite green, production build succeeds, bundle grep proves the new UI string shipped, demo smoke passes, no IP leak, clean git state with per-task commits.</done>
</task>
</tasks>
<threat_model>
## Trust Boundaries
| Boundary | Description |
|----------|-------------|
| executor -> catalog.wavlake.com / CloudFront / Wikimedia | External bytes fetched at execution time get committed into the repo and served by the demo |
| demo visitor -> mock backend | Untrusted public visitors hit the new /api/peer-content route |
## STRIDE Threat Register
| Threat ID | Category | Component | Severity | Disposition | Mitigation Plan |
| T-hj1-01 | Tampering | Downloaded mp3/jpg assets | medium | mitigate | Verify every download with file(1) magic-type + plausible size before committing; fetch only from the verified catalog.wavlake.com/CloudFront/Wikimedia URLs over https |
| T-hj1-02 | Information Disclosure | Demo-served data | high | mitigate | Task 3 gate: grep for 146.59.87.168 across mock-backend.js and demo/ must be empty |
| T-hj1-03 | Tampering (path traversal) | /api/peer-content route | medium | mitigate | Route resolves content_id strictly against PEER_LIBRARY entries (whitelist lookup, never a filesystem path built from request input) |
| T-hj1-04 | Elevation | Paid-content gating in PeerFiles.vue | medium | mitigate | Paid-unowned image click opens the pay modal only; the free-image viewer path is reachable solely when access !== paid; no pre-purchase full-content fetch is added |
</threat_model>
<verification>
- `cd neode-ui && npx vitest run` — full suite green (195 tests baseline).
- `cd neode-ui && npm run build` — vue-tsc + vite build succeed; bundle grep for "Free · shared by peer" hits.
- Mock RPC smoke: Wavlake items searchable via browse-peer aggregation; paid purchase returns audio/mpeg real bytes and appends to owned-list; duplicate ids across all 12 peers <= 3; /api/peer-content answers 206 to a Range probe; owned-list seeded with dated purchases.
- `git log --oneline` shows one focused commit per task; nothing staged under indeedhub/.
</verification>
<success_criteria>
- A1/A2: both Wavlake tracks (real API metadata, real committed bytes) searchable in the peer-files search.
- A3: first Wavlake track is paid; demo ecash purchase autoplays it in the bottom GlobalAudioPlayer bar.
- A4: Cloud -> Paid Files shows seeded purchase history; audio purchases play on click; new purchases persist in the list.
- A5: at most 2-3 deliberately duplicated files across the aggregated peer catalog.
- A6: all photo peer files are real photographs matching their descriptions.
- A7/A8 (both builds): free image click opens the full-screen viewer, free audio -> bottom player, free video -> video modal, paid gating preserved; all payment paths open purchased media in-app.
- demo/peer-media/photo-*.jpg (all 10 replaced with real Wikimedia Commons photos)
commits:
- "14d1a453 feat(demo): Wavlake tracks, real photos, deduped peer catalog, working paid flow"
- "f52c5407 fix(peer-files): free-image lightbox, click-to-open routing, in-app open after every payment rail"
metrics:
duration: 64m
tasks: 3
completed: 2026-07-29
---
# Quick Task 260729-hj1: Peer-Files Media Batch (Wavlake, Paid Track, Real Photos) Summary
Two real Wavlake tracks (paid buy->autoplay flow now delivers real mp3 bytes), seeded purchase history, deduped peer catalog, ten real Commons photographs, and a shared-frontend fix so every peer file click opens the right viewer (free images finally open in the lightbox).
- **Wavlake tracks (A1-A3):** metadata fetched live from `catalog.wavlake.com/v1/tracks/{id}` (title/artist/album/duration confirmed against plan recon), mp3 bytes + 1400x1400 artwork downloaded from the CloudFront CDN and committed. `song-webfive` ("WEBFIVEFOURTHREETWOONE" by Zazawowow, 21 sats, PAID) and `song-michael-saylor` (free). Both verified as real MPEG audio with `file(1)`.
- **Real paid bytes (A3):** the shared `content.owned-get` / `download-peer-paid` / `-invoice` / `-onchain` handler now returns real disk bytes with the correct `mime_type` when the entry has them (text placeholder only for the fictional no-disk films/books). Ecash rail-deduction logic preserved verbatim.
- **Purchase history (A4):** per-session `sessionOwnedContent()` store lazily seeds 3 dated purchases (song-builders 100 sats — audio with real bytes; film-the-signal 2100; book-cypherpunk-essays 210), computed against the session's own federation onions/catalog slices. Every purchase path appends, so Owned badges survive the post-purchase `loadOwned()` refresh. `song-builders` was converted from free to paid (100 sats) so the seeded history contains a playable paid audio item without pre-owning the showcase Wavlake track.
- **Dedupe (A5):**`peerCatalogFor` is now a deterministic one-peer-per-item assignment (index % 12 against the session's node order) plus a 3-item POPULAR list (`song-webfive`, `film-block-height`, `photo-aurora-fjord`) that appears on exactly one extra peer each. Verified: 29/29 ids present, exactly 3 duplicated ids, both Wavlake tracks on trusted peers.
- **Session-stable onions:**`demoFederationNodes()` was regenerated (fresh random onions) on every RPC call; it is now memoised per session (`sessionFederationNodes()`) so catalogs, owned records and the Federation view agree.
- **Real photos (A6):** all ten `photo-*.jpg` picsum placeholders replaced with real Wikimedia Commons photographs (aurora over Lofoten fjord, Lago di Limides/Dolomites, Dotonbori Osaka neon, Erg Chebbi dunes, Black Forest mist, Cliffs of Moher, Iceland winter road, Stowe VT autumn, St Ives harbour, Grindelwald ridge hiker). Each is >=1920px wide, visually inspected, license-credited in its description; `size_bytes` updated to real sizes; filenames kept so `PEER_MEDIA` refs are unchanged.
- **Streaming route:** new `GET /api/peer-content/:onion/:content_id` serving `entry.disk` via `res.sendFile` (Range/206 works — probed). Whitelist lookup only (T-hj1-03), paid entries return 403 so full paid bytes are unreachable without purchase. Demo nginx already proxies `/api/` -> :5959; added the missing `/api` proxy to the vite dev config so dev works too.
- **Paid audio preview:**`content.preview-peer` serves a ~10% leading slice of the real mp3 for audio entries (the Preview button plays it), images unchanged.
- **A8 fix + A7 routing:** card click goes through `openItem()`: owned -> cached viewer/bottom bar; paid playable -> 10% preview; paid non-playable -> pay modal (image never fetched pre-purchase, T-hj1-04); free image -> existing Teleport-to-body viewer as a lightbox with `viewerUrl` pointed at the stream URL (no base64 round-trip); free audio/video -> `playMedia` (bottom bar / video modal).
- **Viewer footer:** "Owned · unlocked" (green) only for owned items; "Free · shared by peer" (neutral) otherwise — also the bundle-grep sentinel. Save button streams free files (`streamDownload`) instead of calling `content.owned-get`.
- **Unified post-payment:**`payWithLightning`, `pollInvoice` and `pollOnchain` now share `openPurchased()` with the ecash flow — mark owned, refresh owned list, audio autoplays in the bottom bar, image/video open in the viewer. `triggerDownload` remains for the explicit Save path.
- **Blob-URL hygiene:**`releaseViewerUrl()` only revokes `blob:` URLs (free items use plain URLs).
- Preview-thumbnail watcher deliberately NOT extended to audio (real nodes return audio bytes there). Cloud.vue/CloudFolder.vue verified to already open free images via MediaLightbox — untouched.
- Regression test added: free image click opens the lightbox.
### Task 3 — Verification
- Full unit suite: **697/697 passed** (baseline in plan said 195; suite has since grown — all green).
- `npm run build` (vue-tsc + vite): success; `grep -rl "Free · shared by peer" web/dist/neode-ui/assets/` hits `PeerFiles-DsotBwvS.js` (build did not no-op).
- Mock RPC smoke (20 checks, all pass): browse-peer aggregation contains both Wavlake titles; paid purchase deducts 21 sats ecash and returns `audio/mpeg` >1MB; purchase appended to owned-list; `/api/peer-content` answers 206 to a Range probe, 403 for paid items, 404 for unknown/traversal ids; paid audio preview is audio bytes; image previews still jpeg.
- Git: two focused code commits, nothing staged under `indeedhub/`, only other agents' pre-existing untracked files remain.
## Deviations from Plan
**1. [Rule 3 - Blocking] Session-memoised federation nodes.** The plan assumed onions were "random per session"; they were actually random per RPC call, which would have made seeded owned-content onions never match what the frontend sees. Added `sessionFederationNodes()` memoisation (per-visitor via the existing session store). Commit 14d1a453.
**2. [Rule 2 - Missing critical] `/api` dev proxy in vite.config.ts.** The demo nginx proxies `/api/` to the mock, but the vite dev server did not — the new route (and the existing `/api/blob`, `/api/app-catalog`) would 404 on :8100 dev. Added a `/api` proxy entry (dev-server-only config; real nodes use nginx). Commit 14d1a453.
**3. [Minor scope choice] `song-builders` converted free -> paid (100 sats).** The plan required a seeded audio purchase backed by real disk bytes, but the only paid audio item is the showcase Wavlake track, which must NOT be pre-owned (it would kill the A3 buy demo). Making one existing song paid provides a legitimately purchasable audio item for the seeded history and an Owned-badge example in the gallery.
## Post-Deploy Live Checklist — http://146.59.87.168:2100 (orchestrator deploys)
1. **Search (A1/A2):** Cloud -> search "Zazawowow" (or "WEBFIVE" / "Michael") — both tracks appear as peer-file results.
2. **Paid buy -> autoplay (A3):** open the peer holding "Zazawowow - WEBFIVEFOURTHREETWOONE.mp3" (21 sats), Buy -> ecash -> Pay: the bottom GlobalAudioPlayer bar appears and the track audibly plays; card flips to green "Owned".
3. **Preview before buying (A3):** the paid track's Preview button plays ~25s of real audio, not silence/artwork.
4. **Paid Files tab (A4):** Cloud -> Paid Files shows 3+ seeded purchases with sats + dates; clicking "Builders, not talkers (Remastered).mp3" plays it in the bottom bar; the fresh Wavlake purchase from step 2 is now listed too.
5. **Dedupe (A5):** browsing several peers, each file appears on ~1 peer; only 3 files (WEBFIVE track, Block Height film, aurora photo) appear on two.
6. **Real photos (A6):** photo cards show real photographs (aurora, Dolomites lake, Osaka neon, dunes, mist, cliffs, Iceland road, Vermont autumn, St Ives, Grindelwald) — no picsum grey placeholders.
7. **Free image lightbox (A8):** clicking any photo card opens the full-screen viewer with backdrop; footer reads "Free · shared by peer"; Save downloads it.
8. **Free audio/video (A7):** clicking "Zazawowow - Michael Michael Saylor.mp3" (free) plays in the bottom bar; a free video (if on the browsed peer) opens the video modal.
9. **Paid gating (A7):** a blurred paid image click opens the pay modal, never the image; paying via the Lightning QR path also opens the content in-app (no orphan browser download).
## Self-Check: PASSED
- demo/content/music/Zazawowow - WEBFIVEFOURTHREETWOONE.mp3 — FOUND (MPEG audio)
- demo/content/music/Zazawowow - Michael Michael Saylor.mp3 — FOUND (MPEG audio)
- demo/peer-media/artwork-webfive.jpg, artwork-michael-saylor.jpg — FOUND (JPEG)
- All 10 demo/peer-media/photo-*.jpg — FOUND (JPEG, >=1920px wide)
- "Connected-nodes card (dashboard Web5 view): the visible nodes list grows/shrinks to fill the card so the gap above the Find Nodes / Refresh buttons is a constant pt-4, regardless of how tall the sibling Node Visibility card makes the xl 2-col grid row (QUICK-JE5-01)"
- "Below xl (single-column/mobile) the list keeps its current max-h-72 cap — no visual/sizing change (QUICK-JE5-01)"
- "Demo build opened inside the Android companion WebView shows NO typing splash and NO /onboarding/intro — it lands on /login as if the intro was already seen (QUICK-JE5-02)"
- "Browser/PWA demo intro behavior is byte-identical to today (replays on every fresh root boot); the skip path writes NOTHING to localStorage (QUICK-JE5-02)"
- "Non-demo builds are a complete no-op for both changes' runtime behavior (QUICK-JE5-02)"
- "All existing unit tests stay green (697) and npm run build succeeds with the new strings present in the built bundle"
artifacts:
- "neode-ui/src/views/web5/Web5ConnectedNodes.vue (flex/scroll fix, no design change)"
- "neode-ui/src/App.vue (companion+demo intro gate at both IS_DEMO branch sites)"
- "neode-ui/src/views/RootRedirect.vue (companion+demo intro gate at both IS_DEMO branch sites)"
key_links:
- "isCompanionApp() from neode-ui/src/utils/openExternal.ts is the single companion-detection source at all four IS_DEMO intro branch sites (same convention as stores/appLauncher.ts lines 224/329)"
- "Web5ConnectedNodes.vue card root is already `flex flex-col`; the fix works entirely inside that column flex (list = flexible middle, footer = non-shrinking bottom)"
---
<objective>
Two small, isolated UI fixes in neode-ui (shared by real + demo builds):
1. **QUICK-JE5-01 — Connected-nodes list flex fix (both builds):** the scrollable nodes
list in the dashboard "Connected Nodes" card must always end at a consistent margin
above the bottom Find Nodes / Refresh buttons, even when the sibling card
(Web5NodeVisibility) stretches the shared grid row. Today the list is hard-capped at
`max-h-72` and the footer uses `mt-auto`, so a tall sibling opens a growing dead gap
between list and buttons.
2. **QUICK-JE5-02 — Companion app skips demo intro (demo build only):** when the demo
runs inside the Android companion WebView (`window.ArchipelagoNative` bridge
injected), skip the typing splash + `/onboarding/intro` entirely and land on /login,
without touching any state the browser demo relies on. Desktop/PWA browser demo
intro must be completely unaffected — the user resets and relies on it before demos.
Purpose: fix a visible layout bug on every node dashboard, and stop the companion app
from replaying the demo cinematic every time the demo is opened in-app.
Output: 2 focused commits on main (frontend only), tests green, verified build.
<files>neode-ui/ (build only — no new source edits expected)</files>
<action>
Per CLAUDE.md "Build / verify" and "Commit & push every unit of work":
1. `cd /home/archipelago/Projects/archy/neode-ui && npm run build` (vue-tsc + vite;
outputs to web/dist/neode-ui). Build must succeed with zero type errors.
2. Bundle grep (build can silently no-op — always grep the built output):
- Fix 1: `grep -rl "xl:max-h-none" /home/archipelago/Projects/archy/web/dist/neode-ui/assets/` must match at least one asset (the new Tailwind class proves the fresh component shipped).
- Fix 2: the gate is inside IS_DEMO code, which a non-demo build may fold away, so
verify against a scratch demo build:
`VITE_DEMO=1 npx vite build --outDir /tmp/claude-1000/-home-archipelago-Projects-archy/3ca40190-d6bb-4f98-9d89-8d2479484065/scratchpad/demo-dist --emptyOutDir`
then confirm a JS chunk contains BOTH the `demoRoute` log string and
`ArchipelagoNative` (heuristic that the companion gate survived into the demo
- Manual spot-check (optional, dev preview :8100 or `npm run dev:mock`): on a wide (xl) window, pad the Node Visibility card content tall and confirm the connected-nodes list expands so the buttons keep an unchanged pt-4 gap; on a narrow window the card looks exactly as before.
- Grep confirms no localStorage writes were added in App.vue/RootRedirect.vue skip paths.
</verification>
<success_criteria>
- QUICK-JE5-01: nodes list ends at a constant pt-4 above the bottom buttons at any xl row height; sub-xl sizing unchanged; no visual design changes.
- QUICK-JE5-02: companion WebView + demo lands on /login with no splash and no /onboarding/intro; browser/PWA demo and non-demo builds byte-identical in behavior; no intro-state storage writes from the skip path.
- Tests green, build verified via bundle grep, two clean path-staged commits, nothing from indeedhub/ touched.
</success_criteria>
<output>
On completion create `.planning/quick/260729-je5-ui-fixes-connected-nodes-scrollable-list/260729-je5-SUMMARY.md` (committed by the orchestrator, not the executor).
- "RootRedirect skip paths deliberately do NOT call log() — log() writes sessionStorage (archipelago_boot_log) and the skip path must write nothing to storage"
- "Cheap test option chosen: unit test for isCompanionApp() bridge detection (the skip's driving mechanism) instead of heavy RootRedirect mount scaffolding"
Connected-nodes list now flexes to fill the xl grid-row (constant pt-4 gap above Find Nodes/Refresh) and the Android companion demo skips the intro straight to /login via isCompanionApp() gates at all four IS_DEMO branch sites, with zero storage writes.
### QUICK-JE5-01 — Connected-nodes list flex fix (`b80e7c34`)
- All three v-show tab panes (Trusted line 57, Observers line 90, Requests line 120) changed from `space-y-2 max-h-72 overflow-y-auto` to `space-y-2 flex-auto min-h-0 overflow-y-auto max-h-72 xl:max-h-none` — the visible pane is now the flexible middle of the card's existing column flex, growing to absorb row height from a tall sibling Web5NodeVisibility card and scrolling internally when constrained.
- Below xl (single-column grid) the `max-h-72` cap remains — sub-xl sizing byte-identical.
- Footer div gets `shrink-0` (kept `mt-auto`) so the buttons can never be compressed.
- No other markup/design changes; Web5.vue grid row (line 59) untouched as planned.
All four IS_DEMO intro branch sites gated on the existing `isCompanionApp()` from `@/utils/openExternal` (static import added to both files):
1. `App.vue` line 435: `if (IS_DEMO && bootPath === '/' && !isCompanionApp()) replayRequested = true` — companion never requests the splash replay; with the mock backend reporting onboarded, `shouldShowIntroSplash` then suppresses the splash.
2. `App.vue` post-splash block (~line 592): companion+demo routes directly to `/login` + `reveal()` (mirrors the seenOnboarding===true branch), avoiding the status-check retry ladder.
3. `RootRedirect.vue``proceedToApp()` (~line 87): companion+demo → `router.replace('/login')` instead of `demoRoute()`.
4. `RootRedirect.vue` onMounted server-up branch (~line 160): same gate.
**Hard invariants verified:**
- Zero storage writes on any skip path — RootRedirect skip paths intentionally do NOT call `log()` because it writes `sessionStorage.archipelago_boot_log`; diff grep for added `localStorage|sessionStorage` lines matched only a comment.
- Non-demo builds: `isCompanionApp()` sits on the right of `IS_DEMO &&` / inside `if (IS_DEMO)` blocks — never reached when IS_DEMO is false (compile-time false in non-demo builds; demo scratch bundle confirmed dead-code folding of the non-demo path).
- Browser/PWA demo: no bridge → `isCompanionApp()` false → all four sites behave byte-identically (intro replays on every fresh root boot).
New test `src/utils/__tests__/openExternal.test.ts`: 3 cases asserting bridge detection (no bridge → false; bridge with openInApp → true; bridge without callable openInApp → false).
## Verification
- Full unit suite: **700 passed, 0 failed** (697 baseline + 3 new).
- Scratch `VITE_DEMO=1` build (scratchpad only, not committed): the plan's single-chunk co-occurrence grep did not match because Vite splits chunks — `demoRoute` lives in `RootRedirect-*.js` while `ArchipelagoNative` lives in the shared `index-*.js`/`Dashboard-*.js` chunks. Verified semantically instead (stronger): RootRedirect chunk contains both gated sites compiled as `if(E()){_.replace("/login")...;return}x();return` where `E` is imported from the index chunk whose `isCompanionApp` implementation checks `openInApp=="function"` on `ArchipelagoNative`.
- `web/dist` is gitignored — left untouched per plan; no dist output committed.
- Both commits path-staged, submodule guard run before each, no `indeedhub/` paths (`git show --stat` clean), `Co-Authored-By: Claude` trailer present.
- Pre-existing untracked files from other agents (`.planning/phases/01-.../01-PATTERNS.md`, `scripts/resilience/.gitignore-reports.tmp`) left alone.
- **Issue:** The plan's heuristic (`grep -rl demoRoute ... | xargs grep -l ArchipelagoNative`) assumes both strings land in one JS chunk; Vite's code splitting puts them in different chunks.
- **Fix:** Verified the actual gate in the RootRedirect demo chunk (minified `if(E()){replace("/login")}` at both sites, `E` = isCompanionApp import) and the `openInApp=="function"` detection in the index chunk.
- **The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.** BTCPay Server published 2.4.2 to close a flaw that was being actively exploited — a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an "Update" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version — including the fallback installer, which would have installed it outright — has been corrected.
- **An app now reports its own version, not a helper's.** Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as "15.17", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.
- **Your node issues its own certificate, so apps stop being flagged as insecure.** Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.
- **An app that is still starting says "starting".** It previously reported "App not reachable", which reads as a failure when the app is simply warming up.
- **Updates and app downloads now come from a proper domain name.** They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off — the signature, not the address, is what makes either source safe.
- Also in this release: the tool app developers run to check their app description no longer rejects every valid file (it needed a program most machines do not have, and reported the missing program as a broken file); and the node's own security audit, which had been reporting all-clear, now actually inspects the files where credentials had been sitting.
- Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source — machine names, addresses and internal working notes — ahead of the code being opened to the public. No behaviour changes for your node.
- Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.125-alpha (2026-08-06)
- **The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.** Since the gate went up, those screens loaded their frame and then showed every number as unreachable. The gate was deliberately hiding your login from the apps it protects — right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential, and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins — Vaultwarden, Nextcloud, Gitea — on every single request; that stops too.
- **IndeeHub heals itself.** Three separate faults fixed: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted roughly ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage — it would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. The storage fault fixes the same trap for every future multi-part app.
- **A missing piece of a running app now gets put back automatically.** If one container of a multi-part app disappears while its siblings are still running, the node treats that as a hole to repair rather than a choice to respect, and rebuilds the missing piece. An app you actually uninstalled stays uninstalled.
- **Send and Receive open clean every time.** Whatever you typed last — an address, an amount, and above all an armed "send all funds" toggle — no longer quietly carries over into the next payment. Choosing "send all funds" also shows the amount being swept instead of a confusing 0.
- **A sweep that cannot happen now says why.** Trying to sweep a balance that is below Bitcoin's dust minimum (about 546 sats) or not yet confirmed used to fail with "check server logs"; it now explains that no transaction can be built from those coins.
- **The camera scanner option no longer vanishes on desktop.** Browsers only allow the live camera on secure (HTTPS) pages, and the scan window silently hid the camera choice on plain connections — which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.
- **App data folders can no longer be "repaired" into a state the app cannot use.** When the node fixed a folder's ownership through its fallback path, it wrote the container's raw user number instead of the translated one, so the fix reported success while the app still could not open its own files — one node's BotFights restarted every ten seconds over exactly this. The translation is now applied.
- Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.124-alpha (2026-08-05)
- **The most important fix in this release: some nodes were left switched off by their own update, and could not switch themselves back on.** The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it *crashes*. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down for over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts, so it survives every future update.
- **Portainer opens again.** Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1, their stored data was written by that version, and the app list pinned a version from two years earlier — so when the container was rebuilt it landed on the old one, which will not read newer data. The correct version is now pinned, older installs upgrade cleanly, and no data was touched.
- **Bitcoin starts reliably again.** A leftover settings file in the Bitcoin folder — one the node itself kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes that file, removes stale copies, and treats any that remain as harmless.
- **Every app screen opens from My Apps again.** The login gate refused to be displayed inside another page at all, which is exactly how My Apps opens an app, so protected apps appeared broken. It now allows only your own node to display it, and refuses everyone else — a distinction the old setting could not express.
- **The app login screen now looks like the node's own.** Same rotating artwork, the same panel, the Archipelago mark, and the app's real icon shown as a tile the way My Apps shows it, instead of a plain box with a letter.
- **The Mesh screen uses wide displays properly.** On very large screens it stacked all five panels on top of each other, clipping three of the headings to a sliver and squeezing the map into a letterbox — more screen producing a worse view. It now shows one panel at a time, filling the space, with the map running edge to edge.
- **You can choose how long you stay signed in.** Settings → Account now offers an inactivity timeout and a hard limit, plus an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle, because there is nobody there to sign them back in.
- Updates now come from `source.archipelago-foundation.org` rather than a bare address, with the old one kept as an automatic fallback for nodes whose clock or name lookup is off. Also included: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.
- Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.123-alpha (2026-08-05)
- **Five more screens on your node were readable by anyone who could reach it, and the previous release's own check said they were fine.** The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they work differently from ordinary apps: they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It therefore neither protected them nor listed them as unprotected. A check that reports success while five screens are open is worse than no check, and this was found by scanning the node from another machine rather than asking the node about itself.
- **What was actually readable was the page, not your money.** Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was verified directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.
- All five now serve only to the node itself, with the login gate in front of them, exactly like the twenty app screens closed in the previous release.
- **Every port on the node now has a stated policy — there are no undecided ones left.** Eleven ports previously had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now require the node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections (federation members authenticate to the federation), NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session, and its dashboard needs its own certificate), Pine's secure listener, and the Lightning REST port, which wallets reach with a macaroon exactly as before.
- Fresh installs are covered too, not just existing nodes. The five screens are delivered as prebuilt images, so a newly flashed node would have come up open even after this fix. All five were rebuilt, published, and then pulled back and inspected to confirm the fix is really inside them.
- Two delivery faults fixed alongside, either of which would have silently undone the above: two of the five screens were reaching nodes through no update path at all, so edits to them never arrived; and a fourth copy of the Bitcoin screen's configuration was being rewritten on every health check, which would have re-opened that port after everything else was corrected.
- Known gaps, disclosed rather than buried: non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.
## v1.7.122-alpha (2026-08-04)
- **Your apps now ask for your node password before they open — over your home network, Tailscale, the mesh and Tor alike.** Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app. This completes the groundwork disclosed in v1.7.121.
- **The things that must stay open stayed open.** Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers — those connections carry their own proof of identity and a login page would simply break them. Every one of these seventeen exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.
- **A private address on your node was answering the mesh without a password.** One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while verifying the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.
- **Tor addresses no longer skip the login.** An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session cookie to check. The login gate now takes those addresses first, closing the last of the four routes that went around it.
- Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after the signed list told them to move, and each would otherwise have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting. Verified by putting a node back into the old state deliberately and watching it repair.
- The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened on a test node. Both now come from the signed list, and a port withdrawn from the login gate is released without needing a restart.
- **The key that signs these updates has been replaced.** The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.
- Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen — and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting caused both incidents behind this work. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — will now meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.
## v1.7.121-alpha (2026-08-04)
- **Making another node "Trusted" now asks for your node password.** Trust was being handed out by machines rather than by you: any node able to reach yours could join and mark itself Trusted, because the check proved only that the caller owned the key it had just presented — never that you had approved it. Trust also spread on its own, since every peer a Trusted node advertised was added as Trusted too, so one grant quietly propagated across the whole federation. Uninvited joins are now capped at Observer, advertised peers arrive as Observers, and raising anyone to Trusted — whether by generating an invite or by changing the dropdown on a node — requires your password. Lowering trust deliberately does not, because the safe action must never be the inconvenient one. Existing peers are left exactly as they are rather than silently demoted, and each one now records how its trust was granted so you can review them.
- **Nodes you have peered with can be messaged straight away.** Peering was not enough: you also had to be within LoRa radio range of the other node once before chat would work. The node picked how to send a message based on which radio was plugged in, and only one of those paths knew how to reach a peer over the mesh's internet transports — so on a node with a different radio, or no radio at all, messaging a peer you had just federated with simply failed until a radio contact happened to appear. Peered nodes are reachable without radio by definition, so that choice no longer depends on the hardware. Radio is still preferred when the other node is actually in range and the message fits.
- The dashboard no longer flickers a vertical line across its cards. A rendering seam appeared at random while moving the mouse, because the two large cards used a background-blur effect that this system already disables everywhere else on the dashboard — that browser mis-draws it inside the dashboard's animated container, and these two cards had been missed when the workaround was written. Diagnosed from a single screenshot rather than by trying to reproduce it.
- The Lightning screen will actually update from now on. Its image was set to "latest", and the container system will not re-fetch a label it already holds, so nodes kept the same Lightning screen forever no matter how many updates shipped. A separate copy of the same setting used only by brand-new installs also described the screen incorrectly, so fresh installs got a screen that never answered.
- Apps that provide their own screens stop rebuilding themselves in a loop. On this system's own node one of them rebuilt every thirty-five seconds indefinitely, burning processor time and restarting the app each round. The node decided a rebuild was needed by comparing file dates against the image's creation date, but a rebuild that changes nothing reuses the existing image and leaves that date untouched — so the condition that triggered the rebuild was still true afterwards, forever. Nodes taking this update repair themselves the first time they check.
- Groundwork you can see but that does not change access yet: the node can now tell you which of its app ports answer without a login, and every port that is deliberately open — Bitcoin's peer connections for syncing the chain, Lightning's wallet connections, the Electrum wallet protocol — now has to state in writing why it is safe, so the list of exceptions is something you can read rather than something you have to discover. The login gate that will sit in front of the rest is built and proven working end to end on a real node, but it is not yet closing any ports; that arrives with the signed app catalog that tells each app to hand its address over.
- Releases can no longer ship an unsigned update file. Signing was skippable, and when it was skipped the release was still committed and tagged — producing an update that every node correctly refuses to install. It had been caught by hand every cycle; now the release simply stops.
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. App ports other than the deliberate exceptions above are still reachable without a login — the gate reports them, and closing them needs the next signed catalog. Three voice-assistant ports are open without authentication and should not be; the correct fix puts them on a private network with the assistant instead, which needs testing on a node that runs both. Two nodes on the fleet still share SSH host keys (detection shipped, rotation remains a deliberate operator decision).
## v1.7.120-alpha (2026-08-02)
- **Security, and the reason to take this update: two ports on your node handed anyone who could reach them complete control of your money, with no password.** The Lightning app's port answered a plain web request with the LND admin macaroon, the TLS certificate and the node's onion address — everything needed to drain the wallet remotely, and the onion meant an attacker kept that ability even after losing access to your network. The Bitcoin app's port reached Bitcoin Core's control interface using credentials the node itself supplied on the caller's behalf, with a wallet loaded. Anything on your home network, your Tailscale network or the mesh could use either one. Both now require you to be logged in. If your node has been reachable by anyone you do not fully trust, treat the Lightning macaroon and the Bitcoin RPC password as known to them.
- The Bitcoin and Lightning app screens can no longer be published as public Tor addresses automatically. They were one app-id away from being handed a worldwide, permanent address as a silent side effect of being installed — which would have re-opened the hole above to the entire internet. Turning Tor on for them deliberately still works; it just never happens on its own.
- **Fixes shipped inside the program now actually reach apps that your system keeps running.** A container the node had been told to uninstall, but that the system service manager kept alive anyway, was quietly skipped by the part of the node that applies configuration — so it never received updates that shipped with the program. This was found the hard way: the Bitcoin control-interface fix above appeared to be installed and silently was not, while the Lightning half applied correctly, which is the most misleading way for a security fix to fail. Both halves are now proven to land on a real node.
- The Lightning and Bitcoin node screens have been rebuilt to match what umbrelOS offers. Lightning gains Overview, Channels, Activity, Insights, Connect and Settings tabs with a sats/BTC switch; Bitcoin gains Insights, Peers, Connect and Sharing. Along the way: every copy button on those screens silently did nothing (the browser blocks clipboard access inside an embedded page) and now works; the channels link led to a dead page; and Node ID showed a bare key instead of the full address someone can actually connect to.
- Updates to the Bitcoin screen show up without a hard refresh. The page was being cached by the browser, so a freshly updated screen kept rendering the previous one.
- The AI sidebar loads again. It was asking for its program files at an address that pointed at the main app's files, where they do not exist, so it silently loaded nothing.
- The navigation above the bottom bar no longer follows you between screens. Back buttons and the mesh tab bar stayed pinned over every other page once you had visited the screen that owns them. Keeping tabs loaded in the background — the change that made switching between them instant — means leaving a screen hides it rather than destroying it, and this floating navigation sits outside the screen it belongs to, so it was never being hidden with it. It is now tied to whether its own screen is on display. The speed is unchanged: the screens are still kept loaded, so returning to one is still instant.
- Wallet: Lightning actions are now offered based on whether you actually have a usable channel rather than just a running node, sending is gated the same way, and an invoice you cannot yet receive offers to install a Lightning node instead of simply failing.
- Onboarding and viewing fixes: the "I have written down my recovery words" tickbox is findable on short screens, paid pictures and videos open in the app's own viewer with a visible loading state instead of a blank browser tab, picture-in-picture survives changing tabs, and the FIPS/Tor labels on peer cards stay put instead of wrapping into the card below.
- Key-material hardening across the node: a node that is already set up refuses to have its identity replaced by an unauthenticated request; first-boot secret generation now fails loudly instead of silently continuing with shared keys; the node proves its TLS certificate and key are actually a matching pair; the Bitcoin Core wallet path that kept a second copy of your spending key outside the encrypted store has been removed; and every place the node generates a key, token or nonce now names its source of randomness explicitly, enforced at build time.
- Federation and mesh: a rotated gateway credential now reaches the already-running container instead of leaving the old one in place, sync failures are surfaced to you instead of being swallowed, and nodes can share their Lightning connection details with a chosen peer over the mesh — the groundwork for opening channels with nodes you already talk to.
- Known gaps, disclosed rather than buried: the 5x real-node lifecycle gate was not run for this release. Two nodes on the fleet still share SSH host keys with each other (detection shipped, rotation is a deliberate operator decision and has not been performed). Bitcoin Core can now reach Tor from its container, but is not yet routed through it — the network mode is becoming a setting you choose, and until then Core's peers remain on the clear internet.
## v1.7.119-alpha (2026-07-31)
- Wallet payments now work on nodes whose channels are private/unannounced. Every invoice-creation call site — the wallet's own Receive flow, and the seller-side paid-content/peer-files flow — only ever sent LND the amount and memo, so LND defaulted private to false and returned invoices with no route hints. Any node whose only usable channel is private or unannounced (the common shape for a channel someone opened to you) was silently unpayable through the wallet, and unpayable through paid file/content sales too. Both call sites now set LND's private flag correctly; this was broken in the field and is the main reason for this release.
- Tor and the mesh's Tor fallback are reliable again. The node's background "doctor" health-checker was fighting Tor over the permission bits on its own hidden-service directory: it compared the directory's mode against the literal string "700", but Tor's own setgid hidden-service mode is 2700 — a value the doctor's check never recognized as correct. Every ~5 minutes it "corrected" the mode back to 700 and restarted Tor to apply it, and Tor immediately reasserted 2700 — a permanent restart loop that meant Tor could never hold onto its consensus/HSDir cache long enough to be useful, breaking the mesh's Tor fallback path entirely. The check now compares only the owner/group/other bits that actually matter (both 700 and 2700 pass; genuinely wrong modes like 750 or 2755 are still corrected and restart Tor), plus a 30-minute restart backoff so no future condition can reproduce the storm.
- Wallet balances and your node's own FIPS identity key (npub) are no longer written to the browser's sessionStorage — caught by an audit of the page-caching work below. Every cache call site in the app now makes an explicit, reviewed decision about whether its data is allowed to persist across a reload, and a one-time migration purges any legacy, unaudited snapshot left behind by an older build.
- Server, Home, Mesh, Chat/AI chat, and the secondary screens (app details, marketplace, cloud, federation, monitoring, router/OpenWrt) now load instantly from cache when you revisit them and refresh quietly in the background, instead of blanking and re-fetching everything on every tab switch — this closes out the page-performance work started back in v1.7.116/117.
- App updates (including this one) now apply automatically in the background instead of waiting on a tap-to-update prompt, matching how kiosk/TV installs already behaved — the reload still waits for any in-progress splash/dashboard animation to finish first, so it won't land mid-motion. This was a direct, explicit decision made with the mid-payment-reload risk spelled out in advance; reverting to a confirmation prompt for beta is a one-line change if wanted later.
- Known gap, disclosed rather than buried: the project's 5x production lifecycle gate (install/UI/stop/start/restart/reinstall/reboot-survive/archipelago-restart-survive/uninstall, run on a real node — CLAUDE.md's own definition of done before a release tag) was NOT run for this release, because its target node was unreachable and running it here would have required rebooting a shared, live build machine out from under other active work. This release's own automated gates (release-gate harness, strict catalog-drift check, the full cargo test suite, a mount-level ISO smoke test, and a headless QEMU boot test) all still ran and passed — this is specifically about the separate 5x real-node lifecycle gate, which is still outstanding and should be run as soon as the node is reachable again.
## v1.7.118-alpha (2026-07-29)
- Fixes mesh radios dropping off on nodes that took the v1.7.117 update. Updates only ever replaced the main program, never the packaged radio helpers — so updated nodes were left running an older radio daemon that didn't understand a new option and quietly gave up, showing "device not connected" with a Connect button that did nothing. The node now checks what its radio daemon supports before using new options, and updates finally carry the radio helpers themselves, so every node gets current radio support with the update instead of only from a fresh install.
@@ -416,7 +507,7 @@
- NetBird's browser proxy now sends API, OAuth, relay, WebSocket, and management traffic through the stable host-published server port at `169.254.1.2:8086`, avoiding stale rootless Podman DNS/IPs after `netbird-server` restarts.
- Mobile App Store category chips now stay visible above the tab bar, Discover is available on mobile, and category selection updates the page route/query so the selected category is actually shown.
- Apps that require a real browser tab now open directly from the app icon tap instead of first entering an in-shell app-session route, including BTCPay, Grafana, Home Assistant, Vaultwarden, Nextcloud, Portainer, OnlyOffice, Tailscale, Uptime Kuma, Gitea, and Nginx Proxy Manager.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on `100.70.96.88` confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
- Validation passed with catalog JSON checks, `npm run type-check`, `cargo fmt --all --check --manifest-path core/Cargo.toml`, and `cargo check -p archipelago --manifest-path core/Cargo.toml`; live checks on a fleet node confirmed Saleor dashboard `9010`/API `8000` and NetBird API/OAuth routes survive `netbird-server` restart.
## v1.7.75-alpha (2026-05-19)
@@ -449,7 +540,7 @@
## v1.7.71-alpha (2026-05-19)
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on `100.70.96.88` where Podman rejected the missing host directory.
- NetBird stack installs now pre-create `/var/lib/archipelago/netbird/data` before binding it into `netbird-server`, fixing the failed install/start path seen on a fleet node where Podman rejected the missing host directory.
- NetBird start/restart ordering now starts `netbird-server` before the dashboard container so lifecycle actions bring the control plane up before the UI.
- App-session invalid IDs and panel-mode fallbacks now return to `/dashboard/apps`, avoiding the stale `/apps` route that could render a 404.
- Mobile launches for apps that block iframes now stay inside the Archipelago app-session fallback instead of automatically opening an external browser tab.
@@ -467,7 +558,7 @@
## v1.7.69-alpha (2026-05-19)
- App installs now allow up to 10 minutes for the initial `package.install` RPC to return, matching slow container image pulls and preventing apps from disappearing from My Apps while the backend is still pulling or retrying mirrors.
- Live diagnostics on `100.70.96.88` confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Live diagnostics on a fleet node confirmed the Gitea install did not fail; the primary registry pull timed out after 300 seconds, the fallback mirror succeeded, and Gitea came up healthy on `3001` while the frontend had already timed out at 15 seconds.
- Gitea and other Docker-image app installs now stay visible during slow registry pulls instead of being marked as failed by the browser before backend install progress can complete.
- Gitea is now categorized as a known Data app in My Apps, so a running Gitea container appears with installed apps instead of being filtered into the Websites/Services split.
- NetBird `0.71.2` is now available in the app catalog and fallback marketplace data as a recommended networking app using the official `docker.io/netbirdio/netbird:0.71.2` image.
@@ -485,8 +576,8 @@
- App session close buttons now return to the previous dashboard screen when possible and otherwise fall back to My Apps, avoiding the 404 page after closing an app launched from an invalid or stale history entry.
- System Update confirmation and mirror modals now teleport to the document body with a full-screen overlay, so they cover the whole app instead of only the right-hand dashboard panel.
- Mobile app launches stay inside Archipelago's app-session webview and hide desktop-only new-tab launch affordances, including apps such as Home Assistant that previously looked like they would leave the mobile shell.
- Live recovery on `100.70.96.88` upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed `spay.tx1138.com`/`www` redirect to BTCPay login over HTTPS and `sapien.tx1138.com`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
- Live recovery on a fleet node upgraded only the `btcpay-server` container to `docker.io/btcpayserver/btcpayserver:2.3.9`, preserved the existing datadir and Postgres database, and confirmed the container is healthy after a pre-upgrade backup.
- Public validation confirmed ``the BTCPay host`/`www` redirect to BTCPay login over HTTPS and `the L484 host`/`www` serve the L484 page over HTTPS using the issued Let's Encrypt certificates.
## v1.7.67-alpha (2026-05-18)
@@ -495,18 +586,18 @@
- Settings What's New is filled through `v1.7.67-alpha`, including the missing historical `v1.7.44-alpha` through `v1.7.66-alpha` entries.
- Bitcoin/Knots/Core shell lifecycle specs now match the Rust app config memory policy: 8 GiB on normal hosts, 4 GiB on low-memory hosts, and pruned Knots uses a larger dbcache on hosts with enough RAM to improve IBD throughput.
- ElectrumX/electrs shell lifecycle specs now match the 4 GiB memory policy used by the Rust app config, reducing drift between first boot, reconcile, and app lifecycle paths.
- Live assessment of `100.70.96.88` identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
- Live assessment of a fleet node identified the current IBD bottlenecks as CPU/thermal/I/O pressure rather than RAM exhaustion, with follow-up work planned for existing-node swap repair, kiosk Chromium CPU reduction, and reconcile failure cleanup.
## v1.7.66-alpha (2026-05-18)
- Nginx Proxy Manager stale-port repair now detects stopped or `Created` Podman records by inspecting `podman ps -a` port metadata, covering records where `podman port nginx-proxy-manager` returns no mapping until start.
- Live recovery on `100.70.96.88` removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
- Live recovery on a fleet node removed only the stale Nginx Proxy Manager container record and recreated it with `8081:81`, `8084:80`, and `8444:443`, preserving `/var/lib/archipelago/nginx-proxy-manager` data.
- Validation confirmed Nginx Proxy Manager recovered as healthy and responds through direct admin port `8081`, host compatibility port `81`, and `/app/nginx-proxy-manager/`.
## v1.7.65-alpha (2026-05-18)
- Orchestrator-backed app starts now run the same pre-start repairs as the legacy Podman path, so Nginx Proxy Manager stale `81:81` container metadata is removed and recreated before the orchestrator tries to start it.
- Live diagnostics on `100.70.96.88` confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
- Live diagnostics on a fleet node confirmed host nginx is healthy while Nginx Proxy Manager has no listeners on `8081`, `8084`, or `8444`, causing host nginx `502` responses for NPM proxy paths.
## v1.7.64-alpha (2026-05-18)
@@ -531,7 +622,7 @@
- Multi-container stack installs now keep their app card in the `Installing` state for up to 20 minutes while dependency containers are being pulled and prepared.
- BTCPay Server installs no longer appear to vanish or fail after two minutes while Postgres and NBXplorer are still being created before the primary `btcpay-server` container exists.
- The stale-transition escape hatch remains short for start, stop, restart, update, and removal operations, so genuinely wedged lifecycle actions still recover quickly.
- Live validation on `100.70.96.88` confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
- Live validation on a fleet node confirmed BTCPay Server completed installation and responds on port `23000` with the expected HTTP redirect.
## v1.7.60-alpha (2026-05-18)
@@ -539,7 +630,7 @@
- Mesh radio auto-detection now skips known non-mesh serial devices such as Sierra Wireless LTE modems and Zooz/Z-Wave sticks, avoiding interference with production peripherals.
- Meshtastic config sync now sends `want_config_id` with the correct protobuf wire type, fixing radio-side `ignore malformed toradio` errors and allowing node-info/contact ingestion.
- The stable `/dev/mesh-radio` udev rule no longer claims every `ttyACM*` device; it only matches known mesh USB serial adapters and known USB CDC ACM radio vendors.
- Live validation on `100.70.96.88` confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
- Live validation on a fleet node confirmed Archipelago selects `/dev/ttyUSB0`, identifies the Meshtastic node, and refreshes 103 mesh contacts.
## v1.7.59-alpha (2026-05-17)
@@ -563,7 +654,7 @@
- Host nginx now serves `/assets/*` hashed frontend chunks as immutable static files with a hard 404 on misses instead of falling back to `index.html`, preventing strict MIME errors when a browser has a stale pre-update HTML shell.
- The SPA HTML shell and service-worker files now revalidate on every load, reducing stale frontend references after OTA updates.
- OTA runtime promotion now installs the bundled `nginx-archipelago.conf` into `/etc/nginx/sites-available/archipelago` and reloads nginx after a successful config test, so frontend cache/fallback fixes reach existing nodes without a manual deploy.
- Local validation passed with `cargo check -p archipelago`; live SSH testing against `100.70.96.88` was not completed because temporary public-key authentication was rejected on the target.
- Local validation passed with `cargo check -p archipelago`; live SSH testing against a fleet node was not completed because temporary public-key authentication was rejected on the target.
## v1.7.57-alpha (2026-05-17)
@@ -644,7 +735,7 @@
- Health monitor no longer pages "Auto-restart failed" for orphaned containers. After a variant switch (bitcoin-core ↔ bitcoin-knots) the previous variant's container could survive uninstall and the health monitor would try restarting it forever. Now skipped silently with a debug log.
- Apps no longer disappear from My Apps when an install fails. The card stays visible with state=Stopped so the user can retry or uninstall, with the failure reason surfaced via the new install_progress.message field.
- "Downloading…" progress now actually advances during multi-image stack pulls. Was sticking at 20% until all pulls finished; now interpolates 20%→70% based on which image of N has landed.
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on OVH and tx1138. Removes a docker.io dependency from first-boot installs.
- Pulled four docker.io images (bitcoin, gitea, nextcloud, valkey) into the lfg2025 registries on the registry mirrors. Removes a docker.io dependency from first-boot installs.
- Resilience harness improvements: install-fail entries no longer vanish, install/uninstall/probe cells are timing-tolerant (60s retry on ui_probe and auth_probe), dep snapshots no longer leak companion containers into the dependent app's "new containers" set.
## v1.7.45-alpha (2026-04-29)
@@ -690,7 +781,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
"description":"Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.",
description:Bot competition arena with 2-player arcade fighting mode. AI bots battle in trivia challenges while humans duke it out with controllers. Built for Bitcoiners.
protocol:tcp # Web UI. Port 7777 on the host is reserved for the Nostr relay.
bind:127.0.0.1
auth:gated
# Writable scratch the baked nginx needs; matches the legacy installer's
# --tmpfs /run + /var/cache/nginx.
Some files were not shown because too many files have changed in this diff
Show More
Reference in New Issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.