diff --git a/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.spec.ts b/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.spec.ts new file mode 100644 index 000000000..05747fb7a --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.spec.ts @@ -0,0 +1,87 @@ +import { describe, expect, it, vi } from "vitest"; +import { fetchRibbonApps, toRibbonApps } from "./marketplaceApps"; + +const BASE = "https://marketplace.example"; + +describe("toRibbonApps", () => { + it("maps marketplace entries and prefers bundled logos", () => { + const apps = toRibbonApps( + { + apps: [ + { + id: "blabsy", + name: "Blabsy", + category: "Social", + logoUrl: "/blabsy.svg", + url: "https://blabsy.example", + }, + { + id: "charter", + name: "Charter Manager", + category: "Governance", + logoUrl: "/charter.png", + url: "https://charter.example", + }, + ], + }, + BASE, + ); + expect(apps).toEqual([ + { + id: "blabsy", + name: "Blabsy", + category: "Social", + logo: "/images/Logo-Blabsy.svg", + url: "https://blabsy.example/", + }, + { + id: "charter", + name: "Charter Manager", + category: "Governance", + logo: `${BASE}/charter.png`, + url: "https://charter.example/", + }, + ]); + }); + + it("skips apps without an https link, and duplicates", () => { + const apps = toRibbonApps({ + apps: [ + { id: "eid-wallet", name: "eID", appStoreUrl: "https://x" }, + { id: "bad", name: "Bad", url: "javascript:alert(1)" }, + { id: "plain", name: "Plain", url: "http://plain.example" }, + { id: "a", name: "A", url: "https://a.example" }, + { id: "a", name: "A again", url: "https://a2.example" }, + ], + }); + expect(apps.map((a) => a.id)).toEqual(["a"]); + }); + + it("drops logos that are not https", () => { + const [app] = toRibbonApps({ + apps: [ + { + id: "x", + name: "X", + logoUrl: "data:image/png;base64,AAAA", + url: "https://x.example", + }, + ], + }); + expect(app.logo).toBeNull(); + expect(app.category).toBe(""); + }); + + it("returns nothing for a malformed payload", () => { + expect(toRibbonApps(null)).toEqual([]); + expect(toRibbonApps({ apps: "nope" })).toEqual([]); + }); +}); + +describe("fetchRibbonApps", () => { + it("throws on a non-2xx response", async () => { + const fetchFn = vi.fn(async () => new Response("", { status: 502 })); + await expect(fetchRibbonApps(BASE, fetchFn)).rejects.toThrow("502"); + expect(fetchFn).toHaveBeenCalledWith(`${BASE}/api/apps`); + }); +}); diff --git a/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.ts b/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.ts new file mode 100644 index 000000000..d7de761df --- /dev/null +++ b/infrastructure/eid-wallet/src/lib/utils/marketplaceApps.ts @@ -0,0 +1,97 @@ +import { cleanPlatformLogo, cleanPlatformName } from "./platformBranding"; + +export const MARKETPLACE_URL = "https://marketplace.w3ds.metastate.foundation"; + +export interface RibbonApp { + id: string; + name: string; + /** English category from the marketplace, e.g. "Social". */ + category: string; + logo: string | null; + url: string; +} + +// Wallet-styled logos, preferred over the marketplace's for these apps. +const BUNDLED_LOGOS: Record = { + blabsy: "/images/Logo-Blabsy.svg", + pictique: "/images/Logo-Pictique.svg", + evoting: "/images/Logo-eVoting.svg", + ecurrency: "/images/Logo-eCurrency.svg", + dreamsync: "/images/Logo-Dreamsync.svg", +}; + +/** Shown until the marketplace answers, and kept if it can't be reached. */ +export const DEFAULT_RIBBON_APPS: RibbonApp[] = [ + ["blabsy", "Blabsy", "Social"], + ["pictique", "Pictique", "Social"], + ["evoting", "eVoting", "Governance"], + ["ecurrency", "eCurrency", "Finance"], + ["dreamsync", "Dreamsync", "Governance"], +].map(([id, name, category]) => ({ + id, + name, + category, + logo: BUNDLED_LOGOS[id], + url: `https://${id}.w3ds.metastate.foundation`, +})); + +/** + * Turns a marketplace `/api/apps` response into ribbon entries. Live entries + * are published by the platforms themselves, so names are cleaned and only + * https links and logos are kept. Apps without a web link (the wallet itself) + * are skipped. + */ +export function toRibbonApps( + payload: unknown, + baseUrl = MARKETPLACE_URL, +): RibbonApp[] { + const items = (payload as { apps?: unknown } | null)?.apps; + if (!Array.isArray(items)) return []; + + const seen = new Set(); + const apps: RibbonApp[] = []; + for (const item of items) { + if (!item || typeof item !== "object") continue; + const { id, name, category, logoUrl, url } = item as Record< + string, + unknown + >; + if (typeof id !== "string" || seen.has(id)) continue; + const cleanName = cleanPlatformName(asString(name)); + const link = cleanPlatformLogo(asString(url)); + if (!cleanName || !link) continue; + + seen.add(id); + apps.push({ + id, + name: cleanName, + category: cleanPlatformName(asString(category)) ?? "", + logo: BUNDLED_LOGOS[id] ?? resolveLogo(asString(logoUrl), baseUrl), + url: link, + }); + } + return apps; +} + +export async function fetchRibbonApps( + baseUrl = MARKETPLACE_URL, + fetchFn: typeof fetch = fetch, +): Promise { + const res = await fetchFn(`${baseUrl}/api/apps`); + if (!res.ok) throw new Error(`Marketplace returned ${res.status}`); + return toRibbonApps(await res.json(), baseUrl); +} + +function asString(value: unknown): string | null { + return typeof value === "string" ? value : null; +} + +// Curated marketplace logos are paths on the marketplace host ("/blabsy.svg"). +function resolveLogo(value: string | null, baseUrl: string): string | null { + if (!value) return null; + try { + return cleanPlatformLogo(new URL(value, baseUrl).toString()); + } catch { + return null; + } +} diff --git a/infrastructure/eid-wallet/src/routes/(app)/main/components/AppsMarketplace.svelte b/infrastructure/eid-wallet/src/routes/(app)/main/components/AppsMarketplace.svelte index f783be713..390d95e8a 100644 --- a/infrastructure/eid-wallet/src/routes/(app)/main/components/AppsMarketplace.svelte +++ b/infrastructure/eid-wallet/src/routes/(app)/main/components/AppsMarketplace.svelte @@ -1,55 +1,43 @@
@@ -72,21 +60,31 @@ const apps: IApp[] = [