Add signed node-scoped demo catalogs and retained app media sessions

This commit is contained in:
archipelago
2026-10-06 00:53:34 -04:00
parent 131c39cf74
commit e54f83df8f
18 changed files with 706 additions and 20 deletions
@@ -0,0 +1,41 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
const demo = () => ({ scope: 'single-node-demo', expires_at: new Date(Date.now() + 60000).toISOString(), apps: {
'node-demo-v4v': { version: '1', manifest: { app: { id: 'node-demo-v4v', name: 'V4V demo', ports: [{ host: 7475, auth: 'gated' }] } } },
}, storefront: { promotions: [{ id: 'node-demo-v4v', headline: 'Sovereign Music' }] } })
afterEach(() => { vi.unstubAllGlobals(); localStorage.clear() })
describe('node-scoped demo catalog', () => {
it.each([{}, [null]])('ignores malformed optional promotions: %j', async promotions => {
vi.resetModules()
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: true, json: async () => ({ ...demo(), storefront: { promotions } }) }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog } = await import('../curatedApps')
const catalog = await fetchAppCatalog()
expect(catalog?.apps.map(app => app.id)).toContain('node-demo-v4v')
expect(catalog?.storefront?.promotions).toEqual([])
})
it('adds only the verified node response and never persists it into shared fallback', async () => {
vi.resetModules()
let available = true
vi.stubGlobal('fetch', vi.fn(async (url: string) => {
if (url === '/api/node-app-catalog') return { ok: available, json: async () => demo() }
if (url === '/api/app-catalog') return { ok: true, json: async () => ({ apps: { mempool: { version: '3' } } }) }
return { ok: false }
}))
const { fetchAppCatalog, portIsGateFronted } = await import('../curatedApps')
const first = await fetchAppCatalog()
expect(first?.apps.map(app => app.id)).toEqual(['mempool', 'node-demo-v4v'])
expect(first?.storefront?.promotions[0]?.headline).toBe('Sovereign Music')
expect(localStorage.getItem('archy_catalog')).not.toContain('node-demo-v4v')
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(true)
available = false
const second = await fetchAppCatalog()
expect(second?.apps.map(app => app.id)).toEqual(['mempool'])
expect(second?.storefront?.promotions ?? []).toHaveLength(0)
expect(portIsGateFronted('node-demo-v4v', 7475)).toBe(false)
})
})
+31 -3
View File
@@ -68,7 +68,7 @@ export interface SignedAppEntry {
description?: string
category?: string
container?: { image?: string }
metadata?: { icon?: string; author?: string; repo?: string }
metadata?: { icon?: string; author?: string; repo?: string; launch?: { media_controls?: string } }
ports?: { host?: number | string; container?: number | string; auth?: string }[]
}
}
@@ -104,6 +104,11 @@ export function signedCatalogToApps(catalog: SignedAppCatalog): MarketplaceApp[]
/** The daemon-verified signed catalog, kept for synchronous port-auth lookups
* after fetchAppCatalog() has run. Test-hookable. */
let signedCatalogCache: SignedAppCatalog | null = null
let nodeCatalogCache: SignedAppCatalog | null = null
export function appHasMediaBridge(id: string): boolean {
return nodeCatalogCache?.apps[id]?.manifest?.app?.metadata?.launch?.media_controls === 'archipelago-v1'
}
/** Launch aliases → the catalog app id that OWNS the UI port.
*
@@ -141,7 +146,7 @@ const CATALOG_APP_ID_ALIASES: Record<string, string> = {
* 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 apps = signedCatalogCache?.apps
const apps = { ...signedCatalogCache?.apps, ...nodeCatalogCache?.apps }
if (!apps) return null
const alias: string | undefined = CATALOG_APP_ID_ALIASES[appId]
const ids: string[] = alias === undefined || alias === appId ? [appId] : [appId, alias]
@@ -188,9 +193,32 @@ const CATALOG_URLS = [
'/catalog.json',
]
/** Node demos are requested separately and never persisted in browser fallback
* storage or the global release catalog. A copied catalog is rejected by the
* daemon unless its release signature and exact node audience match. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
const nodeRequest = fetch('/api/node-app-catalog', { credentials: 'include', signal: AbortSignal.timeout(5000) })
.then(async response => response.ok ? await response.json() : null).catch(() => null)
const [base, node] = await Promise.all([fetchBaseAppCatalog(), nodeRequest])
nodeCatalogCache = null
if (!base || node?.scope !== 'single-node-demo' || !node.apps || Array.isArray(node.apps)
|| !Object.keys(node.apps).every(id => id.startsWith('node-demo-'))
|| !(Date.parse(node.expires_at) > Date.now())) return base
nodeCatalogCache = node as SignedAppCatalog
const known = new Set(base.apps.map(app => app.id))
const additions = signedCatalogToApps(node).filter(app => !known.has(app.id))
const demoIds = new Set(additions.map(app => app.id))
const promotions = (Array.isArray(node.storefront?.promotions) ? node.storefront.promotions : [])
.filter((promotion: CatalogPromotion | null) => promotion && demoIds.has(promotion.id))
return { ...base, apps: [...base.apps, ...additions], storefront: {
popular: base.storefront?.popular ?? [],
promotions: [...(base.storefront?.promotions ?? []), ...promotions],
} }
}
/** Fetch app catalog from remote registry, with local fallback.
* Caches for 1 hour. Returns null only if ALL sources fail. */
export async function fetchAppCatalog(): Promise<AppCatalog | null> {
async function fetchBaseAppCatalog(): Promise<AppCatalog | null> {
// Return cache if fresh
if (cachedCatalog && Date.now() - catalogFetchedAt < CATALOG_TTL) return { ...cachedCatalog, apps: normalizeStoreApps(cachedCatalog.apps) }