merge: bring the open-source readiness work onto the phase-13 branch

Merges gitea-ai/main (65 commits) into the phase-13 branch (419) so one
build carries both lines — the AIUI/assistant/container work and the
open-source readiness work (licensing, the marketplace DID signature layer,
the registry domain migration, the secrets and infrastructure scrub).

Every Rust file auto-merged. The container fixes from this branch and main's
registry-domain migration and node-name genericisation coexist without
manual intervention.

Conflict resolution — all of them were modify/delete, and all were resolved
in main's favour deliberately:

`.planning/**`, `scripts/deploy-to-target.sh` and `scripts/setup-aiui-server.sh`
were deleted by main's `6ba05996` ("security: remove all infrastructure and
internal process material from the repo") and added to .gitignore there.
Keeping this branch's copies would have re-committed internal process and
infrastructure material into a repo being prepared for publication, silently
undoing that cleanup. Resolved with `git rm --cached`, so every file remains
on disk locally and in this branch's history — it is untracked, not lost.
The remaining .planning files this branch added after the merge base were
untracked the same way, so the result is consistent rather than half-tracked.

Container suite 221/221 on the merged tree.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-08 09:02:32 -04:00
co-authored by Claude Opus 5
542 changed files with 5638 additions and 83054 deletions
-1
View File
@@ -148,7 +148,6 @@ State management via Pinia stores. WebSocket patches applied automatically.
- **Dev build**: `../web/dist/neode-ui/`
- **Docker build**: `dist/` (deployed to nginx)
- **Production deploy**: via `scripts/deploy-to-target.sh --live`
## License
-1
View File
@@ -1 +0,0 @@
if('serviceWorker' in navigator) navigator.serviceWorker.register('/dev-sw.js?dev-sw', { scope: '/', type: 'classic' })
-126
View File
@@ -1,126 +0,0 @@
/**
* Copyright 2018 Google Inc. All Rights Reserved.
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
// If the loader is already loaded, just stop.
if (!self.define) {
let registry = {};
// Used for `eval` and `importScripts` where we can't get script URL by other means.
// In both cases, it's safe to use a global var because those functions are synchronous.
let nextDefineUri;
const singleRequire = (uri, parentUri) => {
uri = new URL(uri + ".js", parentUri).href;
return registry[uri] || (
new Promise(resolve => {
if ("document" in self) {
const script = document.createElement("script");
script.src = uri;
script.onload = resolve;
document.head.appendChild(script);
} else {
nextDefineUri = uri;
importScripts(uri);
resolve();
}
})
.then(() => {
let promise = registry[uri];
if (!promise) {
throw new Error(`Module ${uri} didnt register its module`);
}
return promise;
})
);
};
self.define = (depsNames, factory) => {
const uri = nextDefineUri || ("document" in self ? document.currentScript.src : "") || location.href;
if (registry[uri]) {
// Module is already loading or loaded.
return;
}
let exports = {};
const require = depUri => singleRequire(depUri, uri);
const specialDeps = {
module: { uri },
exports,
require
};
registry[uri] = Promise.all(depsNames.map(
depName => specialDeps[depName] || require(depName)
)).then(deps => {
factory(...deps);
return exports;
});
};
}
define(['./workbox-21a80088'], (function (workbox) { 'use strict';
self.skipWaiting();
workbox.clientsClaim();
/**
* The precacheAndRoute() method efficiently caches and responds to
* requests for URLs in the manifest.
* See https://goo.gl/S9QRab
*/
workbox.precacheAndRoute([{
"url": "registerSW.js",
"revision": "3ca0b8505b4bec776b69afdba2768812"
}, {
"url": "index.html",
"revision": "0.nnkdothias"
}], {});
workbox.cleanupOutdatedCaches();
workbox.registerRoute(new workbox.NavigationRoute(workbox.createHandlerBoundToURL("index.html"), {
allowlist: [/^\/$/],
denylist: [/^\/app\//, /^\/rpc\//, /^\/ws/, /^\/aiui\//]
}));
workbox.registerRoute(/^https:\/\/fonts\.googleapis\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "google-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/^https:\/\/fonts\.gstatic\.com\/.*/i, new workbox.CacheFirst({
"cacheName": "gstatic-fonts-cache",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 10,
maxAgeSeconds: 31536000
}), new workbox.CacheableResponsePlugin({
statuses: [0, 200]
})]
}), 'GET');
workbox.registerRoute(/\/rpc\/v1\/.*/i, new workbox.NetworkFirst({
"cacheName": "api-cache",
"networkTimeoutSeconds": 10,
plugins: [new workbox.ExpirationPlugin({
maxEntries: 50,
maxAgeSeconds: 300
})]
}), 'GET');
workbox.registerRoute(/\/assets\/.*/i, new workbox.CacheFirst({
"cacheName": "assets-cache-v2",
plugins: [new workbox.ExpirationPlugin({
maxEntries: 100,
maxAgeSeconds: 2592000
})]
}), 'GET');
}));
File diff suppressed because it is too large Load Diff
@@ -1,7 +1,7 @@
// keepalive-remount-probe.spec.ts — 02-09 gap-closure Task 1, Step B.
//
// Standalone, re-runnable Playwright spec that logs into the deployed
// archi-dev-box build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
// a test node build (D-11) and, for EVERY path in KEEP_ALIVE_PATHS,
// performs a visit -> away (to the neutral /dashboard/settings tab) ->
// return round trip, reporting whether the component instance survived.
//
@@ -284,7 +284,7 @@ interface RoundTripResult {
* next click — mirrors measure.ts's own `dismissOverlays()` (this spec
* deliberately does not import from measure.ts, so the logic is duplicated
* here rather than shared, per the "don't edit the frozen 02-01 harness"
* constraint). archi-dev-box currently runs at 85% disk (02-FINDINGS.md
* constraint). a test node currently runs at 85% disk (02-FINDINGS.md
* Outstanding), which keeps `HealthNotifications.vue`'s disk-usage toast
* live for the whole session; that toast's `.fixed.inset-0…z-[3000]` wrapper
* has no `pointer-events: none`, so it silently intercepts clicks on
+2 -2
View File
@@ -12,7 +12,7 @@
// profiling evidence instead of guessed from source reading alone.
//
// Usage:
// ARCHY_BASE_URL=http://archi-dev-box ARCHY_PASSWORD=*** \
// ARCHY_BASE_URL=http://a test node ARCHY_PASSWORD=*** \
// npx playwright test e2e/perf/profile-revisit.spec.ts --project=chromium --reporter=line
import { expect, test, type Page } from '@playwright/test'
import { SURFACES, type Surface } from './surfaces'
@@ -112,7 +112,7 @@ interface CpuProfile {
}
// Production build has no sourcemaps deployed (confirmed: assets/*.js.map ->
// 404 on archi-dev-box), so minified function names inside vendor-*.js/
// 404 on a test node), so minified function names inside vendor-*.js/
// index-*.js can't be resolved to source. Bucket by the DEPLOYED CHUNK NAME
// instead (still meaningful: vendor = Vue/Pinia/vue-router runtime bundled
// together; index = app entry/shared code; per-route chunk name = that
+1 -1
View File
@@ -89,7 +89,7 @@ test('surface-perf: measure every D-09 surface and write the baseline artifact',
}
const header = {
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228',
baseUrl: baseURL ?? process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
takenAt: new Date().toISOString(),
commit: currentCommit(),
runs: RUNS,
+11 -11
View File
@@ -2877,11 +2877,11 @@ app.post('/rpc/v1', (req, res) => {
return res.json({
result: {
interfaces: [
{ name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.168.1.228/24'] },
{ name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.168.1.230/24'] },
{ name: 'eth0', type: 'ethernet', state: 'up', mac: 'a8:a1:59:3c:f2:10', ipv4: ['192.0.2.10/24'] },
{ name: 'wlan0', type: 'wifi', state: 'up', mac: 'dc:a6:32:12:ab:cd', ipv4: ['192.0.2.14/24'] },
{ name: 'lo', type: 'loopback', state: 'up', mac: '00:00:00:00:00:00', ipv4: ['127.0.0.1/8'] },
{ name: 'podman0', type: 'bridge', state: 'up', mac: '2e:f4:8a:11:22:33', ipv4: ['10.89.0.1/16'] },
{ name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.82.97.63/32'] },
{ name: 'tailscale0', type: 'tunnel', state: 'up', mac: '', ipv4: ['100.64.0.63/32'] },
],
},
})
@@ -3075,11 +3075,11 @@ app.post('/rpc/v1', (req, res) => {
return res.json({
result: {
nodes: [
{ did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.168.1.50' },
{ did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.168.1.51' },
{ did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.168.1.72' },
{ did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.72.19.44' },
{ did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.101.7.23' },
{ did: 'did:key:z6MkhaXgBZDvotDkL5257faiztiGiC2ReMkBe4bR6XBIDNq9', onion: 'disc1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion', pubkey: 'disc1pub', node_address: '192.0.2.50' },
{ did: 'did:key:z6MkpTHR8VNsBxYAAWHut2Geadd9jSwuBV8xRoAnwWsdvktH', onion: 'disc2xyz9wvu8tsr7qpo6nml5kji4hgf3edc2ba.onion', pubkey: 'disc2pub', node_address: '192.0.2.51' },
{ did: 'did:key:z6MkfV2sQpXm4d8YtR1nWc7uHb3eKj9gLa5xPzD6oTiN8rEw', onion: 'disc3mn04pq15rs26tu37vw48xy59za60bc71de.onion', pubkey: 'disc3pub', node_address: '192.0.2.72' },
{ did: 'did:key:z6MkrJ8pWx2yNc5vT9qLb4eHu7dKf1gMa3sPzE6oXiQ8nRvw', onion: 'disc4fg82hi93jk04lm15no26pq37rs48tu59vw.onion', pubkey: 'disc4pub', node_address: '100.64.0.6' },
{ did: 'did:key:z6MkhT4wQn8xPc2vL6sRb9eYu3dJf7gKa1mNzD5oWiE8tXvq', onion: 'disc5xy60za71bc82de93fg04hi15jk26lm37no.onion', pubkey: 'disc5pub', node_address: '100.64.0.23' },
],
},
})
@@ -3604,7 +3604,7 @@ app.post('/rpc/v1', (req, res) => {
name: 'archy-198',
trust_level: 'trusted',
mesh_contact_id: 1,
lan_address: '192.168.1.198:5678',
lan_address: '192.0.2.11:5678',
onion_address: 'peer1abc2def3ghi4jkl5mno6pqr7stu8vwx9yz.onion',
preferred_transport: 'lan',
available_transports: ['mesh', 'lan', 'tor'],
@@ -3640,7 +3640,7 @@ app.post('/rpc/v1', (req, res) => {
name: 'office-node',
trust_level: 'trusted',
mesh_contact_id: null,
lan_address: '192.168.1.42:5678',
lan_address: '192.0.2.42:5678',
onion_address: 'peer4mno6pqr7stu8vwx9yzabc2def3ghi4jkl5.onion',
preferred_transport: 'lan',
available_transports: ['lan', 'tor'],
@@ -4438,7 +4438,7 @@ app.post('/rpc/v1', (req, res) => {
ssid: 'CasaDelSol-5G',
assoc_ssid: 'CasaDelSol-5G',
encryption: 'psk2',
ip: '192.168.1.187',
ip: '192.0.2.15',
internet: true,
radio0_disabled: false,
sta_iface: 'wifinet1',
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "neode-ui",
"version": "1.7.125-alpha",
"version": "1.7.126-alpha",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "neode-ui",
"version": "1.7.125-alpha",
"version": "1.7.126-alpha",
"dependencies": {
"@scure/bip39": "^2.2.0",
"@types/dompurify": "^3.0.5",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "neode-ui",
"private": true,
"version": "1.7.125-alpha",
"version": "1.7.126-alpha",
"type": "module",
"scripts": {
"start": "./start-dev.sh",
+1 -1
View File
@@ -8,7 +8,7 @@ export default defineConfig({
timeout: 10_000,
},
use: {
baseURL: process.env.ARCHY_BASE_URL ?? 'http://192.168.1.228',
baseURL: process.env.ARCHY_BASE_URL ?? 'http://localhost:8100',
viewport: { width: 1440, height: 900 },
screenshot: 'only-on-failure',
trace: 'off',
+25 -25
View File
@@ -1,7 +1,7 @@
{
"version": 2,
"updated": "2026-04-22T00:00:00Z",
"registry": "146.59.87.168:3000/lfg2025",
"registry": "source.archipelago-foundation.org/lfg2025",
"featured": {
"id": "indeedhub",
"banner": "/assets/img/featured/indeedhub-banner.jpg",
@@ -19,7 +19,7 @@
"author": "Bitcoin Knots",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin-knots:latest",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest",
"repoUrl": "https://github.com/bitcoinknots/bitcoin"
},
{
@@ -31,7 +31,7 @@
"author": "Bitcoin Core contributors",
"category": "money",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/bitcoin:28.4",
"dockerImage": "source.archipelago-foundation.org/lfg2025/bitcoin:28.4",
"repoUrl": "https://github.com/bitcoin/bitcoin"
},
{
@@ -43,7 +43,7 @@
"author": "Lightning Labs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/lnd:v0.18.4-beta",
"dockerImage": "source.archipelago-foundation.org/lfg2025/lnd:v0.18.4-beta",
"repoUrl": "https://github.com/lightningnetwork/lnd",
"requires": [
"bitcoin-knots"
@@ -52,13 +52,13 @@
{
"id": "btcpay-server",
"title": "BTCPay Server",
"version": "2.3.9",
"version": "2.4.2",
"description": "Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries.",
"icon": "/assets/img/app-icons/btcpay-server.png",
"author": "BTCPay Server Foundation",
"category": "commerce",
"tier": "core",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.3.9",
"dockerImage": "docker.io/btcpayserver/btcpayserver:2.4.2",
"repoUrl": "https://github.com/btcpayserver/btcpayserver",
"requires": [
"bitcoin-knots"
@@ -73,7 +73,7 @@
"author": "Mempool",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/mempool-frontend:v3.0.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/mempool-frontend:v3.0.1",
"repoUrl": "https://github.com/mempool/mempool",
"requires": [
"bitcoin-knots",
@@ -89,7 +89,7 @@
"author": "Luke Childs",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/electrumx:v1.18.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/electrumx:v1.18.0",
"repoUrl": "https://github.com/spesmilo/electrumx",
"requires": [
"bitcoin-knots"
@@ -103,7 +103,7 @@
"icon": "/assets/img/app-icons/indeedhub.png",
"author": "IndeeHub",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/indeedhub:1.0.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/indeedhub:1.0.0",
"repoUrl": "https://github.com/indeedhub/indeedhub"
},
{
@@ -114,7 +114,7 @@
"icon": "/assets/img/app-icons/botfights.svg",
"author": "BotFights",
"category": "community",
"dockerImage": "146.59.87.168:3000/lfg2025/botfights:1.2.11",
"dockerImage": "source.archipelago-foundation.org/lfg2025/botfights:1.2.11",
"repoUrl": "https://botfights.net",
"containerConfig": {
"ports": [
@@ -172,7 +172,7 @@
"author": "File Browser",
"category": "data",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/filebrowser:v2.27.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/filebrowser:v2.27.0",
"repoUrl": "https://github.com/filebrowser/filebrowser",
"containerConfig": {
"ports": [
@@ -223,7 +223,7 @@
"author": "Vaultwarden",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/vaultwarden:1.30.0-alpine",
"dockerImage": "source.archipelago-foundation.org/lfg2025/vaultwarden:1.30.0-alpine",
"repoUrl": "https://github.com/dani-garcia/vaultwarden",
"containerConfig": {
"ports": [
@@ -243,7 +243,7 @@
"author": "SearXNG",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/searxng:latest",
"dockerImage": "source.archipelago-foundation.org/lfg2025/searxng:latest",
"repoUrl": "https://github.com/searxng/searxng",
"containerConfig": {
"ports": [
@@ -262,7 +262,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/fedimintd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fedimintd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint"
},
{
@@ -274,7 +274,7 @@
"author": "Fedimint",
"category": "money",
"tier": "core",
"dockerImage": "146.59.87.168:3000/lfg2025/fmcd:0.8.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/fmcd:0.8.1",
"repoUrl": "https://github.com/minmoto/fmcd"
},
{
@@ -285,7 +285,7 @@
"icon": "/assets/img/app-icons/fedimint.png",
"author": "Fedimint",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/gatewayd:v0.10.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/gatewayd:v0.10.0",
"repoUrl": "https://github.com/fedimint/fedimint",
"containerConfig": {
"ports": [
@@ -306,7 +306,7 @@
"icon": "/assets/img/app-icons/bark.png",
"author": "Second",
"category": "money",
"dockerImage": "146.59.87.168:3000/lfg2025/barkd:0.3.0",
"dockerImage": "source.archipelago-foundation.org/lfg2025/barkd:0.3.0",
"repoUrl": "https://gitlab.com/ark-bitcoin/bark",
"containerConfig": {
"ports": [
@@ -325,7 +325,7 @@
"icon": "/assets/img/app-icons/jellyfin.webp",
"author": "Jellyfin",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/jellyfin:10.8.13",
"dockerImage": "source.archipelago-foundation.org/lfg2025/jellyfin:10.8.13",
"repoUrl": "https://github.com/jellyfin/jellyfin",
"containerConfig": {
"ports": [
@@ -345,7 +345,7 @@
"icon": "/assets/img/app-icons/immich.png",
"author": "Immich",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/immich-server:release",
"dockerImage": "source.archipelago-foundation.org/lfg2025/immich-server:release",
"repoUrl": "https://github.com/immich-app/immich"
},
{
@@ -356,7 +356,7 @@
"icon": "/assets/img/app-icons/homeassistant.png",
"author": "Home Assistant",
"category": "home",
"dockerImage": "146.59.87.168:3000/lfg2025/home-assistant:2026.7.3",
"dockerImage": "source.archipelago-foundation.org/lfg2025/home-assistant:2026.7.3",
"repoUrl": "https://github.com/home-assistant/core",
"containerConfig": {
"ports": [
@@ -414,7 +414,7 @@
"author": "Tailscale",
"category": "networking",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/tailscale:stable",
"dockerImage": "source.archipelago-foundation.org/lfg2025/tailscale:stable",
"repoUrl": "https://github.com/tailscale/tailscale",
"containerConfig": {
"ports": [
@@ -442,7 +442,7 @@
"author": "Portainer",
"category": "development",
"tier": "optional",
"dockerImage": "146.59.87.168:3000/lfg2025/portainer:2.39.1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/portainer:2.39.1",
"repoUrl": "https://github.com/portainer/portainer",
"containerConfig": {
"ports": [
@@ -487,7 +487,7 @@
"author": "Uptime Kuma",
"category": "data",
"tier": "recommended",
"dockerImage": "146.59.87.168:3000/lfg2025/uptime-kuma:1",
"dockerImage": "source.archipelago-foundation.org/lfg2025/uptime-kuma:1",
"repoUrl": "https://github.com/louislam/uptime-kuma",
"containerConfig": {
"ports": [
@@ -514,7 +514,7 @@
"icon": "/assets/img/app-icons/photoprism.svg",
"author": "PhotoPrism",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/photoprism:240915",
"dockerImage": "source.archipelago-foundation.org/lfg2025/photoprism:240915",
"repoUrl": "https://github.com/photoprism/photoprism",
"containerConfig": {
"ports": [
@@ -537,7 +537,7 @@
"icon": "/assets/img/app-icons/nextcloud.webp",
"author": "Nextcloud",
"category": "data",
"dockerImage": "146.59.87.168:3000/lfg2025/nextcloud:29",
"dockerImage": "source.archipelago-foundation.org/lfg2025/nextcloud:29",
"repoUrl": "https://github.com/nextcloud/server",
"containerConfig": {
"ports": [
Binary file not shown.
Binary file not shown.
+73
View File
@@ -1084,6 +1084,16 @@ class RPCClient {
relay_count: number
first_seen: string
nostr_pubkey: string
/**
* Whether the author proved control of the key their `author.did` names.
* `invalid` manifests are dropped during discovery and should never
* appear here; typed anyway so the UI fails safe rather than falling
* through to "signed" if that ever changes.
*/
signature?:
| { status: 'valid' }
| { status: 'missing' }
| { status: 'invalid'; reason: string }
}>
relay_count: number
}> {
@@ -1148,6 +1158,69 @@ class RPCClient {
})
}
/** This node's Lightning credential state. Digests and counts only — the
* backend never returns macaroon content, so nothing here is sensitive. */
async lndMacaroonStatus(): Promise<LndMacaroonStatus> {
return this.call({ method: 'lnd.macaroon-status', timeout: 30000 })
}
/** Begin a macaroon rotation. Returns as soon as the job is accepted; the
* work takes minutes (LND has to close and reopen its databases), so poll
* `lndMacaroonRotationProgress` for the outcome. */
async lndRotateMacaroons(password: string): Promise<{ status: string }> {
return this.call({
method: 'lnd.rotate-macaroons',
params: { password },
timeout: 30000,
})
}
async lndMacaroonRotationProgress(): Promise<LndRotationProgress> {
return this.call({ method: 'lnd.macaroon-rotation-progress' })
}
}
export type RotationStepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
export interface LndRotationStep {
key: string
label: string
state: RotationStepState
detail: string | null
}
export interface LndRotationProgress {
running: boolean
/** null while running, then the verdict. Lets the UI tell "in progress"
* apart from "finished and failed". */
ok: boolean | null
started_at: string | null
finished_at: string | null
error: string | null
steps: LndRotationStep[]
/** Holds the OLD root key, so it is still secret. The UI tells the operator
* to delete it once every wallet app has been re-paired. */
backup_path: string | null
identity_pubkey: string | null
channels_before: number | null
channels_after: number | null
new_admin_macaroon_sha256: string | null
}
export interface LndMacaroonStatus {
installed: boolean
admin_macaroon_sha256: string | null
/** When LND last minted these credentials, host local time. */
issued_at: string | null
identity_pubkey: string | null
channels_open: number | null
channels_pending: number | null
/** Why LND could not be asked, when it could not. */
lnd_error: string | null
btcpay_uses_internal_lnd: boolean
/** null when BTCPay has no internal Lightning node — an absence, not a fault. */
btcpay_credential_current: boolean | null
rotation: LndRotationProgress
}
export const rpcClient = new RPCClient()
@@ -25,7 +25,7 @@ describe('useAppLauncherStore', () => {
vi.clearAllMocks()
// Default to HTTP to avoid proxy rewriting
Object.defineProperty(window, 'location', {
value: { origin: 'http://192.168.1.228', protocol: 'http:', hostname: '192.168.1.228' },
value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
@@ -71,8 +71,8 @@ describe('useAppLauncherStore', () => {
it('open() never falls through to the iframe overlay', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown app' })
expect(openInApp).toHaveBeenCalledWith('http://192.168.1.228:9999')
store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown app' })
expect(openInApp).toHaveBeenCalledWith('http://192.0.2.10:9999')
expect(store.isOpen).toBe(false)
})
})
@@ -81,7 +81,7 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
// Port 8083 maps to /app/filebrowser/ — should route to session
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
// Default panel mode: sets panelAppId, doesn't open overlay
expect(store.isOpen).toBe(false)
@@ -128,12 +128,12 @@ describe('useAppLauncherStore', () => {
it('routes BTCPay (port 23000) to full-page session', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:23000', title: 'BTCPay' })
store.open({ url: 'http://192.0.2.10:23000', title: 'BTCPay' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:23000',
'http://192.0.2.10:23000',
'_blank',
'noopener,noreferrer',
)
@@ -142,12 +142,12 @@ describe('useAppLauncherStore', () => {
it('normalizes old Nginx Proxy Manager port 81 to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:81', title: 'Nginx Proxy Manager' })
store.open({ url: 'http://192.0.2.10:81', title: 'Nginx Proxy Manager' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8081',
'http://192.0.2.10:8081',
'_blank',
'noopener,noreferrer',
)
@@ -161,7 +161,7 @@ describe('useAppLauncherStore', () => {
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8081', title: 'Nginx Proxy Manager' })
store.open({ url: 'http://192.0.2.10:8081', title: 'Nginx Proxy Manager' })
// Tab-only app on mobile-web: open directly in a new browser tab (the
// companion would use the in-app WebView). No session, no route push, no
@@ -170,7 +170,7 @@ describe('useAppLauncherStore', () => {
expect(store.panelAppId).toBe(null)
expect(mockPush).not.toHaveBeenCalled()
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8081',
'http://192.0.2.10:8081',
'_blank',
'noopener,noreferrer',
)
@@ -179,10 +179,10 @@ describe('useAppLauncherStore', () => {
it('opens Nginx Proxy Manager in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://192.168.1.228/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' })
store.open({ url: 'https://192.0.2.10/app/nginx-proxy-manager/', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.168.1.228/app/nginx-proxy-manager/',
'https://192.0.2.10/app/nginx-proxy-manager/',
'_blank',
'noopener,noreferrer',
)
@@ -192,10 +192,10 @@ describe('useAppLauncherStore', () => {
it('normalizes legacy Nginx Proxy Manager ports to 8081', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8181', title: 'Nginx Proxy Manager' })
store.open({ url: 'http://192.0.2.10:8181', title: 'Nginx Proxy Manager' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8081',
'http://192.0.2.10:8081',
'_blank',
'noopener,noreferrer',
)
@@ -204,10 +204,10 @@ describe('useAppLauncherStore', () => {
it('normalizes legacy Uptime Kuma port 3001 to 3002', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3001', title: 'Uptime Kuma' })
store.open({ url: 'http://192.0.2.10:3001', title: 'Uptime Kuma' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:3002',
'http://192.0.2.10:3002',
'_blank',
'noopener,noreferrer',
)
@@ -218,10 +218,10 @@ describe('useAppLauncherStore', () => {
it('opens Uptime Kuma in new tab using title hint when URL is path-only', () => {
const store = useAppLauncherStore()
store.open({ url: 'https://192.168.1.228/app/uptime-kuma/', title: 'Uptime Kuma' })
store.open({ url: 'https://192.0.2.10/app/uptime-kuma/', title: 'Uptime Kuma' })
expect(mockWindowOpen).toHaveBeenCalledWith(
'https://192.168.1.228/app/uptime-kuma/',
'https://192.0.2.10/app/uptime-kuma/',
'_blank',
'noopener,noreferrer',
)
@@ -231,12 +231,12 @@ describe('useAppLauncherStore', () => {
it('routes Home Assistant (port 8123) to full-page session', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8123', title: 'Home Assistant' })
store.open({ url: 'http://192.0.2.10:8123', title: 'Home Assistant' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:8123',
'http://192.0.2.10:8123',
'_blank',
'noopener,noreferrer',
)
@@ -245,12 +245,12 @@ describe('useAppLauncherStore', () => {
it('routes Grafana (port 3000) to full-page session', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3000', title: 'Grafana' })
store.open({ url: 'http://192.0.2.10:3000', title: 'Grafana' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:3000',
'http://192.0.2.10:3000',
'_blank',
'noopener,noreferrer',
)
@@ -259,12 +259,12 @@ describe('useAppLauncherStore', () => {
it('opens Gitea path URL in new tab', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228/app/gitea/', title: 'Gitea' })
store.open({ url: 'http://192.0.2.10/app/gitea/', title: 'Gitea' })
expect(store.isOpen).toBe(false)
expect(store.panelAppId).toBe(null)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228/app/gitea/',
'http://192.0.2.10/app/gitea/',
'_blank',
'noopener,noreferrer',
)
@@ -273,7 +273,7 @@ describe('useAppLauncherStore', () => {
it('does not map raw port 3001 to gitea session', () => {
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:3001', title: 'Unknown 3001' })
store.open({ url: 'http://192.0.2.10:3001', title: 'Unknown 3001' })
expect(store.panelAppId).toBe(null)
expect(store.isOpen).toBe(true)
@@ -283,11 +283,11 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
// Use an unresolvable URL so it doesn't route to session
store.open({ url: 'http://192.168.1.228:9999', title: 'Unknown', openInNewTab: true })
store.open({ url: 'http://192.0.2.10:9999', title: 'Unknown', openInNewTab: true })
expect(store.isOpen).toBe(false)
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.228:9999',
'http://192.0.2.10:9999',
'_blank',
'noopener,noreferrer',
)
@@ -327,13 +327,13 @@ describe('useAppLauncherStore', () => {
it('routes HTTPS same-host apps via session view', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
const store = useAppLauncherStore()
store.open({ url: 'http://192.168.1.228:8083', title: 'FileBrowser' })
store.open({ url: 'http://192.0.2.10:8083', title: 'FileBrowser' })
// Known port — routes to session (panel mode by default)
expect(store.isOpen).toBe(false)
@@ -344,17 +344,17 @@ describe('useAppLauncherStore', () => {
const store = useAppLauncherStore()
// Unresolvable URL — falls through to iframe overlay
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom App' })
store.open({ url: 'http://192.0.2.10:9999', title: 'Custom App' })
expect(store.isOpen).toBe(true)
expect(store.url).toBe('http://192.168.1.228:9999')
expect(store.url).toBe('http://192.0.2.10:9999')
expect(store.title).toBe('Custom App')
expect(mockWindowOpen).not.toHaveBeenCalled()
})
it('opens unknown different-host URL in iframe overlay', () => {
Object.defineProperty(window, 'location', {
value: { origin: 'https://192.168.1.228', protocol: 'https:', hostname: '192.168.1.228' },
value: { origin: 'https://192.0.2.10', protocol: 'https:', hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
@@ -370,7 +370,7 @@ describe('useAppLauncherStore', () => {
it('close resets state', () => {
const store = useAppLauncherStore()
// Use unknown URL to trigger iframe overlay
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
store.open({ url: 'http://192.0.2.10:9999', title: 'Custom' })
store.close()
@@ -385,7 +385,7 @@ describe('useAppLauncherStore', () => {
const mockButton = { focus: vi.fn() } as unknown as HTMLElement
Object.defineProperty(document, 'activeElement', { value: mockButton, configurable: true })
store.open({ url: 'http://192.168.1.228:9999', title: 'Custom' })
store.open({ url: 'http://192.0.2.10:9999', title: 'Custom' })
store.close()
expect(store.isOpen).toBe(false)
+1 -1
View File
@@ -31,7 +31,7 @@ export const BUNDLED_APPS: BundledApp[] = [
{
id: 'bitcoin-knots',
name: 'Bitcoin Knots',
image: '146.59.87.168:3000/lfg2025/bitcoin-knots:latest',
image: 'source.archipelago-foundation.org/lfg2025/bitcoin-knots:latest',
description: 'Full Bitcoin node with additional features',
icon: '₿',
ports: [{ host: 8334, container: 80 }],
+1 -1
View File
@@ -144,7 +144,7 @@ export const dummyApps: Record<string, PackageDataEntry> = {
'interface-addresses': {
main: {
'tor-address': 'lorabell.onion',
'lan-address': 'http://192.168.1.166'
'lan-address': 'http://192.0.2.13'
}
},
status: ServiceStatus.Running
+1 -1
View File
@@ -129,7 +129,7 @@
<p class="text-sm text-white/60 mb-5">
The URL should be of the form <span class="font-mono text-white/80">host[:port]/namespace</span>
— for example <span class="font-mono text-white/80">ghcr.io/myorg</span> or
<span class="font-mono text-white/80">192.168.1.50:3000/apps</span>. Registries are
<span class="font-mono text-white/80">192.0.2.10:3000/apps</span>. Registries are
added to the end of the list; use "Make primary" to reorder.
</p>
<form class="space-y-3" @submit.prevent="submitRegistry">
+1 -1
View File
@@ -1044,7 +1044,7 @@ function loadCounts() {
// happened to be mounting/activating at the same moment (Home/Server's own
// onMounted bursts), this measurably doubles the concurrent same-origin
// request volume at the single riskiest instant in the session — first
// activation. On archi-dev-box that volume was large enough to leave one
// activation. On a test node that volume was large enough to leave one
// in-flight File Browser request permanently stuck (never resolving,
// confirmed via direct reproduction), which then starved the browser's
// per-origin connection pool and silently broke every subsequent
+1 -1
View File
@@ -368,7 +368,7 @@ const backendPort = 5678
function getLaunchUrl(app: BundledApp): string {
// Prefer lan_address from backend (for apps with custom UIs)
if (app.lan_address) {
// Replace localhost so Launch works when browsing from another machine (e.g. 192.168.1.228)
// Replace localhost so Launch works when browsing from another machine (e.g. a LAN address)
let url = app.lan_address.replace(/localhost/i, currentHost.value)
// LND UI (and other app UIs) need backend URL for live data (logs, getinfo proxy)
if (app.id === 'lnd') {
+1 -1
View File
@@ -764,7 +764,7 @@ async function loadWeb5Status() {
.catch(() => {
// A single slow poll must NOT flip the card to "disconnected" and
// hide balances the user already knows busy nodes routinely blow
// the 5s budget mid-payment or during IO storms (framework-pt user
// the 5s budget mid-payment or during IO storms (a test node user
// report: balances vanished while a payment settled). Only call it
// disconnected after three consecutive failures (~30s of silence).
walletInfoFailures += 1
+5
View File
@@ -306,6 +306,11 @@ async function loadNostrMarketplace() {
trustScore: app.trust_score,
trustTier: app.trust_tier,
relayCount: app.relay_count,
// Default to `missing` rather than leaving it undefined: a node running
// an older backend returns no field at all, and "we couldn't check" must
// never render as "signed".
signature: app.signature ?? { status: 'missing' as const },
authorDid: app.manifest.author.did,
}))
} catch (e) {
nostrError.value = e instanceof Error ? e.message : 'Discovery failed'
+1 -1
View File
@@ -507,7 +507,7 @@ const features = computed(() => {
})
/** App dependency definitions */
const R = '146.59.87.168:3000/lfg2025'
const R = 'source.archipelago-foundation.org/lfg2025'
const APP_DEPENDENCIES: Record<string, { id: string; title: string; dockerImage: string }[]> = {
'electrumx': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
'lnd': [{ id: 'bitcoin-knots', title: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest` }],
+4 -1
View File
@@ -254,7 +254,10 @@
>+ Add mirror</button>
</div>
<p class="text-sm text-white/60 mb-4">
Servers this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one in the list is tried automatically. Downloads always come from the mirror that served the manifest switching primary switches where files come from.
Sources this node checks for updates. The primary is tried first; if it's slow or unreachable, the next one is tried automatically. Downloads always come from the source that served the manifest switching primary switches where files come from.
</p>
<p class="text-sm text-white/50 mb-4">
The two built-in entries are the <strong class="text-white/70">same server</strong>, not two servers: the second reaches it by IP without DNS or TLS, which recovers a node whose DNS is broken or whose clock is wrong. It does not help if the server itself is down. Every update is signature-checked whichever source serves it, so an unencrypted fetch can't substitute a tampered build. For real redundancy, add a mirror on a different host.
</p>
<ul v-if="mirrors.length" class="space-y-2">
<li v-for="(m, i) in mirrors" :key="m.url" class="p-3 bg-white/5 rounded-lg">
@@ -56,7 +56,7 @@ describe('AppSession mobile new-tab apps', () => {
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
@@ -103,7 +103,7 @@ describe('Server network refresh states', () => {
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
if (request.method === 'network.list-interfaces') {
return Promise.resolve({
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.168.1.10'] }],
interfaces: [{ name: 'eth0', type: 'ethernet', state: 'up', mac: '00:11:22:33:44:55', ipv4: ['192.0.2.10'] }],
})
}
if (request.method === 'network.diagnostics') {
@@ -131,7 +131,7 @@ describe('Server network refresh states', () => {
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
expect(wrapper.text()).toContain('192.0.2.10')
const pendingInterfaces = deferred<{ interfaces: [] }>()
vi.mocked(rpcClient.call).mockImplementation((request: { method: string }) => {
@@ -143,7 +143,7 @@ describe('Server network refresh states', () => {
await wrapper.vm.$nextTick()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
expect(wrapper.text()).toContain('192.0.2.10')
expect(wrapper.text()).toContain('Refreshing interfaces...')
pendingInterfaces.reject(new Error('offline'))
@@ -151,7 +151,7 @@ describe('Server network refresh states', () => {
await flushPromises()
expect(wrapper.text()).toContain('eth0')
expect(wrapper.text()).toContain('192.168.1.10')
expect(wrapper.text()).toContain('192.0.2.10')
})
it('keeps Tor services visible while refresh is pending or fails', async () => {
@@ -27,7 +27,6 @@
* The S2/S3/S5 "no pill" assertions pin a recorded product decision, not a
* bug: transport is measured PER PEER PER BROWSE, never per file, so a
* per-file pill would claim a reading the app never took. The reasoning is
* written up in .planning/phases/01-federation-mesh-hardening/01-17-SUMMARY.md.
* If you deliberately add a per-file pill, update that decision record and
* this test together.
*/
@@ -52,39 +52,6 @@ export function resolvePackageKey(routeId: string): string {
/** Apps that depend on Bitcoin being synced */
export const BITCOIN_DEPENDENT_APPS = ['lnd', 'electrumx', 'electrs', 'mempool-electrs', 'btcpay-server', 'btcpayserver']
/** App launch URLs for dev and prod environments */
export const APP_URLS: Record<string, { dev: string; prod: string }> = {
'lorabell': { dev: 'http://192.168.1.166', prod: 'http://192.168.1.166' },
'atob': { dev: 'http://localhost:8102', prod: 'https://app.atobitcoin.io' },
'k484': { dev: 'http://localhost:8103', prod: 'http://localhost:8103' },
'bitcoin': { dev: 'http://localhost:8332', prod: 'http://localhost:8332' },
'btcpay-server': { dev: 'http://localhost:23000', prod: 'http://localhost:23000' },
'homeassistant': { dev: 'http://localhost:8123', prod: 'http://localhost:8123' },
'grafana': { dev: 'http://localhost:3000', prod: 'http://localhost:3000' },
'endurain': { dev: 'http://localhost:8080', prod: 'http://localhost:8080' },
'fedimint': { dev: 'http://localhost:8175', prod: 'http://192.168.1.228:8175' },
'fedimint-gateway': { dev: 'http://localhost:8176', prod: 'http://192.168.1.228:8176' },
'morphos-server': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'lightning-stack': { dev: 'http://localhost:9735', prod: 'http://localhost:9735' },
'mempool': { dev: 'http://localhost:4080', prod: 'http://localhost:4080' },
'ollama': { dev: 'http://localhost:11434', prod: 'http://localhost:11434' },
'searxng': { dev: 'http://localhost:8888', prod: 'http://localhost:8888' },
'nextcloud': { dev: 'http://localhost:8085', prod: 'http://localhost:8085' },
'vaultwarden': { dev: 'http://localhost:8082', prod: 'http://localhost:8082' },
'jellyfin': { dev: 'http://localhost:8096', prod: 'http://localhost:8096' },
'photoprism': { dev: 'http://localhost:2342', prod: 'http://localhost:2342' },
'immich': { dev: 'http://localhost:2283', prod: 'http://localhost:2283' },
'filebrowser': { dev: 'http://localhost:8083', prod: 'http://localhost:8083' },
'nginx-proxy-manager': { dev: 'http://localhost:8081', prod: 'http://localhost:8081' },
'gitea': { dev: 'http://localhost:3001', prod: 'http://localhost:3001' },
'portainer': { dev: 'http://localhost:9000', prod: 'http://localhost:9000' },
'uptime-kuma': { dev: 'http://localhost:3002', prod: 'http://localhost:3002' },
'tailscale': { dev: 'http://localhost:8240', prod: 'http://localhost:8240' },
'lnd': { dev: 'http://localhost:18083', prod: 'http://localhost:18083' },
'bitcoin-knots': { dev: 'http://localhost:8334', prod: 'http://localhost:8334' },
'botfights': { dev: 'http://localhost:9100', prod: 'http://localhost:9100' },
}
/** V3 onion addresses are 56+ chars + .onion. Placeholders like "btcpay.onion" are not real. */
export function isRealOnionAddress(addr: string | undefined): boolean {
return !!(addr && addr.endsWith('.onion') && addr.length >= 60 && addr.length <= 70)
@@ -17,18 +17,18 @@ afterEach(() => vi.unstubAllGlobals())
describe('appOrigin', () => {
it('stays on http for an http dashboard', () => {
setLocation('http:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
setLocation('http:', 'test-node.local')
expect(appOrigin(8334)).toBe('http://test-node.local:8334')
})
it('follows an https dashboard onto the app port', () => {
setLocation('https:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
setLocation('https:', 'test-node.local')
expect(appOrigin(8334)).toBe('https://test-node.local:8334')
})
it('keeps the hostname the user actually typed, not a fixed name', () => {
setLocation('https:', '100.69.68.39')
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
setLocation('https:', '100.64.0.5')
expect(appOrigin(3000)).toBe('https://100.64.0.5:3000')
})
})
@@ -16,19 +16,19 @@ describe('appSessionConfig', () => {
it('resolves direct app ports against the current browser host', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('mempool')).toBe('http://192.168.1.228:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.168.1.228:7778')
expect(resolveAppUrl('botfights')).toBe('http://192.168.1.228:9100')
expect(resolveAppUrl('mempool')).toBe('http://192.0.2.10:4080')
expect(resolveAppUrl('indeedhub')).toBe('http://192.0.2.10:7778')
expect(resolveAppUrl('botfights')).toBe('http://192.0.2.10:9100')
})
it('uses manifest-generated launch ports for apps outside the manual override list', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
@@ -36,12 +36,12 @@ describe('appSessionConfig', () => {
// did-wallet's manifest publishes host port 8088 (apps/did-wallet/
// manifest.yml) — assert against the manifest-generated value, which is
// exactly what this test exists to protect.
expect(resolveAppUrl('did-wallet')).toBe('http://192.168.1.228:8088')
expect(resolveAppUrl('did-wallet')).toBe('http://192.0.2.10:8088')
})
it('does not treat service-only tcp ports as web launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
@@ -51,21 +51,21 @@ describe('appSessionConfig', () => {
it('keeps NetBird on the unified dashboard proxy port', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.168.1.228:8087')
expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087')
})
it('uses backend runtime URLs for apps with dynamic launch surfaces', () => {
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.228' },
value: { hostname: '192.0.2.10' },
writable: true,
configurable: true,
})
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.168.1.228:18083')
expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083')
})
})
@@ -52,7 +52,7 @@ describe('AppIconGrid', () => {
configurable: true,
})
Object.defineProperty(window, 'location', {
value: { hostname: '192.168.1.198' },
value: { hostname: '192.0.2.11' },
writable: true,
configurable: true,
})
@@ -112,7 +112,7 @@ describe('AppIconGrid', () => {
await flushPromises()
expect(mockWindowOpen).toHaveBeenCalledWith(
'http://192.168.1.198:3001',
'http://192.0.2.11:3001',
'_blank',
'noopener,noreferrer',
)
@@ -693,7 +693,7 @@ describe('keepAliveRoutes: widened registration set (02-04)', () => {
// 02-FINDINGS.md's "## Server KeepAlive Root Cause (gap closure)" section)
// rather than a real KeepAlive/lifecycle defect — an authoritative,
// independent `document.elementFromPoint()` hit-test signal on the deployed
// archi-dev-box build directly contradicted the naive selector-match probe's
// a test node build directly contradicted the naive selector-match probe's
// "remounted" verdict for both Server.vue and Web5.vue, and a companion
// diagnostic found the ORIGINAL stamped root still connected and visible
// under a different (unpicked) DOM match. No source change to
@@ -15,7 +15,7 @@ import { TAB_ORDER } from './useRouteTransitions'
* once this cap is exceeded (Vue's own LRU eviction).
*
* 02-08 (FA-D) tuned this against an on-device measurement rather than
* leaving the carried-forward estimate unexamined: on archi-dev-box (real
* leaving the carried-forward estimate unexamined: on a test node (real
* node hardware, deployed build), a headless Chromium session logged in via
* the real UI, cycled every main tab (all of TAB_ORDER incl. the withheld
* `/dashboard/settings`, plus `/dashboard/discover` 11 tabs) through 4 full
+2 -2
View File
@@ -1,6 +1,6 @@
import type { MarketplaceApp } from './types'
const R = '146.59.87.168:3000/lfg2025'
const R = 'source.archipelago-foundation.org/lfg2025'
// ---------- Dynamic catalog from registry ----------
export interface CatalogFeatured {
@@ -84,7 +84,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
return [
{ id: 'bitcoin-knots', title: 'Bitcoin Knots', version: '28.1.0', description: 'Run a full Bitcoin node. Validate and relay blocks and transactions on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-knots.webp', author: 'Bitcoin Knots', dockerImage: `${R}/bitcoin-knots:latest`, repoUrl: 'https://github.com/bitcoinknots/bitcoin' },
{ id: 'bitcoin-core', title: 'Bitcoin Core', version: '28.4', description: 'Reference implementation of the Bitcoin protocol. Run a full node validating and relaying blocks on the Bitcoin network.', icon: '/assets/img/app-icons/bitcoin-core.svg', author: 'Bitcoin Core contributors', dockerImage: 'docker.io/bitcoin/bitcoin:28.4', repoUrl: 'https://github.com/bitcoin/bitcoin' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.3.9', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'btcpay-server', title: 'BTCPay Server', version: '2.4.2', description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.', icon: '/assets/img/app-icons/btcpay-server.png', author: 'BTCPay Server Foundation', dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2', repoUrl: 'https://github.com/btcpayserver/btcpayserver' },
{ id: 'lnd', title: 'LND', version: '0.18.4', description: 'Lightning Network Daemon. Fast and cheap Bitcoin payments through the Lightning Network.', icon: '/assets/img/app-icons/lnd.png', author: 'Lightning Labs', dockerImage: `${R}/lnd:v0.18.4-beta`, repoUrl: 'https://github.com/lightningnetwork/lnd' },
{ id: 'mempool', title: 'Mempool Explorer', version: '3.0.0', description: 'Self-hosted Bitcoin blockchain and mempool visualizer. Monitor transactions without revealing your addresses to third parties.', icon: '/assets/img/app-icons/mempool.webp', author: 'Mempool', dockerImage: `${R}/mempool-frontend:v3.0.0`, repoUrl: 'https://github.com/mempool/mempool' },
{ id: 'homeassistant', title: 'Home Assistant', version: '2024.1', description: 'Open-source home automation. Control smart home devices privately, on your own hardware.', icon: '/assets/img/app-icons/homeassistant.png', author: 'Home Assistant', dockerImage: `${R}/home-assistant:2024.1`, repoUrl: 'https://github.com/home-assistant/core' },
@@ -91,19 +91,19 @@ describe('fleet data helpers', () => {
node_id: 'abcdef123456',
node_name: 'Kitchen Node',
hostname: 'kitchen-node',
server_url: 'https://192.168.1.20',
server_url: 'https://192.0.2.20',
})
const hostOnly = normalizeFleetNode({
node_id: '123456abcdef',
hostname: 'workshop-node',
server_url: 'https://192.168.1.21',
server_url: 'https://192.0.2.21',
})
const idOnly = normalizeFleetNode({ node_id: 'feedfacecafebeef' })
expect(fleetNodeDisplayName(named)).toBe('Kitchen Node')
expect(fleetNodeSubtitle(named)).toBe('kitchen-node')
expect(fleetNodeDisplayName(hostOnly)).toBe('workshop-node')
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.168.1.21')
expect(fleetNodeSubtitle(hostOnly)).toBe('https://192.0.2.21')
expect(fleetNodeDisplayName(idOnly)).toBe('feedface')
expect(fleetNodeSubtitle(idOnly)).toBe('feedfacecafebeef')
})
@@ -32,7 +32,7 @@
</div>
<!-- Trust badge for Nostr community apps -->
<div v-if="app.trustTier" class="flex items-center gap-2 mb-3">
<div v-if="app.trustTier" class="flex items-center flex-wrap gap-2 mb-3">
<span
class="text-xs px-2 py-0.5 rounded-full font-medium"
:class="{
@@ -42,6 +42,31 @@
'bg-red-400/20 text-red-400': app.trustTier === 'untrusted',
}"
>{{ app.trustTier }}</span>
<!--
Authorship, which is a different question from the trust score: the
score blends relay count, provenance and policy compliance, while this
says only whether the author proved control of the key their DID names.
-->
<span
v-if="app.signature"
class="text-xs px-2 py-0.5 rounded-full font-medium inline-flex items-center gap-1"
:class="{
'bg-green-400/20 text-green-400': app.signature.status === 'valid',
'bg-white/10 text-white/50': app.signature.status === 'missing',
'bg-red-400/20 text-red-400': app.signature.status === 'invalid',
}"
:title="signatureTooltip"
>
<svg v-if="app.signature.status === 'valid'" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M5 9V7a5 5 0 0110 0v2a2 2 0 012 2v5a2 2 0 01-2 2H5a2 2 0 01-2-2v-5a2 2 0 012-2zm8-2v2H7V7a3 3 0 016 0z" clip-rule="evenodd" />
</svg>
<svg v-else-if="app.signature.status === 'invalid'" class="w-3 h-3" viewBox="0 0 20 20" fill="currentColor" aria-hidden="true">
<path fill-rule="evenodd" d="M8.257 3.099c.765-1.36 2.722-1.36 3.486 0l5.58 9.92c.75 1.334-.213 2.98-1.742 2.98H4.42c-1.53 0-2.493-1.646-1.743-2.98l5.58-9.92zM11 13a1 1 0 11-2 0 1 1 0 012 0zm-1-8a1 1 0 00-1 1v3a1 1 0 002 0V6a1 1 0 00-1-1z" clip-rule="evenodd" />
</svg>
{{ signatureLabel }}
</span>
<span class="text-xs text-white/40">Score: {{ app.trustScore }}/100</span>
<span v-if="app.relayCount" class="text-xs text-white/40">&middot; {{ app.relayCount }} relay{{ app.relayCount !== 1 ? 's' : '' }}</span>
</div>
@@ -175,6 +200,35 @@ defineEmits<{
launch: [app: MarketplaceApp]
}>()
const signatureLabel = computed(() => {
switch (props.app.signature?.status) {
case 'valid': return 'signed'
case 'invalid': return 'bad signature'
default: return 'unsigned'
}
})
/**
* The badge is two words; the tooltip carries the meaning. "signed" is easy to
* read as "safe", so say what was actually proven that the author holds the
* key their DID names and nothing more.
*/
const signatureTooltip = computed(() => {
const sig = props.app.signature
const did = props.app.authorDid
const shortDid = did && did.length > 24 ? `${did.slice(0, 16)}${did.slice(-6)}` : did
switch (sig?.status) {
case 'valid':
return `Authorship verified: signed by the key ${shortDid ?? 'in author.did'}. `
+ 'This proves who published it, not that the app is safe.'
case 'invalid':
return `Signature did not verify: ${sig.reason}`
default:
return 'No author signature — the publisher\'s identity is unproven. '
+ 'The app may still be fine; nothing has been demonstrated about who wrote it.'
}
})
const installProgressMessage = computed(() => {
const p = props.installProgress
if (!p) return 'Installing'
@@ -29,8 +29,20 @@ export interface MarketplaceApp {
trustScore?: number
trustTier?: string
relayCount?: number
/**
* DID-signature verdict for relay-discovered apps. `undefined` for curated
* and local apps, which don't travel through the marketplace protocol at all.
*/
signature?: AppSignature
/** The `author.did` the signature was checked against, for the tooltip. */
authorDid?: string
}
export type AppSignature =
| { status: 'valid' }
| { status: 'missing' }
| { status: 'invalid'; reason: string }
export type AppScreenshot = string | {
src: string
alt?: string
@@ -46,7 +58,7 @@ export interface InstallProgress {
}
/** Archipelago app registry — all app images are mirrored here */
const REGISTRY = '146.59.87.168:3000/lfg2025'
const REGISTRY = 'source.archipelago-foundation.org/lfg2025'
/** Marketplace app ID -> backend package keys (for "Already Installed" when first-boot/deploy created them) */
export const INSTALLED_ALIASES: Record<string, string[]> = {
@@ -155,11 +167,11 @@ export function getCuratedAppList(): MarketplaceApp[] {
{
id: 'btcpay-server',
title: 'BTCPay Server',
version: '2.3.9',
version: '2.4.2',
description: 'Self-hosted Bitcoin payment processor. Accept Bitcoin payments without intermediaries or fees.',
icon: '/assets/img/app-icons/btcpay-server.png',
author: 'BTCPay Server Foundation',
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.3.9',
dockerImage: 'docker.io/btcpayserver/btcpayserver:2.4.2',
manifestUrl: undefined,
repoUrl: 'https://github.com/btcpayserver/btcpayserver'
},
@@ -390,7 +402,7 @@ export function getCuratedAppList(): MarketplaceApp[] {
description: 'Bitcoin documentary streaming platform with Nostr identity sign-in. Stream God Bless Bitcoin and other educational content about sovereignty and decentralized technology.',
icon: '/assets/img/app-icons/indeedhub.png',
author: 'Indeehub Team',
dockerImage: '146.59.87.168:3000/lfg2025/indeedhub:latest',
dockerImage: 'source.archipelago-foundation.org/lfg2025/indeedhub:latest',
manifestUrl: undefined,
repoUrl: 'https://github.com/indeedhub/indeedhub'
},
@@ -362,6 +362,24 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.126-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.7.126-alpha</span>
<span class="text-xs text-white/40">August 7, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>The most important fix in this release: the update button could take you backwards onto a version withdrawn for a security hole.</strong> BTCPay Server published 2.4.2 to close a flaw that was being actively exploited a way past two-factor authentication. Nodes that had already moved to 2.4.2 were then shown an "Update" button offering 2.3.9, the very release being withdrawn, and taking it would have rolled the node back onto the vulnerable version. The cause was that the node only asked whether the two version numbers differed, never which was newer, so any stale record anywhere could present a rollback as an upgrade. It now refuses to offer a lower version as an update, so a stale record fails safe instead of becoming a trap. BTCPay itself is on 2.4.2, and every place that still named the old version including the fallback installer, which would have installed it outright has been corrected.</p>
<p><strong>An app now reports its own version, not a helper's.</strong> Where an app is made of several parts, the node could read the version of the wrong part: BTCPay showed as "15.17", which is the version of its database, while offering an update to 2.4.2. That is the number update decisions are made from, so a nonsensical pair was being presented as a legitimate upgrade. When the node cannot identify an app's own container it now says so rather than guessing at a neighbour.</p>
<p><strong>Your node issues its own certificate, so apps stop being flagged as insecure.</strong> Each node now has its own certificate authority, with a one-step install from Settings, and app screens are served over the same secure connection as the dashboard rather than dropping back to an unprotected one. Apps answer on both the secure and plain address on the same port, so nothing that worked before stops working.</p>
<p><strong>An app that is still starting says "starting".</strong> It previously reported "App not reachable", which reads as a failure when the app is simply warming up.</p>
<p><strong>Updates and app downloads now come from a proper domain name.</strong> They previously used a bare numeric address over an unprotected connection. Downloads are now encrypted in transit, and the old address is kept as an automatic fallback for nodes whose clock or name lookup is off the signature, not the address, is what makes either source safe.</p>
<p>Also in this release: the tool app developers run to check their app description no longer rejects every valid file, and the node's own security audit which had been reporting all-clear now actually inspects the files where credentials had been sitting.</p>
<p>Housekeeping, disclosed rather than buried: this release removes Archipelago's own infrastructure details from the published source ahead of the code being opened to the public. No behaviour changes for your node.</p>
<p>Known gaps, unchanged from the last release: three voice-assistant ports remain open without authentication. Non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.125-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -0,0 +1,371 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import { rpcClient, type LndMacaroonStatus, type LndRotationProgress } from '@/api/rpc-client'
// A Lightning macaroon is a bearer token: whoever holds one can spend from this
// node's wallet. Rotating them is the only way to take that ability back from
// anything that has seen one a lost phone, a shared screenshot, an app that
// ran a version with a published vulnerability. Until now that meant SSHing in
// and running a script, which in practice meant it did not happen.
//
// The screen is deliberately fact-first. Before anyone clicks the button they
// can see when the credentials were issued, which node they belong to, how many
// channels must survive, and whether anything on this node is already out of
// step because "will this close my channels?" is the question that stops
// people rotating, and the honest answer is on the page.
const status = ref<LndMacaroonStatus | null>(null)
const loading = ref(true)
const loadError = ref('')
const showConfirm = ref(false)
const password = ref('')
const submitting = ref(false)
const confirmError = ref('')
let poll: ReturnType<typeof setInterval> | null = null
/// Epoch ms until which we keep polling even though the node has not reported a
/// running rotation yet.
///
/// Without this the screen can freeze on the one action that most needs to show
/// progress: `rotate()` starts polling, the `load()` right behind it observes a
/// status snapshot that does not yet carry `running: true`, and `syncPolling`
/// cancels the interval. The operator has just invalidated every credential
/// their wallet holds and the page tells them nothing is happening.
///
/// Bounded rather than a plain flag, so a request the node accepted but never
/// acted on stops polling instead of hammering it forever.
let awaitUntil = 0
const AWAIT_START_MS = 120_000
const rotation = computed<LndRotationProgress | null>(() => status.value?.rotation ?? null)
const isRunning = computed(() => rotation.value?.running === true)
/** A finished rotation, successful or not. `ok` is null while running. */
const finished = computed(
() => rotation.value !== null && !rotation.value.running && rotation.value.ok !== null,
)
/** BTCPay embeds a copy of the macaroon inline and cannot self-heal, so it is
* the one dependency that can silently fall out of step. `false` is the state
* worth shouting about; `null` just means BTCPay has no internal node. */
const btcpayStale = computed(() => status.value?.btcpay_credential_current === false)
async function load() {
try {
status.value = await rpcClient.lndMacaroonStatus()
loadError.value = ''
syncPolling()
} catch (e) {
loadError.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
/// Poll only while there is something to watch, so an idle Settings tab isn't
/// waking the node every few seconds.
function syncPolling() {
const running = status.value?.rotation.running === true
if (running) awaitUntil = 0
if (running || Date.now() < awaitUntil) startPolling()
else stopPolling()
}
function startPolling() {
if (poll) return
poll = setInterval(load, 4000)
}
function stopPolling() {
if (poll) {
clearInterval(poll)
poll = null
}
}
function openConfirm() {
password.value = ''
confirmError.value = ''
showConfirm.value = true
}
function closeConfirm() {
showConfirm.value = false
password.value = ''
confirmError.value = ''
}
async function rotate() {
submitting.value = true
confirmError.value = ''
try {
await rpcClient.lndRotateMacaroons(password.value)
closeConfirm()
awaitUntil = Date.now() + AWAIT_START_MS
startPolling()
await load()
} catch (e) {
confirmError.value = e instanceof Error ? e.message : String(e)
} finally {
submitting.value = false
password.value = ''
}
}
function stepIcon(state: string): string {
switch (state) {
case 'done':
return '✓'
case 'failed':
return '✕'
case 'skipped':
return ''
case 'running':
return '…'
default:
return '·'
}
}
function stepClass(state: string): string {
switch (state) {
case 'done':
return 'text-emerald-400'
case 'failed':
return 'text-red-400'
case 'skipped':
return 'text-white/40'
case 'running':
return 'text-orange-300'
default:
return 'text-white/30'
}
}
/** First 16 characters is plenty to compare two digests by eye, and keeps the
* line readable on a phone. */
function shortHash(h: string | null): string {
return h ? `${h.slice(0, 16)}` : '—'
}
onMounted(load)
onUnmounted(stopPolling)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Lightning credentials</h3>
<p class="text-sm text-white/60 mb-4">
Wallet apps like Zeus connect to this node using a Lightning credential a
token that lets them spend. Rotating replaces every one of them, so anything
that copied an old token can no longer use it. Your coins and channels are
not touched: the node keeps its identity and no channel is closed.
</p>
<div v-if="loading" class="text-sm text-white/50">Checking</div>
<div
v-else-if="loadError"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
Could not read the Lightning credential state: {{ loadError }}
</div>
<div
v-else-if="!status?.installed"
class="p-3 bg-white/5 border border-white/10 rounded-lg text-sm text-white/70"
>
Lightning is not set up on this node yet, so there are no credentials to
rotate. Install the Lightning app first.
</div>
<div v-else class="space-y-4">
<!-- What exists right now -->
<dl class="grid grid-cols-1 sm:grid-cols-2 gap-3 text-sm">
<div>
<dt class="text-white/50 text-xs">Issued</dt>
<dd class="text-white/80">{{ status.issued_at || 'unknown' }}</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Credential fingerprint</dt>
<dd class="text-white/80 font-mono text-xs break-all">
{{ shortHash(status.admin_macaroon_sha256) }}
</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Channels that must survive</dt>
<dd class="text-white/80">
<template v-if="status.channels_open !== null">
{{ status.channels_open }} open<span v-if="status.channels_pending">
, {{ status.channels_pending }} pending</span
>
</template>
<span v-else class="text-white/50">not readable Lightning is not answering</span>
</dd>
</div>
<div>
<dt class="text-white/50 text-xs">Node identity</dt>
<dd class="text-white/80 font-mono text-xs break-all">
{{ status.identity_pubkey ? `${status.identity_pubkey.slice(0, 16)}` : '—' }}
</dd>
</div>
</dl>
<!-- Lightning has to be answering for a rotation to be verifiable at all,
so this is a blocker rather than a footnote. -->
<div
v-if="status.lnd_error && !isRunning"
class="p-3 bg-orange-500/10 border border-orange-500/30 rounded-lg text-sm text-orange-100/90"
>
<p class="font-medium mb-1">Lightning is not answering right now.</p>
<p class="text-orange-100/70">
Rotation is blocked until it is: without a reading from before the
change there is no way to prove afterwards that your channels came
back. Wait for Lightning to finish starting and reload this page.
</p>
<p class="text-xs text-orange-100/50 mt-2 font-mono break-all">{{ status.lnd_error }}</p>
</div>
<!-- The failure this whole feature exists to prevent. -->
<div
v-if="btcpayStale"
class="p-3 bg-red-500/10 border border-red-500/30 rounded-lg text-sm text-red-100/90"
>
<p class="font-medium mb-1">BTCPay Server is holding an old Lightning credential.</p>
<p class="text-red-100/70">
BTCPay keeps its own copy of the credential, and the copy it has no
longer works so its Lightning payments will fail even though both
apps look healthy. Rotating now repairs this as part of the run.
</p>
</div>
<!-- Progress. Shown while running and kept afterwards, because the
verdict ("same node, same channels") is the reassurance the operator
came here for. -->
<div v-if="rotation && (isRunning || finished)" class="p-3 bg-white/5 border border-white/10 rounded-lg">
<p class="text-sm font-medium text-white/80 mb-2">
<span v-if="isRunning">Rotating</span>
<span v-else-if="rotation.ok" class="text-emerald-400">Rotation complete</span>
<span v-else class="text-red-400">Rotation failed</span>
</p>
<ul class="space-y-1.5">
<li v-for="step in rotation.steps" :key="step.key" class="text-sm">
<span class="font-mono mr-2" :class="stepClass(step.state)">{{
stepIcon(step.state)
}}</span>
<span :class="step.state === 'pending' ? 'text-white/40' : 'text-white/80'">{{
step.label
}}</span>
<p v-if="step.detail" class="ml-6 text-xs text-white/50">{{ step.detail }}</p>
</li>
</ul>
<p v-if="rotation.error" class="mt-3 text-xs text-red-300/90 break-words">
{{ rotation.error }}
</p>
<div v-if="finished && rotation.ok" class="mt-3 space-y-2 text-xs text-white/60">
<p class="text-white/80">
Re-pair anything that connects to this node Zeus most importantly.
Open the Lightning app and scan its pairing QR again; it serves the
new credential.
</p>
<p v-if="rotation.backup_path">
The old credentials were backed up on the node so a mistake is
recoverable. That backup is still sensitive. Once every app is
re-paired, delete it:
<code class="block mt-1 px-2 py-1 bg-black/30 rounded font-mono break-all"
>sudo rm -rf {{ rotation.backup_path }}</code
>
</p>
</div>
</div>
<button
:disabled="isRunning || !!status.lnd_error"
class="w-full flex items-center justify-center gap-2 px-4 py-2 rounded-lg glass-button glass-button-warning font-medium disabled:opacity-50 disabled:cursor-not-allowed"
@click="openConfirm"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M4 4v5h.582m15.356 2A8.001 8.001 0 004.582 9m0 0H9m11 11v-5h-.581m0 0a8.003 8.003 0 01-15.357-2m15.357 2H15"
/>
</svg>
<span>{{ isRunning ? 'Rotating…' : 'Rotate Lightning credentials' }}</span>
</button>
</div>
</div>
<!-- Confirmation. Teleported to body: a glass-panel ancestor creates a
transform context that would trap a position:fixed backdrop. -->
<Teleport to="body">
<div
v-if="showConfirm"
class="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/60 backdrop-blur-md"
@click.self="closeConfirm"
@keydown.escape="closeConfirm"
>
<div
class="glass-card p-6 max-w-md w-full"
role="dialog"
aria-modal="true"
aria-labelledby="rotate-macaroon-title"
>
<h3 id="rotate-macaroon-title" class="text-lg font-semibold text-white mb-2">
Rotate Lightning credentials
</h3>
<div class="text-sm text-white/70 space-y-2 mb-4">
<p>
<strong class="text-white/90">What changes:</strong> every app paired
with this node stops working until you re-pair it. Zeus and any other
remote wallet will need to scan a fresh pairing code.
</p>
<p>
<strong class="text-white/90">What does not:</strong> your coins and
your channels. The node keeps its identity, nothing is closed, and
this run refuses to report success unless it has confirmed both.
</p>
<p>
Lightning restarts as part of this, which takes a few minutes on a
busy node. Payments cannot be sent or received during that window.
</p>
</div>
<form class="space-y-4" @submit.prevent="rotate">
<label class="block">
<span class="text-xs text-white/60">Confirm with your node password</span>
<input
v-model="password"
type="password"
required
autocomplete="current-password"
class="mt-1 w-full px-3 py-2 rounded-lg bg-white/10 text-white border border-white/20 focus:border-orange-500 focus:ring-1 focus:ring-orange-500"
placeholder="Node password"
/>
</label>
<p v-if="confirmError" class="text-sm text-red-400 break-words">{{ confirmError }}</p>
<div class="flex gap-3">
<button
type="button"
class="flex-1 px-4 py-2 rounded-lg glass-button font-medium"
@click="closeConfirm"
>
Cancel
</button>
<button
type="submit"
:disabled="submitting || !password"
class="flex-1 px-4 py-2 rounded-lg bg-orange-500 text-white font-medium hover:bg-orange-600 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
{{ submitting ? 'Starting…' : 'Rotate' }}
</button>
</div>
</form>
</div>
</div>
</Teleport>
</template>
@@ -6,6 +6,7 @@ import AIDataAccessSection from '@/views/settings/AIDataAccessSection.vue'
import WebhookSection from '@/views/settings/WebhookSection.vue'
import TelemetrySection from '@/views/settings/TelemetrySection.vue'
import NodeCertificateSection from '@/views/settings/NodeCertificateSection.vue'
import LightningCredentialsSection from '@/views/settings/LightningCredentialsSection.vue'
import BackupSection from '@/views/settings/BackupSection.vue'
import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
</script>
@@ -18,6 +19,7 @@ import SystemDangerZone from '@/views/settings/SystemDangerZone.vue'
<WebhookSection />
<TelemetrySection />
<NodeCertificateSection />
<LightningCredentialsSection />
<BackupSection />
<SystemDangerZone />
</template>
@@ -0,0 +1,292 @@
import { flushPromises, mount } from '@vue/test-utils'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import LightningCredentialsSection from '../LightningCredentialsSection.vue'
import { rpcClient } from '@/api/rpc-client'
vi.mock('@/api/rpc-client', () => ({
rpcClient: {
lndMacaroonStatus: vi.fn(),
lndRotateMacaroons: vi.fn(),
lndMacaroonRotationProgress: vi.fn(),
},
}))
const STEP_KEYS = ['preflight', 'backup', 'stop', 'remove', 'start', 'verify', 'btcpay'] as const
type StepState = 'pending' | 'running' | 'done' | 'failed' | 'skipped'
function steps(overrides: Partial<Record<string, { state: StepState; detail?: string }>> = {}) {
return STEP_KEYS.map((key) => ({
key,
label: `label-${key}`,
state: overrides[key]?.state ?? ('pending' as StepState),
detail: overrides[key]?.detail ?? null,
}))
}
function idleRotation() {
return {
running: false,
ok: null,
started_at: null,
finished_at: null,
error: null,
steps: steps(),
backup_path: null,
identity_pubkey: null,
channels_before: null,
channels_after: null,
new_admin_macaroon_sha256: null,
}
}
function status(overrides: Record<string, unknown> = {}) {
return {
installed: true,
admin_macaroon_sha256: 'a'.repeat(64),
issued_at: '2026-08-08 06:03:11',
identity_pubkey: '024a5fd7de13623aeec81095cf8776fedbc0c4109363022c3ec948196202130b92',
channels_open: 3,
channels_pending: 1,
lnd_error: null,
btcpay_uses_internal_lnd: true,
btcpay_credential_current: true,
rotation: idleRotation(),
...overrides,
}
}
function mountSection() {
return mount(LightningCredentialsSection, {
global: { stubs: { Teleport: true } },
})
}
describe('LightningCredentialsSection', () => {
beforeEach(() => {
vi.useFakeTimers()
})
afterEach(() => {
vi.useRealTimers()
vi.clearAllMocks()
})
it('shows what must survive before offering to rotate', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
const wrapper = mountSection()
await flushPromises()
// The channel census is the reassurance an operator needs before clicking a
// button that invalidates every credential their wallet holds.
expect(wrapper.text()).toContain('3 open')
expect(wrapper.text()).toContain('1 pending')
expect(wrapper.text()).toContain('2026-08-08 06:03:11')
// A digest is fine to display; the token itself must never be fetched.
expect(wrapper.text()).toContain('aaaaaaaaaaaaaaaa…')
expect(wrapper.find('button').attributes('disabled')).toBeUndefined()
})
it('warns when BTCPay is stranded on a rotated-out credential', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_credential_current: false }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('BTCPay Server is holding an old Lightning credential')
})
it('stays quiet about BTCPay when there is no internal node to warn about', async () => {
// null means "not configured" — an absence, not a fault. Reporting it as a
// problem would train operators to ignore the warning that matters.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ btcpay_uses_internal_lnd: false, btcpay_credential_current: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).not.toContain('holding an old Lightning credential')
})
it('blocks rotation while LND is not answering', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
lnd_error: 'LND is not answering on its REST port',
channels_open: null,
channels_pending: null,
identity_pubkey: null,
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not answering right now')
// Without a before-reading there is no way to prove the channels came back,
// so the button must be unavailable rather than merely discouraged.
expect(wrapper.find('button').attributes('disabled')).toBeDefined()
})
it('says Lightning is not installed instead of offering a no-op rotation', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({ installed: false, admin_macaroon_sha256: null }),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Lightning is not set up on this node yet')
expect(wrapper.findAll('button')).toHaveLength(0)
})
async function startRotation(wrapper: ReturnType<typeof mountSection>, pw = 'node-password') {
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue(pw)
await wrapper.find('form').trigger('submit')
await flushPromises()
}
it('sends the password and starts polling for progress', async () => {
vi.mocked(rpcClient.lndMacaroonStatus)
.mockResolvedValueOnce(status())
.mockResolvedValue(status({ rotation: { ...idleRotation(), running: true } }))
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
expect(rpcClient.lndRotateMacaroons).toHaveBeenCalledWith('node-password')
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('keeps polling when the first status after starting has not caught up yet', async () => {
// The node accepts the rotation and then answers a status request that was
// computed a moment earlier, still saying `running: false`. Cancelling the
// poll here would freeze the screen on the one action that most needs to show
// progress — the operator has just invalidated every credential their wallet
// holds and would be told nothing is happening.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
const callsBefore = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(4000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBeGreaterThan(callsBefore)
})
it('gives up polling if the node never reports the rotation as running', async () => {
// Bounded, so a request that was accepted but never acted on stops polling
// instead of hammering the node forever.
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockResolvedValue({ status: 'started' })
const wrapper = mountSection()
await flushPromises()
await startRotation(wrapper)
vi.advanceTimersByTime(180_000)
await flushPromises()
const callsAfterGiveUp = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterGiveUp)
})
it('surfaces a rejected password without starting anything', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
vi.mocked(rpcClient.lndRotateMacaroons).mockRejectedValue(
new Error('Password verification failed'),
)
const wrapper = mountSection()
await flushPromises()
await wrapper.find('button').trigger('click')
await wrapper.find('input[type="password"]').setValue('wrong')
await wrapper.find('form').trigger('submit')
await flushPromises()
expect(wrapper.text()).toContain('Password verification failed')
// The dialog stays open so the operator can correct the password.
expect(wrapper.find('input[type="password"]').exists()).toBe(true)
})
it('reports a finished rotation with the re-pair and backup-cleanup steps', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: true,
finished_at: '2026-08-08T12:00:00Z',
steps: steps({
preflight: { state: 'done' },
backup: { state: 'done' },
stop: { state: 'done' },
remove: { state: 'done' },
start: { state: 'done' },
verify: { state: 'done', detail: 'same node, same 3 channel(s)' },
btcpay: { state: 'done' },
}),
backup_path: '/var/lib/archipelago/lnd/macaroon-rotation-20260808T120000Z',
channels_before: 3,
channels_after: 3,
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation complete')
expect(wrapper.text()).toContain('same node, same 3 channel(s)')
expect(wrapper.text()).toContain('Re-pair anything that connects to this node')
// The backup holds the OLD root key, so telling the operator to delete it is
// part of the job, not a nicety.
expect(wrapper.text()).toContain('macaroon-rotation-20260808T120000Z')
})
it('reports a failed rotation as failed rather than silently idle', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(
status({
rotation: {
...idleRotation(),
ok: false,
finished_at: '2026-08-08T12:00:00Z',
error: 'backup incomplete — refusing to delete anything',
steps: steps({ preflight: { state: 'done' }, backup: { state: 'failed' } }),
},
}),
)
const wrapper = mountSection()
await flushPromises()
expect(wrapper.text()).toContain('Rotation failed')
expect(wrapper.text()).toContain('backup incomplete')
})
it('does not poll the node when nothing is running', async () => {
vi.mocked(rpcClient.lndMacaroonStatus).mockResolvedValue(status())
mountSection()
await flushPromises()
const callsAfterLoad = vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length
vi.advanceTimersByTime(30_000)
await flushPromises()
expect(vi.mocked(rpcClient.lndMacaroonStatus).mock.calls.length).toBe(callsAfterLoad)
})
})
@@ -6,7 +6,6 @@ import Web5ConnectedNodes from '../Web5ConnectedNodes.vue'
// its row sibling (via grid `align-items: stretch` + a zero-basis flex
// child), and the tab panes must scroll inside that height rather than
// growing to fit every row. See:
// .planning/todos/pending/2026-07-30-connected-nodes-list-must-scroll-at-row-matched-height.md
vi.mock('vue-router', () => ({
useRouter: () => ({ push: vi.fn() }),
+12 -4
View File
@@ -1,14 +1,22 @@
import { chromium } from './node_modules/playwright/index.mjs';
const BASE = 'https://100.66.157.121';
const PASS = 'ThisIsWeb54321@';
const DIR = '/tmp/claude-1000/-home-debian/97c10035-69a8-40a0-9b55-219eb8ad683a/scratchpad';
// Node under test + credentials come from the environment; never commit literals.
// ARCHY_NODE_URL=https://<node> ARCHY_NODE_PW=… node test-openwrt.mjs
const BASE = process.env.ARCHY_NODE_URL || 'https://127.0.0.1';
const PASS = process.env.ARCHY_NODE_PW;
const DIR = process.env.ARCHY_OUT_DIR || '/tmp/openwrt-test';
if (!PASS) {
console.error('ERROR: ARCHY_NODE_PW must be set in the environment.');
process.exit(2);
}
// Find the OpenWrt router IP from the Tailscale/LAN
const { execSync } = await import('child_process');
let routerIp = '192.168.1.1';
try {
const route = execSync("ssh archipelago@100.66.157.121 'ip route | grep default'", { encoding: 'utf8' }).trim();
const sshTarget = process.env.ARCHY_NODE_SSH || `archipelago@${new URL(BASE).hostname}`;
const route = execSync(`ssh ${sshTarget} 'ip route | grep default'`, { encoding: 'utf8' }).trim();
const match = route.match(/default via ([\d.]+)/);
if (match) routerIp = match[1];
} catch {}
+1 -1
View File
@@ -117,7 +117,7 @@ export default defineConfig({
}
},
server: {
host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.168.1.116:8100)
host: true, // listen on 0.0.0.0 so the dev UI is reachable over the LAN (e.g. http://192.0.2.12:8100)
port: 8100,
proxy: {
'/rpc/v1': {