Compare commits
25
Commits
+1
-1
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Generated
+1
-1
@@ -104,7 +104,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "archipelago"
|
||||
version = "1.8.3-alpha"
|
||||
version = "1.8.4-alpha"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"archipelago-container",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")?;
|
||||
|
||||
@@ -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(¶ms, "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.
|
||||
///
|
||||
|
||||
@@ -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;"));
|
||||
|
||||
@@ -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"));
|
||||
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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)
|
||||
|
||||
Generated
+2
-2
@@ -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,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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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(() => {
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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">₿</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">
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Executable
+377
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user