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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user