From a6149858a455f6dd95ff0f6297977a921f3b8986 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Wed, 30 Sep 2026 18:21:43 +0300 Subject: [PATCH] feat(eid-wallet): load the apps ribbon from the marketplace Add a public GET /api/apps to the marketplace that returns the curated apps.json list plus live AaaS platforms. The wallet's apps ribbon now fetches it and keeps the bundled list if the marketplace can't be reached. --- .../src/lib/utils/marketplaceApps.spec.ts | 87 +++++++++++++++ .../src/lib/utils/marketplaceApps.ts | 97 +++++++++++++++++ .../main/components/AppsMarketplace.svelte | 102 +++++++++--------- .../marketplace/client/server/catalog.ts | 37 +++++++ platforms/marketplace/client/server/routes.ts | 10 ++ 5 files changed, 281 insertions(+), 52 deletions(-) create mode 100644 infrastructure/eid-wallet/src/lib/utils/marketplaceApps.spec.ts create mode 100644 infrastructure/eid-wallet/src/lib/utils/marketplaceApps.ts create mode 100644 platforms/marketplace/client/server/catalog.ts 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[] = [