Compare commits

..
25 Commits
Author SHA1 Message Date
archipelago 9c5164372e chore: release v1.8.4-alpha 2026-08-20 07:15:01 -04:00
archipelagoandClaude Opus 5 e38b148d8e fix(release): spell out how the mnemonic prompt actually submits
The signer reads stdin to EOF, so pressing Enter submits nothing and a
second paste simply appends to the first. Step [6b/8] said only "paste the
release master mnemonic when prompted", which gives no hint that Ctrl-D is
what ends the input — a 24-word phrase arrived today as "invalid word
count: 89", about four pastes concatenated by someone reasonably assuming
Enter had not worked.

sign-manifest.sh already explains this properly; create-release.sh now says
the same thing, including that pasting twice is itself a failure mode.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 06:27:42 -04:00
archipelagoandClaude Opus 5 e03a2fed89 fix(release): surface frontend build failures instead of hiding them
`npm run build 2>&1 | tail -3` threw away npm's exit status, so a failed
build was indistinguishable from a good one. The run continued and blamed
the next check instead — "the frontend build no-opped or its output is
stale" — which points at a stale dist rather than at the build error that
actually happened, and cost a diagnosis cycle today.

Success still prints the same quiet 3 lines; a failure now prints the real
error, keeps the full log, and aborts on the spot.

Verified both branches with a stubbed npm.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 05:24:37 -04:00
archipelagoandClaude Opus 5 3d7de3e902 fix(release): a dateless changelog header silently skipped the release
Demo images / Build & push demo images (push) Failing after 38s
create-release aborted at [4/8] with "web/dist/neode-ui does not contain
v1.8.4-alpha — the frontend build no-opped or its output is stale". The
build had not no-opped: it was fresh, and simply had no 1.8.4 string to
embed.

sync-whats-new.py only matches '## vX.Y.Z (YYYY-MM-DD)'. The entry read
'## v1.8.4-alpha (draft — date set at cut)', so the version was invisible
to it: the gate's whats-new-sync stage reported "87 versions, all present"
while the release being cut had no What's New block. That modal is the
only place a version string appears in the frontend, so the bundle carried
none and the freshness check — correctly — refused it, while naming the
wrong cause. Step [5/8] only greps for '^## v1.8.4-alpha (' so it passed
the draft too.

Three changes: date the v1.8.4-alpha entry, insert the modal block it was
owed, and make the sync tool refuse any version header without a real date
instead of skipping it. Skipping is what let a wrong "all present" through.

Verified: the draft header now fails the check with an explicit message,
the dated one passes (88 versions, up from 87), and a rebuilt bundle
contains 1.8.4-alpha where it did not before.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 03:01:39 -04:00
archipelagoandClaude Opus 5 60c1db98bb fix(ui-tests): raise the vitest timeout so a busy box cannot fail the gate
Demo images / Build & push demo images (push) Failing after 40s
Four unrelated tests failed the release gate at once today — every one of
them "Test timed out in 5000ms", none an assertion. Wall times were 6.3s,
16.5s, 5.5s and 36.2s for tests that normally finish in milliseconds
(useModalKeyboard's takes 349ms on an idle box), and the whole suite took
405s against its usual ~70s. The cause was CPU starvation from a
concurrent cargo build, not anything in the code.

The 5s default says nothing about these tests and everything about the
machine: this box also runs a live node, so a gate run can always collide
with a build or container churn. 20s survives that while still bounding a
genuine hang.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 02:06:14 -04:00
archipelagoandClaude Opus 5 f5b112c508 fix(gate): stop reporting a compile timeout as a test failure
cargo-test-weekly failed twice today with exit 124 at unit 427/429 — the
non-incremental test-profile build running out of wall clock mid-compile,
before a single test executed. The summary said only "FAIL: cargo-test-
weekly", which reads as a broken test and sends you hunting for one that
does not exist.

Two changes: the ceiling goes 1500s -> 3600s (580s was already found too
short; 1500s now dies on the biggest link on a loaded, swapping box), and
stage() names exit 124 as a timeout rather than printing a bare code.

Verified both reporting branches: a timed-out stage and an ordinary
non-zero exit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:50:52 -04:00
archipelagoandClaude Opus 5 5ccef0ac2f feat(release): attach the installer ISO to the Gitea release automatically
Publishing the ISO was a manual step printed as a reminder at the end of
build-iso-release.sh: upload the ISO, its .sha256 and the signed checksum
JSON by hand. Only the OTA binary and frontend tarball were automated.

publish-release-assets.sh now uploads all three when an ISO for the
version exists in image-recipe/results/, with the same supply-chain rules
the OTA manifest already gets: the checksum JSON must be signed by the
pinned release root, the signature must cryptographically verify, and the
image must still match its own .sha256 (a truncated or half-copied ISO is
exactly what a signed checksum exists to expose). After upload it
confirms every asset landed at its exact local size.

The stage runs AFTER main is pushed, deliberately. The ISO is not
referenced by releases/manifest.json, so no node's OTA path depends on
it — running it last means a slow or failed multi-GB upload can never
delay or strand an OTA release that has already been verified. When no
ISO exists yet (the usual case, since the ISO build needs the tag this
script pushes) it explains how to build and attach one, and exits clean.

Uploads take a max-time argument: 4h and a progress bar for the ISO,
where the previous fixed 15-minute silent ceiling would have killed a
multi-GB transfer partway through.

Verified with a stubbed harness: no-ISO skip, missing .sha256, unsigned
checksum, wrong signing key, corrupted image, happy path, and a truncated
upload caught by the size check.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:33:54 -04:00
archipelagoandClaude Opus 5 e79ab37da7 chore(release): bump version to 1.8.4-alpha
Demo images / Build & push demo images (push) Failing after 40s
Left uncommitted by an aborted create-release run on 2026-08-19: the
version bump landed in the tree but the release never reached its tag or
manifest. Committing it so the tree is clean before the release is re-cut.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-20 01:04:04 -04:00
archipelagoandClaude Opus 5 d75963de10 fix(gate): show which UI test failed instead of swallowing it
The ui-unit-tests stage piped vitest through `tail -4`, which cut off the
failure block. A red gate reported "1 failed | 999 passed" and nothing
else — no file, no test name, no assertion — so the failure could not be
diagnosed after the run.

Success still prints the quiet 4-line summary; failure now dumps the full
log and keeps it on disk so a scrolled-off terminal isn't the end of it.

Verified both paths: green run unchanged, and a deliberately failing spec
now surfaces its file, test name, assertion and line number.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 14:33:09 -04:00
archipelagoandClaude Opus 5 c788dff42d style: apply cargo fmt so the release gate can run
The release gate's first real stage is `cargo fmt --check`, and it had
44 diffs across 15 files — enough to abort `create-release.sh` at step 0
before it touched a version number. Some of that drift is mine from the
last two days, some predates it in files I never opened
(bootstrap.rs, ghost_reaper.rs, openwrt/router.rs), and one is the
regenerated fips/app_ports.rs.

No behaviour change — rustfmt only.

Gate now: 8 of 9 green. The remaining red is cargo-test-weekly exiting
124, which is the 25-minute `timeout` expiring during a cold
CARGO_INCREMENTAL=0 rebuild on a loaded node — the tests never started.
Not a test failure.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:38:41 -04:00
archipelagoandClaude Opus 5 bd299318c0 chore(catalog): re-sign the catalog with the new pins, and fix a stale firewall list
Demo images / Build & push demo images (push) Failing after 41s
Regenerates both catalogs from the manifests so the 15 pin bumps become
real. The catalog overrides on-disk manifests on every node, so until
now those bumps were edited but inert.

There are two catalogs and regenerating one is not enough:
generate-app-catalog.sh writes releases/app-catalog.json (the signed one
nodes fetch), while generate-app-catalog.py writes app-catalog/catalog.json
and neode-ui/public/catalog.json (the source pair, the second baked into
the frontend app store). check-app-catalog-drift.py --release --strict
reads the *source* catalog, so regenerating only the release one left it
failing and would have aborted the ISO gate at stage 1 — after the
signing and tagging were already done. Drift is now 0.

The regeneration also rewrote fips/app_ports.rs, which had not been
regenerated since the initial open-source import. Diffing the port values
rather than the reformat: 36 -> 37, a single addition, **8187 — Alby
Hub**. Its port has never been in the FIPS firewall allow-list, and by
the same token neither has any app onboarded since that import. Nothing
else changed.

Catalog signed by the pinned release root and verified with
`ceremony verify`; registry trust floor checked before signing, both
hosts trusted by the deployed fleet.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-19 12:02:40 -04:00
archipelagoandClaude Opus 5 8bc161f40f fix(ecash): repair two mangled warning messages
Both import refusals reached the operator with runs of ~18 spaces mid
sentence — "Importing a different one                  means coins
minted…". The string literals had been written as single long lines with
the line-continuation whitespace baked in rather than escaped, so Rust
preserved it verbatim.

Only visible once the sanitizer stopped swallowing these messages, which
is its own small lesson: the text had been wrong since it was written and
nothing could show it.

Cosmetic, but not trivially so — this is the warning that stops someone
replacing the phrase their balance was minted under, and text that looks
broken is text people stop reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:31:52 -04:00
archipelagoandClaude Opus 5 c19c411b91 chore(apps): mirror and bump the upgrades that carry no data migration
With registry push access, the 24 mirror-backed apps stopped being
blocked. Ten images are now mirrored (single-platform amd64, matching
the existing convention) and their pins moved:

  alby-hub          v1.23.0       -> v1.24.0
  mempool-frontend  v3.0.1        -> v3.3.1     (mempool, archy-mempool-web)
  mempool-backend   v3.0.0        -> v3.3.1
  fedimintd         v0.10.0       -> v0.10.1
  gatewayd          v0.10.0       -> v0.10.1
  nostr-rs-relay    0.9.0         -> 0.10.0
  portainer         2.39.1        -> 2.39.6
  vaultwarden       1.30.0-alpine -> 1.37.1-alpine
  jellyfin          10.8.13       -> 10.11.11
  home-assistant    2026.7.3      -> 2026.8.2

Every one verified pullable from our mirror after copying, so none can
become an image-not-found on a node. image-versions.sh moved in lockstep
— it is the baseline the update badge compares against when the catalog
does not cover an app, and leaving it behind would have kept advertising
an update that had already been applied.

Chosen by risk, not by count: these are patch/minor bumps with no data
migration. The ones held back are held for a reason each — Postgres
15->18 and 16->18 refuse to start on an older cluster, Redis 7->8,
Valkey 7->9, Nextcloud 29->32 must go one major at a time, plus
uptime-kuma 1->2, grafana 10->13, electrumx 1->2, photoprism, and
core-lightning's three years of schema migrations. Those are each a
migration plan, not a pin edit. LND (v0.18.4 -> v0.21.2) is held
separately: it is only a minor bump by version but it migrates its
channel database irreversibly, and this box holds real funds.

Note the checker still reports several of these as behind, and that is
correct: it reads the *catalog* pin, which is what nodes actually act on.
These land when the catalog is regenerated and re-signed.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 11:22:42 -04:00
archipelagoandClaude Opus 5 7d6e52537a chore(apps): bump the pins that can move without mirroring
Of the 33 apps behind upstream, these five pull straight from a public
registry, so their targets exist already and the bump is real work rather
than a promise:

  strfry          1.0.4        -> 1.1.1
  netbird (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine    (nginx) 1.27-alpine  -> 1.31.3-alpine
  pine-piper      2.2.2        -> 2.4.2
  nostr-rs-relay  0.8.9        -> 0.10.0

All five targets verified present upstream with skopeo before editing, so
none of these can turn into an image-not-found on a node.

Deliberately NOT bumped here, though they are also direct-pull:
core-lightning (v23.08 -> v26.06, ~3 years of schema migrations), gitea
(four minors of DB migrations), and netbird-server/netbird-dashboard —
which have to move in lockstep and carry their own migrations. Those are
each a piece of work, not a line edit.

The other 24 are blocked on something else entirely: their images live in
our mirror and none of the upgrade targets have been mirrored yet, so a
pin bump alone would break every install. That needs registry push
credentials.

These take effect when the catalog is regenerated and re-signed — the
catalog overrides on-disk manifests, so editing here changes nothing on a
node until the signing ceremony.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:54:05 -04:00
archipelagoandClaude Opus 5 86923f05a5 fix(ecash): stop the sanitizer eating the import safety rails
Live-checking the import route on the node showed both of its refusals
arriving as "Operation failed. Check server logs for details."

That is not merely opaque here, it is unsafe. The two messages are the
feature's safety rails: "That is not a valid BIP-39 recovery phrase —
check for typos" is the only help someone gets when a pasted phrase has
a bad word, and "This wallet already has a backup phrase… reveal and
write down the current phrase first, then confirm to replace it" is the
warning that stops an operator orphaning the words their balance was
minted under. Masked, the first is unactionable and the second is
invisible — the confirmation checkbox would be the only clue that
anything was at stake.

Same for "no backup phrase yet, nothing to restore from" and the NUT-09
message naming a mint that cannot restore at all.

Caught only because the refusal paths were exercised against the live
node rather than trusted from the unit tests, which see the real message
and never meet the sanitizer.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:43:47 -04:00
archipelagoandClaude Opus 5 ee40880ce5 feat(ecash): import a backup phrase from another NUT-13 wallet
Demo images / Build & push demo images (push) Failing after 2m2s
Bring-your-own, the open question the migration plan left. Point this
wallet at a phrase you already hold — Minibits, Nutstash, cdk-cli — and
its coins become restorable here, which is the other half of "these
words are portable".

Replacing an established phrase is the one genuinely lossy thing this
module can do, so it is treated that way. The coins already held stay
spendable: they are proofs, not derivations, and nothing here touches
`ecash.json`. But they were minted under the *old* phrase, so a restore
will no longer find them. Hence an explicit confirm, a prompt to reveal
and write down the current phrase first, and — most importantly — the
replaced phrase is archived beside the wallet, never overwritten. It may
be the last copy of the words a balance was minted under, and quietly
destroying that is precisely what this module exists to prevent.

Re-importing the phrase already in use is a no-op rather than a
replacement, so it archives nothing.

Counters are deliberately left alone. They are per-keyset and
seed-relative, so under a new seed they merely start high, which costs
nothing because a restore scans from zero regardless. Resetting them
would be the dangerous choice on the day someone imports the phrase they
were already using.

`imported` is its own provenance rather than reusing `independent`: both
mean the node's recovery phrase does not cover the wallet, but only one
of them means the operator already knows where else the words live.

15 NUT-13 tests green, 1000 frontend tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 10:09:19 -04:00
archipelagoandClaude Opus 5 c1a79fdd69 fix(apps): never recommend a release candidate, and flag major jumps
Two things the first run of this script got wrong, both found by reading
its own output rather than by a test.

It recommended MariaDB `13.0.1-ubi10-rc` — a release candidate — because
ordering strips the suffix, so an RC outranks every stable tag
numerically. Pre-releases are now excluded, with one exception that
matters here: a project whose stable line *is* suffixed. LND ships
`-beta` and always has, so a blanket exclusion would report it as
permanently current. The rule is therefore "no pre-release unless the pin
we are on is itself one", which keeps LND honest and MariaDB stable.

And "33 behind" is not an actionable list, because the entries are not
the same kind of work. A patch bump is a pin change; a major bump is
where the data migrations live — Postgres refuses to start on an older
cluster, Nextcloud requires one major at a time. Each row now says which
it is, and the summary names the majors separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:55:18 -04:00
archipelagoandClaude Opus 5 59440ef1ac fix(wallet): a seen receipt stays seen across refreshes
Demo images / Build & push demo images (push) Failing after 2m6s
fc98c1d8 replaced the five-minute timer with "stays until seen", but
kept "seen" in component state — so every page load forgot it and the
entire ecash history came back as new. That is worse than the timer it
replaced: the old behaviour at least let receipts go, this one resurrected
them on every refresh. Reported from the node, and correctly.

Acknowledgement now lives in localStorage, capped at 300 keys.

That opens the opposite trap: on a browser with nothing stored, treating
the whole history as unseen is the same wall of old receipts from the
other direction. So a first run seeds everything older than five minutes
as already seen — the window survives as a first-run heuristic, not as
an expiry. Unreadable storage takes the same path, because reading a
corrupt value as "nothing acknowledged" is the refresh bug wearing a hat.

Also guards the balance readout against NaN. `sats == null` does not
catch it, and arithmetic over a missing field produces it, so it would
have rendered as the literal text "NaN sats" — worse than the zero the
component exists to prevent, since a zero at least looks like a number.

Frontend: 1000 tests green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:30:57 -04:00
archipelagoandClaude Opus 5 603291008b fix(test): restore the ecash network before bouncing the service
The restore proof's cleanup set the network back *after* restarting
archipelago, so the call landed on a socket that wasn't listening yet
and failed silently. A fully green run left the node parked on testnet —
the one outcome a cleanup path must never produce, and worse for being
invisible.

Network first, while the RPC is still up; then the wallet file, then the
restart, then wait for the service back so a check running straight
afterwards doesn't meet a dead socket.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:15:31 -04:00
archipelagoandClaude Opus 5 212e349b19 refine(wallet): the balance readout scans as one column, not a barcode
Demo images / Build & push demo images (push) Failing after 2m7s
Seeing it on the node settled the shape. Two rows of per-cell delays
read as a dense flicker — closer to a progress bar than a display, and
short enough against the row's text to look like an underline.

Three rows laid out column-first fixes both: the three cells of a column
now share a delay, so the lit column travels across as a single scan
line, and at 11px the matrix sits with the text rather than under it.

Verified in a real browser against the live node with the balance RPCs
held open: five placeholders, five distinct rail colours (white, orange,
yellow, purple, blue), 42 cells each, all animating, each announcing
what it is waiting for — and no "0 sats" anywhere on screen while the
calls were in flight. They gave way to real figures on arrival, with
Lightning's genuine 0 correctly shown as a figure rather than left
shimmering.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 09:05:33 -04:00
archipelagoandClaude Opus 5 fa6fe32ef9 feat(wallet): a balance that isn't loaded yet says so, in pixels
Demo images / Build & push demo images (push) Failing after 2m18s
An unloaded balance rendered as `0`. Zero is not a loading state — it is
a number, and it is the one number that frightens people. Someone
opening the dashboard while the RPCs were still in flight was told, in
the wallet's own typeface, that their money was gone.

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

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

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

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

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

Frontend: 996 tests green. Backend: 1436 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:54:12 -04:00
archipelagoandClaude Opus 5 fc98c1d8dd fix(wallet): an incoming payment no longer disappears before you look
Demo images / Build & push demo images (push) Failing after 2m10s
Chasing the "selecting incoming clears a pending token, and there's a
timeout if you don't click" report led here. Instant rails — Lightning,
Cashu, Fedimint, Ark — settle immediately, so there is no confirmation
to wait for and no natural moment for a receipt to leave the Incoming
badge. It was leaving on a five-minute wall clock instead.

So a payment could arrive, raise the badge, and evaporate before anyone
looked; and opening the panel a few minutes late showed nothing, because
the payment you came to check on had already aged out. Worse, once the
count hit zero the badge silently changed meaning — the same click that
opened the panel now navigated to the transactions view instead.

For ecash that is the worst case available. It leaves no public ledger
entry, so this panel was the only place the receipt was ever shown; once
it timed out there was nowhere left to look.

Instant-rail receipts now stay until they have actually been seen, which
is the same unread model the mesh inbox uses. Closing the panel is what
marks them seen, not opening it — marking on open would make a row
vanish under the cursor of someone still reading it. On-chain is
untouched: a confirmation count is a real signal and already does this
job.

Also keys the list on a derived id. Instant rails have no txid, so
`:key="tx.tx_hash"` was `""` for every one of them.

This is my reading of the reported symptoms rather than a confirmed
repro — the operator should check it matches what they saw.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:27:01 -04:00
archipelagoandClaude Opus 5 cbbd20e22e test(ecash): cover cashuB emission, the backup phrase, and restore
Four things the suite could not previously catch:

- The emitted token is cashuA. It is still valid, so nothing fails — the
  send succeeds and the receiver redeems it. The only symptom of cashuB
  encoding falling back is a warning in the journal nobody reads, which
  is exactly the kind of silent regression a route check exists for.
- The wallet has no backup phrase. Without one the coins live in exactly
  one file and nothing can bring them back.
- The phrase changes between reveals, which would orphan every coin
  minted under the previous one.
- Restore double-counts. It runs against a live wallet, so running it
  twice must leave the balance where it was.

Reveal is also asserted to refuse a wrong password: it is the one route
here that hands out key material, and a session alone must not be enough.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:06:27 -04:00
archipelagoandClaude Opus 5 eb48eab946 feat(apps): find out when an app has fallen behind upstream
Nodes offer an update when the signed catalog pins something newer than
what's running, and that machinery is fine. The missing step was the one
before it: nothing told *us* when upstream shipped. A pin could sit at
fedimintd v0.10.0 for months while every node in the fleet correctly and
confidently reported "up to date".

The reason nothing could tell us is that a manifest records only our
mirror — `source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`
says nothing about the project it was mirrored from. So this adds an
optional `app.upstream` block naming the real source, and a script that
asks each one what it has released.

Running it answers the question that prompted this. Of 58 apps, 28 are
behind, including LND v0.18.4-beta against v0.21.2-beta, Bitcoin Core
28.4 against 31.1, and fedimintd/gatewayd v0.10.0 against v0.10.1.

Two choices worth stating. An app with no `upstream` block is reported
as UNTRACKED rather than skipped — a silent skip is how this stayed
invisible, and before this commit all 58 were silently skipped. And a
suggestion prefers our own tag variant: telling someone pinned to
`postgres:16.13-alpine` that the newest tag is `18.6-trixie` is true and
useless, because swapping the base image is a different decision from
bumping a version.

Five apps are deliberately left untracked (barkd, immich-postgres,
indeedhub-minio, lightning-stack, pine-whisper): I could not establish
their upstream with confidence, and a wrong `repo` produces a confident
wrong verdict, which is worse than an honest gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-17 08:04:33 -04:00
103 changed files with 2770 additions and 287 deletions
+1 -1
View File
@@ -1,6 +1,6 @@
# Changelog
## v1.8.4-alpha (draft — date set at cut)
## v1.8.4-alpha (2026-08-20)
- **Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.
+16 -16
View File
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -374,11 +374,11 @@
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -459,12 +459,12 @@
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -552,19 +552,19 @@
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
+3
View File
@@ -2,6 +2,9 @@ app:
id: aiui
name: AI Assistant
version: 0.1.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Conversational AI interface for Archipelago. Quarantined — communicates only via context broker.
internal: true # System-managed, not shown in App Store
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: alby-hub
name: Alby Hub
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: getAlby/hub
description: Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.
category: money
container:
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0
image: source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0
pull_policy: if-not-present
dependencies:
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-btcpay-db
name: BTCPay Postgres
version: "15.17"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres backend for BTCPay and NBXplorer.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-mempool-db
name: Mempool MariaDB
version: 11.4.10
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/mariadb
description: MariaDB backend for the mempool explorer stack.
container:
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: archy-mempool-web
name: Mempool Web
version: 3.0.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Frontend web UI for mempool explorer.
container_name: mempool
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
pull_policy: if-not-present
network: archy-net
+6
View File
@@ -2,6 +2,12 @@ app:
id: archy-nbxplorer
name: NBXplorer
version: 2.6.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dgarage/NBXplorer
description: BTCPay blockchain indexer service.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-core
name: Bitcoin Core
version: 28.4.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoin/bitcoin
description: Reference Bitcoin Core node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-core
+6
View File
@@ -2,6 +2,12 @@ app:
id: bitcoin-knots
name: Bitcoin Knots
version: 28.1.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: bitcoinknots/bitcoin
description: Full Bitcoin Knots node with dynamic prune/full-mode startup based on host disk.
container_name: bitcoin-knots
+3
View File
@@ -2,6 +2,9 @@ app:
id: bitcoin-ui
name: Bitcoin UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP proxy + static site for interacting with the
Bitcoin Core / Bitcoin Knots JSON-RPC. Runs nginx inside a container
+3
View File
@@ -2,6 +2,9 @@ app:
id: botfights
name: BotFights
version: 1.2.11
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
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.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: btcpay-server
name: BTCPay Server
version: 2.4.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: btcpayserver/btcpayserver
description: Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: core-lightning
name: Core Lightning (CLN)
version: 23.08.2
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ElementsProject/lightning
description: Lightning Network implementation in C. Lightweight alternative to LND.
container:
+3
View File
@@ -2,6 +2,9 @@ app:
id: did-wallet
name: Web5 DID Wallet
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Web5 wallet with Decentralized Identifier (DID) support. Manage your digital identity and Web5 assets.
container:
+3
View File
@@ -2,6 +2,9 @@ app:
id: electrs-ui
name: Electrs UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for electrs/electrumx status. Runs
nginx inside a container, serves static assets, and proxies
+6
View File
@@ -2,6 +2,12 @@ app:
id: electrumx
name: ElectrumX
version: 1.18.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: spesmilo/electrumx
description: Electrum server indexing Bitcoin chain data for lightweight wallet queries.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: fedimint-clientd
name: Fedimint Client
version: 0.8.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint-clientd
description: Fedimint ecash client daemon (fmcd). Lets the node hold Fedimint ecash and join federations; the wallet talks to it over a local REST API.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint-gateway
name: Fedimint Gateway
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Fedimint gateway service with automatic LND-or-LDK backend selection.
container:
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: fedimint
name: Fedimint Guardian
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: fedimint/fedimint
description: Federated Bitcoin minting service with built-in Guardian UI. Privacy-preserving Bitcoin custody.
container:
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0
image: source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1
pull_policy: if-not-present
network: archy-net
entrypoint: ["sh", "-lc"]
+6
View File
@@ -2,6 +2,12 @@ app:
id: filebrowser
name: File Browser
version: 2.27.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: filebrowser/filebrowser
description: Baseline Archipelago file manager service.
container:
+3
View File
@@ -2,6 +2,9 @@ app:
id: fips-ui
name: FIPS Mesh
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native dashboard for the FIPS mesh transport. Runs nginx
inside a container with host networking, serves a static dashboard on
+6
View File
@@ -2,6 +2,12 @@ app:
id: gitea
name: Gitea
version: "1.23"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: go-gitea/gitea
description: Self-hosted Git service with built-in container registry, CI/CD, and package hosting.
category: development
+6
View File
@@ -2,6 +2,12 @@ app:
id: grafana
name: Grafana
version: 10.2.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: grafana/grafana
description: Analytics and monitoring platform. Visualize metrics and create dashboards.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: homeassistant
name: Home Assistant
version: 2026.7.3
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: home-assistant/core
description: Open source home automation platform. Control and monitor your smart home devices.
container:
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3
image: source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2
pull_policy: if-not-present
network: pasta
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich-redis
name: Immich Redis
version: "7-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: valkey/valkey
description: Valkey (Redis-compatible) cache for Immich.
# Container named immich_redis (underscore) to match runtime per-app references
+6
View File
@@ -2,6 +2,12 @@ app:
id: immich
name: Immich
version: "2.7.4"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: immich-app/immich
description: Self-hosted photo and video backup with mobile apps and search.
# app_id "immich" = the user-facing launcher (matches the catalog entry's title
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-api
name: IndeedHub API
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub backend API (Nostr auth, media, payments).
category: community
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub-ffmpeg
name: IndeedHub FFmpeg Worker
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: IndeedHub background media transcoding worker.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-postgres
name: IndeedHub Postgres
version: "16.13-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/postgres
description: Postgres database backend for IndeedHub.
category: community
+6
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-redis
name: IndeedHub Redis
version: "7.4.8-alpine"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/redis
description: Redis queue/cache backend for IndeedHub.
category: community
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: indeedhub-relay
name: IndeedHub Nostr Relay
version: "0.9.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: nostr-rs-relay backing IndeedHub's Nostr identity + comments.
category: community
@@ -11,7 +17,7 @@ app:
container_name: indeedhub-relay
container:
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0
image: source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0
pull_policy: if-not-present
network: indeedhub-net
network_aliases: [relay]
+3
View File
@@ -2,6 +2,9 @@ app:
id: indeedhub
name: IndeeHub
version: "1.0.0"
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Bitcoin documentary streaming platform featuring God Bless Bitcoin and other educational content about Bitcoin, sovereignty, and decentralized technology. Sign in with your Nostr identity.
category: community
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: jellyfin
name: Jellyfin
version: 10.8.13
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: jellyfin/jellyfin
description: Free media server. Stream movies, music, and photos.
container:
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13
image: source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11
pull_policy: if-not-present
network: pasta
+3
View File
@@ -2,6 +2,9 @@ app:
id: lnd-ui
name: LND UI
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: |
Archipelago-native HTTP frontend for LND. Runs nginx inside a
container and serves static assets. LND connection info is fetched
+6
View File
@@ -2,6 +2,12 @@ app:
id: lnd
name: LND
version: 0.18.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: lightningnetwork/lnd
description: Lightning Network implementation by Lightning Labs. Enables instant, low-cost Bitcoin payments.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: mempool-api
name: Mempool API
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Backend API for mempool explorer.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0
image: source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1
pull_policy: if-not-present
network: archy-net
# CORE_RPC_HOST must follow the node's actual Bitcoin container — Knots or
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: mempool
name: Mempool Explorer
version: 3.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: mempool/mempool
description: Bitcoin mempool and blockchain explorer. Real-time transaction and block visualization.
container:
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1
image: source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1
image_signature: cosign://...
pull_policy: if-not-present
+3
View File
@@ -2,6 +2,9 @@ app:
id: morphos-server
name: MorphOS Server
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: MorphOS server platform. Decentralized application server.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: netbird-dashboard
name: NetBird Dashboard
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/dashboard
description: NetBird management dashboard (SPA). Internal stack member served through the netbird proxy.
category: networking
+6
View File
@@ -2,6 +2,12 @@ app:
id: netbird-server
name: NetBird Server
version: "0.71.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: netbirdio/netbird
description: NetBird combined management / signal / relay server with an embedded identity provider and STUN. Backend for the self-hosted NetBird mesh VPN.
category: networking
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: netbird
name: NetBird
version: "2.38.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.
category: networking
@@ -12,7 +18,7 @@ app:
container_name: netbird
container:
image: docker.io/library/nginx:1.27-alpine
image: docker.io/library/nginx:1.31.3-alpine
pull_policy: if-not-present
network: netbird-net
# Self-signed TLS cert materialised before create — the dashboard needs a
+6
View File
@@ -2,6 +2,12 @@ app:
id: nextcloud
name: Nextcloud
version: "29"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: nextcloud/server
description: Your own private cloud. File sync, calendars, contacts.
container:
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: nostr-rs-relay
name: Nostr Relay (Rust)
version: 0.8.0
version: 0.10.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: scsibug/nostr-rs-relay
description: High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.
container:
image: scsibug/nostr-rs-relay:0.8.9
image: scsibug/nostr-rs-relay:0.10.0
image_signature: cosign://...
pull_policy: verify-signature
data_uid: "1000:1000"
+6
View File
@@ -2,6 +2,12 @@ app:
id: phoenixd
name: phoenixd
version: 0.9.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: ACINQ/phoenixd
description: Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.
category: money
+6
View File
@@ -2,6 +2,12 @@ app:
id: photoprism
name: PhotoPrism
version: "240915"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: photoprism/photoprism
description: AI-powered photo management with facial recognition.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: pine-openwakeword
name: Pine Wake Word (openWakeWord)
version: "2.1.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-openwakeword
description: Wyoming-protocol openWakeWord wake-word engine. Internal Pine voice-assistant stack member — lets Assist pipelines run wake-word detection on the node (groundwork for the custom "Yo Archy" wake word; stock models like "ok nabu" ship with the image).
category: home
+8 -2
View File
@@ -1,7 +1,13 @@
app:
id: pine-piper
name: Pine Piper (TTS)
version: "2.2.2"
version: "2.4.2"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: rhasspy/wyoming-piper
description: Wyoming-protocol Piper text-to-speech engine. Internal Pine voice-assistant stack member — gives Home Assistant Assist a natural voice for spoken responses on the PineVoice satellite.
category: home
@@ -12,7 +18,7 @@ app:
container_name: pine-piper
container:
image: docker.io/rhasspy/wyoming-piper:2.2.2
image: docker.io/rhasspy/wyoming-piper:2.4.2
pull_policy: if-not-present
network: archy-net
network_aliases: [pine-piper]
+7 -1
View File
@@ -2,6 +2,12 @@ app:
id: pine
name: Pine
version: "1.3.0"
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: dockerhub
repo: library/nginx
description: A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.
category: home
@@ -13,7 +19,7 @@ app:
container_name: pine
container:
image: docker.io/library/nginx:1.27-alpine
image: docker.io/library/nginx:1.31.3-alpine
pull_policy: if-not-present
network: archy-net
network_aliases: [pine]
+7 -1
View File
@@ -2,11 +2,17 @@ app:
id: portainer
name: Portainer
version: 2.19.4
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: portainer/portainer
description: Container management web UI for the local Podman socket.
category: development
container:
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.1
image: source.archipelago-foundation.org/lfg2025/portainer:2.39.6
pull_policy: if-not-present
data_uid: "1000:1000"
+3
View File
@@ -2,6 +2,9 @@ app:
id: router
name: Mesh Router
version: 1.0.0
# Built by this project — there is no upstream release feed to watch.
upstream:
kind: internal
description: Mesh routing and local network management. Provides device discovery, routing, and network topology visualization.
container:
+6
View File
@@ -2,6 +2,12 @@ app:
id: searxng
name: SearXNG
version: 1.0.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: searxng/searxng
description: Privacy-respecting metasearch engine. Search the web without tracking.
container:
+8 -2
View File
@@ -1,11 +1,17 @@
app:
id: strfry
name: Strfry Nostr Relay
version: 0.9.0
version: 1.1.1
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: hoytech/strfry
description: Lightweight Nostr relay written in C++. Alternative to nostr-rs-relay with lower resource usage.
container:
image: dockurr/strfry:1.0.4
image: dockurr/strfry:1.1.1
image_signature: cosign://...
pull_policy: verify-signature
+6
View File
@@ -2,6 +2,12 @@ app:
id: uptime-kuma
name: Uptime Kuma
version: 1.23.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: louislam/uptime-kuma
description: Self-hosted uptime monitoring.
container:
+7 -1
View File
@@ -2,10 +2,16 @@ app:
id: vaultwarden
name: Vaultwarden
version: 1.30.0
# Where this app comes from, so scripts/check-upstream-releases.py can
# tell us when the pin below has fallen behind. Without it nothing can:
# container.image names our mirror, not the project it was mirrored from.
upstream:
kind: github
repo: dani-garcia/vaultwarden
description: Self-hosted password vault with zero-knowledge encryption.
container:
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine
image: source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine
pull_policy: if-not-present
network: pasta
+1 -1
View File
@@ -104,7 +104,7 @@ dependencies = [
[[package]]
name = "archipelago"
version = "1.8.3-alpha"
version = "1.8.4-alpha"
dependencies = [
"anyhow",
"archipelago-container",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "archipelago"
version = "1.8.3-alpha"
version = "1.8.4-alpha"
edition = "2021"
license.workspace = true
description = "Archipelago Bitcoin Node OS - Native backend"
+14 -6
View File
@@ -105,10 +105,7 @@ async fn run_keeper(mut rx: mpsc::Receiver<String>, connected: Arc<AtomicBool>)
/// Discard queued input for `d` — used while no kiosk session exists so the
/// bounded channel can't fill with stale events.
async fn drain_for(rx: &mut mpsc::Receiver<String>, d: Duration) {
let _ = tokio::time::timeout(d, async {
while rx.recv().await.is_some() {}
})
.await;
let _ = tokio::time::timeout(d, async { while rx.recv().await.is_some() {} }).await;
}
/// Find the kiosk page target's WebSocket debugger URL. Prefers the page on
@@ -219,7 +216,11 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
vec![mouse_event(id(), "mouseMoved", cursor, "none", 0, 1)]
}
Some("c") => {
let b = msg.get("b").and_then(Value::as_u64).unwrap_or(1).clamp(1, 3);
let b = msg
.get("b")
.and_then(Value::as_u64)
.unwrap_or(1)
.clamp(1, 3);
let (button, buttons) = match b {
2 => ("middle", 4),
3 => ("right", 2),
@@ -255,7 +256,14 @@ fn translate(raw: &str, cursor: &mut Cursor, id: &mut impl FnMut() -> u64) -> Ve
}
}
fn mouse_event(id: u64, kind: &str, cursor: &Cursor, button: &str, buttons: u32, clicks: u32) -> Value {
fn mouse_event(
id: u64,
kind: &str,
cursor: &Cursor,
button: &str,
buttons: u32,
clicks: u32,
) -> Value {
json!({
"id": id,
"method": "Input.dispatchMouseEvent",
@@ -271,6 +271,7 @@ impl RpcHandler {
"wallet.ecash-seed-status" => self.handle_wallet_ecash_seed_status().await,
"wallet.ecash-seed-reveal" => self.handle_wallet_ecash_seed_reveal(params).await,
"wallet.ecash-restore" => self.handle_wallet_ecash_restore(params).await,
"wallet.ecash-seed-import" => self.handle_wallet_ecash_seed_import(params).await,
"wallet.networking-profits" => self.handle_wallet_networking_profits().await,
// Fedimint ecash (via fedimint-clientd sidecar)
"wallet.fedimint-list" => self.handle_wallet_fedimint_list().await,
+5 -1
View File
@@ -667,7 +667,11 @@ impl RpcHandler {
.get("state")
.and_then(|v| v.as_str())
.map(|s| s == "SETTLED")
.unwrap_or_else(|| body.get("settled").and_then(|v| v.as_bool()).unwrap_or(false));
.unwrap_or_else(|| {
body.get("settled")
.and_then(|v| v.as_bool())
.unwrap_or(false)
});
let amt_paid_sat = body
.get("amt_paid_sat")
.and_then(|v| v.as_str())
@@ -172,6 +172,20 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
"No pending seed generation",
"Submitted words",
"Already set up",
// Ecash backup phrase — these two ARE the feature's safety rails, and
// masking them made it dangerous rather than merely opaque. "That is
// not a valid BIP-39 recovery phrase… check for typos" is the whole
// help someone gets when a pasted phrase has a bad word; and "This
// wallet already has a backup phrase… reveal and write down the
// current phrase first, then confirm to replace it" is the warning
// that stops an operator orphaning the words their balance was minted
// under. Behind "check server logs" the first is unactionable and the
// second is invisible.
"That is not a valid BIP-39",
"This wallet already has a backup phrase",
"This wallet has no backup phrase yet",
// Restore against a mint that never implemented NUT-09.
"This mint does not support restoring",
];
for prefix in &user_facing_prefixes {
if msg.starts_with(prefix) {
@@ -195,6 +209,27 @@ pub(super) fn sanitize_error_message(msg: &str) -> String {
mod sanitize_tests {
use super::sanitize_error_message;
/// The ecash import errors are the feature's safety rails. If the
/// sanitizer eats them, a bad paste gives no hint and — worse — the
/// warning about replacing an established phrase never reaches the person
/// about to do it.
#[test]
fn ecash_backup_phrase_errors_reach_the_operator() {
for msg in [
"That is not a valid BIP-39 recovery phrase: invalid checksum. Check for typos",
"This wallet already has a backup phrase. Importing a different one means coins \
minted under the current phrase will no longer be restorable from words",
"This wallet has no backup phrase yet, so there is nothing to restore from.",
"This mint does not support restoring from a backup phrase (NUT-09).",
] {
let out = sanitize_error_message(msg);
assert_ne!(
out, "Operation failed. Check server logs for details.",
"swallowed: {msg}"
);
}
}
#[test]
fn password_required_sentinel_passes_through_verbatim() {
// The UI machine-reads this sentinel (isPasswordRequired checks
+6 -2
View File
@@ -43,8 +43,12 @@ impl RpcHandler {
// plus a blocking SSH verify per candidate. Inline, one click of
// "scan for routers" held a tokio worker for that whole time.
let routers = tokio::task::spawn_blocking(move || {
tokio::runtime::Handle::current()
.block_on(detect::scan_subnet(subnet, prefix, &ssh_user, &ssh_password))
tokio::runtime::Handle::current().block_on(detect::scan_subnet(
subnet,
prefix,
&ssh_user,
&ssh_password,
))
})
.await
.context("openwrt scan task")?;
+58 -8
View File
@@ -260,13 +260,16 @@ impl RpcHandler {
Ok(Some(seed)) => Some(seed.source()),
_ => None,
};
// Whether the node has an encrypted master seed decides whether the
// "set up" path can derive from it, which is what the operator is
// promised: your node's 24 words already cover your ecash.
// A phrase can always be established. Whether the node has an
// encrypted master seed decides only *which kind*: derived from it
// (the node's 24 words already cover the ecash), or independent (the
// phrase is the only copy). The UI needs both facts to set the right
// expectation before the operator commits to writing something down.
Ok(serde_json::json!({
"active": active,
"source": source,
"can_activate": crate::seed::seed_exists(data_dir),
"can_activate": true,
"derivable_from_node_seed": crate::seed::seed_exists(data_dir),
}))
}
@@ -306,12 +309,22 @@ impl RpcHandler {
}));
}
// No encrypted master seed to derive from — common on nodes onboarded
// before that step existed. The choice here is not "derived or
// independent", it is "independent or no backup at all", so we make
// one and label it honestly. Every surface that shows an
// `independent` phrase says the node's own recovery phrase does not
// cover it.
if !crate::seed::seed_exists(data_dir) {
password.zeroize();
anyhow::bail!(
"This node has no encrypted seed backup, so an ecash recovery \
phrase cannot be derived from it."
);
let seed = crate::wallet::nut13::establish_independent(data_dir).await?;
let words = seed.words();
return Ok(serde_json::json!({
"words": words,
"word_count": words.len(),
"source": seed.source(),
"newly_activated": true,
}));
}
// The backup passphrase may differ from the login password — same
@@ -341,6 +354,43 @@ impl RpcHandler {
}))
}
/// `wallet.ecash-seed-import` — adopt a phrase from another NUT-13 wallet.
///
/// Gated like every other route that touches key material. Replacing an
/// established phrase additionally needs `confirm: true`, because coins
/// minted under the old one stop being restorable from words — they stay
/// spendable, but a restore will not find them. The old phrase is archived
/// beside the wallet rather than overwritten.
pub(super) async fn handle_wallet_ecash_seed_import(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use zeroize::Zeroize;
let params = params.unwrap_or_default();
let words = params
.get("words")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.ok_or_else(|| anyhow::anyhow!("A recovery phrase is required"))?
.to_string();
let confirm = params
.get("confirm")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let mut password = self.verify_reveal_auth(&params, "the ecash seed").await?;
password.zeroize();
let seed =
crate::wallet::nut13::import_mnemonic(&self.config.data_dir, &words, confirm).await?;
Ok(serde_json::json!({
"source": seed.source(),
"word_count": seed.words().len(),
}))
}
/// `wallet.ecash-restore` — rebuild the wallet's coins from its NUT-13
/// phrase by asking a mint which re-derived secrets it has signed.
///
+32 -10
View File
@@ -184,14 +184,17 @@ pub async fn ensure_doctor_installed() {
Err(e) => warn!("nginx listener repair failed (non-fatal): {:#}", e),
}
match run_ha_rpc_proxy_bind_repair().await {
Ok(true) => info!(
"HA bitcoind RPC forwarder rebound dynamically — survives network moves now"
),
Ok(true) => {
info!("HA bitcoind RPC forwarder rebound dynamically — survives network moves now")
}
Ok(false) => debug!("HA bitcoind RPC forwarder absent or already dynamic"),
Err(e) => warn!("HA RPC forwarder bind repair failed (non-fatal): {:#}", e),
}
match run_pull_never_image_repair().await {
Ok(n) if n > 0 => info!(retagged = n, "Healed quadlet image refs orphaned by registry rename"),
Ok(n) if n > 0 => info!(
retagged = n,
"Healed quadlet image refs orphaned by registry rename"
),
Ok(_) => debug!("All quadlet image refs resolve locally"),
Err(e) => warn!("Quadlet image ref repair failed (non-fatal): {:#}", e),
}
@@ -704,7 +707,12 @@ fn parse_socat_static_bind(exec_line: &str) -> Option<(String, String)> {
}
// Only rewrite units pinned to a concrete address; a unit already using
// a computed bind (or none) needs no heal.
let bind = after_listen.split("bind=").nth(1)?.split(',').next()?.trim();
let bind = after_listen
.split("bind=")
.nth(1)?
.split(',')
.next()?
.trim();
if !bind.chars().all(|c| c.is_ascii_digit() || c == '.') || bind.starts_with("127.") {
return None;
}
@@ -731,7 +739,10 @@ async fn run_ha_rpc_proxy_bind_repair() -> Result<bool> {
Ok(s) => s,
Err(_) => return Ok(false), // node never grew the forwarder
};
let Some(exec_line) = unit.lines().find(|l| l.trim_start().starts_with("ExecStart=")) else {
let Some(exec_line) = unit
.lines()
.find(|l| l.trim_start().starts_with("ExecStart="))
else {
return Ok(false);
};
let Some((port, target)) = parse_socat_static_bind(exec_line) else {
@@ -833,7 +844,11 @@ async fn run_pull_never_image_repair() -> Result<usize> {
}
async fn podman_stdout(args: &[&str]) -> String {
match tokio::process::Command::new("podman").args(args).output().await {
match tokio::process::Command::new("podman")
.args(args)
.output()
.await
{
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).into_owned(),
_ => String::new(),
}
@@ -859,7 +874,8 @@ const NGINX_SITES: [&str; 2] = [
"/etc/nginx/sites-available/archipelago-http",
"/etc/nginx/sites-available/archipelago",
];
const NGINX_RESTART_DROPIN: &str = "/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
const NGINX_RESTART_DROPIN: &str =
"/etc/systemd/system/nginx.service.d/10-archipelago-restart.conf";
/// Global IPv4 addresses on this host, minus Tailscale CGNAT (100.64/10) —
/// the same exclusion `setup-node-ca.sh` applies, for the same reason.
@@ -964,7 +980,10 @@ async fn run_nginx_listener_repair() -> Result<bool> {
let status = host_sudo(&["sh", "-lc", &script]).await?;
match status.code() {
Some(0) => changed = true,
Some(3) => warn!(site, "nginx listener repair failed its config test — rolled back"),
Some(3) => warn!(
site,
"nginx listener repair failed its config test — rolled back"
),
_ => warn!(site, "nginx listener repair helper failed"),
}
}
@@ -1826,7 +1845,10 @@ mod tests {
let healed = retarget_https_listeners(cfg, &present).expect("must heal");
assert!(healed.contains("listen 192.168.1.50:443 ssl;"));
assert!(healed.contains("listen 10.44.0.1:443 ssl;"));
assert!(!healed.contains("192.168.63.240"), "stale listener must be dropped");
assert!(
!healed.contains("192.168.63.240"),
"stale listener must be dropped"
);
// Untouched lines survive, and the repair is idempotent.
assert!(healed.contains("listen 80 default_server;"));
assert!(healed.contains("ssl_certificate /x;"));
+16 -7
View File
@@ -216,14 +216,20 @@ pub async fn reap_for_app(app_id: &str) -> usize {
let app_id = app_id.to_string();
reap_matching(move |g| {
g.name.as_deref().is_some_and(|n| {
n == app_id || n.starts_with(&format!("{app_id}-")) || n.ends_with(&format!("-{app_id}"))
n == app_id
|| n.starts_with(&format!("{app_id}-"))
|| n.ends_with(&format!("-{app_id}"))
})
})
.await
}
async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
let ghosts: Vec<Ghost> = find_ghosts().await.into_iter().filter(|g| pred(g)).collect();
let ghosts: Vec<Ghost> = find_ghosts()
.await
.into_iter()
.filter(|g| pred(g))
.collect();
if ghosts.is_empty() {
return 0;
}
@@ -237,7 +243,10 @@ async fn reap_matching(pred: impl Fn(&Ghost) -> bool) -> usize {
);
kill_ghost(ghost).await;
}
info!(count = ghosts.len(), "ghost reaper: reaped ghost containers");
info!(
count = ghosts.len(),
"ghost reaper: reaped ghost containers"
);
ghosts.len()
}
@@ -280,7 +289,9 @@ mod tests {
#[test]
fn a_truncated_or_missing_id_is_not_reapable() {
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none());
assert!(
parse_conmon(&argv(&["/usr/bin/conmon", "-c", "8ea2fc65", "-n", "gitea"])).is_none()
);
assert!(parse_conmon(&argv(&["/usr/bin/conmon", "--api-version", "1"])).is_none());
}
@@ -295,9 +306,7 @@ mod tests {
let app = app.to_string();
let gh = g(name);
gh.name.as_deref().is_some_and(|n| {
n == app
|| n.starts_with(&format!("{app}-"))
|| n.ends_with(&format!("-{app}"))
n == app || n.starts_with(&format!("{app}-")) || n.ends_with(&format!("-{app}"))
})
};
assert!(matches("gitea", "gitea"));
+2 -2
View File
@@ -7,6 +7,6 @@
pub const APP_LAUNCH_PORTS: &[u16] = &[
2283, 2342, 3000, 3001, 3002, 4080, 5180, 7778, 8080, 8081, 8082, 8083, 8084, 8085, 8087, 8088,
8089, 8090, 8096, 8123, 8175, 8176, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380, 11434,
18081, 18083, 23000, 32838, 50002,
8089, 8090, 8096, 8123, 8175, 8176, 8187, 8240, 8334, 8336, 8888, 8999, 9000, 9100, 10380,
11434, 18081, 18083, 23000, 32838, 50002,
];
+6 -2
View File
@@ -538,8 +538,12 @@ async fn same_serial_device(a: &str, b: &str) -> bool {
if a == b {
return true;
}
let ra = fs::canonicalize(a).await.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b).await.unwrap_or_else(|_| PathBuf::from(b));
let ra = fs::canonicalize(a)
.await
.unwrap_or_else(|_| PathBuf::from(a));
let rb = fs::canonicalize(b)
.await
.unwrap_or_else(|_| PathBuf::from(b));
ra == rb
}
+4 -1
View File
@@ -697,7 +697,10 @@ mod tests {
// nodes would derive each other's coins.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_seed) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
assert_ne!(a.to_string(), derive_cashu_mnemonic(&other_seed).unwrap().to_string());
assert_ne!(
a.to_string(),
derive_cashu_mnemonic(&other_seed).unwrap().to_string()
);
}
/// It must NOT be the node's own phrase. Restoring ecash into a
+1 -2
View File
@@ -664,8 +664,7 @@ impl Server {
.map(|(a, _)| *a)
.unwrap_or(0)
+ 1;
let delay =
(90u64 << attempts.min(10)).min(86_400);
let delay = (90u64 << attempts.min(10)).min(86_400);
notify_backoff.insert(
node.did.clone(),
(attempts, now + Duration::from_secs(delay)),
+14 -4
View File
@@ -620,19 +620,23 @@ mod tests {
let token = CashuToken {
token: vec![TokenEntry {
mint: "https://testnut.cashu.space".to_string(),
// Real curve points (G and 2G). The V3 codec never parses `C`,
// so its tests get away with a plausible-looking hex string —
// the V4 encoder hands it to the reference implementation,
// which checks the point is actually on secp256k1.
proofs: vec![
Proof {
amount: 8,
id: "009a1f293253e41e".to_string(),
secret: "abcdef1234567890".to_string(),
c: "02a9acc1e48c25eeeb9289b5031cc57da9fe72f3fe2861d94ec4da0e7f6c2b4e24"
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
.to_string(),
},
Proof {
amount: 2,
id: "009a1f293253e41e".to_string(),
secret: "fedcba0987654321".to_string(),
c: "0279be667ef9dcbbac55a06295ce870b07029bfcdb2dce28d959f2815b16f81798"
c: "02c6047f9441ed7d6d3045406e95c07cd85c778e4b8cef3ca7abac09b95c709ee5"
.to_string(),
},
],
@@ -761,7 +765,10 @@ mod tests {
let short = "01fc0ec0e59cd6fa";
let full = "01fc0ec0e59cd6fa01b7a88f8cd77fce81fd1e64bca67d752e984992b7a3c3a821";
assert!(is_truncated_v2_keyset_id(short));
assert!(full.starts_with(short), "short form must prefix the full id");
assert!(
full.starts_with(short),
"short form must prefix the full id"
);
// It must survive token validation so the swap path can repair it,
// rather than being rejected as malformed.
assert!(validate_keyset_id(short).is_ok());
@@ -780,7 +787,10 @@ mod tests {
let err = validate_keyset_id("00112233445566778899")
.expect_err("9-byte keyset id must be rejected");
let msg = err.to_string();
assert!(msg.contains("10-byte") || msg.contains("unsupported keyset id"), "{msg}");
assert!(
msg.contains("10-byte") || msg.contains("unsupported keyset id"),
"{msg}"
);
// Non-hex ids (the original base64 keyset format) are named as such
// rather than reported as a length problem.
+17 -8
View File
@@ -397,9 +397,8 @@ pub async fn load_accepted_mints(data_dir: &Path) -> Result<AcceptedMints> {
mints: vec![network.default_mint()],
}
} else {
serde_json::from_str(&content).with_context(|| {
format!("Accepted-mints file {} is damaged", path.display())
})?
serde_json::from_str(&content)
.with_context(|| format!("Accepted-mints file {} is damaged", path.display()))?
};
Ok(mints)
}
@@ -1521,7 +1520,10 @@ pub async fn restore_from_seed(data_dir: &Path, mint_url: &str) -> Result<Restor
let mint_key = match keys.key_for_amount(sig.amount) {
Ok(k) => k,
Err(e) => {
warn!("Restored a {} sat output with no matching key: {e:#}", sig.amount);
warn!(
"Restored a {} sat output with no matching key: {e:#}",
sig.amount
);
continue;
}
};
@@ -2312,7 +2314,11 @@ mod tests {
// mint — never the real coins.
save_network(dir, EcashNetwork::Testnet).await.unwrap();
let test_wallet = load_wallet(dir).await.unwrap();
assert_eq!(test_wallet.balance(), 0, "test wallet must not see real coins");
assert_eq!(
test_wallet.balance(),
0,
"test wallet must not see real coins"
);
assert!(test_wallet.mint_url.contains("testnut"));
assert!(load_accepted_mints(dir).await.unwrap().mints[0].contains("testnut"));
@@ -2341,7 +2347,6 @@ mod tests {
assert_eq!(back.proofs[0].proof.secret, "real");
}
#[tokio::test]
async fn a_damaged_wallet_file_fails_loudly_and_is_left_on_disk() {
let tmp = TempDir::new().unwrap();
@@ -2355,7 +2360,9 @@ mod tests {
// It must NOT read as an empty wallet: that is what caused the real
// balance to be overwritten with nothing on the next save.
let err = load_wallet(dir).await.expect_err("damaged wallet must error");
let err = load_wallet(dir)
.await
.expect_err("damaged wallet must error");
assert!(
err.to_string().contains("damaged"),
"error should name the problem: {err}"
@@ -2372,7 +2379,9 @@ mod tests {
std::fs::create_dir_all(dir.join("wallet")).unwrap();
std::fs::write(dir.join("wallet/ecash.json"), " \n").unwrap();
// A create that never got its first write is not damage.
let w = load_wallet(dir).await.expect("empty file is a fresh wallet");
let w = load_wallet(dir)
.await
.expect("empty file is a fresh wallet");
assert_eq!(w.balance(), 0);
}
+20 -5
View File
@@ -191,7 +191,10 @@ impl MintClient {
&self,
keyset_id: &str,
amounts: &[u64],
) -> Result<(Vec<BlindedMessageRequest>, Vec<(Vec<u8>, secp256k1::SecretKey, u64)>)> {
) -> Result<(
Vec<BlindedMessageRequest>,
Vec<(Vec<u8>, secp256k1::SecretKey, u64)>,
)> {
let derived = match &self.recovery {
Some(source) => match source.next_outputs(keyset_id, amounts.len()).await {
Ok(pairs) => Some(pairs),
@@ -320,9 +323,7 @@ impl MintClient {
.filter(|k| !k.keys.is_empty() && k.unit.eq_ignore_ascii_case("sat"))
// Prefer a keyset the mint will still sign with.
.max_by_key(|k| k.active)
.ok_or_else(|| {
anyhow::anyhow!("No active sat keyset found at mint {}", self.url)
})
.ok_or_else(|| anyhow::anyhow!("No active sat keyset found at mint {}", self.url))
}
// ── Mint quotes (NUT-04) ──
@@ -510,7 +511,9 @@ impl MintClient {
"The mint's fee ({fee} sat) consumes this whole amount — nothing would be left"
);
}
debug!("Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee");
debug!(
"Reducing swap outputs {requested} -> {spendable} to cover a {fee} sat mint fee"
);
owned_targets = amount_to_denominations(spendable);
&owned_targets
} else {
@@ -648,6 +651,18 @@ impl MintClient {
if !res.status().is_success() {
let status = res.status();
// NUT-09 is optional. A mint that never implemented it answers 404
// or 405, which `mint_error` would render as "mint returned 404
// with no further detail" — true, and useless to someone trying to
// get their coins back. Name the actual limitation instead.
if matches!(status.as_u16(), 404 | 405 | 501) {
anyhow::bail!(
"This mint does not support restoring from a backup phrase (NUT-09). \
Your coins are safe, but they can only be recovered from a wallet \
file backup while they stay at {}",
self.url
);
}
let body = res.text().await.unwrap_or_default();
return Err(mint_error("Restore", status, &body));
}
+241 -8
View File
@@ -89,6 +89,19 @@ pub enum SeedSource {
/// NUT-13 wallet, but restoring the node from its recovery phrase will
/// *not* bring it back — only these words will.
Independent,
/// Supplied by the operator, from another NUT-13 wallet. Same caveat as
/// `Independent` — the node's recovery phrase does not cover it — but it
/// is worth telling apart, because these words exist somewhere else too
/// and the operator already knows where.
Imported,
}
impl SeedSource {
/// Does restoring the *node* from its recovery phrase bring this wallet
/// back? Only a derived phrase can promise that.
pub fn covered_by_node_seed(&self) -> bool {
matches!(self, Self::NodeSeed)
}
}
/// A loaded ecash wallet seed, ready to derive secrets from.
@@ -215,12 +228,118 @@ pub async fn establish_from_master(
Ok(EcashSeed::from_mnemonic(derived, SeedSource::NodeSeed))
}
/// Establish a wallet seed that is **not** derived from the node's master
/// seed, for a node that has no encrypted master seed to derive from.
///
/// Plenty of nodes are in that position: `identity/master_seed.enc` is written
/// during onboarding, and any node onboarded before that step existed simply
/// does not have one. The choice there is not "derived phrase or independent
/// phrase" — it is "independent phrase or **no backup at all**", and a wallet
/// whose coins can be restored from words the operator holds is strictly
/// better than one whose coins die with a single file.
///
/// The cost is stated plainly rather than hidden: the phrase is recorded as
/// [`SeedSource::Independent`], and every surface that shows it says that
/// restoring the node will *not* bring this wallet back — only these words
/// will. That is a real obligation on the operator, so it must never be the
/// silent default when derivation was possible; [`establish_from_master`] is
/// what a node with a master seed gets.
pub async fn establish_independent(data_dir: &Path) -> Result<EcashSeed> {
if let Some(existing) = load_seed(data_dir).await? {
return Ok(existing);
}
// Same guarded generation path as the node's own seed: a named CSPRNG and
// the degenerate-entropy check, not a dependency's default (KEY-05).
let (mnemonic, _seed) = crate::seed::MasterSeed::generate()?;
write_seed(data_dir, &mnemonic, SeedSource::Independent).await?;
warn!(
"Established an INDEPENDENT ecash backup phrase: this node has no encrypted \
master seed to derive one from, so restoring the node will not restore this \
ecash wallet — only the phrase itself will."
);
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Independent))
}
/// Adopt a phrase the operator supplies, from another NUT-13 wallet.
///
/// This is the "bring your own" path: it points the wallet at someone else's
/// derivation, which is what makes coins held in Minibits, Nutstash or
/// `cdk-cli` restorable here.
///
/// Replacing a phrase is the one genuinely lossy thing this module can do.
/// Coins already in `wallet/ecash.json` stay spendable — they are proofs, not
/// derivations, and nothing here touches them — but they were minted under
/// the *old* phrase, so a future restore will no longer find them. The old
/// phrase is therefore archived rather than overwritten, and replacing an
/// established one needs `confirm`. An operator who imports by mistake must
/// not lose the only copy of the words their balance was minted under.
///
/// Counters are deliberately left alone. They are per-keyset and seed-
/// relative, so under a new seed they merely start high — which costs nothing,
/// since a restore scans from zero regardless. Resetting them would be the
/// dangerous choice if the imported phrase turned out to be the one already
/// in use.
pub async fn import_mnemonic(data_dir: &Path, words: &str, confirm: bool) -> Result<EcashSeed> {
let mnemonic: bip39::Mnemonic = words
.split_whitespace()
.collect::<Vec<_>>()
.join(" ")
.parse()
.map_err(|e| {
anyhow::anyhow!(
"That is not a valid BIP-39 recovery phrase: {e}. Check for typos — \
every word must come from the BIP-39 word list, and the phrase as \
a whole carries a checksum."
)
})?;
if let Some(existing) = load_seed(data_dir).await? {
if existing.mnemonic == mnemonic {
// Importing the phrase already in use: nothing to do, and
// certainly nothing to archive.
return Ok(existing);
}
if !confirm {
anyhow::bail!(
"This wallet already has a backup phrase. Importing a different one \
means coins minted under the current phrase will no longer be \
restorable from words — they stay spendable, but a restore will \
not find them. Reveal and write down the current phrase first, \
then confirm to replace it."
);
}
archive_seed(data_dir).await?;
}
write_seed(data_dir, &mnemonic, SeedSource::Imported).await?;
warn!("Ecash backup phrase REPLACED by an imported one (the previous phrase, if any, was archived)");
Ok(EcashSeed::from_mnemonic(mnemonic, SeedSource::Imported))
}
/// Move the current seed file aside, timestamped, before it is replaced.
///
/// Never deleted and never overwritten: this file may be the last copy of the
/// words a balance was minted under, and the whole point of the module is that
/// such a thing is not casually destroyed.
async fn archive_seed(data_dir: &Path) -> Result<()> {
let from = seed_path(data_dir);
if !from.exists() {
return Ok(());
}
let stamp = chrono::Utc::now().format("%Y%m%dT%H%M%SZ");
let to = data_dir.join(format!("wallet/cashu_seed.replaced-{stamp}.json"));
fs::rename(&from, &to).await.with_context(|| {
format!(
"Could not archive the previous ecash phrase to {}",
to.display()
)
})?;
warn!("Previous ecash phrase archived to {}", to.display());
Ok(())
}
/// Write the seed file at 0600, creating the wallet directory if needed.
async fn write_seed(
data_dir: &Path,
mnemonic: &bip39::Mnemonic,
source: SeedSource,
) -> Result<()> {
async fn write_seed(data_dir: &Path, mnemonic: &bip39::Mnemonic, source: SeedSource) -> Result<()> {
let path = seed_path(data_dir);
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)
@@ -482,8 +601,7 @@ mod tests {
// A *different* master seed must not replace the phrase the existing
// proofs were minted under.
let (other_words, _) = MasterSeed::generate().unwrap();
let (_, other_master) =
MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
let (_, other_master) = MasterSeed::from_mnemonic_words(&other_words.to_string()).unwrap();
let third = establish_from_master(d, &other_master).await.unwrap();
assert_eq!(
first.words(),
@@ -492,6 +610,118 @@ mod tests {
);
}
/// A phrase from another wallet must derive that wallet's secrets — that
/// is the entire point of importing one.
#[tokio::test]
async fn an_imported_phrase_derives_the_other_wallets_secrets() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Stand in for the other wallet: a known phrase and what it derives.
let theirs: bip39::Mnemonic = TEST_MNEMONIC.parse().unwrap();
let expected = EcashSeed::from_mnemonic(theirs.clone(), SeedSource::Imported)
.derive_output(V1_KEYSET, 3)
.unwrap();
let imported = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(imported.source(), SeedSource::Imported);
assert!(!imported.source().covered_by_node_seed());
assert_eq!(imported.derive_output(V1_KEYSET, 3).unwrap().0, expected.0);
// And it is what the wallet uses from now on.
let reloaded = load_seed(d).await.unwrap().expect("persisted");
assert_eq!(reloaded.words(), imported.words());
}
#[tokio::test]
async fn importing_over_an_established_phrase_needs_confirmation() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let (_, master) = MasterSeed::from_mnemonic_words(TEST_MNEMONIC).unwrap();
let original = establish_from_master(d, &master).await.unwrap();
let original_words = original.words();
// Refused without confirmation — replacing a phrase silently would
// orphan every coin minted under it.
let (other, _) = MasterSeed::generate().unwrap();
let err = import_mnemonic(d, &other.to_string(), false)
.await
.expect_err("must not replace without confirmation");
assert!(
err.to_string().contains("already has a backup phrase"),
"{err}"
);
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
original_words,
"a refused import must change nothing"
);
// Confirmed: replaced, and the old phrase archived rather than lost.
import_mnemonic(d, &other.to_string(), true).await.unwrap();
assert_eq!(
load_seed(d).await.unwrap().unwrap().words(),
other.words().map(|w| w.to_string()).collect::<Vec<_>>()
);
let archived: Vec<_> = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.collect();
assert_eq!(archived.len(), 1, "the replaced phrase must be kept");
}
/// Re-importing the phrase already in use is a no-op, not a replacement —
/// it must not archive anything or churn the file.
#[tokio::test]
async fn importing_the_current_phrase_changes_nothing() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let first = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
let again = import_mnemonic(d, TEST_MNEMONIC, false).await.unwrap();
assert_eq!(first.words(), again.words());
let archived = std::fs::read_dir(d.join("wallet"))
.unwrap()
.filter_map(|e| e.ok())
.filter(|e| {
e.file_name()
.to_string_lossy()
.starts_with("cashu_seed.replaced-")
})
.count();
assert_eq!(archived, 0);
}
#[tokio::test]
async fn a_malformed_phrase_is_refused_with_something_actionable() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
// Right shape, wrong checksum — the commonest real mistake.
let bad = TEST_MNEMONIC.replace(" art", " abandon");
let err = import_mnemonic(d, &bad, false).await.expect_err("checksum");
assert!(err.to_string().contains("BIP-39"), "{err}");
assert!(!seed_exists(d), "a rejected phrase must not be written");
assert!(import_mnemonic(d, "not a phrase", false).await.is_err());
assert!(import_mnemonic(d, "", false).await.is_err());
}
/// Whitespace and casing vary wildly in what people paste out of other
/// wallets; the words are what matter.
#[tokio::test]
async fn a_pasted_phrase_survives_untidy_whitespace() {
let dir = tempfile::tempdir().unwrap();
let d = dir.path();
let messy = format!(" {} ", TEST_MNEMONIC.replace(' ', "\n "));
let imported = import_mnemonic(d, &messy, false).await.unwrap();
assert_eq!(imported.words().len(), 24);
assert_eq!(imported.words().join(" "), TEST_MNEMONIC);
}
#[tokio::test]
async fn the_seed_file_is_owner_only() {
let dir = tempfile::tempdir().unwrap();
@@ -502,7 +732,10 @@ mod tests {
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(seed_path(d)).unwrap().permissions().mode();
let mode = std::fs::metadata(seed_path(d))
.unwrap()
.permissions()
.mode();
assert_eq!(mode & 0o777, 0o600, "the ecash phrase must be owner-only");
}
}
+4 -2
View File
@@ -29,8 +29,10 @@ impl Router {
.with_context(|| format!("no address for {}", addr))?;
let tcp = TcpStream::connect_timeout(&resolved, std::time::Duration::from_secs(5))
.with_context(|| format!("TCP connect to {}", addr))?;
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30))).ok();
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30))).ok();
tcp.set_read_timeout(Some(std::time::Duration::from_secs(30)))
.ok();
tcp.set_write_timeout(Some(std::time::Duration::from_secs(30)))
.ok();
Ok(tcp)
}
+45 -6
View File
@@ -190,6 +190,16 @@
.text-white-40 { color: rgba(255,255,255,0.4); }
.text-green { color: #4ade80; } .text-orange { color: #fb923c; } .text-red { color: #f87171; }
.text-purple { color: #a78bfa; } .text-yellow { color: #facc15; } .text-btc { color: #f7931a; }
/* Pixel readout shown in place of a balance that isn't known yet.
An unloaded balance used to render as "0 sats" — zero is a number,
not a loading state, and it is the one number that frightens
people. It inherits currentColor, so each tile shimmers in its own
rail colour. */
.bal-pixels { display: inline-grid; grid-auto-flow: column; grid-template-rows: repeat(3, 4px); grid-auto-columns: 4px; gap: 1px; vertical-align: 0.1em; }
.bal-pixels i { width: 4px; height: 4px; border-radius: 0.5px; background: currentColor; opacity: 0.16; animation: bal-pixel-scan 1.6s ease-in-out infinite; }
@keyframes bal-pixel-scan { 0%, 70%, 100% { opacity: 0.16; } 25% { opacity: 1; } 45% { opacity: 0.42; } }
@media (prefers-reduced-motion: reduce) { .bal-pixels i { animation: none; opacity: 0.35; } }
.bg-green { background: #4ade80; } .bg-yellow { background: #facc15; } .bg-red { background: #f87171; }
.bg-grey { background: rgba(255,255,255,0.35); }
@@ -1070,6 +1080,25 @@
}
function setText(id, text) { const el = document.getElementById(id); if (el) el.textContent = text; }
// 28 cells = 14 columns x 2 rows, delays staggered so the lit column
// travels across the matrix.
// 14 columns x 3 rows, laid out column-first so the three cells of a
// column share a delay and the lit column scans across as one line.
const BAL_PIXELS = '<span class="bal-pixels" role="status" aria-label="Loading balance">' +
Array.from({ length: 42 }, function (_, i) {
return '<i style="animation-delay:' + (Math.floor(i / 3) * 55) + 'ms"></i>';
}).join('') + '</span>';
// Render a balance, or the pixel readout when it is not known yet.
// `sats` must be null/undefined for "not loaded" — passing 0 here
// means the node genuinely has nothing, and says so.
function setBalance(id, sats) {
const el = document.getElementById(id);
if (!el) return;
if (sats === null || sats === undefined) { el.innerHTML = BAL_PIXELS; return; }
el.textContent = fmtAmount(sats);
}
function setHtml(id, html) { const el = document.getElementById(id); if (el) el.innerHTML = html; }
// Classify a peer address the way Umbrel's peers table does.
@@ -1216,12 +1245,22 @@
const lnRemote = num(cb && ((cb.remote_balance && cb.remote_balance.sat) ?? 0));
const lnPending = num(cb && ((cb.pending_open_local_balance && cb.pending_open_local_balance.sat) ?? 0));
setText('balTotal', fmtAmount(onchainConfirmed + lnLocal));
setText('balTotalSub', state.onchain || cb ? 'on-chain + lightning' : 'balances unavailable');
setText('balLightning', fmtAmount(lnLocal));
setText('balLightningSub', lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setText('balOnchain', fmtAmount(onchainConfirmed));
setText('balOnchainSub', onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
// This function runs on every poll, including before the first
// response lands — at which point `state.onchain`/`state.chanbal`
// are still null and every figure below computed to 0. The tiles
// therefore claimed a zero balance on load. A rail with no data
// yet gets the pixel readout instead.
const haveOnchain = !!state.onchain;
const haveChan = !!cb;
setBalance('balTotal', haveOnchain || haveChan ? onchainConfirmed + lnLocal : null);
setText('balTotalSub', haveOnchain || haveChan ? 'on-chain + lightning' : 'waiting for LND');
setBalance('balLightning', haveChan ? lnLocal : null);
setText('balLightningSub', !haveChan ? 'waiting for LND'
: lnPending > 0 ? fmtAmount(lnPending) + ' pending open' : 'spendable over channels');
setBalance('balOnchain', haveOnchain ? onchainConfirmed : null);
setText('balOnchainSub', !haveOnchain ? 'waiting for LND'
: onchainUnconfirmed > 0 ? fmtAmount(onchainUnconfirmed) + ' unconfirmed' : 'confirmed');
setText('liqLocal', fmtAmount(lnLocal));
setText('liqRemote', fmtAmount(lnRemote));
+40
View File
@@ -17,6 +17,45 @@ orchestrator code rather than a per-app installer, but it is not
manifest-declared, and the direction of travel is to replace each case with a
reusable manifest primitive.
## Upstream tracking
A node only offers an app update when the signed catalog pins a newer image
than the one running. That works — but nothing was telling *us* when upstream
had shipped something new, because a manifest records only our mirror
(`source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`), which says
nothing about the project it was mirrored from. So a pin could sit still for
months while every node in the fleet correctly reported "up to date".
`upstream` closes that loop. It is metadata for the release process, never
read by the orchestrator:
```yaml
app:
id: fedimint
version: 0.10.0
upstream:
kind: github # github | dockerhub | internal | manual
repo: fedimint/fedimint
```
| `kind` | Meaning | Needs |
|--------|---------|-------|
| `github` | Watch a project's releases, then its tags. | `repo: owner/name` |
| `dockerhub` | Watch a Docker Hub repository's tags. | `repo: namespace/name` |
| `internal` | Built by this project — there is no upstream feed. | — |
| `manual` | Has releases, but not anywhere machine-readable. | `url:` for a human |
`scripts/check-upstream-releases.py` reads these and prints what is behind;
it exits non-zero when anything tracked has fallen behind, so a release pass
can gate on it. Export `GITHUB_TOKEN` first — a full sweep needs more than
GitHub's 60-per-hour anonymous quota.
An app with **no** `upstream` block is reported as `UNTRACKED` rather than
skipped: silently skipping unknowns is exactly how this gap stayed invisible.
Leaving it out is therefore fine and honest; guessing a wrong `repo` is not,
because a wrong source produces a confident wrong verdict.
## Top-level fields (`app:`)
| Field | Type | Required | Notes |
@@ -37,6 +76,7 @@ reusable manifest primitive.
| `devices` | list of string | — | Host device paths; must start with `/dev/`. |
| `interfaces` | map | — | Launch surfaces, keyed by name (`main`): `{ name, description, type, port, protocol, path }`. |
| `hooks` | LifecycleHooks | — | Allow-listed lifecycle hooks. See [Hooks](#hooks). |
| `upstream` | UpstreamSource | — | Where the app comes from, so release tooling can tell when the pin has fallen behind. See [Upstream tracking](#upstream-tracking). |
| _anything else_ | — | — | Unknown keys are absorbed into an `extensions` map (serde flatten) and treated as transitional metadata — e.g. `container_name`, `metadata`, `category`, `bitcoin_integration`, `lightning_integration`. These are **not** typed schema; do not rely on them being validated. |
## `container:` (ContainerConfig)
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.8.3-alpha",
"version": "1.8.4-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+16 -16
View File
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -193,13 +193,13 @@
{
"id": "nostr-rs-relay",
"title": "Nostr Relay (Rust)",
"version": "0.8.0",
"version": "0.10.0",
"description": "High-performance Nostr relay written in Rust. Host your own decentralized social media relay and earn networking profits.",
"icon": "/assets/img/app-icons/nostrudel.svg",
"author": "Nostr RS Relay",
"category": "community",
"tier": "recommended",
"dockerImage": "scsibug/nostr-rs-relay:0.8.9",
"dockerImage": "scsibug/nostr-rs-relay:0.10.0",
"repoUrl": "https://github.com/scsibug/nostr-rs-relay",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -374,11 +374,11 @@
"id": "pine",
"title": "Pine",
"version": "1.3.0",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node \u2014 block height, sync, peers, Lightning balance \u2014 and, when a Claude API key is set, anything else.",
"description": "A private voice assistant for your home. Pine runs speech-to-text (Whisper), text-to-speech (Piper) and wake-word detection (openWakeWord) on your own node and pairs with a PineVoice satellite speaker, so Home Assistant Assist works locally with nothing sent to the cloud. Ask it about your node — block height, sync, peers, Lightning balance — and, when a Claude API key is set, anything else.",
"icon": "/assets/img/app-icons/pine.svg",
"author": "Archipelago",
"category": "home",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/rhasspy/wyoming"
},
{
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -459,12 +459,12 @@
"id": "netbird",
"title": "NetBird",
"version": "2.38.0",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point \u2014 a TLS proxy in front of the dashboard + server.",
"description": "Self-hosted WireGuard mesh VPN control plane with dashboard, embedded identity provider, management API, signal, relay, and STUN. The user-facing entry point — a TLS proxy in front of the dashboard + server.",
"icon": "/assets/img/app-icons/netbird.svg",
"author": "NetBird",
"category": "networking",
"tier": "recommended",
"dockerImage": "docker.io/library/nginx:1.27-alpine",
"dockerImage": "docker.io/library/nginx:1.31.3-alpine",
"repoUrl": "https://github.com/netbirdio/netbird",
"containerConfig": {
"ports": [
@@ -552,19 +552,19 @@
"id": "alby-hub",
"title": "Alby Hub",
"version": "1.23.0",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect \u2014 one hub, every app pays through it.",
"description": "Self-custodial Lightning wallet hub. Runs its own Lightning node on your Archipelago and connects your apps to it over Nostr Wallet Connect — one hub, every app pays through it.",
"icon": "/assets/img/app-icons/alby-hub.svg",
"author": "Alby",
"category": "money",
"tier": "optional",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"repoUrl": "https://github.com/getAlby/hub"
},
{
"id": "phoenixd",
"title": "phoenixd",
"version": "0.9.0",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own \u2014 it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"description": "Headless Lightning daemon by ACINQ (the Phoenix wallet team). No screen of its own — it exposes a small local API that other apps and tools use to send and receive Lightning payments. Channel liquidity is managed automatically for a fee.",
"icon": "/assets/img/app-icons/phoenixd.svg",
"author": "ACINQ",
"category": "money",
+123
View File
@@ -0,0 +1,123 @@
<script setup lang="ts">
import { computed } from 'vue'
/**
* A balance figure, or — while it is still unknown — a pixel readout in place
* of it.
*
* The problem this exists for: an unloaded balance used to render as `0`.
* Zero is not "loading", it is a *number*, and it is the one number that
* frightens people. Someone opening the dashboard while the RPCs are still in
* flight was told, in the wallet's own typeface, that their money was gone.
* There is no formatting fix for that — the fix is to not claim a figure we
* do not have yet.
*
* So `sats` is nullable, and `null` means "not known yet" rather than "none".
* Callers must keep that distinction alive: a balance ref should start at
* `null` and only become a number when a call actually succeeds.
*
* The placeholder is a small dot-matrix that scans in the rail's own colour —
* it inherits `currentColor`, so the on-chain row shimmers orange, Lightning
* yellow, Cashu purple, Fedimint blue and Ark teal with no colour mapping to
* keep in sync. It is deliberately about as wide as the figure it stands in
* for, so nothing jumps when the real number lands.
*/
const props = withDefaults(
defineProps<{
/** Balance in sats, or null/undefined while it is still unknown. */
sats: number | null | undefined
/** Trailing unit. Set to '' for bare figures. */
suffix?: string
/** Named for screen readers, e.g. "on-chain balance". */
label?: string
}>(),
{ suffix: 'sats', label: 'balance' },
)
// 14 columns × 3 rows, laid out column-first so all three cells of a column
// share a delay and the lit column travels across as a single scan line —
// that is what makes it read as a readout rather than a progress bar.
const COLUMNS = 14
const ROWS = 3
const CELLS = COLUMNS * ROWS
const STEP_MS = 55
/** Delay for cell `i` (1-based), constant within a column. */
function cellDelay(i: number): string {
return `${Math.floor((i - 1) / ROWS) * STEP_MS}ms`
}
/**
* Built as one string rather than interpolated around a `<template>`, so the
* space before the unit cannot be eaten by Vue's whitespace condensing — and
* so a test reading `.text()` sees exactly what a person reads on screen.
*/
/**
* Is there a figure to show at all?
*
* `null`/`undefined` mean "not known yet" — but so does a NaN or an Infinity,
* which is what arithmetic on a missing field quietly produces. Those render
* as the literal text "NaN sats", which is worse than the zero this component
* exists to prevent: at least a zero looks like a number.
*/
const known = computed(() => props.sats != null && Number.isFinite(props.sats))
const display = computed(() => {
if (!known.value) return ''
const figure = (props.sats as number).toLocaleString()
return props.suffix ? `${figure} ${props.suffix}` : figure
})
</script>
<template>
<span
v-if="!known"
class="balance-pixels"
role="status"
aria-live="polite"
:aria-label="`Loading ${props.label}`"
:title="`Loading ${props.label}…`"
>
<span v-for="i in CELLS" :key="i" class="balance-pixel" :style="{ animationDelay: cellDelay(i) }" />
</span>
<span v-else>{{ display }}</span>
</template>
<style scoped>
.balance-pixels {
display: inline-grid;
/* Column-first: children fill top-to-bottom, then across, so consecutive
cells share a column and the delay below scans horizontally. */
grid-auto-flow: column;
grid-template-rows: repeat(3, 3px);
grid-auto-columns: 3px;
gap: 1px;
/* Centred on the text it stands in for, so the row height is unchanged when
the real figure replaces it. */
vertical-align: 0.05em;
}
.balance-pixel {
width: 3px;
height: 3px;
border-radius: 0.5px;
background: currentColor;
opacity: 0.16;
animation: balance-pixel-scan 1.6s ease-in-out infinite;
}
@keyframes balance-pixel-scan {
0%, 70%, 100% { opacity: 0.16; }
25% { opacity: 1; }
45% { opacity: 0.42; }
}
/* Motion is decoration here — the dimmed matrix still reads as "no figure
yet", which is the part that carries the meaning. */
@media (prefers-reduced-motion: reduce) {
.balance-pixel {
animation: none;
opacity: 0.35;
}
}
</style>
+158 -12
View File
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { rpcClient } from '@/api/rpc-client'
import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
@@ -20,8 +20,13 @@ import SeedRevealPanel from '@/components/SeedRevealPanel.vue'
type SeedStatus = {
active: boolean
source: 'node-seed' | 'independent' | null
source: 'node-seed' | 'independent' | 'imported' | null
can_activate: boolean
/** Whether a phrase can be *derived* from the node's recovery phrase. When
* false the wallet still gets a backup — it is just independent, and the
* operator has to keep it themselves. Saying which one they are about to
* get, before they write anything down, is the whole point of this flag. */
derivable_from_node_seed: boolean
}
const status = ref<SeedStatus | null>(null)
@@ -100,6 +105,60 @@ async function copyRevealedWords() {
} catch { /* clipboard unavailable */ }
}
// ── Import ─────────────────────────────────────────────────────────────────
// Bring-your-own: point this wallet at another NUT-13 wallet's derivation, so
// coins held in Minibits, Nutstash or cdk-cli become restorable here.
//
// Replacing an established phrase is the one lossy thing on this screen. The
// coins already held stay spendable — they are proofs, not derivations — but
// they were minted under the old phrase, so a restore will no longer find
// them. Hence the explicit confirmation, and the reminder to write the
// current phrase down first.
const showImportModal = ref(false)
const importWords = ref('')
const importPassword = ref('')
const importCode = ref('')
const importConfirm = ref(false)
const importing = ref(false)
const importError = ref('')
const importDone = ref(false)
const importWordCount = computed(
() => importWords.value.trim().split(/\s+/).filter(Boolean).length,
)
function openImport() {
importWords.value = ''
importPassword.value = ''
importCode.value = ''
importConfirm.value = false
importError.value = ''
importDone.value = false
showImportModal.value = true
}
async function submitImport() {
if (importing.value || !importPassword.value || importWordCount.value === 0) return
importing.value = true
importError.value = ''
try {
const params: Record<string, string | boolean> = {
words: importWords.value.trim(),
password: importPassword.value,
confirm: importConfirm.value,
}
if (importCode.value) params.code = importCode.value
await rpcClient.call({ method: 'wallet.ecash-seed-import', params })
importDone.value = true
importWords.value = ''
await loadStatus()
} catch (e: unknown) {
importError.value = e instanceof Error ? e.message : 'Import failed'
} finally {
importing.value = false
}
}
// ── Restore ────────────────────────────────────────────────────────────────
// The other half of the backup. Safe to run against a working wallet: the
// backend skips coins already held and never re-adds spent ones, so this is
@@ -153,34 +212,42 @@ async function restoreFromPhrase() {
<div class="min-w-0">
<h2 class="text-xl font-semibold text-white/96 mb-1">Ecash backup phrase</h2>
<p v-if="status?.active" class="text-sm text-white/60">
<p v-if="status?.active && status?.source === 'node-seed'" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase, derived from this node's recovery
phrase — so the words you already wrote down cover your ecash too. Reveal it here
if you want to restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>) without handing over the node's own seed.
</p>
<p v-else-if="status?.active" class="text-sm text-white/60">
Your ecash wallet has its own 24-word phrase. Reveal it to write it down, or to
restore your ecash into another wallet (Minibits, Nutstash,
<span class="font-mono">cdk-cli</span>).
</p>
<p v-else class="text-sm text-white/60">
Ecash is a bearer instrument: the coins live in a file on this node, and right now
nothing can bring them back if that file is lost. Setting up a backup phrase fixes
that for every coin minted from then on. It's derived from this node's recovery
phrase, so there's nothing new to write down.
that for every coin minted from then on.
<template v-if="status?.derivable_from_node_seed">
It's derived from this node's recovery phrase, so there's nothing new to write down.
</template>
<template v-else>
This node has no encrypted seed backup to derive from, so the phrase will be its
own — you'll need to write these words down and keep them.
</template>
</p>
<p v-if="status?.source === 'independent'" class="mt-2 text-xs text-orange-300/90">
<p v-if="status?.source === 'independent' || status?.source === 'imported'" class="mt-2 text-xs text-orange-300/90">
This wallet's phrase was <strong>not</strong> derived from the node's recovery
phrase — restoring the node will not bring the ecash back. Write these words down
separately.
</p>
<p v-if="!status?.active && !status?.can_activate" class="mt-2 text-xs text-orange-300/90">
This node has no encrypted seed backup, so a phrase can't be derived from it.
phrase{{ status?.source === 'imported' ? ' — it was imported' : '' }}, so restoring
the node will not bring the ecash back. Only these words will.
</p>
</div>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
:class="!status?.active ? 'bg-orange-500/20 border-orange-400/30' : ''"
:disabled="!status?.active && !status?.can_activate"
@click="openReveal"
>{{ status?.active ? 'Reveal' : 'Set up backup' }}</button>
</div>
@@ -203,8 +270,87 @@ async function restoreFromPhrase() {
<p v-if="restoreMsg" role="status" aria-live="polite" class="mt-3 text-xs alert-success px-3 py-2 rounded-lg">{{ restoreMsg }}</p>
<p v-if="restoreError" role="alert" class="mt-3 text-xs alert-error px-3 py-2 rounded-lg">{{ restoreError }}</p>
</div>
<div class="mt-4 pt-4 border-t border-white/10">
<div class="flex items-start justify-between gap-4">
<p class="text-sm text-white/60 min-w-0">
<span class="text-white/80 font-medium">Use a phrase from another wallet.</span>
Point this wallet at a phrase you already have — from Minibits, Nutstash or
<span class="font-mono">cdk-cli</span> — so its coins can be restored here.
</p>
<button
type="button"
class="shrink-0 glass-button rounded-lg px-4 py-2 text-sm font-medium"
@click="openImport"
>Import</button>
</div>
</div>
</div>
<Teleport to="body">
<div
v-if="showImportModal"
class="fixed inset-0 z-[3000] flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="showImportModal = false"
>
<div class="glass-card p-6 w-full max-w-md" role="dialog" aria-modal="true" aria-labelledby="import-ecash-seed-title">
<h3 id="import-ecash-seed-title" class="text-lg font-semibold text-white mb-1">Import an ecash phrase</h3>
<template v-if="importDone">
<p class="text-sm text-white/70 my-4">
Imported. This wallet now derives its coins from that phrase — run
<span class="text-white/90 font-medium">Restore</span> to pull in the coins it
owns at your mint.
</p>
<button type="button" @click="showImportModal = false" class="w-full glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30">Done</button>
</template>
<template v-else>
<p class="text-sm text-white/60 mb-4">
Paste the 24-word phrase from the other wallet. The coins already in this wallet
stay spendable either way.
</p>
<form @submit.prevent="submitImport" class="space-y-3">
<div>
<label class="block text-xs text-white/60 mb-1">
Recovery phrase
<span class="text-white/30">({{ importWordCount }} word{{ importWordCount === 1 ? '' : 's' }})</span>
</label>
<textarea v-model="importWords" rows="3" spellcheck="false" autocapitalize="none" autocomplete="off" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono focus:outline-none focus:border-white/30" placeholder="abandon abandon abandon …"></textarea>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Password</label>
<input v-model="importPassword" type="password" autocomplete="current-password" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm focus:outline-none focus:border-white/30" placeholder="Your login password" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">2FA code <span class="text-white/30">(if enabled)</span></label>
<input v-model="importCode" inputmode="numeric" autocomplete="one-time-code" class="w-full px-3 py-2 rounded-lg bg-white/5 border border-white/10 text-white text-sm font-mono tracking-widest focus:outline-none focus:border-white/30" placeholder="123456" />
</div>
<label v-if="status?.active" class="flex items-start gap-2 text-xs text-orange-300/90 bg-orange-500/10 border border-orange-400/20 rounded-lg px-3 py-2">
<input type="checkbox" v-model="importConfirm" class="mt-0.5 shrink-0" />
<span>
Replace this wallet's current phrase. Coins minted under the old one stay
spendable but a restore will no longer find them — reveal and write the
current phrase down first. The old phrase is archived on the node, not deleted.
</span>
</label>
<p v-if="importError" role="alert" class="text-xs text-red-300 bg-red-500/10 border border-red-400/20 rounded-lg px-3 py-2">{{ importError }}</p>
<div class="flex gap-2 pt-1">
<button type="button" @click="showImportModal = false" class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium">Cancel</button>
<button
type="submit"
:disabled="importing || !importPassword || importWordCount === 0 || (status?.active && !importConfirm)"
class="flex-1 glass-button rounded-lg px-4 py-2 text-sm font-medium bg-orange-500/20 border-orange-400/30 disabled:opacity-50"
>{{ importing ? 'Importing…' : 'Import' }}</button>
</div>
</form>
</template>
</div>
</div>
</Teleport>
<Teleport to="body">
<div
v-if="showRevealModal"
@@ -221,15 +221,15 @@
<div v-if="arkStatus?.available" class="grid grid-cols-3 gap-2 mb-4">
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Spendable</p>
<p class="text-sm text-teal-400 font-medium">{{ (arkBalance?.spendable_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-teal-400 font-medium"><BalanceAmount :sats="arkBalance?.spendable_sats" label="spendable Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">Pending</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.pending_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.pending_sats" label="pending Ark balance" /></p>
</div>
<div class="p-3 bg-white/5 rounded-lg text-center">
<p class="text-[11px] text-white/40 mb-1">On-chain</p>
<p class="text-sm text-white/70 font-medium">{{ (arkBalance?.onchain_sats ?? 0).toLocaleString() }} sats</p>
<p class="text-sm text-white/70 font-medium"><BalanceAmount :sats="arkBalance?.onchain_sats" label="on-chain Ark balance" /></p>
</div>
</div>
@@ -318,6 +318,7 @@ import { useI18n } from 'vue-i18n'
import { rpcClient } from '@/api/rpc-client'
import BaseModal from '@/components/BaseModal.vue'
import LightningChannelsPanel from '@/components/LightningChannelsPanel.vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useTxExplorer, EXPLORER_PLACEHOLDER } from '@/composables/useTxExplorer'
const { t } = useI18n()
@@ -0,0 +1,139 @@
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import BalanceAmount from '../BalanceAmount.vue'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
import i18n from '@/i18n'
/**
* The distinction this whole component exists to protect: `0` is a balance,
* `null` is the absence of one. Rendering the first when you mean the second
* tells someone their money is gone, in the wallet's own typeface. Every case
* below is really one assertion — that the two never get confused.
*/
describe('BalanceAmount', () => {
it('shows the pixel readout when the balance is not known yet', () => {
const w = mount(BalanceAmount, { props: { sats: null, label: 'on-chain balance' } })
expect(w.find('.balance-pixels').exists()).toBe(true)
expect(w.text()).not.toContain('0')
})
it('treats undefined the same as null', () => {
// Optional props (`arkBalance?.spendable_sats`) arrive as undefined, not
// null, and must not fall through to a figure.
const w = mount(BalanceAmount, { props: { sats: undefined } })
expect(w.find('.balance-pixels').exists()).toBe(true)
})
it('never prints NaN at a person', () => {
// Arithmetic over a missing field produces NaN, which is not caught by a
// null check and renders as the literal text "NaN sats" — worse than the
// zero this component exists to prevent, because at least a zero looks
// like a number.
for (const bad of [NaN, Infinity, -Infinity]) {
const w = mount(BalanceAmount, { props: { sats: bad } })
expect(w.find('.balance-pixels').exists()).toBe(true)
expect(w.text()).toBe('')
}
})
it('shows a genuine zero as a figure, not as loading', () => {
// The inverse mistake: a node that really has no coins must be told so
// plainly, not left shimmering forever.
const w = mount(BalanceAmount, { props: { sats: 0 } })
expect(w.find('.balance-pixels').exists()).toBe(false)
expect(w.text()).toBe('0 sats')
})
it('formats a real balance with thousands separators', () => {
const w = mount(BalanceAmount, { props: { sats: 9922 } })
expect(w.text()).toBe('9,922 sats')
})
it('can drop the unit for bare figures', () => {
const w = mount(BalanceAmount, { props: { sats: 21, suffix: '' } })
expect(w.text()).toBe('21')
})
it('announces what is loading instead of being silently empty', () => {
// A shimmering box with no text is nothing at all to a screen reader.
const w = mount(BalanceAmount, { props: { sats: null, label: 'Cashu balance' } })
const el = w.find('.balance-pixels')
expect(el.attributes('role')).toBe('status')
expect(el.attributes('aria-label')).toBe('Loading Cashu balance')
})
it('inherits the rail colour rather than hard-coding one', () => {
// The pixels are painted with currentColor, which is what makes the
// on-chain row orange and the Cashu row purple with no colour table to
// keep in sync. Guard the mechanism: a literal colour here would drift.
const w = mount(BalanceAmount, { props: { sats: null } })
expect(w.find('.balance-pixel').exists()).toBe(true)
expect(w.html()).not.toMatch(/background:\s*#|rgb\(/)
})
it('renders a 14x3 matrix scanned column by column', () => {
// Column-first layout is what makes the lit column travel across as one
// scan line; per-cell delays would make it crawl diagonally instead.
const w = mount(BalanceAmount, { props: { sats: null } })
const cells = w.findAll('.balance-pixel')
expect(cells.length).toBe(42)
// The three cells of a column share a delay; the next column steps on.
const delay = (i: number) => cells[i]?.attributes('style') ?? ''
expect(delay(0)).toBe(delay(1))
expect(delay(1)).toBe(delay(2))
expect(delay(3)).not.toBe(delay(2))
})
})
describe('HomeWalletCard balances', () => {
const base = {
animate: false,
walletConnected: true,
walletOnchain: null,
walletLightning: null,
walletEcash: null,
walletFedimint: null,
walletArk: null,
walletTransactions: [],
isDev: false,
}
const mountCard = (props: Record<string, unknown>) =>
mount(HomeWalletCard, { props: { ...base, ...props }, global: { plugins: [i18n] } })
it('shows no figures at all before anything has loaded', () => {
const w = mountCard({})
// Six rows could be showing 0 sats here; none of them may.
expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
expect(w.text()).not.toMatch(/\b0 sats\b/)
})
it('withholds the total until every rail it sums is known', () => {
// A total computed with nulls as 0 would read *lower* than the rails
// beneath it — worse than showing nothing, because it looks authoritative.
const w = mountCard({ walletOnchain: 5000, walletLightning: null, walletEcash: 0, walletFedimint: 0 })
expect(w.text()).not.toContain('5,000 sats\n')
expect(w.findAll('.balance-pixels').length).toBeGreaterThan(0)
})
it('sums the total once every rail has reported', () => {
const w = mountCard({ walletOnchain: 9000, walletLightning: 900, walletEcash: 22, walletFedimint: 0 })
expect(w.text()).toContain('9,922 sats')
expect(w.findAll('.balance-pixels').length).toBe(0)
})
it('shows an empty wallet as zero rather than as loading', () => {
const w = mountCard({ walletOnchain: 0, walletLightning: 0, walletEcash: 0, walletFedimint: 0 })
expect(w.findAll('.balance-pixels').length).toBe(0)
expect(w.text()).toContain('0 sats')
})
it('keeps the Ark row hidden while its balance is unknown', () => {
// Ark only appears once barkd reports something; "unknown" must not be
// read as "> 0" and conjure a row on the many nodes with no Ark sidecar.
const loaded = { walletOnchain: 1, walletLightning: 0, walletEcash: 0, walletFedimint: 0 }
expect(mountCard({ ...loaded, walletArk: null }).text()).not.toContain('Ark')
expect(mountCard({ ...loaded, walletArk: 0 }).text()).not.toContain('Ark')
expect(mountCard({ ...loaded, walletArk: 7 }).text()).toContain('Ark')
})
})
+9 -5
View File
@@ -121,8 +121,8 @@
<div class="p-3 rounded-lg bg-white/5 border border-white/10">
<div class="flex items-center justify-between gap-3">
<span class="text-xs text-white/60">On-chain wallet balance</span>
<span class="text-sm font-mono" :class="walletOnchainSats >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
{{ walletOnchainSats.toLocaleString() }} sats
<span class="text-sm font-mono" :class="(walletOnchainSats ?? 0) >= ZEUS_CHANNEL_MIN_SATS ? 'text-green-400' : 'text-white/85'">
<BalanceAmount :sats="walletOnchainSats" label="on-chain balance" />
</span>
</div>
<p class="text-xs text-white/45 mt-1">Minimum 150,000 · maximum 1,500,000 on-chain sats required.</p>
@@ -136,10 +136,10 @@
</button>
<button
@click="completeFundStep(step)"
:disabled="walletOnchainSats <= 0"
:disabled="(walletOnchainSats ?? 0) <= 0"
class="glass-button glass-button-sm rounded-lg px-5 py-2 text-sm font-medium disabled:opacity-40"
>
{{ walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
{{ walletOnchainSats == null ? 'Checking balance…' : walletOnchainSats > 0 ? 'Continue' : 'Waiting for funds…' }}
</button>
</div>
</template>
@@ -224,6 +224,7 @@
<script setup lang="ts">
import { computed, onUnmounted, ref, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useRoute, useRouter, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
import { useAppStore } from '@/stores/app'
@@ -435,7 +436,10 @@ function goBack() {
// ── Fund-wallet step: live sync status + on-chain balance ───────────────────
const showFundModal = ref(false)
const walletOnchainSats = ref(0)
// null while the first balance call is still out — "0 sats" and "we haven't
// asked yet" must not look the same on a screen that then says
// "Waiting for funds…".
const walletOnchainSats = ref<number | null>(null)
const hasFundStep = computed(() => goal.value?.steps.some((s) => s.action === 'fund') ?? false)
const fundStepActive = computed(() => {
+19 -7
View File
@@ -678,9 +678,19 @@ const showScanModal = ref(false); const showSendModal = ref(false); const showRe
async function devFaucet() { try { await rpcClient.call({ method: 'dev.faucet', params: { amount_sats: 1_000_000 } }); await loadWeb5Status() } catch { /* ignore */ } }
const walletConnected = ref(false); const walletOnchain = ref(0); const walletLightning = ref(0); const walletEcash = ref(0); const walletFedimint = ref(0)
// Balances start as `null`, not 0. Zero is a *number*, and it is the one
// number that frightens people — rendering it before any call has returned
// told someone opening the dashboard, in the wallet's own typeface, that
// their money was gone. `null` means "not known yet" and paints a pixel
// readout instead; a rail only becomes a number when a call actually
// succeeds, so a real 0 is still a real 0.
const walletConnected = ref(false)
const walletOnchain = ref<number | null>(null)
const walletLightning = ref<number | null>(null)
const walletEcash = ref<number | null>(null)
const walletFedimint = ref<number | null>(null)
let walletInfoFailures = 0
const walletArk = ref(0)
const walletArk = ref<number | null>(null)
const walletTransactions = ref<WalletTransaction[]>([])
// Overlay the local Mempool app when it's running; otherwise route through
@@ -728,11 +738,13 @@ function hydrateWalletSnapshot() {
const raw = localStorage.getItem(WALLET_SNAPSHOT_KEY)
if (!raw) return
const s = JSON.parse(raw)
walletOnchain.value = s.onchain ?? 0
walletLightning.value = s.lightning ?? 0
walletEcash.value = s.ecash ?? 0
walletFedimint.value = s.fedimint ?? 0
walletArk.value = s.ark ?? 0
// A snapshot key that isn't there was never known — leave it unknown
// rather than inventing a zero for it.
walletOnchain.value = s.onchain ?? null
walletLightning.value = s.lightning ?? null
walletEcash.value = s.ecash ?? null
walletFedimint.value = s.fedimint ?? null
walletArk.value = s.ark ?? null
walletConnected.value = s.connected === true
if (Array.isArray(s.transactions)) walletTransactions.value = s.transactions
} catch { /* corrupt/absent snapshot — fresh load fills in */ }
@@ -0,0 +1,172 @@
import { describe, it, expect, beforeEach } from 'vitest'
import { mount } from '@vue/test-utils'
import i18n from '@/i18n'
import HomeWalletCard from '@/views/home/HomeWalletCard.vue'
/**
* Instant rails (Lightning, Cashu, Fedimint, Ark) settle immediately, so
* there is no confirmation count to retire an incoming receipt from the
* badge. It used to leave on a five-minute wall clock, which meant a payment
* could arrive and vanish before anyone looked — and for ecash, which leaves
* no public ledger entry, this panel was the only place the receipt was ever
* shown.
*
* These cases pin the replacement: a receipt stays until it has been seen.
*/
const now = () => Math.floor(Date.now() / 1000)
function tx(over: Record<string, unknown> = {}) {
return {
tx_hash: '',
amount_sats: 1000,
direction: 'incoming' as const,
num_confirmations: 1,
time_stamp: now(),
total_fees: 0,
dest_addresses: [],
label: '',
block_height: 0,
kind: 'cashu' as const,
...over,
}
}
const base = {
animate: false,
walletConnected: true,
walletOnchain: 0,
walletLightning: 0,
walletEcash: 0,
walletFedimint: 0,
walletArk: 0,
isDev: false,
}
const mountCard = (transactions: ReturnType<typeof tx>[]) =>
mount(HomeWalletCard, {
props: { ...base, walletTransactions: transactions },
global: { plugins: [i18n] },
})
// Acknowledgement is stored per-browser, so each case starts from a clean
// slate — otherwise one test's "seen" set silently satisfies the next.
beforeEach(() => localStorage.clear())
describe('incoming payments', () => {
it('retires a receipt on acknowledgement, never on age alone', async () => {
// On a browser that has acknowledged before, an unacknowledged receipt
// stays put however old it is — age is not the signal, being seen is.
// (A brand-new browser is the separate first-run case below.)
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify(['some-earlier-receipt']))
const w = mountCard([tx({ time_stamp: now() - 7200 })])
expect(w.text()).toContain('Incoming 1')
})
it('does not clear the receipt merely because the panel was opened', async () => {
// "Selecting incoming clears a pending token" — opening must show it,
// not consume it. Someone reading the row must be able to keep reading.
const w = mountCard([tx()])
await w.find('button').trigger('click')
expect(w.text()).toContain('Incoming Transactions')
expect(w.text()).toContain('+1,000 sats')
})
it('marks it seen once the panel is closed again', async () => {
localStorage.setItem('archy-seen-incoming-v1', JSON.stringify([]))
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click') // open
await badge.trigger('click') // close — acknowledges
expect(w.text()).not.toContain('Incoming 1')
})
it('still surfaces a payment that arrives after an earlier one was seen', async () => {
const first = tx({ amount_sats: 1000, time_stamp: now() - 60 })
const w = mountCard([first])
const badge = w.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(w.text()).not.toContain('Incoming 1')
// A different payment must not inherit the first one's acknowledgement.
await w.setProps({ walletTransactions: [first, tx({ amount_sats: 2500, time_stamp: now() })] })
expect(w.text()).toContain('Incoming 1')
})
it('leaves on-chain transactions on their confirmation count', () => {
// On-chain has a real signal and is deliberately untouched: it drops out
// at three confirmations regardless of whether anyone looked.
const unconfirmed = mountCard([tx({ kind: 'onchain', num_confirmations: 0, tx_hash: 'abc' })])
expect(unconfirmed.text()).toContain('Incoming 1')
const settled = mountCard([tx({ kind: 'onchain', num_confirmations: 6, tx_hash: 'abc' })])
expect(settled.text()).not.toContain('Incoming 1')
})
it('lists several instant receipts separately even without txids', () => {
// Instant rails carry no txid, so keying the list on tx_hash gave every
// row the same empty key and Vue reused one node for all of them.
const w = mountCard([
tx({ amount_sats: 1000, time_stamp: now() - 10 }),
tx({ amount_sats: 2000, time_stamp: now() - 20, kind: 'lightning' }),
tx({ amount_sats: 3000, time_stamp: now() - 30, kind: 'fedimint' }),
])
expect(w.text()).toContain('Incoming 3')
})
it('does not show a receipt again after a refresh', async () => {
// The bug this whole model was supposed to prevent, and briefly caused:
// "seen" lived in component state, so every page load forgot it and the
// entire ecash history came back as new. Remounting is a refresh.
const received = tx({ amount_sats: 4200, time_stamp: now() - 30 })
const first = mountCard([received])
const badge = first.find('button')
await badge.trigger('click')
await badge.trigger('click')
expect(first.text()).not.toContain('Incoming 1')
const afterRefresh = mountCard([received])
expect(afterRefresh.text()).not.toContain('Incoming 1')
})
it('does not greet a brand-new browser with the whole history', () => {
// Nothing acknowledged yet and a long history: treating all of it as
// unseen would be the same wall of old receipts, just from the other
// direction. Only what is genuinely recent counts as news on a first run.
const old = [
tx({ amount_sats: 100, time_stamp: now() - 86400 }),
tx({ amount_sats: 200, time_stamp: now() - 3600 }),
tx({ amount_sats: 300, time_stamp: now() - 600 }),
]
expect(mountCard(old).text()).not.toContain('Incoming')
// …but a receipt from moments ago still is.
localStorage.clear()
expect(mountCard([...old, tx({ amount_sats: 400, time_stamp: now() - 5 })]).text())
.toContain('Incoming 1')
})
it('survives unreadable storage without resurrecting the history', () => {
// A corrupt value must not read as "nothing has been acknowledged" — that
// is precisely the refresh bug wearing a different hat.
localStorage.setItem('archy-seen-incoming-v1', '{not json')
const w = mountCard([tx({ amount_sats: 100, time_stamp: now() - 86400 })])
expect(w.text()).not.toContain('Incoming')
})
it('does not silently turn into a navigation button while the panel is open', async () => {
// The badge is two controls in one: with receipts it toggles the panel,
// without them it navigates to the full transactions view. If the list
// empties while the panel is open, the click under the user's cursor used
// to change meaning and take them to another screen.
const w = mountCard([tx()])
const badge = w.find('button')
await badge.trigger('click')
expect(w.text()).toContain('Incoming Transactions')
await w.setProps({ walletTransactions: [] })
await badge.trigger('click')
expect(w.emitted('showTransactions')).toBeUndefined()
})
})
@@ -68,7 +68,9 @@ describe('AppHeroSection', () => {
it('disables app controls while a container action is running', () => {
const wrapper = mountHero({ pendingAction: 'restart' })
expect(wrapper.text()).toContain('Restarting...')
// A real ellipsis, not three dots — the label changed in 9ccc325a and
// this assertion was left behind.
expect(wrapper.text()).toContain('Restarting…')
expect(wrapper.findAll('button').every(button => button.attributes('disabled') !== undefined)).toBe(true)
})
@@ -2,6 +2,7 @@
export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"alby-hub": 8187,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
@@ -38,6 +39,7 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
export const GENERATED_APP_TITLES: Record<string, string> = {
"aiui": "AI Assistant",
"alby-hub": "Alby Hub",
"archy-btcpay-db": "BTCPay Postgres",
"archy-mempool-db": "Mempool MariaDB",
"archy-mempool-web": "Mempool Web",
@@ -82,6 +84,7 @@ export const GENERATED_APP_TITLES: Record<string, string> = {
"netbird-server": "NetBird Server",
"nextcloud": "Nextcloud",
"nostr-rs-relay": "Nostr Relay (Rust)",
"phoenixd": "phoenixd",
"photoprism": "PhotoPrism",
"pine": "Pine",
"pine-openwakeword": "Pine Wake Word (openWakeWord)",
+118 -27
View File
@@ -22,7 +22,7 @@
</div>
<div class="flex items-center gap-2">
<button
@click="incomingTxCount > 0 ? (showIncomingTxPanel = !showIncomingTxPanel) : $emit('showTransactions')"
@click="incomingTxCount > 0 || showIncomingTxPanel ? toggleIncomingPanel() : $emit('showTransactions')"
:class="incomingTxCount > 0 ? 'incoming-tx-badge' : 'text-white/50 hover:text-white/80 text-xs px-2 py-1 rounded-lg bg-white/5 hover:bg-white/10 transition-colors'"
class="shrink-0"
>
@@ -59,14 +59,14 @@
<div v-if="showIncomingTxPanel && incomingTransactions.length > 0" class="mb-4 rounded-xl overflow-hidden border border-green-500/20">
<div class="px-4 py-2.5 bg-green-500/10 border-b border-green-500/15 flex items-center justify-between">
<span class="text-xs font-medium text-green-400 uppercase tracking-wide">Incoming Transactions</span>
<button @click="showIncomingTxPanel = false" class="text-white/40 hover:text-white/70 transition-colors">
<button @click="toggleIncomingPanel" class="text-white/40 hover:text-white/70 transition-colors">
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /></svg>
</button>
</div>
<div class="divide-y divide-white/5">
<div
v-for="tx in incomingTransactions"
:key="tx.tx_hash"
:key="txKey(tx)"
class="flex items-center justify-between gap-3 px-4 py-3 hover:bg-white/5 transition-colors"
:class="isOnchain(tx) ? 'cursor-pointer' : ''"
@click="isOnchain(tx) && $emit('openInMempool', tx.tx_hash)"
@@ -117,7 +117,7 @@
<span class="text-lg text-orange-500 font-bold">&#x20bf;</span>
<span class="text-sm font-medium text-white">{{ t('web5.totalBitcoin') }}</span>
</div>
<span class="text-white text-sm font-semibold">{{ walletTotal.toLocaleString() }} sats</span>
<span class="text-white text-sm font-semibold"><BalanceAmount :sats="walletTotal" label="total balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -126,7 +126,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.onChain') }}</span>
</div>
<span class="text-orange-500 text-sm font-medium">{{ walletOnchain.toLocaleString() }} sats</span>
<span class="text-orange-500 text-sm font-medium"><BalanceAmount :sats="walletOnchain" label="on-chain balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -135,7 +135,7 @@
</svg>
<span class="text-sm text-white/80">{{ t('web5.lightning') }}</span>
</div>
<span class="text-yellow-400 text-sm font-medium">{{ walletLightning.toLocaleString() }} sats</span>
<span class="text-yellow-400 text-sm font-medium"><BalanceAmount :sats="walletLightning" label="Lightning balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -146,7 +146,7 @@
</svg>
<span class="text-sm text-white/80">Cashu</span>
</div>
<span class="text-purple-400 text-sm font-medium">{{ walletEcash.toLocaleString() }} sats</span>
<span class="text-purple-400 text-sm font-medium"><BalanceAmount :sats="walletEcash" label="Cashu balance" /></span>
</div>
<div class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
<div class="flex items-center gap-3">
@@ -155,7 +155,7 @@
</svg>
<span class="text-sm text-white/80">Fedimint</span>
</div>
<span class="text-blue-400 text-sm font-medium">{{ walletFedimint.toLocaleString() }} sats</span>
<span class="text-blue-400 text-sm font-medium"><BalanceAmount :sats="walletFedimint" label="Fedimint balance" /></span>
</div>
<!-- Only rendered once barkd reports a balance — most nodes don't run the Ark sidecar -->
<div v-if="(walletArk ?? 0) > 0" class="flex items-center justify-between p-3 bg-white/5 rounded-lg">
@@ -165,7 +165,7 @@
</svg>
<span class="text-sm text-white/80">Ark</span>
</div>
<span class="text-teal-400 text-sm font-medium">{{ (walletArk ?? 0).toLocaleString() }} sats</span>
<span class="text-teal-400 text-sm font-medium"><BalanceAmount :sats="walletArk" label="Ark balance" /></span>
</div>
</div>
<div class="home-card-buttons flex gap-2 mt-auto pt-4 shrink-0">
@@ -198,7 +198,8 @@
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { ref, computed, watch } from 'vue'
import BalanceAmount from '@/components/BalanceAmount.vue'
import { useI18n } from 'vue-i18n'
const { t } = useI18n()
@@ -220,11 +221,13 @@ export interface WalletTransaction {
const props = defineProps<{
animate: boolean
walletConnected: boolean
walletOnchain: number
walletLightning: number
walletEcash: number
walletFedimint: number
walletArk?: number
// `null` = not loaded yet, `0` = genuinely empty. Keeping those apart is
// what lets the card show a pixel readout instead of claiming a figure.
walletOnchain: number | null
walletLightning: number | null
walletEcash: number | null
walletFedimint: number | null
walletArk?: number | null
walletTransactions: WalletTransaction[]
isDev: boolean
}>()
@@ -241,32 +244,120 @@ defineEmits<{
const showIncomingTxPanel = ref(false)
const walletTotal = computed(() =>
props.walletOnchain + props.walletLightning + props.walletEcash + props.walletFedimint + (props.walletArk ?? 0)
)
// The total is only a number once every rail that makes it up is. Summing
// with nulls treated as 0 would quietly under-report the balance — a total
// smaller than the rails beneath it is worse than showing nothing.
const walletTotal = computed<number | null>(() => {
const rails = [props.walletOnchain, props.walletLightning, props.walletEcash, props.walletFedimint]
if (rails.some(v => v == null)) return null
return rails.reduce((a: number, b) => a + (b as number), 0) + (props.walletArk ?? 0)
})
function isOnchain(tx: WalletTransaction): boolean {
return !tx.kind || tx.kind === 'onchain'
}
// Instant rails (lightning/cashu/fedimint/ark) settle immediately — there is
// no confirmation to wait for, so they only get the "incoming" treatment for
// a short window after receipt instead of sitting in the badge forever.
const INSTANT_INCOMING_WINDOW_SECS = 5 * 60
const nowSecs = ref(Math.floor(Date.now() / 1000))
let nowTimer: ReturnType<typeof setInterval> | null = null
onMounted(() => { nowTimer = setInterval(() => { nowSecs.value = Math.floor(Date.now() / 1000) }, 30000) })
onUnmounted(() => { if (nowTimer) clearInterval(nowTimer) })
// Instant rails (lightning/cashu/fedimint/ark) settle immediately, so there is
// no confirmation to wait for and no natural moment for them to leave the
// badge. They used to drop out on a five-minute wall clock, which meant a
// receipt could appear and then silently disappear before anyone looked at it
// — and if you opened the panel a few minutes late, the payment you came to
// check on had already evaporated. For ecash that is the worst case
// available: it leaves no public ledger entry, so this panel was the only
// place the receipt was ever shown.
//
// So they stay until they have actually been *seen*. Opening the panel is
// what marks them seen, which is the same unread model the mesh inbox uses.
// On-chain is unchanged — a confirmation count is a real signal and does the
// job by itself.
//
// "Seen" has to outlive the page, or this is worse than the timer it
// replaced: held only in component state, every refresh forgot the
// acknowledgement and the whole ecash history came back as new. It is stored
// per-node in localStorage, capped so it cannot grow without bound.
const SEEN_KEY = 'archy-seen-incoming-v1'
const SEEN_CAP = 300
// On a browser that has never stored an acknowledgement, treating the entire
// history as unseen would greet the user with a wall of old receipts. Only
// what arrived in the last few minutes is genuinely news on a first run.
const FIRST_RUN_GRACE_SECS = 5 * 60
function loadSeen(): { seen: Set<string>; firstRun: boolean } {
try {
const raw = localStorage.getItem(SEEN_KEY)
if (raw === null) return { seen: new Set(), firstRun: true }
const parsed: unknown = JSON.parse(raw)
return { seen: new Set(Array.isArray(parsed) ? (parsed as string[]) : []), firstRun: false }
} catch {
// Unreadable storage must not resurrect the history — treat it as a fresh
// start rather than as "nothing acknowledged".
return { seen: new Set(), firstRun: true }
}
}
const initial = loadSeen()
const seenIncoming = ref(initial.seen)
let needsFirstRunSeeding = initial.firstRun
function persistSeen() {
try {
// Keep the newest entries; the oldest can never resurface anyway, because
// an instant-rail receipt that far back is long gone from the history the
// backend returns.
const keys = [...seenIncoming.value].slice(-SEEN_CAP)
seenIncoming.value = new Set(keys)
localStorage.setItem(SEEN_KEY, JSON.stringify(keys))
} catch { /* storage full or unavailable — acknowledgement stays in-session */ }
}
function txKey(tx: WalletTransaction): string {
// Instant rails have no txid to key on, so fall back to rail+time+amount.
return tx.tx_hash || `${tx.kind ?? 'onchain'}:${tx.time_stamp}:${tx.amount_sats}`
}
// Seed the first run once history actually arrives — at mount the list is
// still empty, so there is nothing to judge yet.
watch(
() => props.walletTransactions,
(txs) => {
if (!needsFirstRunSeeding || txs.length === 0) return
needsFirstRunSeeding = false
const cutoff = Math.floor(Date.now() / 1000) - FIRST_RUN_GRACE_SECS
for (const tx of txs) {
if (tx.direction !== 'incoming' || isOnchain(tx)) continue
if (tx.time_stamp < cutoff) seenIncoming.value.add(txKey(tx))
}
persistSeen()
},
{ immediate: true },
)
const incomingTransactions = computed(() =>
props.walletTransactions.filter(tx => {
if (tx.direction !== 'incoming') return false
if (isOnchain(tx)) return tx.num_confirmations < 3
return nowSecs.value - tx.time_stamp < INSTANT_INCOMING_WINDOW_SECS
return !seenIncoming.value.has(txKey(tx))
})
)
const incomingTxCount = computed(() => incomingTransactions.value.length)
/// Open or close the panel. Closing is what acknowledges the instant-rail
/// receipts currently listed — marking them on *open* would make a row vanish
/// under the cursor of someone still reading it.
function toggleIncomingPanel() {
if (showIncomingTxPanel.value) {
for (const tx of incomingTransactions.value) {
if (!isOnchain(tx)) seenIncoming.value.add(txKey(tx))
}
// Vue tracks Set mutations, but reassigning keeps the dependency obvious.
seenIncoming.value = new Set(seenIncoming.value)
persistSeen()
showIncomingTxPanel.value = false
return
}
showIncomingTxPanel.value = true
}
function railBadge(tx: WalletTransaction): string {
if (tx.kind === 'lightning') return '⚡ Instant'
if (tx.kind === 'cashu') return 'Cashu'
@@ -362,6 +362,22 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.4-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.4-alpha</span>
<span class="text-xs text-white/40">August 20, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the "app is restarting" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (auth: open), documented in the developer guide.</p>
<p>**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.</p>
<p>**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.</p>
<p>**"Are you sure?" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.</p>
<p>**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the "Radio detected" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.</p>
<p>**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so "the radio is working" and "the internet is doing the radio's job" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show "unknown" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)</p>
<p>**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle.</p>
</div>
</div>
<!-- v1.8.3-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
+10
View File
@@ -12,6 +12,16 @@ export default defineConfig({
test: {
environment: 'jsdom',
globals: true,
// Vitest's 5s default is not a statement about these tests — the whole
// 1000-test suite runs in ~70s on an idle box. It is a statement about
// the machine. This one also runs a live node, so a release gate can
// collide with a cargo build or a container churn, and starved workers
// blow 5s on tests that normally take milliseconds: on 2026-08-20 four
// unrelated tests timed out at once (one after 36s of wall clock) purely
// from CPU contention, failing the gate with nothing actually broken.
// 20s keeps real hangs bounded while surviving a busy box.
testTimeout: 20_000,
hookTimeout: 20_000,
setupFiles: ['./vitest.setup.ts'],
root: '.',
passWithNoTests: true,
+21 -17
View File
@@ -1,29 +1,33 @@
{
"changelog": [
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
],
"components": [
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.3-alpha",
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
"size_bytes": 59900696
"new_version": "1.8.4-alpha",
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
"size_bytes": 63850680
},
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
"new_version": "1.8.3-alpha",
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
"size_bytes": 97615528
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
"new_version": "1.8.4-alpha",
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
"size_bytes": 97641658
}
],
"release_date": "2026-08-14",
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
"release_date": "2026-08-20",
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.3-alpha"
"version": "1.8.4-alpha"
}
+247 -45
View File
@@ -46,6 +46,9 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "0.1.0"
}
},
@@ -56,7 +59,7 @@
"app": {
"category": "money",
"container": {
"image": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.23.0",
"image": "source.archipelago-foundation.org/lfg2025/alby-hub:v1.24.0",
"pull_policy": "if-not-present"
},
"dependencies": [
@@ -123,6 +126,10 @@
"no_new_privileges": true,
"readonly_root": true
},
"upstream": {
"kind": "github",
"repo": "getAlby/hub"
},
"version": "1.23.0",
"volumes": [
{
@@ -192,6 +199,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/postgres"
},
"version": "15.17",
"volumes": [
{
@@ -265,6 +276,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/mariadb"
},
"version": "11.4.10",
"volumes": [
{
@@ -288,7 +303,7 @@
"sync_required": false
},
"container": {
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"network": "archy-net",
"pull_policy": "if-not-present"
},
@@ -331,6 +346,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "mempool/mempool"
},
"version": "3.0.1"
}
},
@@ -408,6 +427,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "dgarage/NBXplorer"
},
"version": "2.6.0",
"volumes": [
{
@@ -508,7 +531,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=1024 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -586,6 +609,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "bitcoin/bitcoin"
},
"version": "28.4.0",
"volumes": [
{
@@ -654,7 +681,7 @@
},
"container": {
"custom_args": [
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=550 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
"BITCOIND=\"$(command -v bitcoind || true)\"; if [ -z \"$BITCOIND\" ]; then\n BITCOIND=\"$(find /opt -path '*/bin/bitcoind' -type f 2>/dev/null | sort | tail -n 1)\";\nfi; if [ -z \"$BITCOIND\" ]; then\n echo \"bitcoind not found in image\" >&2;\n exit 127;\nfi; RPC_USER=\"$(printenv BITCOIN_RPC_USER)\"; RPC_PASS=\"$(printenv BITCOIN_RPC_PASS)\"; RPC_CONF=\"/tmp/rpc.conf\"; umask 077; { echo \"rpcuser=$RPC_USER\"; echo \"rpcpassword=$RPC_PASS\"; } > \"$RPC_CONF\"; if [ -f /home/bitcoin/.bitcoin/bitcoin.conf ]; then\n echo \"archipelago: ignoring legacy datadir bitcoin.conf; RPC config comes from $RPC_CONF\" >&2;\nfi; RPC_TXRELAY_AUTH=\"$(printenv BITCOIN_RPC_TXRELAY_RPCAUTH || true)\"; DISK_GB_VALUE=\"$(printenv DISK_GB || true)\"; RPC_HEADROOM=\"-rpcthreads=16 -rpcworkqueue=256\"; RPC_TXRELAY_FLAGS=\"-rpcwhitelistdefault=0\"; if [ -n \"$RPC_TXRELAY_AUTH\" ]; then\n RPC_TXRELAY_FLAGS=\"$RPC_TXRELAY_FLAGS -rpcauth=$RPC_TXRELAY_AUTH -rpcwhitelist=txrelay:sendrawtransaction,submitpackage,testmempoolaccept,getmempoolinfo,getrawmempool,getmempoolentry,getnetworkinfo,getblockchaininfo,getblockcount,getblockhash,getblock,getblockheader,getrawtransaction,gettxout,gettxspendingprevout,decoderawtransaction,decodescript,estimatesmartfee,uptime,ping,getconnectioncount,getpeerinfo,getindexinfo,getdeploymentinfo,getchaintips\";\nfi; if [ \"${DISK_GB_VALUE:-0}\" -lt 1000 ]; then\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -prune=50000 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=2048 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nelse\n exec \"$BITCOIND\" -datadir=/home/bitcoin/.bitcoin -conf=\"$RPC_CONF\" -allowignoredconf=1 -printtoconsole=0 -server=1 -txindex=1 -rpcallowip=0.0.0.0/0 -rpcbind=0.0.0.0:8332 -listen=1 -bind=0.0.0.0:8333 -dbcache=4096 -par=0 -maxconnections=125 $RPC_HEADROOM $RPC_TXRELAY_FLAGS;\nfi"
],
"data_uid": "100101:100101",
"derived_env": [
@@ -732,6 +759,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "bitcoinknots/bitcoin"
},
"version": "28.1.0",
"volumes": [
{
@@ -811,6 +842,9 @@
"network_policy": "host",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
@@ -918,6 +952,9 @@
"seccomp_profile": "default",
"user": 999
},
"upstream": {
"kind": "internal"
},
"version": "1.2.11",
"volumes": [
{
@@ -1038,7 +1075,8 @@
"name": "BTCPay Server",
"ports": [
{
"auth": "gated",
"auth": "open",
"auth_rationale": "BTCPay enforces its own login for administration, and its checkout, invoice and webhook endpoints are designed to be reached by anonymous payers and payment processors.",
"bind": "127.0.0.1",
"container": 49392,
"host": 23000,
@@ -1055,6 +1093,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "btcpayserver/btcpayserver"
},
"version": "2.4.2",
"volumes": [
{
@@ -1140,6 +1182,10 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "github",
"repo": "ElementsProject/lightning"
},
"version": "23.08.2",
"volumes": [
{
@@ -1209,6 +1255,9 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
@@ -1270,6 +1319,9 @@
"network_policy": "host",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
@@ -1362,6 +1414,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "spesmilo/electrumx"
},
"version": "1.18.0",
"volumes": [
{
@@ -1378,7 +1434,7 @@
"version": "v1.18.0"
},
"fedimint": {
"image": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"image": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"manifest": {
"app": {
"bitcoin_integration": {
@@ -1408,7 +1464,7 @@
"sh",
"-lc"
],
"image": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"image": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.1",
"network": "archy-net",
"pull_policy": "if-not-present",
"secret_env": [
@@ -1489,6 +1545,10 @@
"network_policy": "isolated",
"readonly_root": true
},
"upstream": {
"kind": "github",
"repo": "fedimint/fedimint"
},
"version": "0.10.0",
"volumes": [
{
@@ -1502,7 +1562,7 @@
]
}
},
"version": "v0.10.0"
"version": "v0.10.1"
},
"fedimint-clientd": {
"manifest": {
@@ -1571,6 +1631,10 @@
"network_policy": "bridge",
"readonly_root": true
},
"upstream": {
"kind": "github",
"repo": "fedimint/fedimint-clientd"
},
"version": "0.8.0",
"volumes": [
{
@@ -1587,7 +1651,7 @@
"version": "0.8.0"
},
"fedimint-gateway": {
"image": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"image": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"manifest": {
"app": {
"bitcoin_integration": {
@@ -1615,7 +1679,7 @@
"name": "fedimint-gateway-hash"
}
],
"image": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"image": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.1",
"network": "archy-net",
"pull_policy": "if-not-present",
"secret_env": [
@@ -1679,6 +1743,10 @@
"network_policy": "isolated",
"readonly_root": true
},
"upstream": {
"kind": "github",
"repo": "fedimint/fedimint"
},
"version": "0.10.0",
"volumes": [
{
@@ -1700,7 +1768,7 @@
]
}
},
"version": "v0.10.0"
"version": "v0.10.1"
},
"filebrowser": {
"image": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
@@ -1762,6 +1830,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "filebrowser/filebrowser"
},
"version": "2.27.0",
"volumes": [
{
@@ -1828,6 +1900,9 @@
"network_policy": "host",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
@@ -1902,7 +1977,8 @@
},
"ports": [
{
"auth": "gated",
"auth": "open",
"auth_rationale": "Gitea enforces its own account login on every page and API route; git clients authenticate with basic-auth/tokens and cannot complete a browser login challenge.",
"bind": "127.0.0.1",
"container": 3000,
"host": 3001,
@@ -1933,6 +2009,10 @@
"no_new_privileges": false,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "go-gitea/gitea"
},
"version": "1.23",
"volumes": [
{
@@ -2016,6 +2096,10 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "github",
"repo": "grafana/grafana"
},
"version": "10.2.0",
"volumes": [
{
@@ -2032,11 +2116,11 @@
"version": "10.2.0"
},
"homeassistant": {
"image": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"image": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"image": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.8.2",
"network": "pasta",
"pull_policy": "if-not-present"
},
@@ -2109,6 +2193,10 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "github",
"repo": "home-assistant/core"
},
"version": "2026.7.3",
"volumes": [
{
@@ -2122,7 +2210,7 @@
]
}
},
"version": "2026.7.3"
"version": "2026.8.2"
},
"immich": {
"image": "source.archipelago-foundation.org/lfg2025/immich-server:release",
@@ -2211,6 +2299,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "immich-app/immich"
},
"version": "2.7.4",
"volumes": [
{
@@ -2331,6 +2423,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "valkey/valkey"
},
"version": "7-alpine",
"volumes": []
}
@@ -2453,6 +2549,9 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
@@ -2561,6 +2660,9 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
@@ -2619,6 +2721,9 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
@@ -2758,6 +2863,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/postgres"
},
"version": "16.13-alpine",
"volumes": [
{
@@ -2816,6 +2925,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/redis"
},
"version": "7.4.8-alpine",
"volumes": [
{
@@ -2836,7 +2949,7 @@
"app": {
"category": "community",
"container": {
"image": "source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0",
"image": "source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0",
"network": "indeedhub-net",
"network_aliases": [
"relay"
@@ -2870,6 +2983,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "scsibug/nostr-rs-relay"
},
"version": "0.9.0",
"volumes": [
{
@@ -2886,11 +3003,11 @@
"version": "0.9.0"
},
"jellyfin": {
"image": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"image": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"image": "source.archipelago-foundation.org/lfg2025/jellyfin:10.11.11",
"network": "pasta",
"pull_policy": "if-not-present"
},
@@ -2950,6 +3067,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "jellyfin/jellyfin"
},
"version": "10.8.13",
"volumes": [
{
@@ -2971,7 +3092,7 @@
]
}
},
"version": "10.8.13"
"version": "10.11.11"
},
"lightning-stack": {
"manifest": {
@@ -3159,6 +3280,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "lightningnetwork/lnd"
},
"version": "0.18.4",
"volumes": [
{
@@ -3219,6 +3344,9 @@
"network_policy": "host",
"readonly_root": false
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": []
}
@@ -3226,11 +3354,11 @@
"version": "1.7.123-alpha"
},
"mempool": {
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"images": {
"archy-mempool-db": "source.archipelago-foundation.org/lfg2025/mariadb:11.4.10",
"archy-mempool-web": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"mempool-api": "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0"
"archy-mempool-web": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"mempool-api": "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1"
},
"manifest": {
"app": {
@@ -3239,7 +3367,7 @@
"sync_required": true
},
"container": {
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"image": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.3.1",
"image_signature": "cosign://...",
"pull_policy": "if-not-present"
},
@@ -3294,6 +3422,10 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "github",
"repo": "mempool/mempool"
},
"version": "3.0.0",
"volumes": [
{
@@ -3307,7 +3439,7 @@
]
}
},
"version": "v3.0.1"
"version": "v3.3.1"
},
"mempool-api": {
"manifest": {
@@ -3324,7 +3456,7 @@
"template": "{{BITCOIN_HOST}}"
}
],
"image": "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.0.0",
"image": "source.archipelago-foundation.org/lfg2025/mempool-backend:v3.3.1",
"network": "archy-net",
"pull_policy": "if-not-present",
"secret_env": [
@@ -3394,6 +3526,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "mempool/mempool"
},
"version": "3.0.0",
"volumes": [
{
@@ -3460,6 +3596,9 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
@@ -3486,7 +3625,7 @@
"key": "/var/lib/archipelago/netbird/tls.key"
}
],
"image": "docker.io/library/nginx:1.27-alpine",
"image": "docker.io/library/nginx:1.31.3-alpine",
"network": "netbird-net",
"pull_policy": "if-not-present"
},
@@ -3567,6 +3706,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/nginx"
},
"version": "2.38.0",
"volumes": [
{
@@ -3680,6 +3823,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "netbirdio/dashboard"
},
"version": "2.38.0",
"volumes": []
}
@@ -3776,6 +3923,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "netbirdio/netbird"
},
"version": "0.71.2",
"volumes": [
{
@@ -3867,6 +4018,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "nextcloud/server"
},
"version": "29",
"volumes": [
{
@@ -3887,12 +4042,12 @@
"version": "latest"
},
"nostr-rs-relay": {
"image": "source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.9.0",
"image": "source.archipelago-foundation.org/lfg2025/nostr-rs-relay:0.10.0",
"manifest": {
"app": {
"container": {
"data_uid": "1000:1000",
"image": "scsibug/nostr-rs-relay:0.8.9",
"image": "scsibug/nostr-rs-relay:0.10.0",
"image_signature": "cosign://...",
"pull_policy": "verify-signature"
},
@@ -3946,7 +4101,11 @@
"seccomp_profile": "default",
"user": 1000
},
"version": "0.8.0",
"upstream": {
"kind": "github",
"repo": "scsibug/nostr-rs-relay"
},
"version": "0.10.0",
"volumes": [
{
"options": [
@@ -3959,7 +4118,7 @@
]
}
},
"version": "0.9.0"
"version": "0.10.0"
},
"nostr-vpn": {
"image": "source.archipelago-foundation.org/lfg2025/nostr-vpn:v0.3.7",
@@ -4039,6 +4198,10 @@
"no_new_privileges": true,
"readonly_root": true
},
"upstream": {
"kind": "github",
"repo": "ACINQ/phoenixd"
},
"version": "0.9.0",
"volumes": [
{
@@ -4122,6 +4285,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "photoprism/photoprism"
},
"version": "240915",
"volumes": [
{
@@ -4148,7 +4315,7 @@
"key": "/var/lib/archipelago/pine/tls.key"
}
],
"image": "docker.io/library/nginx:1.27-alpine",
"image": "docker.io/library/nginx:1.31.3-alpine",
"network": "archy-net",
"network_aliases": [
"pine"
@@ -4252,6 +4419,10 @@
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "dockerhub",
"repo": "library/nginx"
},
"version": "1.3.0",
"volumes": [
{
@@ -4358,6 +4529,10 @@
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "rhasspy/wyoming-openwakeword"
},
"version": "2.1.0",
"volumes": [
{
@@ -4382,7 +4557,7 @@
"--voice",
"en_GB-alba-medium"
],
"image": "docker.io/rhasspy/wyoming-piper:2.2.2",
"image": "docker.io/rhasspy/wyoming-piper:2.4.2",
"network": "archy-net",
"network_aliases": [
"pine-piper"
@@ -4438,7 +4613,11 @@
"no_new_privileges": true,
"readonly_root": false
},
"version": "2.2.2",
"upstream": {
"kind": "github",
"repo": "rhasspy/wyoming-piper"
},
"version": "2.4.2",
"volumes": [
{
"options": [
@@ -4451,7 +4630,7 @@
]
}
},
"version": "2.2.2"
"version": "2.4.2"
},
"pine-whisper": {
"manifest": {
@@ -4538,13 +4717,13 @@
"version": "3.4.2"
},
"portainer": {
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"manifest": {
"app": {
"category": "development",
"container": {
"data_uid": "1000:1000",
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"image": "source.archipelago-foundation.org/lfg2025/portainer:2.39.6",
"pull_policy": "if-not-present"
},
"dependencies": [
@@ -4602,6 +4781,10 @@
"no_new_privileges": true,
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "portainer/portainer"
},
"version": "2.19.4",
"volumes": [
{
@@ -4631,7 +4814,7 @@
]
}
},
"version": "2.39.1"
"version": "2.39.6"
},
"router": {
"manifest": {
@@ -4711,6 +4894,9 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "internal"
},
"version": "1.0.0",
"volumes": [
{
@@ -4789,6 +4975,10 @@
"seccomp_profile": "default",
"user": 1000
},
"upstream": {
"kind": "github",
"repo": "searxng/searxng"
},
"version": "1.0.0",
"volumes": [
{
@@ -4808,7 +4998,7 @@
"manifest": {
"app": {
"container": {
"image": "dockurr/strfry:1.0.4",
"image": "dockurr/strfry:1.1.1",
"image_signature": "cosign://...",
"pull_policy": "verify-signature"
},
@@ -4861,7 +5051,11 @@
"readonly_root": true,
"seccomp_profile": "default"
},
"version": "0.9.0",
"upstream": {
"kind": "github",
"repo": "hoytech/strfry"
},
"version": "1.1.1",
"volumes": [
{
"options": [
@@ -4882,7 +5076,7 @@
]
}
},
"version": "0.9.0"
"version": "1.1.1"
},
"tailscale": {
"image": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
@@ -4954,6 +5148,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "louislam/uptime-kuma"
},
"version": "1.23.0",
"volumes": [
{
@@ -4970,11 +5168,11 @@
"version": "1"
},
"vaultwarden": {
"image": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"image": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"manifest": {
"app": {
"container": {
"image": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"image": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.37.1-alpine",
"network": "pasta",
"pull_policy": "if-not-present"
},
@@ -5037,6 +5235,10 @@
"network_policy": "isolated",
"readonly_root": false
},
"upstream": {
"kind": "github",
"repo": "dani-garcia/vaultwarden"
},
"version": "1.30.0",
"volumes": [
{
@@ -5050,11 +5252,11 @@
]
}
},
"version": "1.30.0-alpine"
"version": "1.37.1-alpine"
}
},
"schema": 1,
"signature": "0aaf681435434b6e325269745d1fc3f90686c5391525d6b591666d6e36097bef0e5b999e2b9fe37d7a945e59aa40db74ed618824ab78c7fc6af73271c62ff20a",
"signature": "97628de24e3ffa17f639c663e19881cf6dea8c79aab272fe9c5442a4e951b3f0d257fee21aa9ce6158e3824b56a337acb71805f6fd34245e48345b86b46ec007",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"updated": "2026-08-13"
"updated": "2026-08-19"
}
+21 -17
View File
@@ -1,29 +1,33 @@
{
"changelog": [
"**The network map on TVs: no more blank page, no more frozen page — and it moves again.** The map's entrance animation needed a smoothness that TV kiosk hardware can't always deliver, so the page could sit blank until a refresh; the previous fix cured the freeze by stopping the animation entirely, which went too far. Now the map appears instantly with everything already in place, then resumes its calm orbital motion at a gentler pace suited to TVs. Resizing or rotating any screen also redraws the map properly instead of leaving it tiny, stretched, or empty.",
"**The dashboard's corner logo is back to normal.** The new glossy paint finish was meant for the big emblem on the screensaver, intro, and login screens — it had quietly spread to the small logo in the dashboard header, where it looked wrong. Each screen now gets exactly the treatment intended for it.",
"**App icons no longer vanish in My Apps.** The freshly restyled Alby Hub and phoenixd icons could render as blank squares in some views — a subtlety in how the icon files declared their size. Fixed at the source, and the icon tool app developers use now produces immune files."
"**Apps with their own login can now skip the node's login screen — Gitea and BTCPay Server do so out of the box.** Some apps bring a complete account system of their own, and putting the node's password page in front of them broke real workflows: git clients can't answer a browser login, and a BTCPay checkout link handed to a customer must open for that customer. These apps are now served directly on their own login, while the node still fronts the connection for everything else it does (embedding fixes, the \"app is restarting\" page, Tor). Every app gets a new **Settings → app → Access control** switch, so you can put the node login back in front of any app — or take it away from one — with one click, effective immediately. App developers declare the default in their manifest (`auth: open`), documented in the developer guide.",
"**The phone remote now works inside apps on the TV — tap, scroll, and type everywhere.** The companion remote and keyboard drove the dashboard beautifully but died at the edge of any app screen (Gitea, BTCPay, and friends): for the browser, each app is a separate website embedded in the page, and simulated input is forbidden from crossing that wall. The on-screen display now accepts the remote's input the way a real mouse and keyboard arrive — below the page, through the browser itself — so it lands anywhere on screen, app screens and tabs included. Taps click, two-finger scrolling scrolls the app, and typing goes into whichever field you tapped. Existing kiosks pick this up with the update, no reinstall needed.",
"**While you're driving with the phone remote, the old mouse pointer gets out of the way.** The computer's own pointer used to sit frozen wherever the physical mouse last left it — a second, dead cursor next to the live orange one. It now hides while the remote is in use and returns half a minute after the last remote input.",
"**\"Are you sure?\" questions no longer freeze the remote.** A handful of confirmations (clearing mesh history, rebooting, deleting a backup, uninstalling an app) used the browser's built-in popup, which stops the whole page — including remote input — until someone clicks it with a real mouse. From the couch, that meant asking a question you couldn't answer. All of them are now proper in-app windows in the house style, fully driveable by remote.",
"**A mesh radio now connects no matter which port it's plugged into — or replugged into.** Moving a radio to a different USB port could leave the mesh silently down: the node only checked a short fixed list of port names (a radio landing outside it was invisible), a hand-set serial-port override quietly outranked the device you'd just approved in the \"Radio detected\" window, and one whole family of boards (Espressif-based radios like recent Heltec/T-Deck models) never received a stable device name at all — the exact combination found live on a fleet machine this week. All three are fixed: every serial port is scanned, choosing a radio in the detection window clears any stale override, and Espressif boards get the same stable name as everyone else.",
"**Mesh signal strength is honest now.** Every peer heard over Reticulum radio reported a signal strength of exactly 0 — which is also what you'd see with no radio at all, and what peers reached over the internet showed. Real receptions now show their true signal reading, and anything that arrived over a relay or the internet says so by showing none — so \"the radio is working\" and \"the internet is doing the radio's job\" no longer look identical. (The reading depends on the radio's firmware reporting it; boards that don't report per-packet signal stats show \"unknown\" rather than a made-up number, and the new radio diagnostics show at a glance whether yours reports them.)",
"**A background error that repeated every 90 seconds, forever, is gone.** After setting up a node from its recovery phrase, the node kept introducing itself to its federation partners with its old temporary identity papers while signing with its new ones — every partner rejected the introduction, and both sides logged an error about it every minute and a half until the next restart. The identity switch now updates everything at once, a rejected introduction is no longer misreported as delivered, and a partner who has already answered is no longer re-asked on every cycle."
],
"components": [
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago",
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago",
"name": "archipelago",
"new_version": "1.8.3-alpha",
"sha256": "23b608bfce575212edb873ded3e125505f42be0db6dc06ce5f9c1e51c232c22d",
"size_bytes": 59900696
"new_version": "1.8.4-alpha",
"sha256": "c4d3a4fdecfdc2a972f808c7f98225b29dc65333418038bb016a5f0bd79d3551",
"size_bytes": 63850680
},
{
"current_version": "1.8.3-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.3-alpha/archipelago-frontend-1.8.3-alpha.tar.gz",
"name": "archipelago-frontend-1.8.3-alpha.tar.gz",
"new_version": "1.8.3-alpha",
"sha256": "13ca772e9a36c266b67e6eff2d51366616cdbedeab3a05ba5eba340332d3d6d4",
"size_bytes": 97615528
"current_version": "1.8.4-alpha",
"download_url": "https://source.archipelago-foundation.org/lfg2025/archy/releases/download/v1.8.4-alpha/archipelago-frontend-1.8.4-alpha.tar.gz",
"name": "archipelago-frontend-1.8.4-alpha.tar.gz",
"new_version": "1.8.4-alpha",
"sha256": "790de85816a7ad49480dc99134022279e4f40b69bd9b0983a6393373a3bdd7cc",
"size_bytes": 97641658
}
],
"release_date": "2026-08-14",
"signature": "35d5f56ef77cda1866051a3ff06f6e09ff0a23ac7b000a1ac4cf24e04cecabff00be7f3e240fc82ec605bff80c5690c55c296a300018562940d71ffc5df26c00",
"release_date": "2026-08-20",
"signature": "94ffb717166c6062ddbea97476942285903d629543bf3b9ec435697a1b8c31243dedb0f3c8f874daf3d9c0974a6342bf42b7e9d34b00680aa63a1d441dbc4805",
"signed_by": "did:key:z6Mkfu5LT8d4DjETtrkATvHh9Dvcbnr7zBCUwfau8Sw7DLWT",
"version": "1.8.3-alpha"
"version": "1.8.4-alpha"
}
+4 -3
View File
@@ -193,7 +193,8 @@ echo " ISO: $ISO ($(du -h "$ISO" | cut -f1))"
echo " SHA256: $(cut -d' ' -f1 "$SHA_FILE")"
echo
echo " Next steps (publisher, offline mnemonic required):"
echo " 1. scripts/sign-iso-checksums.sh $ISO"
echo " 2. upload ISO + .sha256 + signed checksum JSON alongside the"
echo " v$VERSION Gitea release assets"
echo " 1. bash scripts/sign-iso-checksums.sh $ISO"
echo " 2. bash scripts/publish-release-assets.sh $VERSION"
echo " (attaches the ISO, its .sha256 and the signed checksum JSON to"
echo " the v$VERSION Gitea release, then verifies the stored sizes)"
summary 0
+377
View File
@@ -0,0 +1,377 @@
#!/usr/bin/env python3
"""Report which app pins have fallen behind their upstream project.
Why this exists
---------------
A node only offers an app update when the signed catalog pins a newer image
than the one running (`container/app_catalog.rs::available_update_for_app`).
That machinery works. What was missing is the step *before* it: nothing told
us when upstream had shipped something new, so a catalog pin could sit at
fedimintd v0.10.0 for months and every node in the fleet would correctly and
confidently report "up to date".
The reason nothing could tell us is that the manifests never recorded where an
app comes from. `container.image` names our *mirror*
(`source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0`), which says
nothing about the project it was mirrored from. So this script reads a new
optional `app.upstream` block (see docs/app-manifest-spec.md), asks that
source what its latest release is, and prints what is behind.
An app with no `upstream` block is reported as UNTRACKED rather than skipped.
A silent skip is how this gap stayed invisible in the first place.
Usage
-----
scripts/check-upstream-releases.py # check everything
scripts/check-upstream-releases.py fedimint lnd # check named apps
scripts/check-upstream-releases.py --offline # no network; coverage only
scripts/check-upstream-releases.py --json # machine-readable
Exit status is 1 when any tracked app is behind, so CI or the release
checklist can gate on it.
"""
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.request
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import yaml
REPO_ROOT = Path(__file__).resolve().parent.parent
APPS_DIR = REPO_ROOT / "apps"
CATALOG = REPO_ROOT / "releases" / "app-catalog.json"
USER_AGENT = "archipelago-upstream-check/1"
TIMEOUT = 20
# ── Version handling ───────────────────────────────────────────────────────
def version_parts(tag: str) -> tuple[int, ...] | None:
"""Numeric components of a version tag, for ordering only.
Mirrors `image_versions::parse_version_parts` on the node: accepts a
leading `v` and ignores a pre-release suffix. Returns None for opaque tags
(`RELEASE.2024-11-07T00-52-20Z`), which are reported but never *ordered* —
guessing an order for those is how you end up advertising a downgrade.
"""
if not tag:
return None
core = tag.strip().lstrip("vV").split("-")[0].split("+")[0]
if not re.fullmatch(r"\d+(\.\d+)*", core):
return None
return tuple(int(p) for p in core.split("."))
def tag_of(image: str) -> str:
"""The tag from an image reference, ignoring a registry port."""
if "@" in image: # digest pin — no tag to compare
return ""
last = image.rsplit("/", 1)[-1]
return last.split(":", 1)[1] if ":" in last else "latest"
# ── Upstream sources ───────────────────────────────────────────────────────
class RateLimited(RuntimeError):
"""GitHub refused because we are over the anonymous quota."""
def http_json(url: str, headers: dict[str, str] | None = None) -> Any:
hdrs = {"User-Agent": USER_AGENT, **(headers or {})}
# Anonymous GitHub allows 60 requests an hour, and a full sweep needs more
# than that. A token raises it to 5000 — worth exporting before a release
# pass, and the failure below says so rather than reporting every app as
# broken.
token = os.environ.get("GITHUB_TOKEN") or os.environ.get("GH_TOKEN")
if token and "api.github.com" in url:
hdrs["Authorization"] = f"Bearer {token}"
req = urllib.request.Request(url, headers=hdrs)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT) as res: # noqa: S310
return json.loads(res.read().decode())
except urllib.error.HTTPError as e:
if e.code in (403, 429) and "rate limit" in (e.read().decode(errors="replace").lower()):
raise RateLimited(
"GitHub rate limit reached — export GITHUB_TOKEN and re-run"
) from e
raise
def latest_github(repo: str, current: str = "") -> str:
"""Newest release tag for `owner/name`.
Three sources, in descending order of what the project *means*: the marked
latest release, then the release list (many projects publish only
pre-releases, or never mark a latest), then plain git tags. The last one
matters more than it looks — electrumx, strfry and nostr-rs-relay all tag
releases without creating GitHub Release objects, and stopping at the
release list reported them as having "no orderable version tags" when they
were simply tagged instead.
"""
try:
return str(http_json(f"https://api.github.com/repos/{repo}/releases/latest")["tag_name"])
except (urllib.error.HTTPError, KeyError):
pass
try:
releases = http_json(f"https://api.github.com/repos/{repo}/releases?per_page=50")
best = _highest([r["tag_name"] for r in releases if not r.get("draft")], current)
if best:
return best
except urllib.error.HTTPError:
pass
tags = http_json(f"https://api.github.com/repos/{repo}/tags?per_page=100")
return _highest([t["name"] for t in tags], current)
def latest_dockerhub(repo: str, current: str = "") -> str:
"""Newest version-like tag on Docker Hub (`library/nginx`, `valkey/valkey`)."""
url = f"https://hub.docker.com/v2/repositories/{repo}/tags?page_size=100&ordering=last_updated"
results = http_json(url).get("results", [])
return _highest([t["name"] for t in results], current)
PRERELEASE = re.compile(r"(^|[-.])(rc|alpha|beta|dev|pre|snapshot|nightly|canary|test)([-.\d]|$)", re.I)
def is_prerelease(tag: str) -> bool:
"""Does this tag advertise itself as not-yet-stable?
Needed because ordering ignores the suffix: `13.0.1-ubi10-rc` outranks
every stable MariaDB tag numerically, so the first run of this script
recommended shipping a release candidate to the fleet.
"""
return bool(PRERELEASE.search(tag.strip().lstrip("vV")))
def _variant(tag: str) -> str:
"""The non-numeric suffix of a tag: `1.27-alpine` → `alpine`."""
core = tag.strip().lstrip("vV")
m = re.match(r"\d+(\.\d+)*[-.]?(.*)$", core)
return (m.group(2) if m else "").lower()
def _highest(tags: list[str], current: str = "") -> str:
"""The highest orderable tag, preferring our own variant.
Preferring the variant is what makes the answer actionable rather than
merely true: a node pinned to `postgres:16.13-alpine` is not helped by
being told the newest tag is `18.6-trixie`. Same version, different base
image — swapping it is a different decision from bumping a version.
"""
ranked = [(version_parts(t), t) for t in tags]
ranked = [(p, t) for p, t in ranked if p is not None]
# Never propose a pre-release to someone on a stable tag. The exception is
# a project whose stable line *is* suffixed — LND ships `-beta` and always
# has, so excluding those outright would report it as permanently current.
if not is_prerelease(current):
stable = [(p, t) for p, t in ranked if not is_prerelease(t)]
if stable:
ranked = stable
if not ranked:
return ""
want = _variant(current)
if want:
same = [(p, t) for p, t in ranked if _variant(t) == want]
if same:
return max(same)[1]
return max(ranked)[1]
FETCHERS = {"github": latest_github, "dockerhub": latest_dockerhub}
# ── Manifest reading ───────────────────────────────────────────────────────
@dataclass
class AppPin:
app_id: str
manifest_version: str
image: str
upstream: dict[str, Any] = field(default_factory=dict)
catalog_image: str = ""
def load_apps(only: list[str]) -> list[AppPin]:
catalog_images: dict[str, str] = {}
if CATALOG.exists():
catalog = json.loads(CATALOG.read_text()).get("apps", {})
for app_id, entry in catalog.items():
image = entry.get("containers") or entry.get("image") or ""
catalog_images[app_id] = image if isinstance(image, str) else ""
pins: list[AppPin] = []
for path in sorted(APPS_DIR.glob("*/manifest.yml")):
try:
doc = yaml.safe_load(path.read_text()) or {}
except yaml.YAMLError as e:
print(f"warning: {path} is not valid YAML ({e})", file=sys.stderr)
continue
app = doc.get("app") or {}
app_id = app.get("id") or path.parent.name
if only and app_id not in only:
continue
pins.append(
AppPin(
app_id=app_id,
manifest_version=str(app.get("version") or ""),
image=str((app.get("container") or {}).get("image") or ""),
upstream=app.get("upstream") or {},
catalog_image=catalog_images.get(app_id, ""),
)
)
return pins
# ── Reporting ──────────────────────────────────────────────────────────────
def check(pin: AppPin, offline: bool) -> dict[str, Any]:
# The catalog pin is what nodes actually act on, so it is the number that
# matters; the manifest is the fallback for apps the catalog doesn't cover.
shipped_image = pin.catalog_image or pin.image
shipped = tag_of(shipped_image) or pin.manifest_version
row: dict[str, Any] = {
"app": pin.app_id,
"shipped": shipped,
"source": "catalog" if pin.catalog_image else "manifest",
"upstream_kind": pin.upstream.get("kind", ""),
"latest": "",
"status": "",
"jump": "",
"note": "",
}
kind = pin.upstream.get("kind")
if not kind:
row["status"] = "UNTRACKED"
row["note"] = "no app.upstream block — nothing can tell us when this app moves"
return row
if kind == "internal":
row["status"] = "INTERNAL"
row["note"] = pin.upstream.get("note", "built by this project — no upstream to track")
return row
if kind == "manual":
row["status"] = "MANUAL"
row["note"] = pin.upstream.get("url", "check by hand")
return row
if kind not in FETCHERS:
row["status"] = "UNKNOWN-KIND"
row["note"] = f"unsupported upstream.kind {kind!r}"
return row
if offline:
row["status"] = "SKIPPED"
row["note"] = "offline"
return row
ref = pin.upstream.get("repo") or ""
if not ref:
row["status"] = "UNKNOWN-KIND"
row["note"] = f"upstream.kind {kind} needs a repo"
return row
try:
latest = FETCHERS[kind](ref, shipped)
except RateLimited as e:
# Distinct from ERROR: the pin may be perfectly current, we just
# couldn't ask. Reporting it as a failure would train people to ignore
# the column that matters.
row["status"] = "RATE-LIMITED"
row["note"] = str(e)
return row
except Exception as e: # noqa: BLE001 — any failure is "we couldn't ask"
row["status"] = "ERROR"
row["note"] = str(e)
return row
row["latest"] = latest
if not latest:
row["status"] = "ERROR"
row["note"] = "upstream published no orderable version tags"
return row
ours, theirs = version_parts(shipped), version_parts(latest)
if ours is None or theirs is None:
row["status"] = "UNCOMPARABLE"
row["note"] = "opaque tag — compare by hand"
elif theirs > ours:
row["status"] = "BEHIND"
# How big a jump matters more than the fact of one. A major bump is
# where data migrations live — Postgres will refuse to start on an
# older cluster, Nextcloud requires one major at a time — so these are
# a different piece of work from a patch bump, not a longer version of
# the same one.
row["jump"] = "major" if theirs[0] != ours[0] else (
"minor" if len(theirs) > 1 and len(ours) > 1 and theirs[1] != ours[1] else "patch"
)
if row["jump"] == "major":
row["note"] = "major version — check for a data migration before bumping"
elif theirs < ours:
row["status"] = "AHEAD"
row["note"] = "we ship newer than upstream's latest release"
else:
row["status"] = "CURRENT"
return row
def main() -> int:
ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("apps", nargs="*", help="app ids to check (default: all)")
ap.add_argument("--offline", action="store_true", help="no network — report coverage only")
ap.add_argument("--json", action="store_true", help="machine-readable output")
args = ap.parse_args()
pins = load_apps(args.apps)
if not pins:
print("no manifests matched", file=sys.stderr)
return 2
rows = [check(p, args.offline) for p in pins]
if args.json:
print(json.dumps(rows, indent=2))
else:
width = max(len(r["app"]) for r in rows)
for r in sorted(rows, key=lambda r: (r["status"] != "BEHIND", r["app"])):
status = r["status"] + (f"/{r['jump']}" if r.get("jump") else "")
line = f"{r['app']:<{width}} {status:<19} {r['shipped'] or '-':<18}"
if r["latest"]:
line += f"→ {r['latest']:<18}"
if r["note"]:
line += f" {r['note']}"
print(line.rstrip())
behind = [r["app"] for r in rows if r["status"] == "BEHIND"]
untracked = [r["app"] for r in rows if r["status"] == "UNTRACKED"]
print()
print(f"{len(rows)} apps · {len(behind)} behind · {len(untracked)} untracked")
majors = [r["app"] for r in rows if r["status"] == "BEHIND" and r.get("jump") == "major"]
if behind:
print("Behind: " + ", ".join(behind))
if majors:
print(f"Of those, {len(majors)} are MAJOR jumps that may need a data "
f"migration, not just a pin bump: " + ", ".join(majors))
print("Bump the pin, regenerate and re-sign the catalog, and nodes will offer the update.")
if untracked:
print("Untracked apps cannot ever be reported as behind — add an app.upstream block.")
return 1 if any(r["status"] == "BEHIND" for r in rows) else 0
if __name__ == "__main__":
sys.exit(main())
+23 -2
View File
@@ -155,7 +155,18 @@ cd "$PROJECT_ROOT"
echo "[4/8] Building frontend..."
cd "$PROJECT_ROOT/neode-ui"
npm run build 2>&1 | tail -3
# `| tail -3` discards npm's exit status, so a FAILED build looked identical
# to a successful one and the run marched on to report the far more
# confusing "build no-opped or its output is stale" a few lines later.
# Keep the quiet 3-line output, but surface the real error when it breaks.
build_log=$(mktemp -t neode-build.XXXXXX.log)
if npm run build >"$build_log" 2>&1; then
tail -3 "$build_log"; rm -f "$build_log"
else
echo "Error: the frontend build FAILED — full log kept at $build_log" >&2
tail -40 "$build_log" >&2
exit 1
fi
cd "$PROJECT_ROOT"
# npm run build wipes web/dist — fold AIUI straight back in. The OTA tarball
@@ -217,7 +228,17 @@ if [ ! -x "$SIGNER" ]; then
exit 1
fi
if [ -n "${RELEASE_MASTER_MNEMONIC:-}" ] || [ -t 0 ]; then
echo "[6b/8] Signing release manifest (paste the release master mnemonic when prompted)..."
echo "[6b/8] Signing release manifest..."
# The signer reads stdin to EOF, so Enter alone submits NOTHING and a
# second paste just appends to the first. That is how a 24-word phrase
# arrived as "invalid word count: 89" on 2026-08-20 — roughly four
# pastes concatenated, because nothing appeared to happen after Enter.
echo "════════════════════════════════════════════════════════════════"
echo " Paste the release master mnemonic ONCE, press Enter, then"
echo " press Ctrl-D on the new line to submit."
echo " Enter by itself will NOT submit; pasting twice concatenates"
echo " the phrases and fails on word count."
echo "════════════════════════════════════════════════════════════════"
"$SIGNER" ceremony sign "$PROJECT_ROOT/releases/manifest.json"
"$SIGNER" ceremony verify "$PROJECT_ROOT/releases/manifest.json"
else
+9 -9
View File
@@ -32,8 +32,8 @@ LND_IMAGE="$ARCHY_REGISTRY/lnd:v0.18.4-beta"
ELECTRUMX_IMAGE="$ARCHY_REGISTRY/electrumx:v1.18.0"
# Mempool stack
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.0.0"
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.0.1"
MEMPOOL_BACKEND_IMAGE="$ARCHY_REGISTRY/mempool-backend:v3.3.1"
MEMPOOL_WEB_IMAGE="$ARCHY_REGISTRY/mempool-frontend:v3.3.1"
MARIADB_IMAGE="$ARCHY_REGISTRY/mariadb:11.4.10"
# BTCPay
@@ -43,13 +43,13 @@ POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
BTCPAY_POSTGRES_IMAGE="$ARCHY_REGISTRY/postgres:15.17"
# Apps
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.7.3"
HOMEASSISTANT_IMAGE="$ARCHY_REGISTRY/home-assistant:2026.8.2"
GRAFANA_IMAGE="$ARCHY_REGISTRY/grafana:10.2.0"
UPTIME_KUMA_IMAGE="$ARCHY_REGISTRY/uptime-kuma:1"
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.8.13"
JELLYFIN_IMAGE="$ARCHY_REGISTRY/jellyfin:10.11.11"
PHOTOPRISM_IMAGE="$ARCHY_REGISTRY/photoprism:240915"
OLLAMA_IMAGE="$ARCHY_REGISTRY/ollama:latest"
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.30.0-alpine"
VAULTWARDEN_IMAGE="$ARCHY_REGISTRY/vaultwarden:1.37.1-alpine"
NEXTCLOUD_IMAGE="$ARCHY_REGISTRY/nextcloud:29"
SEARXNG_IMAGE="$ARCHY_REGISTRY/searxng:latest"
# OnlyOffice removed — incompatible with rootless Podman (internal postgres/rabbitmq fail)
@@ -63,7 +63,7 @@ NPM_IMAGE="$ARCHY_REGISTRY/nginx-proxy-manager:latest"
# container was recreated: "database schema version does not align with the
# server version" — it migrates a DB forward, never backward. Pinned
# forward and published as a concrete tag so this is reproducible.
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.1"
PORTAINER_IMAGE="$ARCHY_REGISTRY/portainer:2.39.6"
# Networking
TAILSCALE_IMAGE="$ARCHY_REGISTRY/tailscale:stable"
@@ -74,8 +74,8 @@ ALPINE_TOR_IMAGE="$ARCHY_REGISTRY/alpine-tor:0.4.8.13"
ADGUARDHOME_IMAGE="$ARCHY_REGISTRY/adguardhome:v0.107.55"
# Fedimint
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.0"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.0"
FEDIMINT_IMAGE="$ARCHY_REGISTRY/fedimintd:v0.10.1"
FEDIMINT_GATEWAY_IMAGE="$ARCHY_REGISTRY/gatewayd:v0.10.1"
# fmcd = Fedimint client daemon (iroh-capable, fedimint-client 0.8.2). Built
# from minmoto/fmcd. Bundled on the ISO in BOTH modes (full CONTAINER_IMAGES
# list and the unbundled core bundle) and auto-created by first-boot as a
@@ -97,7 +97,7 @@ REDIS_IMAGE="$ARCHY_REGISTRY/redis:7.4.8"
VALKEY_IMAGE="$ARCHY_REGISTRY/valkey:8.1.6"
# Nostr
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.9.0"
NOSTR_RS_RELAY_IMAGE="$ARCHY_REGISTRY/nostr-rs-relay:0.10.0"
STRFRY_IMAGE="$ARCHY_REGISTRY/strfry:1.0.4"
NOSTR_VPN_IMAGE="$ARCHY_REGISTRY/nostr-vpn:v0.3.7"
NOSTR_VPN_UI_IMAGE="$ARCHY_REGISTRY/nostr-vpn-ui:latest"
+97 -2
View File
@@ -100,15 +100,22 @@ fi
release_id=$(printf '%s' "$release_json" | python3 -c 'import json,sys; print(json.load(sys.stdin)["id"])')
asset_names=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets" | python3 -c 'import json,sys; print("\n".join(a["name"] for a in json.load(sys.stdin)))')
# upload_asset <path> <name> [max_seconds]
# The 900s default is ample for the ~98MB frontend tarball but nowhere near
# enough for a multi-GB ISO, which also deserves a visible progress bar
# rather than sitting mute for the better part of an hour.
upload_asset() {
local path="$1"
local name="$2"
local max_time="${3:-900}"
if printf '%s\n' "$asset_names" | grep -Fxq "$name"; then
echo "Asset $name already exists; leaving it in place."
return
fi
echo "Uploading $name..."
curl --fail --show-error --silent --http1.1 --connect-timeout 20 --max-time 900 \
local noise=(--silent)
if [ "$max_time" -gt 900 ]; then noise=(--progress-bar); fi
echo "Uploading $name ($(du -h "$path" | cut -f1))..."
curl --fail --show-error "${noise[@]}" --http1.1 --connect-timeout 20 --max-time "$max_time" \
-u "$auth" \
-F "attachment=@$path" \
"$api/releases/$release_id/assets?name=$name" >/dev/null
@@ -131,3 +138,91 @@ echo "Assets verified. Pushing main to $REMOTE (this makes v${VERSION} live)..."
git -C "$PROJECT_ROOT" push "$REMOTE" main
echo "Release v${VERSION} published and verified on $REMOTE."
# ── ISO publication (optional) ───────────────────────────────────────
# Deliberately AFTER main is pushed. The ISO is not referenced by
# releases/manifest.json, so no node's OTA path depends on it — running it
# last means a slow or failed multi-GB upload can never delay, or strand,
# an OTA release that has already been verified.
#
# Skipped cleanly when this version has no ISO yet: create-release.sh runs
# long before scripts/build-iso-release.sh, which needs the very tag this
# script pushes. Re-run this script after building the ISO to attach it.
# SKIP_ISO=1 bypasses the stage entirely.
if [ "${SKIP_ISO:-0}" = "1" ]; then
echo "SKIP_ISO=1 — not publishing an ISO."
exit 0
fi
ISO=$(ls -t "$PROJECT_ROOT"/image-recipe/results/archipelago-installer-"$VERSION"*-x86_64_RC*.iso 2>/dev/null | head -1 || true)
if [ -z "$ISO" ]; then
echo
echo "No ISO built for v${VERSION} — OTA published without one."
echo " Build it: bash scripts/build-iso-release.sh"
echo " Sign it: bash scripts/sign-iso-checksums.sh <iso>"
echo " Attach it: bash scripts/publish-release-assets.sh $VERSION $REMOTE"
exit 0
fi
echo
echo "Publishing ISO: $(basename "$ISO")"
ISO_SHA_FILE="$ISO.sha256"
ISO_SIG_FILE="$ISO.sha256.json"
[ -f "$ISO_SHA_FILE" ] || fail "missing $(basename "$ISO_SHA_FILE") — re-run scripts/build-iso-release.sh"
[ -f "$ISO_SIG_FILE" ] || fail "the ISO checksum is unsigned. Run: bash scripts/sign-iso-checksums.sh $ISO"
# Same supply-chain rule as the OTA manifest: anything published must be
# signed by the pinned release root, and the crypto must actually verify —
# a present-but-bogus signature is the failure mode worth catching.
grep -q '"signature":' "$ISO_SIG_FILE" \
&& grep -q "\"signed_by\": \"$EXPECTED_DID\"" "$ISO_SIG_FILE" \
|| fail "$(basename "$ISO_SIG_FILE") is not signed by the release root — run: bash scripts/sign-iso-checksums.sh $ISO"
if [ -x "$PROJECT_ROOT/core/target/release/archipelago" ]; then
"$PROJECT_ROOT/core/target/release/archipelago" ceremony verify "$ISO_SIG_FILE" \
|| fail "the ISO checksum signature failed cryptographic verification"
fi
# Never upload an image that no longer matches its own checksum. A truncated
# or half-copied ISO is exactly what a signed checksum exists to expose, and
# catching it here is far cheaper than on someone's flashed USB stick.
echo "Checking the ISO against its recorded sha256 (reads the whole image)..."
(cd "$(dirname "$ISO")" && sha256sum --check --status "$(basename "$ISO_SHA_FILE")") \
|| fail "$(basename "$ISO") does not match its .sha256 — rebuild it; do not publish this image"
ISO_NAME=$(basename "$ISO")
# 4h ceiling: a multi-GB image over a domestic uplink is not a 15-minute job.
upload_asset "$ISO" "$ISO_NAME" 14400
upload_asset "$ISO_SHA_FILE" "$ISO_NAME.sha256"
upload_asset "$ISO_SIG_FILE" "$ISO_NAME.sha256.json"
# Verify what actually landed. Re-downloading a multi-GB ISO would cost far
# more than it proves — the signed .sha256.json already lets anyone verify
# the bytes independently — so confirm each asset exists and that Gitea's
# stored size matches the local file exactly.
echo "Verifying uploaded ISO assets..."
assets_json=$(curl -fsS -u "$auth" "$api/releases/$release_id/assets")
python3 - "$assets_json" \
"$ISO_NAME" "$(stat -c%s "$ISO")" \
"$ISO_NAME.sha256" "$(stat -c%s "$ISO_SHA_FILE")" \
"$ISO_NAME.sha256.json" "$(stat -c%s "$ISO_SIG_FILE")" <<'PY' \
|| fail "ISO asset verification failed — the release is missing or has a truncated ISO"
import json
import sys
assets = {a["name"]: a for a in json.loads(sys.argv[1])}
args = sys.argv[2:]
bad = []
for name, size in zip(args[0::2], args[1::2]):
asset = assets.get(name)
if asset is None:
bad.append(f"{name}: missing from the release")
elif int(asset["size"]) != int(size):
bad.append(f"{name}: uploaded {asset['size']} bytes, local file is {size}")
else:
print(f" OK {name} ({asset['size']} bytes)")
for b in bad:
print(" FAIL " + b, file=sys.stderr)
sys.exit(1 if bad else 0)
PY
echo "ISO for v${VERSION} published and verified on $REMOTE."
+34
View File
@@ -28,6 +28,15 @@ MONTHS = ["", "January", "February", "March", "April", "May", "June", "July",
HEADER_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((\d{4})-(\d{2})-(\d{2})\)")
# A header that names a version but carries no YYYY-MM-DD date. Such a line
# does not match HEADER_RE, so the version is INVISIBLE here — the tool then
# reports "all present" while the very release being cut has no modal block.
# That is what happened to v1.8.4-alpha on 2026-08-20: the changelog said
# "(draft — date set at cut)", this check passed, and create-release then
# aborted at the frontend step because the built bundle contained no version
# string at all (the What's New modal is the only place one appears).
UNDATED_RE = re.compile(r"^## (v\d+\.\d+\.\d+\S*) \((?!\d{4}-\d{2}-\d{2}\))")
def parse_changelog():
"""Return [(version, 'Month D, YYYY', [bullet, ...]), ...] newest-first."""
@@ -48,6 +57,16 @@ def parse_changelog():
return entries
def undated_versions():
"""[(version, whole line), ...] for version headers with no real date."""
found = []
for line in CHANGELOG.read_text().splitlines():
m = UNDATED_RE.match(line)
if m:
found.append((m.group(1), line.strip()))
return found
def existing_versions():
text = MODAL.read_text()
return set(re.findall(r"<!-- (v\d+\.\d+\.\d+\S*) -->", text))
@@ -78,6 +97,21 @@ def render_block(entry):
def main():
check = "--check" in sys.argv
# Refuse to reason about a changelog whose newest entry has no date:
# silently skipping it is what makes this check pass while the release
# is in fact missing its modal block.
undated = undated_versions()
if undated:
for ver, line in undated:
print(f"FAIL: CHANGELOG entry for {ver} carries no release date:",
file=sys.stderr)
print(f" {line}", file=sys.stderr)
print("A dated '## vX.Y.Z (YYYY-MM-DD)' header is required before the "
"modal can be synced — without it the version is invisible to "
"this tool and never reaches the built frontend.", file=sys.stderr)
return 1
entries = parse_changelog()
have = existing_versions()
missing = [e for e in entries if e["ver"] not in have]

Some files were not shown because too many files have changed in this diff Show More