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
+125 -16
View File
@@ -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<string, SignedAppEntry>
}
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<AppCatalog | null> {
// 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<string, MarketplaceApp>()
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