merge: bring main (v1.7.125 + .126 work) into phase-13 branch pre-deploy

63 main commits since the fork point — gate cookie-strip fix, named-volume
create fix, appgate catalog classification, RNode error surfacing — merged
so 13-14/13-15 on-device verification runs against current production code.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 09:33:40 -04:00
co-authored by Claude Fable 5
117 changed files with 5120 additions and 632 deletions
+1 -6
View File
@@ -311,31 +311,26 @@ const showChatPanel = computed(() =>
activeTab.value === 'chat' || isWideDesktop.value || (isMobile.value && mobileShowChat.value)
)
const showBitcoinPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'bitcoin'
if (isMobile.value) return mobileTab.value === 'bitcoin'
return activeTab.value === 'bitcoin'
})
const showDeadmanPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'deadman'
if (isMobile.value) return mobileTab.value === 'deadman'
return activeTab.value === 'deadman'
})
const showAssistantPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'assistant'
if (isMobile.value) return mobileTab.value === 'assistant'
return activeTab.value === 'assistant'
})
const showMapPanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'map'
if (isMobile.value) return mobileTab.value === 'map'
return activeTab.value === 'map'
})
const showDevicePanel = computed(() => {
if (isVeryWideDesktop.value) return true
if (isWideDesktop.value) return toolsTab.value === 'device'
if (isMobile.value) return mobileTab.value === 'device'
return activeTab.value === 'device'
@@ -2683,7 +2678,7 @@ async function downloadAttachment(payload: MeshAttachmentPayload) {
<!-- Tools panels (3rd column on wide screens) -->
<div class="mesh-tools-wrapper" data-controller-zone="mesh-tools">
<div v-if="isWideDesktop && !isVeryWideDesktop" class="mesh-tools-tab-bar">
<div v-if="isWideDesktop" class="mesh-tools-tab-bar">
<button class="mesh-tab" :class="{ active: toolsTab === 'bitcoin' }" @click="toolsTab = 'bitcoin'">Bitcoin</button>
<button class="mesh-tab" :class="{ active: toolsTab === 'deadman' }" @click="toolsTab = 'deadman'">
Dead Man
@@ -4,12 +4,15 @@ export const GENERATED_APP_PORTS: Record<string, number> = {
"aiui": 5180,
"archy-mempool-web": 4080,
"archy-nbxplorer": 32838,
"bitcoin-ui": 8334,
"botfights": 9100,
"btcpay-server": 23000,
"did-wallet": 8088,
"electrs-ui": 50002,
"electrumx": 50002,
"fedimint": 8175,
"filebrowser": 8083,
"fips-ui": 8336,
"gitea": 3001,
"grafana": 3000,
"homeassistant": 8123,
+225 -2
View File
@@ -7,12 +7,17 @@ const mesh = useMeshStore()
const rebooting = ref(false)
const rebootError = ref<string | null>(null)
const rebootMessage = ref<string | null>(null)
async function handleReboot() {
rebooting.value = true
rebootError.value = null
rebootMessage.value = null
try {
await mesh.rebootRadio()
const res = await mesh.rebootRadio()
// The backend now waits for the device's acknowledgement and says what
// actually happened — show it instead of silently going idle again.
rebootMessage.value = res.message || 'Reboot command acknowledged by the radio.'
} catch (e) {
rebootError.value = e instanceof Error ? e.message : 'Failed to reboot radio'
} finally {
@@ -20,6 +25,121 @@ async function handleReboot() {
}
}
// ── RNode (Reticulum) RF settings — full round-trip with device read-back ──
// Recommended plans per region for Reticulum RNode radios. EU868 is the
// operator-validated Portugal plan (869.4625 MHz keeps clear of the default
// community channel while staying in the 10%-duty 869.4869.65 sub-band;
// airtime locks match EU duty-cycle law). Others use the RNS community
// conventions for the band with the region's legal power cap.
const RNODE_REGION_PLANS: Record<string, { frequency: number; bandwidth: number; spreading_factor: number; coding_rate: number; txpower: number; airtime_limit_short: number | null; airtime_limit_long: number | null }> = {
EU868: { frequency: 869462500, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 14, airtime_limit_short: 25, airtime_limit_long: 10 },
US915: { frequency: 914875000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AU915: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
ANZ: { frequency: 916800000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
AS923: { frequency: 923200000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 13, airtime_limit_short: null, airtime_limit_long: null },
IN865: { frequency: 866000000, bandwidth: 125000, spreading_factor: 8, coding_rate: 5, txpower: 17, airtime_limit_short: null, airtime_limit_long: null },
}
const rnodeForm = ref({
enabled: true,
port: '',
frequency: '',
bandwidth: '125000',
spreading_factor: '8',
coding_rate: '5',
txpower: '17',
airtime_limit_short: '',
airtime_limit_long: '',
})
const rnodeLive = ref<Record<string, unknown> | null>(null)
const rnodeLiveError = ref<string | null>(null)
const rnodeLoading = ref(false)
const rnodeApplying = ref(false)
const rnodeResult = ref<{ ok: boolean; confirmed: boolean; message: string } | null>(null)
let rnodeSeeded = false
const rnodeRegionPlan = computed(() => (form.value.region ? RNODE_REGION_PLANS[form.value.region] : undefined))
function setRnodeRecommendedForRegion() {
const plan = rnodeRegionPlan.value
if (!plan) return
rnodeForm.value.frequency = String(plan.frequency)
rnodeForm.value.bandwidth = String(plan.bandwidth)
rnodeForm.value.spreading_factor = String(plan.spreading_factor)
rnodeForm.value.coding_rate = String(plan.coding_rate)
rnodeForm.value.txpower = String(plan.txpower)
rnodeForm.value.airtime_limit_short = plan.airtime_limit_short != null ? String(plan.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = plan.airtime_limit_long != null ? String(plan.airtime_limit_long) : ''
}
async function loadRnodeConfig() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
const s = res.settings as Record<string, unknown>
if (!rnodeSeeded && s) {
rnodeSeeded = true
rnodeForm.value.enabled = s.enabled !== false
rnodeForm.value.port = (s.port as string) ?? ''
rnodeForm.value.frequency = String(s.frequency ?? '')
rnodeForm.value.bandwidth = String(s.bandwidth ?? '125000')
rnodeForm.value.spreading_factor = String(s.spreading_factor ?? '8')
rnodeForm.value.coding_rate = String(s.coding_rate ?? '5')
rnodeForm.value.txpower = String(s.txpower ?? '17')
rnodeForm.value.airtime_limit_short = s.airtime_limit_short != null ? String(s.airtime_limit_short) : ''
rnodeForm.value.airtime_limit_long = s.airtime_limit_long != null ? String(s.airtime_limit_long) : ''
}
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not load RNode settings'
} finally {
rnodeLoading.value = false
}
}
async function applyRnodeSettings() {
rnodeApplying.value = true
rnodeResult.value = null
try {
const res = await mesh.applyRnodeConfig({
enabled: rnodeForm.value.enabled,
port: rnodeForm.value.port.trim() || null,
frequency: Number(rnodeForm.value.frequency),
bandwidth: Number(rnodeForm.value.bandwidth),
spreading_factor: Number(rnodeForm.value.spreading_factor),
coding_rate: Number(rnodeForm.value.coding_rate),
txpower: Number(rnodeForm.value.txpower),
airtime_limit_short: rnodeForm.value.airtime_limit_short === '' ? null : Number(rnodeForm.value.airtime_limit_short),
airtime_limit_long: rnodeForm.value.airtime_limit_long === '' ? null : Number(rnodeForm.value.airtime_limit_long),
})
rnodeResult.value = { ok: res.applied, confirmed: !!res.confirmed, message: res.message }
if (res.live) rnodeLive.value = res.live
} catch (e) {
rnodeResult.value = { ok: false, confirmed: false, message: e instanceof Error ? e.message : 'Apply failed' }
} finally {
rnodeApplying.value = false
}
}
async function refreshRnodeLive() {
rnodeLoading.value = true
try {
const res = await mesh.getRnodeConfig()
rnodeLive.value = res.live
rnodeLiveError.value = res.live_error
} catch (e) {
rnodeLiveError.value = e instanceof Error ? e.message : 'Could not read the radio state'
} finally {
rnodeLoading.value = false
}
}
function fmtMhz(v: unknown): string {
const n = Number(v)
return Number.isFinite(n) && n > 0 ? `${(n / 1e6).toFixed(4)} MHz` : '—'
}
// ── Editable settings (persisted via mesh.configure) ──
const form = ref({
region: '',
@@ -157,6 +277,18 @@ async function saveSettings() {
saving.value = false
}
}
// Load the RNode settings + live state as soon as the panel knows a
// Reticulum radio is (or is pinned as) the device. Declared LAST: with
// `immediate: true` the source getter runs at setup, and `effectiveKind`
// must already exist (the SendBitcoinModal TDZ-crash lesson).
watch(
() => effectiveKind.value,
(kind) => {
if (kind === 'reticulum') void loadRnodeConfig()
},
{ immediate: true },
)
</script>
<template>
@@ -202,7 +334,7 @@ async function saveSettings() {
Program the radio's RF settings with the fields below every radio on your mesh must match{{ selectedRegion ? ` (${selectedRegion.band} MHz band)` : '' }}.
</p>
<p v-else-if="effectiveKind === 'reticulum'" class="text-[11px] text-sky-300/80 mt-1">
RNode RF parameters are managed by the Reticulum daemon's interface config on this node.
Pick your region, then use "Set recommended for region" in the RNode section below.
</p>
</div>
<div>
@@ -271,6 +403,96 @@ async function saveSettings() {
Saved settings program the radio on its next connect (it reboots once to apply). Leave all four empty to keep the radio's own settings.
</p>
</div>
<!-- RNode (Reticulum) RF settings: the device's CURRENT values shown
first (radio-confirmed read-back), then every parameter editable,
with apply → device confirmation. Actions stack in a column. -->
<div v-if="effectiveKind === 'reticulum'" class="mt-4">
<div class="flex items-center justify-between mb-2">
<h5 class="text-xs font-semibold text-white/80">RNode radio — current device settings</h5>
<button class="text-[11px] text-sky-300/80 hover:text-sky-200 disabled:opacity-50" :disabled="rnodeLoading" @click="refreshRnodeLive">
{{ rnodeLoading ? 'Reading' : 'Refresh' }}
</button>
</div>
<div v-if="rnodeLive" class="rounded-lg bg-white/[0.04] border border-white/10 p-3 mb-3 grid grid-cols-2 sm:grid-cols-4 gap-2 text-xs">
<div><span class="text-white/40 block">Status</span><span :class="rnodeLive.online ? 'text-green-400' : 'text-amber-400'">{{ rnodeLive.online ? 'Online' : 'Detected, not online' }}</span></div>
<div><span class="text-white/40 block">Port</span><span class="text-white/80">{{ rnodeLive.port || '' }}</span></div>
<div><span class="text-white/40 block">Frequency</span><span class="text-white/80">{{ fmtMhz(rnodeLive.r_frequency ?? rnodeLive.frequency) }}</span></div>
<div><span class="text-white/40 block">Bandwidth</span><span class="text-white/80">{{ rnodeLive.r_bandwidth ?? rnodeLive.bandwidth ?? '' }} Hz</span></div>
<div><span class="text-white/40 block">Spreading</span><span class="text-white/80">SF {{ rnodeLive.r_spreadingfactor ?? rnodeLive.spreadingfactor ?? '' }}</span></div>
<div><span class="text-white/40 block">Coding rate</span><span class="text-white/80">4/{{ rnodeLive.r_codingrate ?? rnodeLive.codingrate ?? '' }}</span></div>
<div><span class="text-white/40 block">TX power</span><span class="text-white/80">{{ rnodeLive.r_txpower ?? rnodeLive.txpower ?? '' }} dBm</span></div>
<div><span class="text-white/40 block">Airtime limits</span><span class="text-white/80">{{ rnodeLive.r_airtime_limit_short ?? rnodeLive.airtime_limit_short ?? '' }}% / {{ rnodeLive.r_airtime_limit_long ?? rnodeLive.airtime_limit_long ?? '' }}%</span></div>
</div>
<p v-else-if="rnodeLiveError" class="text-[11px] text-amber-400/80 mb-3">{{ rnodeLiveError }}</p>
<h5 class="text-xs font-semibold text-white/80 mb-2">RNode RF parameters</h5>
<div class="grid gap-3 grid-cols-2 sm:grid-cols-4">
<div>
<label class="block text-xs text-white/60 mb-1">Frequency (Hz)</label>
<input v-model="rnodeForm.frequency" inputmode="numeric" placeholder="869462500" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Bandwidth (Hz)</label>
<select v-model="rnodeForm.bandwidth" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="bw in ['7800','10400','15600','20800','31250','41700','62500','125000','250000','500000']" :key="bw" :value="bw">{{ bw }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Spreading factor</label>
<select v-model="rnodeForm.spreading_factor" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="sf in [5,6,7,8,9,10,11,12]" :key="sf" :value="String(sf)">SF {{ sf }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Coding rate</label>
<select v-model="rnodeForm.coding_rate" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60">
<option v-for="cr in [5,6,7,8]" :key="cr" :value="String(cr)">4/{{ cr }}</option>
</select>
</div>
<div>
<label class="block text-xs text-white/60 mb-1">TX power (dBm)</label>
<input v-model="rnodeForm.txpower" inputmode="numeric" placeholder="14" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime short (%)</label>
<input v-model="rnodeForm.airtime_limit_short" inputmode="decimal" placeholder="25" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Airtime long (%)</label>
<input v-model="rnodeForm.airtime_limit_long" inputmode="decimal" placeholder="10" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
<div>
<label class="block text-xs text-white/60 mb-1">Serial port</label>
<input v-model="rnodeForm.port" placeholder="auto-detect" class="w-full rounded-lg bg-white/[0.06] border border-white/10 text-white px-3 py-2 text-sm focus:outline-none focus:border-orange-400/60" />
</div>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="rnodeForm.enabled" type="checkbox" class="h-4 w-4 accent-orange-500" />
RNode interface enabled
</label>
<!-- Actions: stacked in a column on purpose (operator layout request) -->
<div class="flex flex-col gap-2 mt-4 max-w-sm">
<button
class="glass-button px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="!rnodeRegionPlan || rnodeApplying"
@click="setRnodeRecommendedForRegion"
>
{{ rnodeRegionPlan ? `Set recommended for ${form.region}` : 'Pick a region above first' }}
</button>
<button
class="glass-button glass-button-warning px-4 py-2 rounded-lg text-sm font-medium disabled:opacity-50"
:disabled="rnodeApplying"
@click="applyRnodeSettings"
>
{{ rnodeApplying ? 'Applying waiting for the radio to confirm' : 'Apply & Confirm on Device' }}
</button>
</div>
<p v-if="rnodeResult" class="text-xs mt-2" :class="rnodeResult.ok && rnodeResult.confirmed ? 'text-green-400' : rnodeResult.ok ? 'text-amber-400' : 'text-red-400'">
<template v-if="rnodeResult.ok && rnodeResult.confirmed">✓ </template>{{ rnodeResult.message }}
</p>
</div>
<label class="flex items-center gap-2 mt-3 text-sm text-white/80 cursor-pointer">
<input v-model="form.broadcastIdentity" type="checkbox" class="h-4 w-4 accent-orange-500" />
Periodically broadcast this node's identity on the mesh
@@ -304,6 +526,7 @@ async function saveSettings() {
<template v-else>Reboot Radio</template>
</button>
<p class="mesh-device-reboot-hint">Use this if the device stops responding to sent messages or seems stuck.</p>
<p v-if="rebootMessage" class="text-xs text-green-400 mt-1">{{ rebootMessage }}</p>
<p v-if="rebootError" class="mesh-device-reboot-error">{{ rebootError }}</p>
</div>
</div>
+20 -11
View File
@@ -43,17 +43,26 @@
.mesh-columns-wide .mesh-chat-card { grid-column: 2; grid-row: 1; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-wrapper { grid-column: 3; grid-row: 1; display: flex; flex-direction: column; gap: 0; min-height: 0; overflow: hidden; }
.mesh-columns-wide .mesh-tools-tab-bar { display: flex; gap: 2px; background: rgba(0,0,0,0.3); border-radius: 10px; padding: 3px; flex-shrink: 0; margin-bottom: 12px; }
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(420px, 0.95fr); }
.mesh-columns-very-wide .mesh-tools-wrapper { display: grid; grid-template-rows: minmax(0, 1fr) minmax(0, 0.85fr) minmax(0, 1fr); gap: 12px; overflow: hidden; }
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-very-wide .mesh-tools-wrapper .mesh-map-panel { min-height: 0; height: 100%; overflow: hidden; }
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide:not(.mesh-columns-very-wide) .mesh-tools-wrapper .mesh-map-panel { flex: 1 1 auto; min-height: 0; height: auto; }
.mesh-columns-very-wide .mesh-tools-tab-bar { display: none; }
/* A very wide screen gets a roomier third column — but the SAME tabbed
panel as every other desktop width. It used to stack Bitcoin, Dead Man,
AI, the map and Device on top of each other in fixed grid rows, which on
a real 2560px display clipped the first three headings to a few pixels,
letterboxed the map, and pushed Radio Settings into a scroll — more
screen producing a worse view (reported with a screenshot 2026-08-05).
One tab at a time, filling the column, is what makes the map edge to
edge and every control reachable without scrolling. */
.mesh-columns-very-wide { grid-template-columns: minmax(300px, 340px) minmax(460px, 1.05fr) minmax(460px, 1fr); }
.mesh-columns-wide .mesh-tools-wrapper .mesh-bitcoin-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-deadman-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-assistant-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-device-panel,
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel {
flex: 1 1 auto; min-height: 0; height: auto; overflow-y: auto;
}
/* The map is the one panel with nothing to scroll: let it consume the
column edge to edge rather than sitting in a letterbox. */
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel { overflow: hidden; padding: 0; }
.mesh-columns-wide .mesh-tools-wrapper .mesh-map-panel > * { height: 100%; width: 100%; }
.mesh-columns-wide .mesh-mobile-back-btn,
.mesh-columns-wide .mesh-tab-bar { display: none; }
.mesh-status-card { padding: 16px; flex-shrink: 0; }
@@ -362,6 +362,70 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.7.125-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.125-alpha</span>
<span class="text-xs text-white/40">August 6, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>The Lightning, Bitcoin, Electrum and mesh screens work again behind the login gate.</strong> Since the gate went up, those screens would load their frame and then show every number as unreachable. The gate was deliberately hiding your login from the apps it protects right for third-party apps, wrong for the node's own screens, which need that login to fetch your data. The gate now removes only its own credential and the node's own screens explicitly receive yours. The same mistake was also quietly signing you out of apps with their own logins Vaultwarden, Nextcloud, Gitea on every single request; that stops too.</p>
<p><strong>IndeeHub heals itself.</strong> Three separate faults: its database helper was recreated with permissions too tight to read its own files (it had crashed and restarted about ten thousand times on one node); on another node two of its seven parts could never be recreated at all because of how the node asked for their storage the node would remove the old part and then fail to build its replacement, leaving the app half-missing forever; and a regenerated password could lock the app out of a database that keeps the original. All three are fixed, and the storage fault fixes the same trap for every future multi-part app.</p>
<p><strong>Send and Receive open clean every time.</strong> Whatever you typed last an address, an amount, and above all an armed "send all funds" toggle no longer quietly carries over into the next payment. And choosing "send all funds" now shows the amount being swept instead of a confusing 0.</p>
<p><strong>The camera scanner option no longer vanishes on desktop.</strong> Browsers only allow the live camera on secure (HTTPS) pages, and the scan window used to silently hide the camera choice on plain connections, which read as "the scanner is gone". The option now stays visible and explains itself, and the photo and paste routes always work. The companion app's built-in scanner is untouched.</p>
<p>Also: the app login page uses the Archipelago mark and stays centred on phones with the keyboard open, app icons in the install window are no longer cropped, and when the node fails to build a container it now records the actual reason instead of a one-line stub that hid the cause of the IndeeHub fault for days.</p>
</div>
</div>
<!-- v1.7.124-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.124-alpha</span>
<span class="text-xs text-white/40">August 5, 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 here: some nodes were left switched off by their own update, and could not switch themselves back on.</strong> The node replaces its program and then exits, expecting the system to start it again — but nodes installed from older images carried a setting that only restarts the program if it <em>crashes</em>. A clean, deliberate exit looked like success, so nothing restarted it, and the node sat dead showing "server starting" with nothing able to start it. One of ours was down over two hours this way, and three of four checked had the same setting waiting to bite. Your node now repairs that setting itself the first time it starts.</p>
<p><strong>Portainer opens again.</strong> Its screen reported the app as not responding because the app was quietly refusing to start: nodes have been running Portainer 2.39.1 and their stored data was written by that version, while the app list pinned a version from two years earlier — so a rebuilt container landed on the old one, which will not read newer data. The correct version is pinned now, older installs upgrade cleanly, and no data was touched.</p>
<p><strong>Bitcoin starts reliably again.</strong> A leftover settings file in the Bitcoin folder — one the node kept rewriting and Bitcoin no longer reads — is treated as fatal by Bitcoin, so affected nodes restarted every few seconds forever. The node no longer writes it, clears stale copies, and treats any that remain as harmless.</p>
<p><strong>Every app screen opens from My Apps again.</strong> The login gate refused to be displayed inside another page at all — which is exactly how My Apps opens an app — so protected apps looked broken. It now allows only your own node to display it and refuses everyone else.</p>
<p><strong>The app login screen looks like the node's own now:</strong> same rotating artwork, same panel, the Archipelago mark, and the app's real icon as a tile the way My Apps shows it, instead of a plain box with a letter.</p>
<p><strong>The Mesh screen uses wide displays properly.</strong> On very large screens it stacked all five panels, clipping three headings to a sliver and squeezing the map into a letterbox. It now shows one panel at a time, filling the space, with the map edge to edge.</p>
<p><strong>You can choose how long you stay signed in.</strong> Settings → Account offers an inactivity timeout, a hard limit, and an option to re-enter your password before sending funds. TV and kiosk screens are never signed out for sitting idle — there is nobody there to sign them back in.</p>
<p>Updates now come from source.archipelago-foundation.org rather than a bare address, with the old one kept as an automatic fallback. Also: clearer wallet errors from ecash mints, and mesh peers reconnecting via their last known address before falling back to the wider network.</p>
<p>Known gaps, disclosed rather than buried: three voice-assistant ports remain open without authentication. Non-browser clients — phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web — meet the login page and need an access token. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.123-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.123-alpha</span>
<span class="text-xs text-white/40">August 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Five more screens on your node were readable by anyone who could reach it — and the last release's own check said they were fine.</strong> The Bitcoin, Lightning, Electrum, FIPS mesh and Fedimint Guardian screens each answered on their port with no login. They were missed because they run directly on the node's network rather than behind its container plumbing, so there was no address to pin and their descriptions listed no port at all — and the node builds its list of what to protect from exactly those descriptions. It neither protected them nor listed them as unprotected. Found by scanning the node from another machine instead of asking the node about itself.</p>
<p><strong>What was readable was the page, not your money.</strong> Every request on those ports that could have returned a credential — the Lightning connection details, the wallet passthrough, container logs, and every node command — already required a login and still refused without one. The Lightning macaroon fix from v1.7.120 was checked directly rather than assumed. What leaked was the screen itself: layout and code, no wallet data, no keys.</p>
<p>All five now serve only to the node itself, with the login gate in front, exactly like the twenty app screens closed in the last release.</p>
<p><strong>Every port on your node now has a stated policy — none are left undecided.</strong> Eleven had no instruction either way and stayed open by default. The BotFights arena, the router screen and the Pine voice screen now ask for your node password. The ones that genuinely cannot take a login page stay open with a written reason: Fedimint's guardian and gateway connections, NetBird's management and dashboard ports (your VPN devices carry their own credentials and cannot hold a browser session), Pine's secure listener, and the Lightning REST port that wallets reach with a macaroon exactly as before.</p>
<p>Freshly flashed nodes are covered too, not only existing ones those five screens ship as prebuilt images, so a new node would otherwise have come up open even after this fix. All five were rebuilt, published, then pulled back and inspected to confirm the fix is really inside them.</p>
<p>Known gaps, disclosed rather than buried: non-browser clients phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web meet the login page and need an access token. Three voice-assistant ports remain open without authentication; the correct fix puts them on a private network with the assistant. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.122-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.122-alpha</span>
<span class="text-xs text-white/40">August 4, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p><strong>Your apps now ask for your node password before they open over your home network, Tailscale, the mesh and Tor alike.</strong> Until now anyone who could reach your node could open Immich, Nextcloud, Vaultwarden, Jellyfin, Grafana and the rest simply by typing the address and port, with no login at all. Twenty app screens now sit behind the same login you use for the node, showing you which app you are opening, and honouring two-factor if you have it switched on. Logging in at an app address logs you into the dashboard too, so it is one password, not one per app.</p>
<p><strong>The things that must stay open stayed open.</strong> Zeus and other remote wallets still reach your Lightning node directly, Electrum wallets still connect, and Bitcoin still talks to its peers those connections carry their own proof of identity and a login page would simply break them. Every one of these exceptions now has to state in writing why it is safe to leave open, so the list is something you can read rather than something you have to discover.</p>
<p><strong>A private address on your node was answering the mesh without a password.</strong> One app's port was marked as being for this machine only, and the part of the node that carries mesh traffic did not know that — it forwarded requests from the whole mesh straight to it. Found while checking the work above on a real node, not in testing. That path now refuses anything marked machine-only, and the app is reachable only from the node itself, as intended.</p>
<p><strong>Tor addresses no longer skip the login.</strong> An app published as a .onion address was handed straight to the app, because a Tor visitor carries no session. Those addresses now arrive at the login gate first, closing the last route that went around it.</p>
<p>Nodes fix themselves after this update. Apps installed before this system used its current container setup kept their old wide-open address even after being told to move, and each would have needed hand-holding on every node. Your node now notices the difference and rebuilds those apps itself, keeping their data, within about half a minute of starting.</p>
<p>The node had been reading two different sets of instructions about its own apps — the signed list it downloads, and older copies on disk — which is how a port meant to stay private was briefly opened. Both now come from the signed list.</p>
<p><strong>The key that signs these updates has been replaced.</strong> The previous signing key was exposed where it should not have been, so it is treated as compromised and this release installs its replacement. This update is the last one signed with the old key, by necessity — it is the one that teaches your node the new one.</p>
<p>Known gaps, disclosed rather than buried: eleven app ports still have no stated policy — BotFights, the Fedimint gateway, NetBird, the voice assistant's own screens and the router screen and remain reachable without a login until each is decided deliberately; the node reports them rather than guessing, because guessing at an unstated setting is what caused two incidents this week. Three voice-assistant ports are still open without authentication; the correct fix puts them on a private network with the assistant. Non-browser apps phone apps for Vaultwarden, Home Assistant or Jellyfin, and git over the web will meet the login page and need an access token; say the word if you use one and it can be exempted. The 5x real-node lifecycle gate was not run for this release.</p>
</div>
</div>
<!-- v1.7.121-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
@@ -5,6 +5,7 @@ import { useAppStore } from '@/stores/app'
import AccountInfoSection from '@/views/settings/AccountInfoSection.vue'
import ChangePasswordSection from '@/views/settings/ChangePasswordSection.vue'
import TwoFactorSection from '@/views/settings/TwoFactorSection.vue'
import SessionTimeoutSection from '@/views/settings/SessionTimeoutSection.vue'
const router = useRouter()
const { t } = useI18n()
@@ -24,6 +25,7 @@ async function handleLogout() {
<AccountInfoSection />
<ChangePasswordSection />
<TwoFactorSection />
<SessionTimeoutSection />
<!-- Logout Button -->
<button
@@ -289,6 +289,15 @@ defineExpose({ loadBackups })
(and 2FA code, if enabled). Only reveal it somewhere private anyone with these
words controls this node.
</p>
<a
href="/entropy/"
target="_blank"
rel="noopener"
class="inline-flex items-center gap-1 mt-2 text-sm text-orange-300/90 hover:text-orange-200 transition-colors"
>
How your seed &amp; keys work the full guide
<svg class="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"/></svg>
</a>
</div>
<button
type="button"
@@ -0,0 +1,151 @@
<script setup lang="ts">
/**
* How long a login lasts on this node.
*
* Presented as two plain questions "sign me out after quiet" and "always
* sign me out after" rather than as the two-token mechanism underneath.
* The distinction that matters to the operator is that the second one is
* what actually guarantees a login ends: this dashboard polls constantly,
* so an idle timeout alone never fires on an open tab.
*/
import { ref, onMounted, computed } from 'vue'
import { rpcClient } from '@/api/rpc-client'
type Policy = {
idle_timeout_secs: number
absolute_timeout_secs: number | null
reauth_for_funds: boolean
}
const idle = ref<number>(86400)
const absolute = ref<number | null>(30 * 24 * 3600)
const reauthForFunds = ref(true)
const loading = ref(true)
const saving = ref(false)
const saved = ref(false)
const error = ref('')
// Offered as presets rather than a free number field: the useful values are
// few, and a box accepting "5" invites someone to lock themselves out.
const idleChoices = [
{ label: '15 minutes', value: 15 * 60 },
{ label: '1 hour', value: 3600 },
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
]
const absoluteChoices = [
{ label: '1 day', value: 86400 },
{ label: '1 week', value: 7 * 24 * 3600 },
{ label: '30 days', value: 30 * 24 * 3600 },
{ label: '90 days', value: 90 * 24 * 3600 },
{ label: 'Never', value: null },
]
const shortIdleWarning = computed(() => idle.value <= 3600)
async function load() {
loading.value = true
try {
const p = await rpcClient.call<Policy>({ method: 'auth.session-policy.get', params: {} })
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
loading.value = false
}
}
async function save() {
saving.value = true
saved.value = false
error.value = ''
try {
// The backend clamps and returns what it stored, so reflect that back
// rather than assuming our values were taken verbatim.
const p = await rpcClient.call<Policy>({
method: 'auth.session-policy.set',
params: {
idle_timeout_secs: idle.value,
absolute_timeout_secs: absolute.value,
reauth_for_funds: reauthForFunds.value,
},
})
idle.value = p.idle_timeout_secs
absolute.value = p.absolute_timeout_secs
reauthForFunds.value = p.reauth_for_funds
saved.value = true
setTimeout(() => { saved.value = false }, 2500)
} catch (e) {
error.value = e instanceof Error ? e.message : String(e)
} finally {
saving.value = false
}
}
onMounted(load)
</script>
<template>
<div class="mb-6">
<h3 class="text-base font-medium text-white/90 mb-1">Session timeout</h3>
<p class="text-sm text-white/60 mb-4">
How long this node keeps you signed in. TV and kiosk screens are never
signed out for sitting idle there is nobody there to sign them back in.
</p>
<div v-if="error" role="alert" class="mb-4 p-3 bg-red-500/20 border border-red-500/40 rounded-lg text-red-200 text-sm">
{{ error }}
</div>
<div v-if="!loading" class="space-y-4">
<div>
<label for="idle-timeout" class="block text-sm font-medium text-white/80 mb-2">Sign me out after this much inactivity</label>
<select
id="idle-timeout"
v-model.number="idle"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in idleChoices" :key="c.value" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p v-if="shortIdleWarning" class="text-xs text-orange-300/80 mt-2">
Short timeouts are what payment-industry rules ask for when funds are involved expect to sign in often.
</p>
</div>
<div>
<label for="absolute-timeout" class="block text-sm font-medium text-white/80 mb-2">Always sign me out after</label>
<select
id="absolute-timeout"
v-model="absolute"
class="w-full px-4 py-3 bg-transparent border border-white/20 rounded-lg text-white focus:outline-none focus:border-white/40"
>
<option v-for="c in absoluteChoices" :key="String(c.value)" :value="c.value" class="bg-neutral-900">{{ c.label }}</option>
</select>
<p class="text-xs text-white/50 mt-2">
Counts from when you signed in, whatever you are doing. This is the one that
guarantees a session ends: an open dashboard is never idle, so the setting
above would not fire on it.
</p>
</div>
<label class="flex items-start gap-3 cursor-pointer">
<input v-model="reauthForFunds" type="checkbox" class="mt-1 accent-orange-500" />
<span class="text-sm text-white/80">
Ask for my password again before sending funds
<span class="block text-xs text-white/50">Recommended. Applies however recently you signed in.</span>
</span>
</label>
<button
:disabled="saving"
class="w-full glass-button px-6 py-3 rounded-lg font-medium transition-all hover:bg-black/70 disabled:opacity-50"
@click="save"
>
{{ saving ? 'Saving…' : (saved ? 'Saved' : 'Save session settings') }}
</button>
</div>
</div>
</template>