fix(ui): gate-fronted https launches + signed-catalog App Store
Demo images / Build & push demo images (push) Failing after 36s

directAppUrl(), the legacy open() path, and resolveRuntimeLaunchUrl()
now upgrade to https only for ports the app gate fronts — decided from
the signed catalog's embedded manifest ports (auth gated/open), so
plain-HTTP publishes (legacy installs, auth:none API ports like
Cuprate's RPC) keep http instead of failing outright. fetchAppCatalog()
merges the daemon-verified signed catalog into the App Store listing
(signed entries appear immediately; community copy supplies featured
and curated metadata), and Marketplace.vue uses the same dynamic fetcher
as Discover so the grid sees signed-new apps too.
This commit is contained in:
archipelago
2026-08-31 18:41:00 -04:00
parent b8593c9090
commit 46cb0bfd37
7 changed files with 260 additions and 68 deletions
@@ -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')
})
})
@@ -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<string, string> = {
* 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}`
}