fix(ui): https app launches and the nostr bridge follow the frame's real origin

Three launcher/bridge defects combined to make HTTPS dashboards look
broken while HTTP ones worked:

1. portAuth() looked the launch port up under the name the user clicks
   ('mempool-web', 'lnd', 'bitcoin-knots'…), but the signed catalog
   declares those ports under the manifest id that owns them
   (archy-mempool-web, lnd-ui, bitcoin-ui). The lookup missed,
   portIsGateFronted answered false, and an HTTPS dashboard handed app
   frames http:// URLs — blocked as mixed content: mempool and IndeeHub
   'did not connect', bitcoin knots/core opened http:// in a new tab.
   Resolution now follows launch aliases, then a port-wide catalog scan
   that only answers when every declarer of that port agrees (a port
   any app publishes as plain HTTP is never upgraded to https).

2. The signed-catalog cache was only warmed by the Store/Discover
   views, so a user who went straight to My Apps launched apps with an
   empty cache. Warmed at dashboard mount now — fetchAppCatalog()
   already memoizes with a 1h TTL.

3. The NIP-07 bridge compared event.origin for strict equality with the
   recorded (http) app URL and replied to the recorded URL as the
   postMessage targetOrigin — both break the moment a frame is scheme-
   upgraded (cached HSTS did exactly that): every nostr request was
   silently dropped and replies to the stale origin threw. The bridge
   now matches host+port (scheme deliberately ignored) and always
   replies to event.origin — the frame's real origin.

Unit tests cover alias resolution (incl. bitcoin-knots→8334→https),
the conservative port-scan, and scheme-agnostic sender matching.
This commit is contained in:
archipelago
2026-09-01 10:29:05 -04:00
parent e382e679ae
commit 3347b8b8b9
7 changed files with 191 additions and 27 deletions
@@ -0,0 +1,79 @@
import { afterEach, describe, expect, it } from 'vitest'
import { __setSignedCatalogForTests, portAuth, portIsGateFronted, type SignedAppCatalog } from '../curatedApps'
/** Catalog fragments mirroring the live signed catalog's port declarations
* (releases/app-catalog.json, 2026-09-01). */
const catalog = (apps: SignedAppCatalog['apps']): SignedAppCatalog => ({ apps })
const FULL = catalog({
'archy-mempool-web': {
version: '3.0.1',
manifest: { app: { id: 'archy-mempool-web', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
},
'mempool': {
version: '3.0.0',
manifest: { app: { id: 'mempool', ports: [{ host: 4080, container: 8080, auth: 'gated' }] } },
},
'lnd-ui': {
version: '1.0.0',
manifest: { app: { id: 'lnd-ui', ports: [{ host: 18083, container: 18083, auth: 'gated' }] } },
},
'bitcoin-ui': {
version: '1.0.0',
manifest: { app: { id: 'bitcoin-ui', ports: [{ host: 8334, container: 8334, auth: 'gated' }] } },
},
'bitcoin-knots': {
version: '29.3',
manifest: { app: { id: 'bitcoin-knots', ports: [{ host: 8332, container: 8332, auth: 'none' }] } },
},
'electrs-ui': {
version: '1.0.0',
manifest: { app: { id: 'electrs-ui', ports: [{ host: 50002, container: 50002, auth: 'gated' }] } },
},
})
afterEach(() => __setSignedCatalogForTests(null))
describe('portAuth', () => {
it('resolves the UI port through the launch alias, not just the app id', () => {
__setSignedCatalogForTests(FULL)
// 'mempool-web' has no catalog entry of its own; archy-mempool-web owns 4080.
expect(portIsGateFronted('mempool-web', 4080)).toBe(true)
// 'bitcoin-knots' declares 8332 (auth none) but its UI port 8334 is owned
// by bitcoin-ui — the alias must find it, or the new-tab button hands
// out an http:// URL on an HTTPS dashboard (2026-09-01 report).
expect(portIsGateFronted('bitcoin-knots', 8334)).toBe(true)
expect(portIsGateFronted('lnd', 18083)).toBe(true)
expect(portIsGateFronted('electrs', 50002)).toBe(true)
})
it('keeps a port the app itself publishes as plain HTTP off the gate', () => {
__setSignedCatalogForTests(FULL)
expect(portAuth('bitcoin-knots', 8332)).toBe('none')
expect(portIsGateFronted('bitcoin-knots', 8332)).toBe(false)
})
it('answers null for unknown apps and ports (never assume TLS)', () => {
__setSignedCatalogForTests(FULL)
expect(portAuth('never-installed-app', 1234)).toBeNull()
expect(portIsGateFronted('bitcoin-ui', 9999)).toBe(false)
})
it('falls back to a unanimous port-wide scan for unknown ids', () => {
__setSignedCatalogForTests(FULL)
// No alias for this id, but every declarer of 4080 says gated.
expect(portIsGateFronted('some-future-alias', 4080)).toBe(true)
})
it('refuses the port-wide scan when declarers disagree (no TLS guess)', () => {
__setSignedCatalogForTests(catalog({
'app-a': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'gated' }] } } },
'app-b': { version: '1', manifest: { app: { ports: [{ host: 7000, auth: 'none' }] } } },
}))
expect(portAuth('unknown-app', 7000)).toBeNull()
})
it('returns null without a warmed catalog (cache miss, not a guess)', () => {
expect(portAuth('mempool-web', 4080)).toBeNull()
})
})
+50 -5
View File
@@ -79,15 +79,60 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
* after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null
/** Launch aliases → the catalog app id that OWNS the UI port.
*
* The launcher knows apps by several historical names (`mempool-web`, `lnd`,
* `electrs`…); the signed catalog knows them by manifest id. Without this
* map the port-auth lookup below misses, `portIsGateFronted` answers false,
* and an HTTPS dashboard hands the app session an http:// frame URL — which
* the browser then blocks outright as mixed content. That is exactly how
* Mempool and IndeeHub “did not connect” over HTTPS while working fine over
* HTTP (2026-09-01). */
const CATALOG_APP_ID_ALIASES: Record<string, string> = {
'mempool-web': 'archy-mempool-web',
'mempool-electrs': 'electrs-ui',
'electrs': 'electrs-ui',
'archy-electrs-ui': 'electrs-ui',
'lnd': 'lnd-ui',
'archy-lnd-ui': 'lnd-ui',
'bitcoin-knots': 'bitcoin-ui',
'bitcoin-core': 'bitcoin-ui',
'fedimintd': 'fedimint',
'immich_server': 'immich',
}
/** 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). */
* unknown to the signed catalog (legacy curated installs).
*
* Resolution order: the app's own manifest, then its alias (the manifest
* that actually owns the UI port), then — only for ports no known id
* declares — a port-wide scan of the catalog. The scan must be UNANIMOUS:
* a host port that any app publishes as plain HTTP (`none`) must never be
* answered `gated`, or an https frame URL would point at a port that never
* serves TLS. */
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
const apps = signedCatalogCache?.apps
if (!apps) return null
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
for (const id of ids) {
const ports = apps[id]?.manifest?.app?.ports
if (!Array.isArray(ports)) continue
const hit = ports.find(p => String(p.host) === String(hostPort))
if (hit?.auth) return hit.auth
}
let found: string | null = null
for (const entry of Object.values(apps)) {
const ports = entry?.manifest?.app?.ports
if (!Array.isArray(ports)) continue
const hit = ports.find(p => String(p.host) === String(hostPort))
if (!hit?.auth) continue
if (found === null) found = hit.auth
else if (found !== hit.auth) return null
}
return found
}
/** Whether an app's host port is fronted by the node's app gate (and so