diff --git a/docs/docs/Post Platform Guide/platform-evault-registration.md b/docs/docs/Post Platform Guide/platform-evault-registration.md index 6c56900c1..936b968f6 100644 --- a/docs/docs/Post Platform Guide/platform-evault-registration.md +++ b/docs/docs/Post Platform Guide/platform-evault-registration.md @@ -69,6 +69,7 @@ const { w3id, uri } = data; **Request fields** + | Field | Description | | --- | --- | | `registryEntropy` | The entropy token from Step 1. | @@ -84,9 +85,62 @@ const { w3id, uri } = data; | `w3id` | The platform's assigned W3ID / eName. | | `uri` | The endpoint URI of the newly provisioned eVault. | -### Step 3 — Persist the mapping +### Step 3 — Write the PlatformProfile into the eVault + +With the eVault provisioned, write a **PlatformProfile** MetaEnvelope into it. This is the record other participants read to discover your platform — the **Marketplace**, for example, pulls every PlatformProfile from **Awareness-as-a-Service** and renders one card per platform. Its `url`, `logoUrl`, and `category` fields are what drive that card, so fill them in. + +The profile is stored under the **User-profile ontology** (`550e8400-e29b-41d4-a716-446655440000`) with a public ACL (`["*"]`). It is distinguished from an ordinary user profile by the presence of a `platformName` field — that is exactly the marker consumers filter on. -Save `w3id` and `uri` locally so the platform can resolve and reuse its eVault on every subsequent boot, and so the existence check in Step 0 short-circuits. Cerberus stores this under the fixed key `cerberus-platform`. +```ts +const now = new Date().toISOString(); +const platformProfile = { + platformName: "cerberus", // stable slug; the discovery marker + displayName: "Cerberus Platform", + description: "Secure messaging and group management platform", + version: "1.0.0", + ename: w3id, + isActive: true, + isArchived: false, + createdAt: now, + updatedAt: now, + url: "https://cerberus.w3ds.metastate.foundation", // public web app URL + logoUrl: "https://cerberus.w3ds.metastate.foundation/logo.png", // absolute logo URL + category: "Social", // Identity | Social | Governance | Wellness | Finance | Storage | Productivity +}; + +// storeMetaEnvelope only needs the X-ENAME header (no Bearer token). +await client.request(STORE_META_ENVELOPE, { + input: { + ontology: "550e8400-e29b-41d4-a716-446655440000", + payload: platformProfile, + acl: ["*"], + }, +}); +``` + +**Profile fields** + +| Field | Type | Purpose | +| --- | --- | --- | +| `platformName` | string | Stable machine slug and the marker consumers filter on to tell a platform profile apart from a user profile. | +| `displayName` | string | Human-readable name shown to users. | +| `description` | string | Short blurb describing the platform. | +| `version` | string | Platform profile version. | +| `ename` | string | The platform's own eName (`w3id` from Step 2). | +| `isActive` | boolean | Whether the platform is live; consumers hide `false`. | +| `isArchived` | boolean | Soft-delete flag; consumers hide `true`. | +| `createdAt` / `updatedAt` | string | ISO-8601 timestamps. | +| `url` | string | **Public web app URL** — where "Open App" links. Required for the platform to be launchable from the Marketplace. | +| `logoUrl` | string | **Absolute URL** to the platform logo. Leave empty (`""`) to fall back to a placeholder icon. | +| `category` | string | One of `Identity`, `Social`, `Governance`, `Wellness`, `Finance`, `Storage`, `Productivity` (or a custom value — it becomes a filter chip). | + +:::note +`storeMetaEnvelope` creates a **new** MetaEnvelope each call. To later change a field (e.g. add a `url` that predates this schema), update the existing profile by id with `updateMetaEnvelope(id, …)` — which requires a Bearer token — rather than calling `storeMetaEnvelope` again, otherwise you create a duplicate profile. +::: + +### Step 4 — Persist the mapping + +Save `w3id` and `uri` locally so the platform can resolve and reuse its eVault on every subsequent boot, and so the existence check in Step 0 short-circuits. Cerberus stores this under the fixed key `cerberus-platform`. Mirror the same `url`/`logoUrl`/`category` into the locally cached profile data so a later `updatePlatformProfile()` doesn't drop them. Once persisted, helpers such as `getPlatformEName()` and `getPlatformEVaultUri()` read straight from this mapping. @@ -118,4 +172,5 @@ Requests to the platform eVault are then made against that endpoint with the pla 1. On boot, check whether the platform eVault already exists — provision only if it doesn't. 2. Fetch an entropy token from the Registry. 3. `POST /provision` on the Provisioner with the entropy token, a fresh namespace, a verification id, and the platform public key. -4. Persist the returned `w3id` and `uri` so the platform can resolve and reuse its eVault on every subsequent boot. +4. Write a **PlatformProfile** MetaEnvelope (User-profile ontology, `acl: ["*"]`) into the eVault, including `url`, `logoUrl`, and `category` so consumers like the Marketplace can discover and render the platform. +5. Persist the returned `w3id` and `uri` (and mirror `url`/`logoUrl`/`category`) so the platform can resolve and reuse its eVault on every subsequent boot. diff --git a/platforms/cerberus/client/src/services/PlatformEVaultService.ts b/platforms/cerberus/client/src/services/PlatformEVaultService.ts index 8ee0c4c24..388ae509a 100644 --- a/platforms/cerberus/client/src/services/PlatformEVaultService.ts +++ b/platforms/cerberus/client/src/services/PlatformEVaultService.ts @@ -36,6 +36,9 @@ interface PlatformProfile { createdAt: string; updatedAt: string; isArchived: boolean; + url: string; + logoUrl: string; + category: string; } export class PlatformEVaultService { @@ -132,6 +135,9 @@ export class PlatformEVaultService { description: "Cerberus - Secure messaging and group management platform", version: "1.0.0", + url: "https://cerberus.w3ds.metastate.foundation", + logoUrl: "", + category: "Social", }; await mappingRepository.save(mapping); @@ -205,6 +211,9 @@ export class PlatformEVaultService { createdAt: now, updatedAt: now, isArchived: false, + url: "https://cerberus.w3ds.metastate.foundation", + logoUrl: "", + category: "Social", }; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/platforms/dreamsync/api/src/services/PlatformEVaultService.ts b/platforms/dreamsync/api/src/services/PlatformEVaultService.ts index f5be28b55..9e13fe5d8 100644 --- a/platforms/dreamsync/api/src/services/PlatformEVaultService.ts +++ b/platforms/dreamsync/api/src/services/PlatformEVaultService.ts @@ -36,6 +36,9 @@ interface PlatformProfile { createdAt: string; updatedAt: string; isArchived: boolean; + url: string; + logoUrl: string; + category: string; } export class PlatformEVaultService { @@ -132,6 +135,9 @@ export class PlatformEVaultService { description: "DreamSync - AI-powered wishlist matching and collaboration platform", version: "1.0.0", + url: "https://dreamsync.w3ds.metastate.foundation", + logoUrl: "", + category: "Wellness", }; await mappingRepository.save(mapping); @@ -205,6 +211,9 @@ export class PlatformEVaultService { createdAt: now, updatedAt: now, isArchived: false, + url: "https://dreamsync.w3ds.metastate.foundation", + logoUrl: "", + category: "Wellness", }; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/platforms/ecurrency/api/src/services/PlatformEVaultService.ts b/platforms/ecurrency/api/src/services/PlatformEVaultService.ts index 522addcfe..38f1b5510 100644 --- a/platforms/ecurrency/api/src/services/PlatformEVaultService.ts +++ b/platforms/ecurrency/api/src/services/PlatformEVaultService.ts @@ -36,6 +36,9 @@ interface PlatformProfile { createdAt: string; updatedAt: string; isArchived: boolean; + url: string; + logoUrl: string; + category: string; } export class PlatformEVaultService { @@ -132,6 +135,9 @@ export class PlatformEVaultService { description: "eCurrency - Digital currency and ledger management platform", version: "1.0.0", + url: "https://ecurrency.w3ds.metastate.foundation", + logoUrl: "", + category: "Finance", }; await mappingRepository.save(mapping); @@ -205,6 +211,9 @@ export class PlatformEVaultService { createdAt: now, updatedAt: now, isArchived: false, + url: "https://ecurrency.w3ds.metastate.foundation", + logoUrl: "", + category: "Finance", }; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/platforms/esigner/api/src/services/PlatformEVaultService.ts b/platforms/esigner/api/src/services/PlatformEVaultService.ts index 9d1b013ad..99eeb793c 100644 --- a/platforms/esigner/api/src/services/PlatformEVaultService.ts +++ b/platforms/esigner/api/src/services/PlatformEVaultService.ts @@ -36,6 +36,9 @@ interface PlatformProfile { createdAt: string; updatedAt: string; isArchived: boolean; + url: string; + logoUrl: string; + category: string; } export class PlatformEVaultService { @@ -132,6 +135,9 @@ export class PlatformEVaultService { description: "eSigner - Digital signature and document signing platform", version: "1.0.0", + url: "https://esigner.w3ds.metastate.foundation", + logoUrl: "", + category: "Productivity", }; await mappingRepository.save(mapping); @@ -205,6 +211,9 @@ export class PlatformEVaultService { createdAt: now, updatedAt: now, isArchived: false, + url: "https://esigner.w3ds.metastate.foundation", + logoUrl: "", + category: "Productivity", }; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/platforms/file-manager/api/src/services/PlatformEVaultService.ts b/platforms/file-manager/api/src/services/PlatformEVaultService.ts index 1d537b773..e01d9cf1e 100644 --- a/platforms/file-manager/api/src/services/PlatformEVaultService.ts +++ b/platforms/file-manager/api/src/services/PlatformEVaultService.ts @@ -36,6 +36,9 @@ interface PlatformProfile { createdAt: string; updatedAt: string; isArchived: boolean; + url: string; + logoUrl: string; + category: string; } export class PlatformEVaultService { @@ -132,6 +135,9 @@ export class PlatformEVaultService { description: "File Manager - Cloud storage and file management platform", version: "1.0.0", + url: "https://file-manager.w3ds.metastate.foundation", + logoUrl: "", + category: "Storage", }; await mappingRepository.save(mapping); @@ -205,6 +211,9 @@ export class PlatformEVaultService { createdAt: now, updatedAt: now, isArchived: false, + url: "https://file-manager.w3ds.metastate.foundation", + logoUrl: "", + category: "Storage", }; for (let attempt = 1; attempt <= maxRetries; attempt++) { diff --git a/platforms/marketplace/client/client/src/pages/app-detail.tsx b/platforms/marketplace/client/client/src/pages/app-detail.tsx index fc895e955..c8105e84e 100644 --- a/platforms/marketplace/client/client/src/pages/app-detail.tsx +++ b/platforms/marketplace/client/client/src/pages/app-detail.tsx @@ -57,7 +57,14 @@ export default function AppDetailPage() { const [, params] = useRoute("/app/:id"); const appId = params?.id; - const app = appsData.find(a => a.id === appId); + // Live platforms from awareness, used to resolve apps not in the static list. + const { data: platformsData, isLoading: platformsLoading } = useQuery<{ platforms: any[] }>({ + queryKey: ["/api/platforms"], + }); + + const staticApp = appsData.find(a => a.id === appId); + const dynamicApp = platformsData?.platforms?.find((p: any) => p.id === appId); + const app: any = staticApp ?? dynamicApp; const details = appId ? appDetails[appId] : null; // Fetch platform references from eReputation @@ -69,6 +76,14 @@ export default function AppDetailPage() { const references = (referencesData as any)?.references || []; + if (!app && platformsLoading) { + return ( +
+

Loading…

+
+ ); + } + if (!app) { return (
diff --git a/platforms/marketplace/client/client/src/pages/home-page.tsx b/platforms/marketplace/client/client/src/pages/home-page.tsx index 77d218669..7fde0afed 100644 --- a/platforms/marketplace/client/client/src/pages/home-page.tsx +++ b/platforms/marketplace/client/client/src/pages/home-page.tsx @@ -1,5 +1,6 @@ import { useState } from "react"; import { Link } from "wouter"; +import { useQuery } from "@tanstack/react-query"; import { Input } from "@/components/ui/input"; import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; @@ -7,14 +8,42 @@ import { Search, Store, ArrowRight } from "lucide-react"; import w3dsLogo from "@assets/w3dslogo.svg"; import appsData from "@/data/apps.json"; +type App = { + id: string; + name: string; + description: string; + category: string; + logoUrl?: string | null; + url?: string; + appStoreUrl?: string; + playStoreUrl?: string; +}; + +const BASE_CATEGORIES = ["Identity", "Social", "Governance", "Wellness", "Finance", "Storage", "Productivity"]; + export default function HomePage() { const [searchQuery, setSearchQuery] = useState(""); const [selectedCategory, setSelectedCategory] = useState("All Apps"); - const apps = appsData; - const isLoadingApps = false; + // Live platforms published to awareness; merged with the curated static list. + const { data: platformsData, isLoading: isLoadingApps } = useQuery<{ platforms: App[] }>({ + queryKey: ["/api/platforms"], + }); + + const staticApps = appsData as App[]; + const staticIds = new Set(staticApps.map((a) => a.id.toLowerCase())); + const staticNames = new Set(staticApps.map((a) => a.name.toLowerCase())); + + // Static entries win on collision (they carry richer logos/URLs/descriptions). + const dynamicApps = (platformsData?.platforms ?? []).filter( + (p) => !staticIds.has(p.id.toLowerCase()) && !staticNames.has(p.name.toLowerCase()), + ); + const apps: App[] = [...staticApps, ...dynamicApps]; - const categories = ["All Apps", "Identity", "Social", "Governance", "Wellness", "Finance", "Storage", "Productivity"]; + const categories = [ + "All Apps", + ...Array.from(new Set([...BASE_CATEGORIES, ...apps.map((a) => a.category)])), + ]; const filteredApps = apps.filter(app => { const matchesSearch = app.name.toLowerCase().includes(searchQuery.toLowerCase()) || @@ -95,7 +124,7 @@ export default function HomePage() {
- {isLoadingApps ? ( + {isLoadingApps && apps.length === 0 ? (
{[...Array(6)].map((_, i) => (
diff --git a/platforms/marketplace/client/package.json b/platforms/marketplace/client/package.json index 7be0120a9..ea12efb89 100644 --- a/platforms/marketplace/client/package.json +++ b/platforms/marketplace/client/package.json @@ -12,6 +12,7 @@ "dependencies": { "@hookform/resolvers": "^3.10.0", "@jridgewell/trace-mapping": "^0.3.25", + "dotenv": "^16.4.5", "@radix-ui/react-accordion": "^1.2.4", "@radix-ui/react-alert-dialog": "^1.1.7", "@radix-ui/react-aspect-ratio": "^1.1.3", diff --git a/platforms/marketplace/client/server/aaas.ts b/platforms/marketplace/client/server/aaas.ts new file mode 100644 index 000000000..aefb11096 --- /dev/null +++ b/platforms/marketplace/client/server/aaas.ts @@ -0,0 +1,159 @@ +/** + * Read side for the marketplace: pull live platform listings from + * Awareness-as-a-Service (AaaS). + * + * Platforms publish a PlatformProfile into their own eVault at provisioning + * time (see each platform's PlatformEVaultService). It is stored under the + * User-profile ontology and distinguished from ordinary user profiles by a + * `platformName` field. AaaS fans those writes out and exposes them at + * `GET /api/packets?ontology=...`, which is what we page through here. + * + * The AaaS API key is a secret, so this only ever runs server-side. + * + * Env: + * AWARENESS_SERVICE_URL base URL of AaaS (default http://localhost:4100) + * AWARENESS_API_KEY Bearer key (aaas_…) issued from the AaaS portal + */ + +// User-profile ontology — platform profiles are stored under it, tagged with +// a `platformName`. Same id the platforms write with. +const USER_ONTOLOGY = "550e8400-e29b-41d4-a716-446655440000"; + +const AWARENESS_SERVICE_URL = + process.env.AWARENESS_SERVICE_URL || "http://localhost:4100"; +const AWARENESS_API_KEY = process.env.AWARENESS_API_KEY; + +/** A single packet as returned by AaaS `GET /api/packets`. */ +interface Packet { + id: string; + ontology: string; + w3id: string | null; + data: Record | null; + receivedAt: string; +} + +interface PacketsResponse { + packets: Packet[]; + hasMore: boolean; + nextCursor: string | null; +} + +/** The marketplace card shape (matches the static apps.json entries). */ +export interface MarketplacePlatform { + id: string; + name: string; + description: string; + category: string; + logoUrl: string | null; + url: string; + ename: string; +} + +const base = AWARENESS_SERVICE_URL.replace(/\/$/, ""); + +/** One page of packets for the given filters. */ +async function page( + params: Record, + cursor?: string | null, +): Promise { + const query = new URLSearchParams(); + query.set("limit", "200"); + for (const [k, v] of Object.entries(params)) query.set(k, String(v)); + if (cursor) query.set("cursor", cursor); + + const url = `${base}/api/packets?${query.toString()}`; + console.log(`[awareness] GET ${url}`); + const res = await fetch(url, { + headers: { Authorization: `Bearer ${AWARENESS_API_KEY}` }, + }); + const body = await res.text(); + if (!res.ok) { + console.error( + `[awareness] ${res.status} ${res.statusText} from /api/packets: ${body.slice(0, 500)}`, + ); + throw new Error(`AaaS /api/packets returned ${res.status}`); + } + try { + return JSON.parse(body) as PacketsResponse; + } catch (err) { + console.error( + `[awareness] failed to parse /api/packets response (first 500 chars): ${body.slice(0, 500)}`, + ); + throw err; + } +} + +/** Every packet matching the filters, paged to exhaustion (newest last). */ +async function all(params: Record): Promise { + const out: Packet[] = []; + let cursor: string | null | undefined; + do { + const res = await page(params, cursor); + out.push(...(res.packets ?? [])); + cursor = res.hasMore ? res.nextCursor : null; + } while (cursor); + return out; +} + +/** + * All published platform profiles, deduped by eName (last write wins), mapped + * to the marketplace card shape. Returns [] when AaaS is not configured so + * local dev without AaaS still works. + */ +export async function listPlatforms(): Promise { + if (!AWARENESS_API_KEY) { + console.warn( + "[awareness] AWARENESS_API_KEY is not set — returning no live platforms", + ); + return []; + } + console.log( + `[awareness] listPlatforms: base=${base} ontology=${USER_ONTOLOGY}`, + ); + + const packets = await all({ ontology: USER_ONTOLOGY }); + console.log(`[awareness] fetched ${packets.length} packet(s) total`); + + // Packets are ordered oldest→newest, so a plain Map keeps the last write. + const byEname = new Map(); + let profileCount = 0; + let skippedNoPlatformName = 0; + let skippedArchived = 0; + for (const p of packets) { + const data = p.data; + // Isolate platform profiles from ordinary user profiles sharing the + // ontology; skip archived/inactive platforms. + if (!data || typeof data.platformName !== "string" || !data.platformName) { + skippedNoPlatformName++; + continue; + } + if (data.isArchived === true || data.isActive === false) { + skippedArchived++; + continue; + } + + const ename = (p.w3id ?? (data.ename as string | undefined)) || ""; + if (!ename) continue; + + profileCount++; + byEname.set(ename, { + id: data.platformName, + name: data.displayName || data.platformName, + description: data.description || "", + category: data.category || "Other", + logoUrl: data.logoUrl || null, + url: data.url || "", + ename, + }); + } + + const platforms = Array.from(byEname.values()); + console.log( + `[awareness] platform profiles=${profileCount} (deduped=${platforms.length}), ` + + `skipped: non-platform=${skippedNoPlatformName}, archived/inactive=${skippedArchived}`, + ); + console.log( + `[awareness] platforms: ${platforms.map((p) => p.id).join(", ") || "(none)"}`, + ); + return platforms; +} diff --git a/platforms/marketplace/client/server/env.ts b/platforms/marketplace/client/server/env.ts new file mode 100644 index 000000000..3d4595e0f --- /dev/null +++ b/platforms/marketplace/client/server/env.ts @@ -0,0 +1,9 @@ +import path from "path"; +import { config } from "dotenv"; + +// Load the repo-root .env so process.env is populated before any other module +// (e.g. ./aaas) reads it. Imported first in index.ts. At runtime this file is +// bundled into dist/index.js, so import.meta.dirname is client/dist; four levels +// up (dist → client → marketplace → platforms → repo root) is the shared .env. +// The same relative path holds in dev, where it runs from client/server. +config({ path: path.resolve(import.meta.dirname, "../../../../.env") }); diff --git a/platforms/marketplace/client/server/index.ts b/platforms/marketplace/client/server/index.ts index af9d2ebda..8ff832e27 100644 --- a/platforms/marketplace/client/server/index.ts +++ b/platforms/marketplace/client/server/index.ts @@ -1,3 +1,4 @@ +import "./env"; // must be first: loads repo-root .env before any env reads import express, { type Request, Response, NextFunction } from "express"; import { registerRoutes } from "./routes"; import { setupVite, serveStatic, log } from "./vite"; diff --git a/platforms/marketplace/client/server/routes.ts b/platforms/marketplace/client/server/routes.ts index 61ea20db6..f00058d33 100644 --- a/platforms/marketplace/client/server/routes.ts +++ b/platforms/marketplace/client/server/routes.ts @@ -1,5 +1,6 @@ import type { Express } from "express"; import { createServer, type Server } from "http"; +import { listPlatforms } from "./aaas"; const EREPUTATION_API_URL = process.env.EREPUTATION_API_URL || "http://localhost:8765"; @@ -9,6 +10,19 @@ export async function registerRoutes(app: Express): Promise { res.json({ status: "ok", message: "Marketplace server is running" }); }); + // Live platform listings pulled from Awareness-as-a-Service. Degrades to an + // empty list if AaaS is unavailable or unconfigured, so the static catalog + // still renders. + app.get("/api/platforms", async (_req, res) => { + try { + const platforms = await listPlatforms(); + res.json({ platforms, count: platforms.length }); + } catch (error: any) { + console.error("Error fetching platforms from awareness:", error); + res.json({ platforms: [], count: 0, error: error.message }); + } + }); + // Get platform references from eReputation API app.get("/api/platforms/:platformId/references", async (req, res) => { try { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f0b078fdd..67b478bff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3463,6 +3463,9 @@ importers: date-fns: specifier: ^3.6.0 version: 3.6.0 + dotenv: + specifier: ^16.4.5 + version: 16.6.1 embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@18.3.1)