Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
87 changes: 87 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/marketplaceApps.spec.ts
Original file line number Diff line number Diff line change
@@ -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`);
});
});
97 changes: 97 additions & 0 deletions infrastructure/eid-wallet/src/lib/utils/marketplaceApps.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
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<string>();
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<RibbonApp[]> {
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;
}
}
Original file line number Diff line number Diff line change
@@ -1,55 +1,43 @@
<script lang="ts">
import { m } from "$lib/i18n";
import {
DEFAULT_RIBBON_APPS,
MARKETPLACE_URL,
type RibbonApp,
fetchRibbonApps,
} from "$lib/utils/marketplaceApps";
import { ArrowRight01Icon } from "@hugeicons/core-free-icons";
import { HugeiconsIcon } from "@hugeicons/svelte";

interface IApp {
name: string;
category: () => string;
logo: string;
url: string;
}
import { onMount } from "svelte";

interface IAppsMarketplaceProps {
href?: string;
}

const {
href = "https://marketplace.w3ds.metastate.foundation/",
}: IAppsMarketplaceProps = $props();
const { href = `${MARKETPLACE_URL}/` }: IAppsMarketplaceProps = $props();

const CATEGORY_LABELS: Record<string, () => string> = {
social: m.marketplace_category_social,
governance: m.marketplace_category_governance,
finance: m.marketplace_category_finance,
};

let apps = $state<RibbonApp[]>(DEFAULT_RIBBON_APPS);
let failedLogos = $state<Record<string, true>>({});

const apps: IApp[] = [
{
name: "Blabsy",
category: m.marketplace_category_social,
logo: "/images/Logo-Blabsy.svg",
url: "https://blabsy.w3ds.metastate.foundation",
},
{
name: "Pictique",
category: m.marketplace_category_social,
logo: "/images/Logo-Pictique.svg",
url: "https://pictique.w3ds.metastate.foundation",
},
{
name: "eVoting",
category: m.marketplace_category_governance,
logo: "/images/Logo-eVoting.svg",
url: "https://evoting.w3ds.metastate.foundation",
},
{
name: "eCurrency",
category: m.marketplace_category_finance,
logo: "/images/Logo-eCurrency.svg",
url: "https://ecurrency.w3ds.metastate.foundation",
},
{
name: "Dreamsync",
category: m.marketplace_category_governance,
logo: "/images/Logo-Dreamsync.svg",
url: "https://dreamsync.w3ds.metastate.foundation",
},
];
function categoryLabel(category: string): string {
return CATEGORY_LABELS[category.toLowerCase()]?.() ?? category;
}

onMount(() => {
fetchRibbonApps()
.then((list) => {
if (list.length > 0) apps = list;
})
.catch((error) => {
console.warn("Could not load marketplace apps:", error);
});
});
</script>

<section class="mt-8">
Expand All @@ -72,29 +60,39 @@ const apps: IApp[] = [
<div
class="apps-carousel -mx-5 px-5 flex gap-3 overflow-x-auto snap-x snap-mandatory scroll-pl-5 scroll-pr-5"
>
{#each apps as app (app.name)}
{#each apps as app (app.id)}
<a
href={app.url}
target="_blank"
rel="noopener noreferrer"
class="snap-start shrink-0 w-28 h-32 bg-card-alternative rounded-3xl px-3 py-4 flex flex-col items-start justify-between active:opacity-70"
>
<img
src={app.logo}
alt=""
width="40"
height="40"
class="block w-10 h-10 object-contain"
aria-hidden="true"
/>
{#if app.logo && !failedLogos[app.id]}
<img
src={app.logo}
alt=""
width="40"
height="40"
class="block w-10 h-10 object-contain"
aria-hidden="true"
onerror={() => (failedLogos[app.id] = true)}
/>
{:else}
<div
class="w-10 h-10 rounded-xl bg-white text-black-900 font-semibold text-lg flex items-center justify-center"
aria-hidden="true"
>
{Array.from(app.name)[0]?.toUpperCase()}
</div>
{/if}
<div>
<p
class="font-medium text-lg text-black-900 leading-tight truncate w-full"
>
{app.name}
</p>
<p class="text-black-500 leading-tight">
{app.category()}
{categoryLabel(app.category)}
</p>
</div>
</a>
Expand Down
37 changes: 37 additions & 0 deletions platforms/marketplace/client/server/catalog.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import appsData from "../client/src/data/apps.json";
import { listPlatforms } from "./aaas";

export interface CatalogApp {
id: string;
name: string;
description: string;
category: string;
logoUrl?: string | null;
url?: string;
appStoreUrl?: string;
playStoreUrl?: string;
}

/**
* The curated apps.json list followed by live AaaS platforms. Curated entries
* win on an id or name collision. If AaaS fails, only the curated list is
* returned.
*/
export async function listCatalogApps(): Promise<CatalogApp[]> {
const curated = appsData as CatalogApp[];
let live: CatalogApp[] = [];
try {
live = await listPlatforms();
} catch (error) {
console.error("Error fetching platforms from awareness:", error);
}

const ids = new Set(curated.map((a) => a.id.toLowerCase()));
const names = new Set(curated.map((a) => a.name.toLowerCase()));
return [
...curated,
...live.filter(
(p) => !ids.has(p.id.toLowerCase()) && !names.has(p.name.toLowerCase()),
),
];
}
10 changes: 10 additions & 0 deletions platforms/marketplace/client/server/routes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Express } from "express";
import { createServer, type Server } from "http";
import { listPlatforms } from "./aaas";
import { listCatalogApps } from "./catalog";

const EREPUTATION_API_URL = process.env.EREPUTATION_API_URL || "http://localhost:8765";

Expand All @@ -23,6 +24,15 @@ export async function registerRoutes(app: Express): Promise<Server> {
}
});

// Full catalogue (curated + live) for other clients, e.g. the eID wallet's
// apps ribbon. Public, read-only data, so any origin may read it.
app.get("/api/apps", async (_req, res) => {
const apps = await listCatalogApps();
res.set("Access-Control-Allow-Origin", "*");
res.set("Cache-Control", "public, max-age=300");
res.json({ apps, count: apps.length });
});

// Get platform references from eReputation API
app.get("/api/platforms/:platformId/references", async (req, res) => {
try {
Expand Down
Loading