fix: harden node upgrades and prepare 1.9.0-alpha

This commit is contained in:
archipelago
2026-10-05 12:43:49 -04:00
parent 138a541d01
commit daac47cac4
129 changed files with 9910 additions and 794 deletions
+1 -1
View File
@@ -366,7 +366,7 @@ function onDrop(e: DragEvent) {
}
async function handleUpload(files: File[]) {
await cloudStore.uploadFiles(files)
await cloudStore.uploadFiles(files, route.fullPath)
}
async function handleDelete(path: string) {
+4 -3
View File
@@ -331,6 +331,7 @@ let discoverAnimationDone = false
</script>
<script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import AppSearchField from '@/components/AppSearchField.vue'
import { ref, computed, onBeforeUnmount, onMounted } from 'vue'
import { useRouter, RouterLink } from 'vue-router'
@@ -407,7 +408,7 @@ const catalogStorefront = useCachedResource<CatalogStorefront | null>({
ttlMs: 300_000,
persist: true,
}).data
const communityApps = computed(() => catalogResource.data.value ?? [])
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way.
@@ -535,9 +536,9 @@ const allApps = computed(() => {
const nostrMerged = nostrApps.value
.filter(app => !existingIds.has(app.id))
.map(app => ({ ...app, category: app.category || categorizeCommunityApp(app), source: 'nostr' }))
return [...base, ...nostrMerged]
return normalizeStoreApps([...base, ...nostrMerged])
}
return base
return normalizeStoreApps(base)
})
const filteredApps = computed(() => {
+1 -1
View File
@@ -288,7 +288,7 @@
<WalletScanModal :show="showScanModal" @close="showScanModal = false" @sent="loadWeb5Status()" />
<SendBitcoinModal :show="showSendModal" @close="showSendModal = false" @sent="loadWeb5Status()" @scan="showSendModal = false; showScanModal = true" />
<ReceiveBitcoinModal :show="showReceiveModal" @close="showReceiveModal = false" @received="loadWeb5Status()" @scan="showReceiveModal = false; showScanModal = true" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @close="showTransactionsModal = false" />
<TransactionsModal :show="showTransactionsModal" :transactions="walletTransactions" @updated="loadWeb5Status" @close="showTransactionsModal = false" />
<WalletSettingsModal :show="showWalletSettingsModal" @close="showWalletSettingsModal = false" @changed="loadWeb5Status()" />
</div>
</template>
+9 -11
View File
@@ -50,12 +50,10 @@
{{ section.name }}
</button>
</div>
<input
<AppSearchField
v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="app-header-search px-4 py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
:label="t('marketplace.searchApps')"
/>
<RefreshIndicator :state="catalogResource.entry.loadState" label="Refreshing app store catalog" />
</div>
@@ -82,12 +80,10 @@
type="button"
>{{ section.name }}</button>
</div>
<input
<AppSearchField
v-model="searchQuery"
type="text"
:placeholder="t('marketplace.searchPlaceholder')"
:aria-label="t('marketplace.searchApps')"
class="w-full px-4 py-3 md:py-2 bg-white/10 border border-white/20 rounded-lg text-white placeholder-white/50 focus:outline-none focus:border-white/40 transition-colors"
:label="t('marketplace.searchApps')"
/>
</div>
</div>
@@ -168,6 +164,7 @@ let marketplaceAnimationDone = false
</script>
<script setup lang="ts">
import { normalizeStoreApps } from './apps/serviceNames'
import { ref, computed, onMounted, onBeforeUnmount, watch } from 'vue'
import { useRouter, useRoute, RouterLink } from 'vue-router'
import { useI18n } from 'vue-i18n'
@@ -181,6 +178,7 @@ import { useCollapsingHeaderTabs } from '@/composables/useCollapsingHeaderTabs'
import { useContainersScanTimeout } from '@/composables/useContainersScanTimeout'
import { useCachedResource } from '@/composables/useCachedResource'
import RefreshIndicator from '@/components/RefreshIndicator.vue'
import AppSearchField from '@/components/AppSearchField.vue'
import InstallVersionModal from '@/components/InstallVersionModal.vue'
import { APP_STORE_CATEGORIES, APP_STORE_SECTIONS } from './appStoreCategories'
import MarketplaceAppCard from './marketplace/MarketplaceAppCard.vue'
@@ -275,7 +273,7 @@ const catalogResource = useCachedResource<MarketplaceApp[]>({
ttlMs: 300_000,
persist: true,
})
const communityApps = computed(() => catalogResource.data.value ?? [])
const communityApps = computed(() => normalizeStoreApps(catalogResource.data.value ?? []))
const loadingCommunity = computed(() => catalogResource.entry.loadState === 'loading')
// Keep-last-value error banner (D-07): a failed background refresh never
// raises a toast, it only surfaces here — content stays on screen either way.
@@ -383,10 +381,10 @@ const allApps = computed(() => {
const category = app.category || categorizeCommunityApp(app)
return { ...app, category, source: 'nostr' }
})
return [...base, ...nostrMerged]
return normalizeStoreApps([...base, ...nostrMerged])
}
return base
return normalizeStoreApps(base)
})
const filteredApps = computed(() => {
@@ -89,11 +89,25 @@ describe('appsConfig service filtering', () => {
const entries: Array<[string, PackageDataEntry]> = [
['cuprate', makePkg('cuprate', 'Cuprate', 'money')],
['archy-cuprate-ui', makePkg('archy-cuprate-ui', 'Cuprate UI companion', 'money')],
['cuprate-ui', makePkg('cuprate-ui', 'Cuprate UI', 'money')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['cuprate'])
expect(filterEntriesForTab(entries, 'services', 'all').map(([id]) => id)).toEqual([])
})
it('groups NetBird parts and BTCPay aliases without hiding a legacy-only installation', () => {
const entries: Array<[string, PackageDataEntry]> = [
['netbird', makePkg('netbird', 'NetBird', 'networking')],
['netbird-dashboard', makePkg('netbird-dashboard', 'NetBird Dashboard', 'networking')],
['netbird-server', makePkg('netbird-server', 'NetBird Server', 'networking')],
['btcpay', makePkg('btcpay', 'BTCPay', 'money')],
['btcpay-server', makePkg('btcpay-server', 'BTCPay Server', 'commerce')],
]
expect(filterEntriesForTab(entries, 'apps', 'all').map(([id]) => id)).toEqual(['netbird', 'btcpay-server'])
expect(filterEntriesForTab(entries, 'services', 'all')).toEqual([])
expect(filterEntriesForTab([entries[3]!], 'apps', 'all').map(([id]) => id)).toEqual(['btcpay'])
})
it('falls back to packaged app icon when static icon token is not a path', () => {
const pkg = makePkg('gitea', 'Gitea', 'dev')
pkg['static-files']!.icon = 'git-branch'
+4 -2
View File
@@ -35,7 +35,7 @@ const INTERNAL_TOOLING_NAMES = new Set([
// Cuprate's dashboard is bundled as a companion of the primary cuprate
// package; showing the generated container as a second Services entry
// defeats the one-app presentation.
'archy-cuprate-ui',
'archy-cuprate-ui', 'cuprate-ui', 'netbird-dashboard', 'netbird-server',
])
export function isInternalToolingPackage(id: string, pkg?: PackageDataEntry): boolean {
@@ -54,7 +54,7 @@ export function isServicePackage(id: string, pkg?: PackageDataEntry): boolean {
// Known app -> category mappings (matches App Store categorisation)
export const APP_CATEGORY_MAP: Record<string, string> = {
'bitcoin-core': 'money', 'bitcoin-knots': 'money', 'bitcoin-ui': 'money', 'cuprate': 'money', 'cuprate-ui': 'money', 'electrumx': 'money', 'electrs': 'money',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce',
'lnd': 'money', 'mempool': 'money', 'mempool-web': 'money', 'btcpay-server': 'commerce', 'btcpay': 'commerce', 'btcpayserver': 'commerce',
'fedimint': 'money', 'fedimint-gateway': 'money',
'indeedhub': 'media', 'jellyfin': 'media', 'photoprism': 'media', 'immich': 'media',
'nextcloud': 'data', 'vaultwarden': 'data', 'filebrowser': 'data', 'cryptpad': 'data',
@@ -115,11 +115,13 @@ export function filterEntriesForTab(
selectedCategory: string,
): Array<[string, PackageDataEntry]> {
const hasMempool = entries.some(([id]) => id === 'mempool')
const hasBtcpay = entries.some(([id]) => id === 'btcpay-server')
return entries.filter(([id, pkg]) => {
// Older daemons can retain the frontend manifest alias during a restart.
// Keep one tile while the updated scanner converges; a legacy-only node
// must still be able to see and operate its sole Mempool entry.
if (hasMempool && ['mempool-web', 'mempool-frontend', 'archy-mempool-web'].includes(id)) return false
if (hasBtcpay && ['btcpay', 'btcpayserver'].includes(id)) return false
if (isInternalToolingPackage(id, pkg)) return false
const wantsWebsites = activeTab === 'websites' || activeTab === 'services'
const isWebsite = isWebsitePackage(id, pkg)
+13
View File
@@ -29,6 +29,7 @@ export const SERVICE_NAMES = new Set([
'mysql-mempool', 'mempool-api', 'archy-mempool-web',
'archy-bitcoin-ui', 'archy-lnd-ui', 'archy-electrs-ui',
'bitcoin-ui', 'lnd-ui', 'electrs-ui',
'cuprate-ui', 'netbird-dashboard', 'netbird-server',
'indeedhub-postgres', 'indeedhub-redis', 'indeedhub-minio',
'indeedhub-api', 'indeedhub-ffmpeg',
'indeedhub-relay', 'indeedhub-build_api_1', 'indeedhub-build_ffmpeg-worker_1',
@@ -64,3 +65,15 @@ export function isServiceContainer(id: string): boolean {
export function isStoreListedApp(id: string): boolean {
return !isServiceContainer(id) && !NODE_INTERNAL_IDS.has(id)
}
/** Normalize every catalog source, including persisted caches from older UIs. */
export function normalizeStoreApps<T extends { id: string }>(apps: readonly T[]): T[] {
const result = new Map<string, T>()
for (const app of apps) {
if (!isStoreListedApp(app.id)) continue
const id = ['btcpay', 'btcpayserver'].includes(app.id) ? 'btcpay-server' : app.id
// Prefer the real app's metadata to a legacy image-pin-only alias.
if (!result.has(id) || app.id === id) result.set(id, { ...app, id })
}
return [...result.values()]
}
@@ -0,0 +1,26 @@
import { describe, expect, it } from 'vitest'
import { signedCatalogToApps } from '../curatedApps'
import { normalizeStoreApps } from '../../apps/serviceNames'
describe('one App Store card per product', () => {
it('keeps Cuprate and NetBird components out and prefers canonical BTCPay metadata', () => {
const entries = {
btcpay: { version: '2.4.3' },
'btcpay-server': { version: '2.4.3', manifest: { app: { name: 'BTCPay Server', category: 'commerce', metadata: { icon: '/btcpay.svg' } } } },
cuprate: { version: '0.1' }, 'cuprate-ui': { version: '1.7' },
netbird: { version: '2.38' }, 'netbird-dashboard': { version: '2.38' }, 'netbird-server': { version: '0.71' },
}
for (const apps of [entries, Object.fromEntries(Object.entries(entries).reverse())]) {
const result = signedCatalogToApps({ apps })
expect(result.map(x => x.id).sort()).toEqual(['btcpay-server', 'cuprate', 'netbird'])
expect(result.find(x => x.id === 'btcpay-server')).toMatchObject({ title: 'BTCPay Server', category: 'commerce', icon: '/btcpay.svg', version: '2.4.3' })
}
})
it('normalizes persisted/community entries repeatedly without hiding independent services', () => {
const stale = ['cuprate-ui', 'netbird-server', 'netbird-dashboard', 'btcpay', 'btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'].map(id => ({ id }))
const result = normalizeStoreApps(stale)
expect(result.map(x => x.id)).toEqual(['btcpay-server', 'angor-indexer', 'angor-relay', 'electrumx'])
expect(normalizeStoreApps(result)).toEqual(result)
expect(normalizeStoreApps([{ id: 'btcpay' }])).toEqual([{ id: 'btcpay-server' }])
})
})
+8 -6
View File
@@ -1,5 +1,5 @@
import type { MarketplaceApp } from './types'
import { isStoreListedApp } from '../apps/serviceNames'
import { isStoreListedApp, normalizeStoreApps } from '../apps/serviceNames'
const R = 'source.archipelago-foundation.org/lfg2025'
@@ -98,7 +98,7 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
source: 'signed-catalog',
})
}
return out
return normalizeStoreApps(out)
}
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
@@ -192,7 +192,7 @@ const CATALOG_URLS = [
* Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }
// The daemon-verified signed catalog first (release-root signature checked
// server-side): it is what makes a newly published app appear without a
@@ -239,16 +239,16 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
// apps); signed entries fill version/image gaps and append brand-new apps.
const byId = new Map<string, MarketplaceApp>()
for (const app of signedApps) byId.set(app.id, app)
for (const app of community?.apps ?? []) {
for (const app of normalizeStoreApps(community?.apps ?? [])) {
const existing = byId.get(app.id)
byId.set(app.id, existing ? { ...app, version: app.version || existing.version, dockerImage: app.dockerImage || existing.dockerImage } : app)
byId.set(app.id, existing ? { ...app, version: existing.version || app.version, dockerImage: existing.dockerImage || app.dockerImage } : app)
}
const merged: AppCatalog = {
version: community?.version ?? 1,
registry: community?.registry ?? R,
featured: signedFeatured ?? community?.featured,
storefront: signedStorefront ?? community?.storefront,
apps: [...byId.values()],
apps: normalizeStoreApps([...byId.values()]),
}
cachedCatalog = merged
catalogFetchedAt = Date.now()
@@ -261,6 +261,7 @@ export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const stored = localStorage.getItem('archy_catalog')
if (stored) {
cachedCatalog = JSON.parse(stored) as AppCatalog
cachedCatalog.apps = normalizeStoreApps(cachedCatalog.apps)
catalogFetchedAt = Date.now() - CATALOG_TTL + 5 * 60 * 1000 // re-check in 5 min
return cachedCatalog
}
@@ -314,6 +315,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool', 'mempool-web', 'archy-mempool-web'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -72,6 +72,7 @@ export const INSTALLED_ALIASES: Record<string, string[]> = {
mempool: ['mempool-web', 'mempool-api', 'archy-mempool-web', 'archy-mempool-db'],
bitcoin: ['bitcoin-knots'],
btcpay: ['btcpay-server', 'archy-btcpay-db', 'archy-nbxplorer'],
'btcpay-server': ['btcpay-server', 'btcpay', 'btcpayserver'],
immich: ['immich-server', 'immich-app', 'immich_server', 'immich_postgres', 'immich_redis'],
nextcloud: ['nextcloud-aio', 'nextcloud-server'],
fedimint: ['fedimint-gateway'],
@@ -362,16 +362,27 @@ init()
</button>
</div>
<div class="overflow-y-auto flex-1 min-h-0 space-y-6 pr-1">
<!-- v1.8.23-alpha -->
<!-- v1.9.0-alpha -->
<div>
<div class="flex items-center gap-2 mb-3">
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.8.23-alpha</span>
<span class="text-xs text-white/40">October 1, 2026</span>
<span class="text-xs font-mono px-2 py-0.5 rounded bg-orange-500/20 text-orange-300">v1.9.0-alpha</span>
<span class="text-xs text-white/40">October 5, 2026</span>
</div>
<div class="space-y-3 text-sm text-white/80 pl-3 border-l border-white/10">
<p>Keep Cuprate and NetBird supporting components out of app listings and consolidate BTCPay Server under Commerce.</p>
<p>Default on-chain sends, channel opens and cooperative closes to a dynamic next-block fee target, preserving explicit slower and custom choices.</p>
<p>Add reviewed fee-bump quotes, explicit budgets and durable operation tracking for supported wallet transactions.</p>
<p>Preserve Nginx Proxy Manager storage, same-node upstream connectivity, certificates and access controls through managed migrations.</p>
<p>Restrict public management access while retaining configured public apps and ACME certificate validation.</p>
<p>Serve the Mempool explorer on the Angor indexer origin alongside its API.</p>
<p>Include the self-contained LoRa flashing tool and explicit board selection in update and installer payloads.</p>
<p>Preserve paid-file Lightning entitlements across restarts and recover settled invoices from LND. Retry delivery without paying again and retain purchased files in the owned cache.</p>
<p>Return explicit payment-status errors with safe retry guidance when verification is unavailable.</p>
<p>Show compact upload progress across screens, retain the original destination, and cancel active and queued uploads.</p>
<p>Keep upload progress on its original screen, show completion there or notify on other screens, and cancel active and queued uploads.</p>
<p>Resume interrupted uploads while the app remains open, preserve the original destination, and verify saved file contents before reporting completion.</p>
<p>Provision a unique private File Browser login on each node while keeping Cloud sign-in automatic and preserving existing accounts and files.</p>
<p>Update Nostr dependencies to reject forged relay events and oversized encrypted messages; preserve native signing and encryption compatibility.</p>
<p>Allow apps to opt in to a validated public-key list of user identities without granting signing access.</p>
<p>Make transaction filters transparent and horizontally scrollable on mobile.</p>
<p>Keep Immich internal services out of My Apps, avoid false recovery states for healthy stacks, and allow removal of retired catalog apps.</p>
<p>Repair the redundant managed Portainer network override that can prevent startup, preserving custom overrides and persistent state.</p>