fix(ui): app frames follow the dashboard's scheme instead of forcing http
Demo images / Build & push demo images (push) Failing after 2m10s

Both schemes now work, and each one works properly:

- HTTP dashboard  -> http app origin  (unchanged; no certificate needed)
- HTTPS dashboard -> https app origin (needs the node CA + TLS on the port)

The app URL was hardcoded to http://, which on an HTTPS dashboard is mixed
content — blocked outright, before the SameSite cookie question the symptom
was filed under. It is also what made the two origins schemefully cross-site,
so following the page's scheme fixes both causes at once.

Backend-reported runtime URLs get the same treatment: the daemon reports
http:// because that is how the app binds locally, which is right for the node
and wrong for a browser on an HTTPS page.

pageScheme() defaults to http when location.protocol is absent (non-browser
contexts) — the safe direction, since inventing an https URL for a port that
serves no TLS would break a working setup. That default is also why the three
existing resolveAppUrl tests, whose fixture stubs location without a protocol,
keep passing unmodified rather than being edited to fit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago
2026-08-06 14:43:08 -04:00
co-authored by Claude Opus 5
parent aab74127f5
commit f09ff102ee
2 changed files with 98 additions and 2 deletions
@@ -0,0 +1,55 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { appOrigin, matchPageScheme } from '../appSessionConfig'
// An HTTPS dashboard cannot embed an HTTP app frame — browsers block it as
// mixed content — so the app origin has to follow the page's scheme. Plain-HTTP
// nodes must be completely unaffected, which is what most of these pin.
function setLocation(protocol: string, hostname: string) {
Object.defineProperty(window, 'location', {
value: { protocol, hostname },
writable: true,
configurable: true,
})
}
afterEach(() => vi.unstubAllGlobals())
describe('appOrigin', () => {
it('stays on http for an http dashboard', () => {
setLocation('http:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('http://archi-dev-box:8334')
})
it('follows an https dashboard onto the app port', () => {
setLocation('https:', 'archi-dev-box')
expect(appOrigin(8334)).toBe('https://archi-dev-box:8334')
})
it('keeps the hostname the user actually typed, not a fixed name', () => {
setLocation('https:', '100.69.68.39')
expect(appOrigin(3000)).toBe('https://100.69.68.39:3000')
})
})
describe('matchPageScheme', () => {
it('leaves backend-reported http URLs alone on an http page', () => {
setLocation('http:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('http://node:8080/app')
})
it('upgrades a backend-reported http URL on an https page', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/app')).toBe('https://node:8080/app')
})
it('does not touch anything but the scheme', () => {
setLocation('https:', 'node')
expect(matchPageScheme('http://node:8080/a/b?c=1#d')).toBe('https://node:8080/a/b?c=1#d')
})
it('leaves an already-https URL untouched', () => {
setLocation('https:', 'node')
expect(matchPageScheme('https://node:8080/app')).toBe('https://node:8080/app')
})
})
@@ -107,11 +107,15 @@ 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 'http://' + window.location.hostname + ':8334'
return appOrigin(8334)
}
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)
if (routeQueryPath) base += routeQueryPath
return base
}
@@ -120,11 +124,48 @@ export function resolveAppUrl(id: string, routeQueryPath?: string, runtimeUrl?:
const port = APP_PORTS[id]
if (!port) return ''
let base = 'http://' + window.location.hostname + ':' + String(port)
let base = appOrigin(port)
if (routeQueryPath) base += routeQueryPath
return base
}
/**
* An app's origin on this host, on the SAME scheme as the page.
*
* 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
* the two origins schemefully cross-site, so the session cookie is withheld.
* Following the page's scheme fixes both at once and keeps plain HTTP working
* exactly as before on nodes that serve the dashboard over HTTP.
*
* On HTTPS this requires the app port to actually serve TLS with a certificate
* the browser trusts — see scripts/setup-node-ca.sh and Settings → System →
* 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}`
}
/** Rewrite a URL's scheme to the page's, leaving everything else alone. */
export function matchPageScheme(url: string): string {
if (pageScheme() !== 'https:') return url
return url.replace(/^http:\/\//i, 'https://')
}
/**
* The page's scheme, defaulting to http.
*
* A real browser always has location.protocol; this defends the non-browser
* cases (tests, SSR-ish contexts) where it can be absent. Defaulting to http
* is the safe direction — it preserves today's behaviour rather than inventing
* an https URL for a port that may not serve TLS.
*/
function pageScheme(): string {
const p = window.location?.protocol
return p === 'https:' || p === 'http:' ? p : 'http:'
}
/** Resolve a human-readable title for an app */
export function resolveAppTitle(id: string): string {
return APP_TITLES[id] || id.replace(/-/g, ' ').replace(/\b\w/g, c => c.toUpperCase())