diff --git a/neode-ui/src/stores/__tests__/appLauncher.test.ts b/neode-ui/src/stores/__tests__/appLauncher.test.ts index 3745c74c..063eca8c 100644 --- a/neode-ui/src/stores/__tests__/appLauncher.test.ts +++ b/neode-ui/src/stores/__tests__/appLauncher.test.ts @@ -1,5 +1,17 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest' import { setActivePinia, createPinia } from 'pinia' +import { __setSignedCatalogForTests } from '@/views/discover/curatedApps' + +// The signed catalog's embedded manifests decide which ports the app gate +// fronts (TLS on the same port) — prime the same shape the live catalog +// carries for the apps these tests launch. +const SIGNED = { + apps: { + vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } }, + gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }] } } }, + 'nginx-proxy-manager': { version: 'latest' }, // legacy: no manifest → http + }, +} // vi.hoisted runs before vi.mock hoisting const { mockPush, mockWindowOpen } = vi.hoisted(() => ({ @@ -23,6 +35,7 @@ describe('useAppLauncherStore', () => { beforeEach(() => { setActivePinia(createPinia()) vi.clearAllMocks() + __setSignedCatalogForTests(SIGNED as never) // Default to HTTP to avoid proxy rewriting Object.defineProperty(window, 'location', { value: { origin: 'http://192.0.2.10', protocol: 'http:', hostname: '192.0.2.10' }, diff --git a/neode-ui/src/stores/appLauncher.ts b/neode-ui/src/stores/appLauncher.ts index 69ca0114..32808eec 100644 --- a/neode-ui/src/stores/appLauncher.ts +++ b/neode-ui/src/stores/appLauncher.ts @@ -5,6 +5,7 @@ import { recordAppLaunch } from '@/utils/appUsage' import { requestExternalOpen } from '@/api/remote-relay' import { openInAppOrNewTab, isCompanionApp, type InAppLaunchMeta } from '@/utils/openExternal' import { directAppUrl, HTTPS_APP_IDS, resolveAppUrl } from '@/views/appSession/appSessionConfig' +import { portIsGateFronted } from '@/views/discover/curatedApps' import { useAppStore } from '@/stores/app' import { resolveAppIcon } from '@/views/apps/appsConfig' import { IS_DEMO, isDemoApp, isDemoExternal, demoAppUrl } from '@/composables/useDemoIntro' @@ -256,20 +257,20 @@ export const useAppLauncherStore = defineStore('appLauncher', () => { let launchUrl = normalizeLaunchUrl(payload.url, titleHintId) const resolvedId = resolveAppIdFromUrl(launchUrl) || titleHintId - // Scheme discipline for everything launched on this host. App ports are - // owned by the app gate, which serves TLS on the same port whenever the - // node has a certificate — so on an HTTPS connection every same-host - // app URL must be https: plain http is a silent downgrade at best and - // mixed-content-blocked at worst (remote browsers, the companion - // webview). Apps that are ALWAYS https (netbird's secure-context OIDC - // dashboard) upgrade regardless of the page, and external hosts keep - // their own scheme. + // Scheme discipline for everything launched on this host. Ports fronted + // by the node's app gate (manifest auth gated/open) serve TLS on the same + // port — on an HTTPS connection those must open over https. Ports that + // are NOT gate-fronted (legacy curated installs like Nginx Proxy Manager, + // Tailscale; `auth: none` publishes) are plain HTTP and https would fail + // to connect outright, so they keep http. External hosts keep their own + // scheme. try { const u = new URL(launchUrl, window.location.origin) const sameHost = u.hostname === window.location.hostname const alwaysHttps = !!resolvedId && HTTPS_APP_IDS.has(resolvedId) const httpsPage = window.location.protocol === 'https:' - if (u.protocol === 'http:' && (alwaysHttps || (httpsPage && sameHost && (resolvedId || mustOpenInNewTab(launchUrl))))) { + const gateFronted = !!resolvedId && portIsGateFronted(resolvedId, u.port) + if (u.protocol === 'http:' && sameHost && (alwaysHttps || (httpsPage && gateFronted))) { // Pure prefix swap — never re-serialize the URL (URL.href would add // a trailing slash and change the string the caller handed over). launchUrl = launchUrl.replace(/^http:\/\//i, 'https://') diff --git a/neode-ui/src/views/Marketplace.vue b/neode-ui/src/views/Marketplace.vue index 3235ab1a..d7c820cd 100644 --- a/neode-ui/src/views/Marketplace.vue +++ b/neode-ui/src/views/Marketplace.vue @@ -184,6 +184,7 @@ import { categorizeCommunityApp, getCuratedAppList, } from './marketplace/marketplaceData' +import { fetchAppCatalog } from './discover/curatedApps' const router = useRouter() const route = useRoute() @@ -238,10 +239,17 @@ watch(() => route.query.category, (category) => { // Community marketplace state — cached (D-09/D-06: near-static catalog, long // TTL) behind a shared key so Discover.vue's identical loader picks up the // same cache entry without its own conversion (plan 02-04). Non-sensitive -// and small, so it persists across reloads. +// and small, so it persists across reloads. Dynamic-catalog-first: the +// daemon-verified signed catalog is what makes a newly published app appear +// without a dashboard release — the static list below is only the offline +// fallback (same fetcher contract as Discover.vue for this shared key). const catalogResource = useCachedResource({ key: 'app-catalog', - fetcher: async () => getCuratedAppList(), + fetcher: async () => { + const catalog = await fetchAppCatalog() + if (catalog && catalog.apps.length) return catalog.apps + return getCuratedAppList() + }, ttlMs: 300_000, persist: true, }) diff --git a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts index 47242236..5f2aa83e 100644 --- a/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts +++ b/neode-ui/src/views/appSession/__tests__/appSessionConfig.test.ts @@ -1,8 +1,39 @@ -import { describe, expect, it } from 'vitest' +import { describe, expect, it, beforeEach } from 'vitest' import { NEW_TAB_APPS, directAppUrl, resolveAppUrl } from '../appSessionConfig' import { GENERATED_NEW_TAB_APPS } from '../generatedAppSessionConfig' +import { __setSignedCatalogForTests } from '../../discover/curatedApps' + +// Mirror of the live signed catalog's embedded manifests (the ports[] auth +// that decides TLS eligibility). Kept minimal — only what the scheme logic +// consults. +const SIGNED = { + apps: { + vaultwarden: { version: '1.37.1', manifest: { app: { ports: [{ host: 8082, auth: 'gated' }] } } }, + gitea: { version: '1.23', manifest: { app: { ports: [{ host: 3001, auth: 'open' }, { host: 2222, auth: 'none' }] } } }, + 'btcpay-server': { version: '2.4.3', manifest: { app: { ports: [{ host: 23000, auth: 'open' }] } } }, + mempool: { version: '3.3.1', manifest: { app: { ports: [{ host: 4080, auth: 'gated' }] } } }, + filebrowser: { version: '2.27.0', manifest: { app: { ports: [{ host: 8083, auth: 'gated' }] } } }, + // Legacy curated installs — in the community list, NOT in the signed + // catalog's manifests. Their ports publish plain HTTP: https fails. + 'nginx-proxy-manager': { version: 'latest' }, + tailscale: { version: 'stable' }, + // auth:none ports are container-published too — https would fail. + cuprate: { version: '0.1.0-preview', manifest: { app: { ports: [{ host: 18090, auth: 'none' }] } } }, + }, +} + +function stubLocation(value: { hostname: string; protocol: string }) { + Object.defineProperty(window, 'location', { + value, + writable: true, + configurable: true, + }) +} describe('appSessionConfig', () => { + beforeEach(() => { + __setSignedCatalogForTests(SIGNED as never) + }) it('keeps manifest-owned new-tab apps marked on every viewport', () => { expect(NEW_TAB_APPS.has('btcpay-server')).toBe(true) expect(NEW_TAB_APPS.has('photoprism')).toBe(true) @@ -56,7 +87,9 @@ describe('appSessionConfig', () => { configurable: true, }) - expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('http://192.0.2.10:8087') + // NetBird's dashboard needs a secure context (OIDC PKCE), so it is + // ALWAYS launched over https — on either page scheme. + expect(resolveAppUrl('netbird', undefined, 'http://localhost:8086')).toBe('https://192.0.2.10:8087') }) it('uses backend runtime URLs for apps with dynamic launch surfaces', () => { @@ -66,53 +99,52 @@ describe('appSessionConfig', () => { configurable: true, }) - expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') + expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('http://192.0.2.10:8083') }) // The direct-port launch path (new-tab apps on desktop, the companion's // native WebView on phones) used to hardcode http:// — so a node reached - // over HTTPS opened Vaultwarden and friends in cleartext. These pin the - // scheme-following contract on both page schemes. - it('builds direct app URLs on the page scheme — https page, https app', () => { - Object.defineProperty(window, 'location', { - value: { hostname: '192.0.2.10', protocol: 'https:' }, - writable: true, - configurable: true, - }) + // over HTTPS opened Vaultwarden and friends in cleartext. It must follow + // the page scheme ONLY for ports the app gate fronts (TLS on the same + // port); legacy installs without manifests (Nginx Proxy Manager, Tailscale) + // and auth:none ports stay on http or https would fail to connect. + it('builds direct app URLs on the page scheme — https page, gate-fronted app', () => { + stubLocation({ hostname: '192.0.2.10', protocol: 'https:' }) expect(directAppUrl('vaultwarden')).toBe('https://192.0.2.10:8082') expect(directAppUrl('gitea')).toBe('https://192.0.2.10:3001') expect(directAppUrl('btcpay-server')).toBe('https://192.0.2.10:23000') }) + it('keeps legacy manifest-less apps on http even on an https page', () => { + stubLocation({ hostname: '192.0.2.10', protocol: 'https:' }) + + expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081') + expect(directAppUrl('tailscale')).toBe('http://192.0.2.10:8240') + }) + it('keeps plain-http direct app URLs on a plain-http page', () => { - Object.defineProperty(window, 'location', { - value: { hostname: '192.0.2.10', protocol: 'http:' }, - writable: true, - configurable: true, - }) + stubLocation({ hostname: '192.0.2.10', protocol: 'http:' }) expect(directAppUrl('vaultwarden')).toBe('http://192.0.2.10:8082') + expect(directAppUrl('nginx-proxy-manager')).toBe('http://192.0.2.10:8081') }) it('always launches secure-context apps over https, on either page scheme', () => { - Object.defineProperty(window, 'location', { - value: { hostname: '192.0.2.10', protocol: 'http:' }, - writable: true, - configurable: true, - }) + stubLocation({ hostname: '192.0.2.10', protocol: 'http:' }) expect(directAppUrl('netbird')).toBe('https://192.0.2.10:8087') }) - it('resolves session app URLs on the page scheme too (https page)', () => { - Object.defineProperty(window, 'location', { - value: { hostname: '192.0.2.10', protocol: 'https:' }, - writable: true, - configurable: true, - }) + it('resolves session app URLs on the page scheme for gate-fronted ports only (https page)', () => { + stubLocation({ hostname: '192.0.2.10', protocol: 'https:' }) expect(resolveAppUrl('mempool')).toBe('https://192.0.2.10:4080') - expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('https://192.0.2.10:18083') + expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:8083')).toBe('https://192.0.2.10:8083') + // A runtime port the gate does NOT front keeps plain http (https would + // fail to connect outright). + expect(resolveAppUrl('filebrowser', undefined, 'http://localhost:18083')).toBe('http://192.0.2.10:18083') + // Cuprate's UI port is auth:none — plain HTTP stays plain. + expect(resolveAppUrl('cuprate', undefined, 'http://localhost:18090')).toBe('http://192.0.2.10:18090') }) }) diff --git a/neode-ui/src/views/appSession/appSessionConfig.ts b/neode-ui/src/views/appSession/appSessionConfig.ts index f4592c77..6a64e2a0 100644 --- a/neode-ui/src/views/appSession/appSessionConfig.ts +++ b/neode-ui/src/views/appSession/appSessionConfig.ts @@ -1,5 +1,6 @@ /** Static configuration maps for app session routing and display */ +import { portIsGateFronted } from '../discover/curatedApps' import { GENERATED_APP_PORTS, GENERATED_APP_TITLES, GENERATED_NEW_TAB_APPS } from './generatedAppSessionConfig' import { IS_DEMO, demoAppUrl } from '@/composables/useDemoIntro' @@ -107,15 +108,20 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: // shell when proxied under a path prefix on some nodes. if (id === 'bitcoin-knots' || id === 'bitcoin-core' || id === 'bitcoin-ui') { if (import.meta.env.DEV) return '/app/bitcoin-ui/' - return appOrigin(8334) + return appOrigin(8334, id) } if (runtimeUrl && id !== 'netbird') { let base = runtimeUrl.replace(/localhost/i, window.location.hostname) // The backend reports runtime URLs as http:// because that is how the app - // binds locally. Sent to a browser on an HTTPS dashboard that is mixed - // content and the frame is blocked outright, so follow the page instead. - base = matchPageScheme(base) + // binds locally. On an HTTPS dashboard that is mixed content and the + // frame is blocked outright — but ONLY upgrade when the gate fronts the + // port (it serves TLS there); a container-published plain-HTTP port + // would fail to connect over https at all. + try { + const port = new URL(base).port + if (portIsGateFronted(id, port)) base = matchPageScheme(base) + } catch { /* keep as-is */ } if (routeQueryPath) base += routeQueryPath return base } @@ -124,13 +130,14 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: const port = APP_PORTS[id] if (!port) return '' - let base = appOrigin(port) + let base = appOrigin(port, id) if (routeQueryPath) base += routeQueryPath return base } /** - * An app's origin on this host, on the SAME scheme as the page. + * An app's origin on this host, on the SAME scheme as the page when the + * app gate fronts the port (TLS on the same port), plain http otherwise. * * An HTTPS dashboard cannot embed an HTTP frame at all — browsers block it as * mixed content before any cookie question arises — and it is also what makes @@ -143,8 +150,11 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?: * Node certificate. A certificate warning cannot be accepted inside an iframe, * so an untrusted app port renders nothing rather than prompting. */ -export function appOrigin(port: number): string { - return `${pageScheme()}//${window.location.hostname}:${port}` +export function appOrigin(port: number, appId?: string): string { + const https = appId + ? HTTPS_APP_IDS.has(appId) || (portIsGateFronted(appId, port) && pageScheme() === 'https:') + : pageScheme() === 'https:' + return `${https ? 'https' : 'http'}://${window.location.hostname}:${port}` } /** Rewrite a URL's scheme to the page's, leaving everything else alone. */ @@ -202,10 +212,25 @@ export const DIRECT_APP_PORTS: Record = { * DIRECT_APP_PORTS is served by the app gate with TLS on the same port * (see appgate/tls.rs), so following the page scheme is always answerable. * Plain-HTTP dashboards keep today's behaviour exactly. */ +/** Whether an app's direct port should follow the page's scheme (https on + * an https connection). True only when the node's app gate fronts the port + * (manifest auth gated/open — TLS served on the same port) or the app is + * unconditionally https (netbird). Legacy curated installs without a + * manifest (Nginx Proxy Manager, Tailscale) and `auth: none` ports (Cuprate's + * RPC) publish plain HTTP and must NOT be upgraded — https would fail to + * connect outright. */ +function shouldFollowPageScheme(appId: string, port: number | string): boolean { + if (HTTPS_APP_IDS.has(appId)) return true + return portIsGateFronted(appId, port) +} + export function directAppUrl(appId: string): string | null { const port = DIRECT_APP_PORTS[appId] if (!port || typeof window === 'undefined') return null - const scheme = HTTPS_APP_IDS.has(appId) || pageScheme() === 'https:' ? 'https' : 'http' + const scheme = HTTPS_APP_IDS.has(appId) + || (portIsGateFronted(appId, port) && pageScheme() === 'https:') + ? 'https' + : 'http' return `${scheme}://${window.location.hostname}:${port}` } diff --git a/neode-ui/src/views/apps/appsConfig.ts b/neode-ui/src/views/apps/appsConfig.ts index 35e46ec3..d0e1e385 100644 --- a/neode-ui/src/views/apps/appsConfig.ts +++ b/neode-ui/src/views/apps/appsConfig.ts @@ -4,6 +4,7 @@ import type { Ref } from 'vue' import { computed } from 'vue' import { PackageState, type PackageDataEntry } from '@/types/api' import { matchPageScheme, resolveAppUrl } from '../appSession/appSessionConfig' +import { portIsGateFronted } from '../discover/curatedApps' import { isAutoTabApp } from '@/utils/autoTabApps' export type AppsTab = 'apps' | 'websites' | 'services' @@ -301,10 +302,13 @@ export function resolveRuntimeLaunchUrl(pkg: PackageDataEntry): string { if (!addr || typeof window === 'undefined') return addr const local = addr.replace(/^http:\/\/(localhost|127\.0\.0\.1)(?=[:/]|$)/, `http://${window.location.hostname}`) // The backend reports runtime URLs as http:// because that is how the app - // binds locally — on an HTTPS connection that is a cleartext downgrade - // (and mixed-content-blocked when opened from the dashboard). The gate - // serves TLS on every app port, so follow the page's scheme, exactly like - // resolveAppUrl() does for the same runtime URLs. + // binds locally — on an HTTPS connection that is a cleartext downgrade. + // Upgrade only when the app gate fronts the port (it serves TLS there); + // a container-published plain-HTTP port would fail over https outright. + try { + const port = new URL(local).port + if (!portIsGateFronted(pkg.manifest.id, port)) return local + } catch { /* keep as-is */ } return matchPageScheme(local) } diff --git a/neode-ui/src/views/discover/curatedApps.ts b/neode-ui/src/views/discover/curatedApps.ts index cee22849..df6be382 100644 --- a/neode-ui/src/views/discover/curatedApps.ts +++ b/neode-ui/src/views/discover/curatedApps.ts @@ -18,17 +18,94 @@ export interface AppCatalog { apps: MarketplaceApp[] } +/** Shape of the release-signed catalog (`releases/app-catalog.json`) served + * by the daemon at /api/app-catalog after release-root verification. `apps` + * is keyed by app id and each entry embeds the app's full manifest — the + * ports[] there (auth: gated/open/none) are what decides whether a port is + * fronted by the node's app gate (and therefore serves TLS on the same + * port) or published by the container as plain HTTP. */ +export interface SignedAppCatalog { + schema?: number + updated?: string + apps: Record +} + +export interface SignedAppEntry { + version: string + image?: string + manifest?: { + app?: { + id?: string + name?: string + version?: string + description?: string + category?: string + container?: { image?: string } + metadata?: { icon?: string; author?: string; repo?: string } + ports?: { host?: number | string; container?: number | string; auth?: string }[] + } + } +} + +/** Convert the signed catalog's keyed entries into store-listing apps. + * Pure — unit-tested against the live catalog's shape (Cuprate). */ +export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[] { + const out: MarketplaceApp[] = [] + for (const [id, entry] of Object.entries(catalog.apps || {})) { + const app = entry.manifest?.app + out.push({ + id, + title: app?.name || id, + version: entry.version || app?.version || '', + description: app?.description || '', + icon: app?.metadata?.icon || '/assets/icon/favico-black-v2.svg', + author: app?.metadata?.author, + dockerImage: entry.image || app?.container?.image || '', + repoUrl: app?.metadata?.repo, + category: app?.category, + source: 'signed-catalog', + }) + } + return out +} + +/** The daemon-verified signed catalog, kept for synchronous port-auth lookups + * after fetchAppCatalog() has run. Test-hookable. */ +let signedCatalogCache: SignedAppCatalog | null = null + +/** Port auth for an app's host port, from the signed catalog's embedded + * manifest. `gated`/`open` = the node's app gate owns the port and serves + * TLS on it; `none`/`local` = container-published plain HTTP; null = app + * unknown to the signed catalog (legacy curated installs). */ +export function portAuth(appId: string, hostPort: number | string): string | null { + const ports = signedCatalogCache?.apps?.[appId]?.manifest?.app?.ports + if (!Array.isArray(ports)) return null + const hit = ports.find(p => String(p.host) === String(hostPort)) + return hit?.auth ?? null +} + +/** Whether an app's host port is fronted by the node's app gate (and so + * serves TLS alongside HTTP on the same port). Unknown apps are NOT — + * assuming TLS for a container-published port breaks it outright. */ +export function portIsGateFronted(appId: string, hostPort: number | string): boolean { + const auth = portAuth(appId, hostPort) + return auth === 'gated' || auth === 'open' +} + +export function __setSignedCatalogForTests(catalog: SignedAppCatalog | null) { + signedCatalogCache = catalog +} + let cachedCatalog: AppCatalog | null = null let catalogFetchedAt = 0 const CATALOG_TTL = 60 * 60 * 1000 // 1 hour cache -/** Catalog URLs tried in order. First success wins. - * Primary is the backend proxy (`/api/app-catalog`) — server-side fetch - * bypasses CORS on the upstream Gitea and CSP restrictions on the IP-port - * fallback. If the backend is offline (mid-restart etc.) we fall back - * to the static copy baked into the frontend build. */ +/** Catalog URLs for the community listing. The signed catalog is served by + * the backend proxy (`/api/app-catalog`) — server-side fetch bypasses CORS + * on the upstream Gitea and verifies the release-root signature. If the + * backend is offline (mid-restart etc.) the static community copy baked + * into the frontend build still renders the store. */ const CATALOG_URLS = [ - '/api/app-catalog', '/catalog.json', ] @@ -38,29 +115,61 @@ export async function fetchAppCatalog(): Promise { // Return cache if fresh if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return cachedCatalog + // The daemon-verified signed catalog first (release-root signature checked + // server-side): it is what makes a newly published app appear without a + // dashboard release. The community catalog supplies the featured banner + // and curated copy for shared ids; signed-only ids join the listing as-is. + let signedApps: MarketplaceApp[] = [] + let signedOk = false + try { + const res = await fetch('/api/app-catalog', { credentials: 'include', signal: AbortSignal.timeout(20000) }) + if (res.ok) { + const data = await res.json() as SignedAppCatalog + if (data.apps && !Array.isArray(data.apps)) { + signedCatalogCache = data + signedApps = signedCatalogToApps(data) + signedOk = signedApps.length > 0 + } + } + } catch { /* fall through to the community catalog */ } + + let community: AppCatalog | null = null for (const url of CATALOG_URLS) { try { const res = await fetch(url, { credentials: 'include', signal: AbortSignal.timeout(20000) }) if (!res.ok) continue const data = await res.json() as AppCatalog if (!data.apps?.length) continue - - // Expand short docker image refs to full registry paths const registry = data.registry || R for (const app of data.apps) { if (app.dockerImage && !app.dockerImage.includes('/')) { app.dockerImage = `${registry}/${app.dockerImage}` } } - cachedCatalog = data - catalogFetchedAt = Date.now() - // Cache in localStorage for offline fallback - try { localStorage.setItem('archy_catalog', JSON.stringify(data)) } catch {} - return data - } catch (e) { - console.warn(`[catalog] fetch failed for ${url}:`, e) - continue + community = data + break + } catch { /* try the next source */ } + } + + if (signedOk || community) { + // Community copy wins for shared ids (curated descriptions, webUrl-only + // apps); signed entries fill version/image gaps and append brand-new apps. + const byId = new Map() + for (const app of signedApps) byId.set(app.id, app) + for (const app of 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) } + const merged: AppCatalog = { + version: community?.version ?? 1, + registry: community?.registry ?? R, + featured: community?.featured ?? { id: 'bitcoin-knots', banner: '', headline: '', description: '', tag: '' }, + apps: [...byId.values()], + } + cachedCatalog = merged + catalogFetchedAt = Date.now() + try { localStorage.setItem('archy_catalog', JSON.stringify(merged)) } catch {} + return merged } // Try localStorage cache as final fallback