From 2788d5b79228b6bc7e27f605cee529b1f8b5a69e Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 26 Feb 2026 19:31:51 +0300 Subject: [PATCH 1/6] feat: add W3DS Gateway embeddable web component and notification helpers --- packages/w3ds-gateway/README.md | 127 ++++ packages/w3ds-gateway/package.json | 39 ++ .../w3ds-gateway/src/__tests__/modal.test.ts | 202 ++++++ .../src/__tests__/notifications.test.ts | 134 ++++ .../src/__tests__/resolver.test.ts | 248 ++++++++ packages/w3ds-gateway/src/capabilities.ts | 382 ++++++++++++ packages/w3ds-gateway/src/icons.ts | 108 ++++ packages/w3ds-gateway/src/index.ts | 51 ++ packages/w3ds-gateway/src/modal.ts | 590 ++++++++++++++++++ packages/w3ds-gateway/src/notifications.ts | 155 +++++ packages/w3ds-gateway/src/resolver.ts | 212 +++++++ packages/w3ds-gateway/src/schemas.ts | 74 +++ packages/w3ds-gateway/src/types.ts | 72 +++ packages/w3ds-gateway/tsconfig.build.json | 20 + packages/w3ds-gateway/tsconfig.json | 17 + pnpm-lock.yaml | 12 + test.html | 69 ++ 17 files changed, 2512 insertions(+) create mode 100644 packages/w3ds-gateway/README.md create mode 100644 packages/w3ds-gateway/package.json create mode 100644 packages/w3ds-gateway/src/__tests__/modal.test.ts create mode 100644 packages/w3ds-gateway/src/__tests__/notifications.test.ts create mode 100644 packages/w3ds-gateway/src/__tests__/resolver.test.ts create mode 100644 packages/w3ds-gateway/src/capabilities.ts create mode 100644 packages/w3ds-gateway/src/icons.ts create mode 100644 packages/w3ds-gateway/src/index.ts create mode 100644 packages/w3ds-gateway/src/modal.ts create mode 100644 packages/w3ds-gateway/src/notifications.ts create mode 100644 packages/w3ds-gateway/src/resolver.ts create mode 100644 packages/w3ds-gateway/src/schemas.ts create mode 100644 packages/w3ds-gateway/src/types.ts create mode 100644 packages/w3ds-gateway/tsconfig.build.json create mode 100644 packages/w3ds-gateway/tsconfig.json create mode 100644 test.html diff --git a/packages/w3ds-gateway/README.md b/packages/w3ds-gateway/README.md new file mode 100644 index 000000000..f26a743cf --- /dev/null +++ b/packages/w3ds-gateway/README.md @@ -0,0 +1,127 @@ +# w3ds-gateway + +Resolve W3DS eNames to application URLs and present an app chooser. + +## What it does + +Given an **eName** (W3ID) and a **schemaId** (content type from the ontology), the gateway determines which platforms can handle that content and builds deep links into each one. + +Think of it as Android's "Open with..." system, but for the W3DS ecosystem. + +## Usage + +### TypeScript / Node.js + +```ts +import { resolveEName, SchemaIds } from "w3ds-gateway"; + +const result = await resolveEName( + { + ename: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a", + schemaId: SchemaIds.SocialMediaPost, + entityId: "post-123", + }, + { + registryUrl: "https://registry.w3ds.metastate.foundation", + }, +); + +for (const app of result.apps) { + console.log(`${app.platformName}: ${app.url}`); +} +// Pictique: https://pictique.w3ds.metastate.foundation/home +// Blabsy: https://blabsy.w3ds.metastate.foundation/tweet/post-123 +``` + +### Synchronous (no Registry call) + +```ts +import { resolveENameSync, SchemaIds } from "w3ds-gateway"; + +const result = resolveENameSync({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + entityId: "file-456", +}); +// → File Manager, eSigner +``` + +### In Notification Messages + +```ts +import { buildGatewayLink, SchemaIds } from "w3ds-gateway"; + +const link = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.SignatureContainer, + entityId: "container-123", + linkText: "Choose app to view document", +}); +// → 'Choose app to view document' +``` + +## Embeddable Web Component + +Drop `` into any platform — works in Svelte, React, or plain HTML: + +```html + + + + + +``` + +### JS API + +| Method / Property | Description | +| ----------------- | ----------------------------------- | +| `el.open()` | Open the chooser modal | +| `el.close()` | Close the chooser modal | +| `el.isOpen` | Whether the modal is currently open | + +### Events + +| Event | Detail | Description | +| ---------------- | ---------------------- | ---------------------------------- | +| `gateway-open` | — | Fired when the modal opens | +| `gateway-close` | — | Fired when the modal closes | +| `gateway-select` | `{ platformKey, url }` | Fired when user clicks an app link | + +## Supported Schemas + +| Schema | Platforms | +| ---------------------- | ------------------------------- | +| User Profile | Pictique, Blabsy | +| Social Media Post | Pictique, Blabsy | +| Group / Chat | Pictique, Blabsy, Group Charter | +| Message | Pictique, Blabsy | +| Voting Observation | Cerberus, eReputation | +| Ledger (Transaction) | eCurrency | +| Currency | eCurrency | +| Poll | eVoting, eReputation | +| Vote | eVoting | +| Vote Reputation Result | eVoting, eReputation | +| Wishlist | DreamSync | +| Charter Signature | Group Charter | +| Reference Signature | eReputation | +| File | File Manager, eSigner | +| Signature Container | eSigner, File Manager | + +## Architecture + +The gateway is **frontend-first** by design: + +1. **Static capability map** — derived from the `.mapping.json` files across all platforms +2. **Optional Registry integration** — fetches live platform URLs from `GET /platforms` +3. **URL template system** — builds deep links using platform routes +4. **No dedicated backend required** — the resolver runs client-side or as a thin API endpoint diff --git a/packages/w3ds-gateway/package.json b/packages/w3ds-gateway/package.json new file mode 100644 index 000000000..8629a1631 --- /dev/null +++ b/packages/w3ds-gateway/package.json @@ -0,0 +1,39 @@ +{ + "name": "w3ds-gateway", + "version": "0.1.0", + "description": "W3DS Gateway — resolve eNames to application URLs and present an app chooser", + "type": "module", + "scripts": { + "build": "tsc -p tsconfig.build.json", + "test": "vitest run", + "test:watch": "vitest", + "check-types": "tsc --noEmit" + }, + "license": "MIT", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./modal": { + "import": "./dist/modal.js", + "types": "./dist/modal.d.ts" + } + }, + "files": [ + "dist" + ], + "typesVersions": { + "*": { + "modal": [ + "./dist/modal.d.ts" + ] + } + }, + "devDependencies": { + "typescript": "~5.6.2", + "vitest": "^3.0.9" + } +} diff --git a/packages/w3ds-gateway/src/__tests__/modal.test.ts b/packages/w3ds-gateway/src/__tests__/modal.test.ts new file mode 100644 index 000000000..f8417e6bc --- /dev/null +++ b/packages/w3ds-gateway/src/__tests__/modal.test.ts @@ -0,0 +1,202 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, beforeAll, beforeEach } from "vitest"; + +// Import the modal (side-effect: registers the custom element) +import "../modal.js"; +import { W3dsGatewayChooser } from "../modal.js"; +import { configurePlatformUrls } from "../capabilities.js"; + +// Configure test platform URLs +beforeAll(() => { + configurePlatformUrls({ + pictique: "http://localhost:5173", + blabsy: "http://localhost:8080", + "file-manager": "http://localhost:3005", + esigner: "http://localhost:3006", + evoting: "http://localhost:3000", + }); +}); + +describe("W3dsGatewayChooser web component", () => { + beforeEach(() => { + // Clean up any existing instances + document.body.innerHTML = ""; + }); + + it("registers the custom element", () => { + const ctor = customElements.get("w3ds-gateway-chooser"); + expect(ctor).toBeDefined(); + expect(ctor).toBe(W3dsGatewayChooser); + }); + + it("creates an element with shadow DOM", () => { + const el = document.createElement("w3ds-gateway-chooser"); + document.body.appendChild(el); + + expect(el.shadowRoot).toBeDefined(); + expect(el.shadowRoot).not.toBeNull(); + }); + + it("starts closed by default", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + document.body.appendChild(el); + + expect(el.isOpen).toBe(false); + const backdrop = el.shadowRoot!.querySelector(".gateway-backdrop"); + expect(backdrop).not.toBeNull(); + expect(backdrop!.classList.contains("open")).toBe(false); + }); + + it("opens via the open() method", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + document.body.appendChild(el); + + el.open(); + + expect(el.isOpen).toBe(true); + const backdrop = el.shadowRoot!.querySelector(".gateway-backdrop"); + expect(backdrop!.classList.contains("open")).toBe(true); + }); + + it("closes via the close() method", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + document.body.appendChild(el); + + el.open(); + el.close(); + + expect(el.isOpen).toBe(false); + const backdrop = el.shadowRoot!.querySelector(".gateway-backdrop"); + expect(backdrop!.classList.contains("open")).toBe(false); + }); + + it("dispatches gateway-open event on open()", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + document.body.appendChild(el); + + let opened = false; + el.addEventListener("gateway-open", () => { opened = true; }); + el.open(); + + expect(opened).toBe(true); + }); + + it("dispatches gateway-close event on close()", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + document.body.appendChild(el); + + let closed = false; + el.addEventListener("gateway-close", () => { closed = true; }); + el.open(); + el.close(); + + expect(closed).toBe(true); + }); + + it("shows error when ename is missing on open", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("schema-id", "some-schema"); + document.body.appendChild(el); + + el.open(); + // Wait for async resolve + await new Promise((r) => setTimeout(r, 50)); + + const error = el.shadowRoot!.querySelector(".gateway-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("Missing data"); + }); + + it("shows error when schema-id is missing on open", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + document.body.appendChild(el); + + el.open(); + await new Promise((r) => setTimeout(r, 50)); + + const error = el.shadowRoot!.querySelector(".gateway-error"); + expect(error).not.toBeNull(); + expect(error!.textContent).toContain("Missing data"); + }); + + it("renders app links for known schema", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); // SocialMediaPost + el.setAttribute("entity-id", "post-123"); + document.body.appendChild(el); + + el.open(); + // Wait for async resolve (no registry, uses defaults) + await new Promise((r) => setTimeout(r, 100)); + + const links = el.shadowRoot!.querySelectorAll(".gateway-app-link"); + expect(links.length).toBeGreaterThan(0); + + // SocialMediaPost should have Pictique and Blabsy + const hrefs = Array.from(links).map((l) => (l as HTMLAnchorElement).href); + const pictique = hrefs.find((h) => h.includes("localhost:5173")); + const blabsy = hrefs.find((h) => h.includes("localhost:8080")); + expect(pictique).toBeDefined(); + expect(blabsy).toBeDefined(); + }); + + it("renders empty state for unknown schema", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "nonexistent-schema-id"); + document.body.appendChild(el); + + el.open(); + await new Promise((r) => setTimeout(r, 100)); + + const empty = el.shadowRoot!.querySelector(".gateway-empty"); + expect(empty).not.toBeNull(); + }); + + it("opens automatically when 'open' attribute is present", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@test-user"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + el.setAttribute("open", ""); + document.body.appendChild(el); + + // Should auto-open on connect + expect(el.isOpen).toBe(true); + }); + + it("shows eName in the footer", async () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + el.setAttribute("ename", "@alice-test"); + el.setAttribute("schema-id", "550e8400-e29b-41d4-a716-446655440001"); + el.setAttribute("entity-id", "post-1"); + document.body.appendChild(el); + + el.open(); + await new Promise((r) => setTimeout(r, 100)); + + const footer = el.shadowRoot!.querySelector(".gateway-footer"); + expect(footer).not.toBeNull(); + expect(footer!.textContent).toContain("@alice-test"); + }); + + it("has proper ARIA attributes", () => { + const el = document.createElement("w3ds-gateway-chooser") as W3dsGatewayChooser; + document.body.appendChild(el); + + const modal = el.shadowRoot!.querySelector(".gateway-modal"); + expect(modal!.getAttribute("role")).toBe("dialog"); + expect(modal!.getAttribute("aria-modal")).toBe("true"); + }); +}); diff --git a/packages/w3ds-gateway/src/__tests__/notifications.test.ts b/packages/w3ds-gateway/src/__tests__/notifications.test.ts new file mode 100644 index 000000000..a5064e69f --- /dev/null +++ b/packages/w3ds-gateway/src/__tests__/notifications.test.ts @@ -0,0 +1,134 @@ +import { describe, it, expect } from "vitest"; +import { + buildGatewayUri, + buildGatewayData, + buildGatewayLink, + SchemaIds, +} from "../index.js"; + +describe("buildGatewayUri", () => { + it("builds a w3ds-gateway:// URI with ename and schemaId", () => { + const uri = buildGatewayUri({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + }); + + expect(uri).toMatch(/^w3ds-gateway:\/\/resolve\?/); + expect(uri).toContain("ename=%40user-uuid"); + expect(uri).toContain(`schemaId=${SchemaIds.File}`); + expect(uri).not.toContain("entityId"); + }); + + it("includes entityId when provided", () => { + const uri = buildGatewayUri({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + entityId: "file-123", + }); + + expect(uri).toContain("entityId=file-123"); + }); +}); + +describe("buildGatewayData", () => { + it("returns structured data with auto-generated label", () => { + const data = buildGatewayData({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + entityId: "file-123", + }); + + expect(data.ename).toBe("@user-uuid"); + expect(data.schemaId).toBe(SchemaIds.File); + expect(data.entityId).toBe("file-123"); + expect(data.label).toBe("Open File"); + expect(data.gatewayUri).toMatch(/^w3ds-gateway:\/\/resolve\?/); + }); + + it("uses custom linkText as label", () => { + const data = buildGatewayData({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + linkText: "View the file", + }); + + expect(data.label).toBe("View the file"); + }); + + it("falls back to 'Open content' for unknown schemas", () => { + const data = buildGatewayData({ + ename: "@user-uuid", + schemaId: "unknown-schema", + }); + + expect(data.label).toBe("Open content"); + }); +}); + +describe("buildGatewayLink", () => { + it("builds an tag with w3ds-gateway: protocol", () => { + const html = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.SignatureContainer, + entityId: "container-123", + }); + + expect(html).toContain('href="w3ds-gateway://resolve?'); + expect(html).toContain('class="w3ds-gateway-link"'); + expect(html).toContain('data-ename="@user-uuid"'); + expect(html).toContain(`data-schema-id="${SchemaIds.SignatureContainer}"`); + expect(html).toContain('data-entity-id="container-123"'); + expect(html).toContain("Open Signature Container"); + }); + + it("uses custom link text", () => { + const html = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + linkText: "Click to open", + }); + + expect(html).toContain(">Click to open"); + }); + + it("includes data-fallback-href when fallbackUrl is provided", () => { + const html = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + fallbackUrl: "https://control-panel.example.com/gateway", + }); + + expect(html).toContain('data-fallback-href="https://control-panel.example.com/gateway"'); + }); + + it("does not include data-entity-id when entityId is missing", () => { + const html = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.User, + }); + + expect(html).not.toContain("data-entity-id"); + }); + + it("escapes HTML entities in link text", () => { + const html = buildGatewayLink({ + ename: "@user-uuid", + schemaId: SchemaIds.File, + linkText: '', + }); + + expect(html).not.toContain(" + * + * + * Usage (JS): + * import 'w3ds-gateway/modal'; + * const el = document.createElement('w3ds-gateway-chooser'); + * el.setAttribute('ename', '@user-uuid'); + * el.setAttribute('schema-id', '550e8400-...'); + * el.setAttribute('entity-id', 'post-123'); + * document.body.appendChild(el); + * el.open(); + * + * Usage (React): + * import 'w3ds-gateway/modal'; + * + * ref.current.open(); + * + * Usage (Svelte): + * import 'w3ds-gateway/modal'; + * + * el.open(); + */ + +import { + PLATFORM_CAPABILITIES, + getPlatformUrls, +} from "./capabilities.js"; +import { SchemaLabels } from "./schemas.js"; +import { PLATFORM_ICONS, FALLBACK_ICON } from "./icons.js"; +import type { ResolvedApp } from "./types.js"; +import type { SchemaId } from "./schemas.js"; + +const PLATFORM_COLORS: Record = { + pictique: { bg: "#fdf2f8", hover: "#fce7f3", border: "#fbcfe8" }, + blabsy: { bg: "#eff6ff", hover: "#dbeafe", border: "#bfdbfe" }, + "file-manager": { bg: "#eff6ff", hover: "#dbeafe", border: "#bfdbfe" }, + esigner: { bg: "#eff6ff", hover: "#dbeafe", border: "#bfdbfe" }, + evoting: { bg: "#fef2f2", hover: "#fee2e2", border: "#fecaca" }, + dreamsync: { bg: "#eef2ff", hover: "#e0e7ff", border: "#c7d2fe" }, + ecurrency: { bg: "#ecfeff", hover: "#cffafe", border: "#a5f3fc" }, + ereputation: { bg: "#fff7ed", hover: "#ffedd5", border: "#fed7aa" }, + cerberus: { bg: "#fef2f2", hover: "#fee2e2", border: "#fecaca" }, + "group-charter": { bg: "#fff7ed", hover: "#ffedd5", border: "#fed7aa" }, + emover: { bg: "#ecfeff", hover: "#cffafe", border: "#a5f3fc" }, +}; + +// ─── Resolver (inline, no external API needed) ───────────────────────────── + +async function fetchRegistryPlatforms( + registryUrl: string, +): Promise> { + try { + const response = await fetch(`${registryUrl}/platforms`); + if (!response.ok) return {}; + const urls: (string | null)[] = await response.json(); + + const keyOrder = [ + "pictique", "blabsy", "group-charter", "cerberus", "evoting", + "dreamsync", "ereputation", "ecurrency", "emover", "esigner", + "file-manager", + ]; + + const result: Record = {}; + for (let i = 0; i < keyOrder.length && i < urls.length; i++) { + if (urls[i]) result[keyOrder[i]] = urls[i]!; + } + return result; + } catch { + return {}; + } +} + +function buildUrl(template: string, baseUrl: string, entityId: string, ename: string): string { + return template + .replace("{baseUrl}", baseUrl.replace(/\/+$/, "")) + .replace("{entityId}", encodeURIComponent(entityId)) + .replace("{ename}", encodeURIComponent(ename)); +} + +async function resolve( + ename: string, + schemaId: string, + entityId: string, + registryUrl?: string, + platformUrlOverrides?: Record, +): Promise<{ schemaLabel: string; apps: ResolvedApp[] }> { + let platformUrls: Record = { ...getPlatformUrls() }; + + if (registryUrl) { + const registryUrls = await fetchRegistryPlatforms(registryUrl); + platformUrls = { ...platformUrls, ...registryUrls }; + } + + if (platformUrlOverrides) { + platformUrls = { ...platformUrls, ...platformUrlOverrides }; + } + + const handlers = PLATFORM_CAPABILITIES[schemaId] ?? []; + + const apps: ResolvedApp[] = handlers + .filter((h) => platformUrls[h.platformKey]) + .map((h) => ({ + platformName: h.platformName, + platformKey: h.platformKey, + url: buildUrl(h.urlTemplate, platformUrls[h.platformKey], entityId, ename), + label: h.label, + icon: h.icon, + })); + + const schemaLabel = SchemaLabels[schemaId as SchemaId] ?? "Unknown content type"; + + return { schemaLabel, apps }; +} + +// ─── Styles (shadow DOM) ──────────────────────────────────────────────────── + +const STYLES = ` +:host { + display: contents; +} + +.gateway-backdrop { + position: fixed; + inset: 0; + z-index: 10000; + display: flex; + align-items: center; + justify-content: center; + background: rgba(0, 0, 0, 0.4); + backdrop-filter: blur(4px); + -webkit-backdrop-filter: blur(4px); + opacity: 0; + transition: opacity 0.2s ease; + pointer-events: none; +} + +.gateway-backdrop.open { + opacity: 1; + pointer-events: auto; +} + +.gateway-modal { + width: 100%; + max-width: 420px; + margin: 0 16px; + background: white; + border-radius: 16px; + box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25); + overflow: hidden; + transform: scale(0.95) translateY(10px); + transition: transform 0.2s ease; + font-family: system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; +} + +.gateway-backdrop.open .gateway-modal { + transform: scale(1) translateY(0); +} + +.gateway-header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 16px 24px; + border-bottom: 1px solid #f3f4f6; +} + +.gateway-header-text h2 { + margin: 0; + font-size: 18px; + font-weight: 600; + color: #111827; + line-height: 1.4; +} + +.gateway-header-text p { + margin: 2px 0 0; + font-size: 13px; + color: #6b7280; +} + +.gateway-close { + background: none; + border: none; + padding: 6px; + border-radius: 8px; + cursor: pointer; + color: #9ca3af; + transition: background 0.15s, color 0.15s; + display: flex; + align-items: center; + justify-content: center; +} + +.gateway-close:hover { + background: #f3f4f6; + color: #4b5563; +} + +.gateway-close svg { + width: 20px; + height: 20px; +} + +.gateway-body { + padding: 16px 24px; +} + +.gateway-apps { + display: flex; + flex-direction: column; + gap: 8px; +} + +.gateway-app-link { + display: flex; + align-items: center; + gap: 16px; + padding: 14px 16px; + border-radius: 12px; + border: 1px solid #e5e7eb; + text-decoration: none; + transition: background 0.15s, border-color 0.15s; + cursor: pointer; +} + +.gateway-app-link:hover { + border-color: transparent; +} + +.gateway-app-icon { + width: 36px; + height: 36px; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; +} + +.gateway-app-icon svg { + width: 100%; + height: 100%; + display: block; +} + +.gateway-app-info { + flex: 1; + min-width: 0; +} + +.gateway-app-name { + font-size: 15px; + font-weight: 500; + color: #111827; + margin: 0; +} + +.gateway-app-label { + font-size: 13px; + color: #6b7280; + margin: 2px 0 0; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.gateway-arrow { + flex-shrink: 0; + color: #9ca3af; +} + +.gateway-arrow svg { + width: 18px; + height: 18px; +} + +.gateway-loading { + display: flex; + align-items: center; + justify-content: center; + padding: 32px 0; + gap: 12px; + color: #6b7280; + font-size: 14px; +} + +.gateway-spinner { + width: 24px; + height: 24px; + border: 3px solid #e5e7eb; + border-top-color: #3b82f6; + border-radius: 50%; + animation: gateway-spin 0.6s linear infinite; +} + +@keyframes gateway-spin { + to { transform: rotate(360deg); } +} + +.gateway-error { + background: #fef2f2; + color: #991b1b; + padding: 12px 16px; + border-radius: 8px; + font-size: 14px; +} + +.gateway-error strong { + display: block; + margin-bottom: 4px; +} + +.gateway-empty { + text-align: center; + padding: 32px 0; + color: #6b7280; + font-size: 14px; +} + +.gateway-empty-icon { + font-size: 36px; + margin-bottom: 8px; +} + +.gateway-footer { + border-top: 1px solid #f3f4f6; + padding: 10px 24px; + text-align: center; +} + +.gateway-footer-ename { + font-size: 12px; + color: #9ca3af; +} + +.gateway-footer-ename code { + background: #f3f4f6; + padding: 2px 6px; + border-radius: 4px; + font-family: ui-monospace, SFMono-Regular, 'SF Mono', Menlo, monospace; + font-size: 11px; +} +`; + +// ─── Web Component ────────────────────────────────────────────────────────── + +const CLOSE_SVG = ``; +const ARROW_SVG = ``; + +// Use a safe base class. In non-browser environments (Node.js, SSR), HTMLElement +// doesn't exist — we substitute a no-op class so the module can be imported +// without throwing. The real custom element only registers when `customElements` +// is available (i.e. in a browser). + +const SafeHTMLElement = + typeof HTMLElement !== "undefined" + ? HTMLElement + : (class {} as unknown as typeof HTMLElement); + +export class W3dsGatewayChooser extends SafeHTMLElement { + private shadow: ShadowRoot; + private backdrop!: HTMLDivElement; + private headerText!: HTMLDivElement; + private body!: HTMLDivElement; + private footer!: HTMLDivElement; + private _isOpen = false; + + static get observedAttributes() { + return ["ename", "schema-id", "entity-id", "registry-url", "open"]; + } + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.render(); + if (this.hasAttribute("open")) { + this.open(); + } + } + + attributeChangedCallback(name: string, oldValue: string | null, newValue: string | null) { + if (oldValue === newValue) return; + if (name === "open") { + if (newValue !== null) { + this.open(); + } else { + this.close(); + } + } else if (this._isOpen) { + // Re-resolve when attributes change while open + this.doResolve(); + } + } + + // ── Public API ── + + /** Open the chooser modal */ + open() { + this._isOpen = true; + if (!this.backdrop) this.render(); + this.backdrop.classList.add("open"); + this.doResolve(); + this.dispatchEvent(new CustomEvent("gateway-open")); + } + + /** Close the chooser modal */ + close() { + this._isOpen = false; + if (this.backdrop) { + this.backdrop.classList.remove("open"); + } + this.dispatchEvent(new CustomEvent("gateway-close")); + } + + /** Check if the modal is currently open */ + get isOpen(): boolean { + return this._isOpen; + } + + // ── Internal ── + + private get ename(): string { + return this.getAttribute("ename") ?? ""; + } + + private get schemaId(): string { + return this.getAttribute("schema-id") ?? ""; + } + + private get entityId(): string { + return this.getAttribute("entity-id") ?? ""; + } + + private get registryUrl(): string | undefined { + return this.getAttribute("registry-url") ?? undefined; + } + + private render() { + const style = document.createElement("style"); + style.textContent = STYLES; + + this.backdrop = document.createElement("div"); + this.backdrop.className = "gateway-backdrop"; + this.backdrop.addEventListener("click", (e) => { + if (e.target === this.backdrop) this.close(); + }); + this.backdrop.addEventListener("keydown", (e) => { + if (e.key === "Escape") this.close(); + }); + + const modal = document.createElement("div"); + modal.className = "gateway-modal"; + modal.setAttribute("role", "dialog"); + modal.setAttribute("aria-modal", "true"); + modal.setAttribute("aria-label", "Open with application"); + + // Header + const header = document.createElement("div"); + header.className = "gateway-header"; + + this.headerText = document.createElement("div"); + this.headerText.className = "gateway-header-text"; + this.headerText.innerHTML = `

Open with...

`; + + const closeBtn = document.createElement("button"); + closeBtn.className = "gateway-close"; + closeBtn.setAttribute("aria-label", "Close"); + closeBtn.innerHTML = CLOSE_SVG; + closeBtn.addEventListener("click", () => this.close()); + + header.appendChild(this.headerText); + header.appendChild(closeBtn); + + // Body + this.body = document.createElement("div"); + this.body.className = "gateway-body"; + + // Footer + this.footer = document.createElement("div"); + this.footer.className = "gateway-footer"; + + modal.appendChild(header); + modal.appendChild(this.body); + modal.appendChild(this.footer); + this.backdrop.appendChild(modal); + + this.shadow.appendChild(style); + this.shadow.appendChild(this.backdrop); + } + + private async doResolve() { + const { ename, schemaId, entityId, registryUrl } = this; + + if (!ename || !schemaId) { + this.body.innerHTML = `
Missing dataeName and schema-id attributes are required.
`; + this.footer.innerHTML = ""; + return; + } + + // Loading state + this.body.innerHTML = `
Resolving applications...
`; + this.footer.innerHTML = ""; + + try { + const result = await resolve(ename, schemaId, entityId, registryUrl); + + // Update header subtitle + this.headerText.innerHTML = `

Open with...

${this.escapeHtml(result.schemaLabel)}

`; + + if (result.apps.length === 0) { + this.body.innerHTML = `
🤷

No applications can handle this content type.

`; + this.footer.innerHTML = ""; + return; + } + + // Render app list + const container = document.createElement("div"); + container.className = "gateway-apps"; + + for (const app of result.apps) { + const link = document.createElement("a"); + link.className = "gateway-app-link"; + link.href = app.url; + link.target = "_blank"; + link.rel = "noopener noreferrer"; + + const colors = PLATFORM_COLORS[app.platformKey] ?? { bg: "#f9fafb", hover: "#f3f4f6", border: "#e5e7eb" }; + link.style.backgroundColor = colors.bg; + link.style.borderColor = colors.border; + link.addEventListener("mouseenter", () => { link.style.backgroundColor = colors.hover; }); + link.addEventListener("mouseleave", () => { link.style.backgroundColor = colors.bg; }); + + const icon = PLATFORM_ICONS[app.platformKey] ?? FALLBACK_ICON; + + link.innerHTML = ` + ${icon} +
+

${this.escapeHtml(app.platformName)}

+

${this.escapeHtml(app.label)}

+
+ ${ARROW_SVG} + `; + + link.addEventListener("click", () => { + this.dispatchEvent(new CustomEvent("gateway-select", { + detail: { platformKey: app.platformKey, url: app.url }, + })); + }); + + container.appendChild(link); + } + + this.body.innerHTML = ""; + this.body.appendChild(container); + + // Footer + this.footer.innerHTML = `eName: ${this.escapeHtml(ename)}`; + } catch (err) { + const msg = err instanceof Error ? err.message : "Unknown error"; + this.body.innerHTML = `
Resolution failed${this.escapeHtml(msg)}
`; + this.footer.innerHTML = ""; + } + } + + private escapeHtml(str: string): string { + const div = document.createElement("div"); + div.textContent = str; + return div.innerHTML; + } +} + +// Register the custom element +if (typeof customElements !== "undefined" && !customElements.get("w3ds-gateway-chooser")) { + customElements.define("w3ds-gateway-chooser", W3dsGatewayChooser); +} diff --git a/packages/w3ds-gateway/src/notifications.ts b/packages/w3ds-gateway/src/notifications.ts new file mode 100644 index 000000000..210a928f5 --- /dev/null +++ b/packages/w3ds-gateway/src/notifications.ts @@ -0,0 +1,155 @@ +/** + * W3DS Gateway — Notification Helpers + * + * Utility functions for embedding gateway links in notification messages. + * Platforms use these in NotificationService classes to create messages + * whose links open the embeddable `` modal. + * + * The generated links use a `w3ds-gateway:` custom protocol that platform + * frontends intercept. When a message renderer encounters a link with + * `href="w3ds-gateway://resolve?ename=...&schemaId=...&entityId=..."`, + * it opens the web component chooser instead of navigating. + * + * For platforms that haven't integrated the interceptor yet, an optional + * fallback URL can be provided — the `data-fallback-href` attribute carries + * it alongside the gateway protocol. + */ + +import { SchemaLabels } from "./schemas.js"; +import type { SchemaId } from "./schemas.js"; + +export interface GatewayLinkOptions { + /** The eName of the entity owner */ + ename: string; + /** The ontology schema ID of the content */ + schemaId: string; + /** The entity ID of the specific resource */ + entityId?: string; + /** Custom link text (defaults to a label derived from the schema) */ + linkText?: string; + /** Optional fallback URL for platforms that haven't integrated the interceptor */ + fallbackUrl?: string; +} + +/** + * Structured gateway link data that platforms can use to open the chooser. + * + * This is framework-agnostic — platforms can serialize it as JSON in a + * message payload, or convert it to the `w3ds-gateway:` protocol link. + */ +export interface GatewayLinkData { + ename: string; + schemaId: string; + entityId?: string; + label: string; + /** The `w3ds-gateway://resolve?...` URI */ + gatewayUri: string; +} + +/** + * Build a `w3ds-gateway://resolve?...` URI that encodes the eName, schemaId, + * and entityId. Platform frontends intercept this protocol to open the + * `` web component. + * + * @example + * ```ts + * buildGatewayUri({ ename: "@user-uuid", schemaId: SchemaIds.File, entityId: "file-1" }) + * // → "w3ds-gateway://resolve?ename=%40user-uuid&schemaId=a1b...&entityId=file-1" + * ``` + */ +export function buildGatewayUri(options: Pick): string { + const params = new URLSearchParams({ + ename: options.ename, + schemaId: options.schemaId, + }); + if (options.entityId) { + params.set("entityId", options.entityId); + } + return `w3ds-gateway://resolve?${params.toString()}`; +} + +/** + * Build structured gateway link data. Useful for platforms that want to + * store gateway metadata as JSON instead of raw HTML. + * + * @example + * ```ts + * const data = buildGatewayData({ + * ename: "@user-uuid", + * schemaId: SchemaIds.File, + * entityId: "file-1", + * }); + * // → { ename: "...", schemaId: "...", entityId: "file-1", + * // label: "Open File", gatewayUri: "w3ds-gateway://resolve?..." } + * ``` + */ +export function buildGatewayData(options: GatewayLinkOptions): GatewayLinkData { + const label = + options.linkText ?? + `Open ${SchemaLabels[options.schemaId as SchemaId] ?? "content"}`; + return { + ename: options.ename, + schemaId: options.schemaId, + entityId: options.entityId, + label, + gatewayUri: buildGatewayUri(options), + }; +} + +/** + * Build an HTML anchor that uses the `w3ds-gateway:` protocol. + * + * Platform message renderers should intercept clicks on links whose + * `href` starts with `w3ds-gateway://` and open the + * `` web component instead of navigating. + * + * If a `fallbackUrl` is provided, it's placed in a `data-fallback-href` + * attribute so non-integrated renderers can still navigate somewhere useful. + * + * @example + * ```ts + * const html = buildGatewayLink({ + * ename: "@user-uuid", + * schemaId: SchemaIds.SignatureContainer, + * entityId: "container-123", + * linkText: "View the signed document", + * }); + * // → 'View the signed document' + * ``` + */ +export function buildGatewayLink(options: GatewayLinkOptions): string { + const uri = buildGatewayUri(options); + const label = + options.linkText ?? + `Open ${SchemaLabels[options.schemaId as SchemaId] ?? "content"}`; + + const attrs: string[] = [ + `href="${uri}"`, + `class="w3ds-gateway-link"`, + `data-ename="${escapeAttr(options.ename)}"`, + `data-schema-id="${escapeAttr(options.schemaId)}"`, + ]; + + if (options.entityId) { + attrs.push(`data-entity-id="${escapeAttr(options.entityId)}"`); + } + + if (options.fallbackUrl) { + attrs.push(`data-fallback-href="${escapeAttr(options.fallbackUrl)}"`); + } + + return `${escapeHtml(label)}`; +} + + +// ─── Internal helpers ─────────────────────────────────────────────────────── + +function escapeHtml(str: string): string { + return str.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +function escapeAttr(str: string): string { + return str.replace(/&/g, "&").replace(/"/g, """).replace(//g, ">"); +} diff --git a/packages/w3ds-gateway/src/resolver.ts b/packages/w3ds-gateway/src/resolver.ts new file mode 100644 index 000000000..e7db4bf3d --- /dev/null +++ b/packages/w3ds-gateway/src/resolver.ts @@ -0,0 +1,212 @@ +/** + * W3DS Gateway — Resolver + * + * Resolves an eName + schemaId to a list of applications that can open the content. + * Works entirely client-side using the static capabilities map and optional + * Registry calls for dynamic platform URL resolution. + */ + +import { + PLATFORM_CAPABILITIES, + getPlatformUrls, +} from "./capabilities.js"; +import { SchemaLabels } from "./schemas.js"; +import type { SchemaId } from "./schemas.js"; +import type { + GatewayResolveInput, + GatewayResolveResult, + ResolvedApp, +} from "./types.js"; + +export interface GatewayResolverOptions { + /** + * Override platform base URLs. Keys are platform keys (e.g. "pictique"). + * If a platform key is not present here, falls back to the URLs set via `configurePlatformUrls()`. + */ + platformUrls?: Record; + + /** + * Optional Registry base URL. If provided, the resolver will call + * GET /platforms to retrieve live platform URLs. + * If not provided, only static URLs from platformUrls / defaults are used. + */ + registryUrl?: string; + + /** + * Custom fetch function (useful for SSR or testing). + * Defaults to globalThis.fetch. + */ + fetch?: typeof globalThis.fetch; +} + +/** + * Fetches platform URLs from the Registry's /platforms endpoint. + * Returns a map of platform keys to base URLs. + */ +async function fetchRegistryPlatforms( + registryUrl: string, + fetchFn: typeof globalThis.fetch, +): Promise> { + try { + const response = await fetchFn(`${registryUrl}/platforms`); + if (!response.ok) return {}; + + const urls: (string | null)[] = await response.json(); + + // The Registry returns an ordered array of URLs matching this order: + // Pictique, Blabsy, Group Charter, Cerberus, eVoting, + // DreamSync, eReputation, eCurrency, eMoving, eSigner, File Manager + const keyOrder = [ + "pictique", + "blabsy", + "group-charter", + "cerberus", + "evoting", + "dreamsync", + "ereputation", + "ecurrency", + "emover", + "esigner", + "file-manager", + ]; + + const result: Record = {}; + for (let i = 0; i < keyOrder.length && i < urls.length; i++) { + const url = urls[i]; + if (url) { + result[keyOrder[i]] = url; + } + } + return result; + } catch { + return {}; + } +} + +/** + * Builds a concrete URL from a template and parameters. + */ +function buildUrl( + template: string, + baseUrl: string, + entityId: string, + ename: string, +): string { + return template + .replace("{baseUrl}", baseUrl.replace(/\/+$/, "")) + .replace("{entityId}", encodeURIComponent(entityId)) + .replace("{ename}", encodeURIComponent(ename)); +} + +/** + * Resolve an eName + schemaId to a list of applications that can open it. + * + * @example + * ```ts + * const result = await resolveEName({ + * ename: "@e4d909c2-5d2f-4a7d-9473-b34b6c0f1a5a", + * schemaId: "550e8400-e29b-41d4-a716-446655440001", // SocialMediaPost + * entityId: "post-123", + * }); + * + * // result.apps → [ + * // { platformName: "Pictique", url: "https://pictique.../home", ... }, + * // { platformName: "Blabsy", url: "https://blabsy.../tweet/post-123", ... }, + * // ] + * ``` + */ +export async function resolveEName( + input: GatewayResolveInput, + options: GatewayResolverOptions = {}, +): Promise { + const fetchFn = options.fetch ?? globalThis.fetch; + const { ename, schemaId, entityId = "" } = input; + + // 1. Determine platform base URLs + let platformUrls: Record = { + ...getPlatformUrls(), + }; + + // Merge in Registry URLs if available + if (options.registryUrl) { + const registryUrls = await fetchRegistryPlatforms( + options.registryUrl, + fetchFn, + ); + platformUrls = { ...platformUrls, ...registryUrls }; + } + + // Merge in explicit overrides (highest priority) + if (options.platformUrls) { + platformUrls = { ...platformUrls, ...options.platformUrls }; + } + + // 2. Look up handlers for this schema + const handlers = PLATFORM_CAPABILITIES[schemaId] ?? []; + + // 3. Build resolved app entries + const apps: ResolvedApp[] = handlers + .filter((handler) => platformUrls[handler.platformKey]) + .map((handler) => { + const baseUrl = platformUrls[handler.platformKey]; + return { + platformName: handler.platformName, + platformKey: handler.platformKey, + url: buildUrl(handler.urlTemplate, baseUrl, entityId, ename), + label: handler.label, + icon: handler.icon, + }; + }); + + // 4. Schema label + const schemaLabel = + SchemaLabels[schemaId as SchemaId] ?? "Unknown content type"; + + return { + ename, + schemaId, + schemaLabel, + apps, + }; +} + +/** + * Synchronous version of resolveEName that doesn't fetch from Registry. + * Uses only the provided platformUrls and/or defaults. + */ +export function resolveENameSync( + input: GatewayResolveInput, + platformUrls?: Record, +): GatewayResolveResult { + const { ename, schemaId, entityId = "" } = input; + + const urls: Record = { + ...getPlatformUrls(), + ...platformUrls, + }; + + const handlers = PLATFORM_CAPABILITIES[schemaId] ?? []; + + const apps: ResolvedApp[] = handlers + .filter((handler) => urls[handler.platformKey]) + .map((handler) => { + const baseUrl = urls[handler.platformKey]; + return { + platformName: handler.platformName, + platformKey: handler.platformKey, + url: buildUrl(handler.urlTemplate, baseUrl, entityId, ename), + label: handler.label, + icon: handler.icon, + }; + }); + + const schemaLabel = + SchemaLabels[schemaId as SchemaId] ?? "Unknown content type"; + + return { + ename, + schemaId, + schemaLabel, + apps, + }; +} diff --git a/packages/w3ds-gateway/src/schemas.ts b/packages/w3ds-gateway/src/schemas.ts new file mode 100644 index 000000000..91c82a08a --- /dev/null +++ b/packages/w3ds-gateway/src/schemas.ts @@ -0,0 +1,74 @@ +/** + * W3DS Gateway — Schema IDs + * + * Canonical ontology schema identifiers used across the W3DS ecosystem. + * Each schema ID corresponds to a global data type defined in the Ontology service. + * These are extracted from the mapping files found across all platforms. + */ +export const SchemaIds = { + /** User profile */ + User: "550e8400-e29b-41d4-a716-446655440000", + + /** Social media post (tweet, photo post, comment) */ + SocialMediaPost: "550e8400-e29b-41d4-a716-446655440001", + + /** Group / chat room */ + Group: "550e8400-e29b-41d4-a716-446655440003", + + /** Chat message */ + Message: "550e8400-e29b-41d4-a716-446655440004", + + /** Voting observation / vote cast */ + VotingObservation: "550e8400-e29b-41d4-a716-446655440005", + + /** Ledger entry (financial transaction) */ + Ledger: "550e8400-e29b-41d4-a716-446655440006", + + /** Currency definition */ + Currency: "550e8400-e29b-41d4-a716-446655440008", + + /** Poll definition */ + Poll: "660e8400-e29b-41d4-a716-446655440100", + + /** Individual vote on a poll */ + Vote: "660e8400-e29b-41d4-a716-446655440101", + + /** Vote reputation results */ + VoteReputationResult: "660e8400-e29b-41d4-a716-446655440102", + + /** Wishlist */ + Wishlist: "770e8400-e29b-41d4-a716-446655440000", + + /** Charter signature */ + CharterSignature: "1d83fada-581d-49b0-b6f5-1fe0766da34f", + + /** Reference signature (reputation) */ + ReferenceSignature: "2e94fada-581d-49b0-b6f5-1fe0766da35f", + + /** File */ + File: "a1b2c3d4-e5f6-7890-abcd-ef1234567890", + + /** Signature container (eSigner / file-manager) */ + SignatureContainer: "b2c3d4e5-f6a7-8901-bcde-f12345678901", +} as const; + +export type SchemaId = (typeof SchemaIds)[keyof typeof SchemaIds]; + +/** Human-readable labels for each schema type */ +export const SchemaLabels: Record = { + [SchemaIds.User]: "User Profile", + [SchemaIds.SocialMediaPost]: "Post", + [SchemaIds.Group]: "Group / Chat", + [SchemaIds.Message]: "Message", + [SchemaIds.VotingObservation]: "Voting Observation", + [SchemaIds.Ledger]: "Transaction", + [SchemaIds.Currency]: "Currency", + [SchemaIds.Poll]: "Poll", + [SchemaIds.Vote]: "Vote", + [SchemaIds.VoteReputationResult]: "Vote Result", + [SchemaIds.Wishlist]: "Wishlist", + [SchemaIds.CharterSignature]: "Charter Signature", + [SchemaIds.ReferenceSignature]: "Reference", + [SchemaIds.File]: "File", + [SchemaIds.SignatureContainer]: "Signature Container", +}; diff --git a/packages/w3ds-gateway/src/types.ts b/packages/w3ds-gateway/src/types.ts new file mode 100644 index 000000000..a16c2a435 --- /dev/null +++ b/packages/w3ds-gateway/src/types.ts @@ -0,0 +1,72 @@ +/** + * W3DS Gateway — Types + */ + +/** A platform that is registered in the W3DS ecosystem */ +export interface Platform { + /** Display name shown in the gateway chooser */ + name: string; + /** Unique key identifier (lowercase, e.g. "pictique", "blabsy") */ + key: string; + /** Base URL of the platform frontend (resolved at runtime from Registry or env) */ + baseUrl: string; + /** Optional icon URL or icon key for UI rendering */ + icon?: string; + /** Optional description of the platform */ + description?: string; +} + +/** Describes how a specific platform handles a specific schema type */ +export interface PlatformHandler { + /** Platform key (matches Platform.key) */ + platformKey: string; + /** Display name of the platform */ + platformName: string; + /** + * URL template with placeholders: + * {baseUrl} — platform base URL + * {entityId} — the ID of the entity to open + * {ename} — the eName (W3ID) related to the entity + */ + urlTemplate: string; + /** Human-readable label for this action (e.g. "View post", "Open chat") */ + label: string; + /** Optional icon key */ + icon?: string; +} + +/** A resolved app option ready to be displayed in the chooser */ +export interface ResolvedApp { + /** Platform display name */ + platformName: string; + /** Platform key */ + platformKey: string; + /** The concrete URL the user can navigate to */ + url: string; + /** Action label (e.g. "View post on Pictique") */ + label: string; + /** Optional icon */ + icon?: string; +} + +/** Input required to resolve an eName to application URLs */ +export interface GatewayResolveInput { + /** The eName (W3ID) to resolve */ + ename: string; + /** The ontology schema ID indicating the type of content */ + schemaId: string; + /** The entity ID (local or global) of the specific resource */ + entityId?: string; +} + +/** Result from the gateway resolver */ +export interface GatewayResolveResult { + /** The eName that was resolved */ + ename: string; + /** Schema ID that was looked up */ + schemaId: string; + /** Human-readable label for the schema type */ + schemaLabel: string; + /** List of applications that can handle this content */ + apps: ResolvedApp[]; +} diff --git a/packages/w3ds-gateway/tsconfig.build.json b/packages/w3ds-gateway/tsconfig.build.json new file mode 100644 index 000000000..fe2bc6b4c --- /dev/null +++ b/packages/w3ds-gateway/tsconfig.build.json @@ -0,0 +1,20 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist", "**/*.test.ts"] +} diff --git a/packages/w3ds-gateway/tsconfig.json b/packages/w3ds-gateway/tsconfig.json new file mode 100644 index 000000000..7ed9ed457 --- /dev/null +++ b/packages/w3ds-gateway/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "declaration": true, + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true + }, + "include": ["src"], + "exclude": ["node_modules", "dist"] +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index fcd70727d..7f7f43fd8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,6 +157,9 @@ importers: tailwind-merge: specifier: ^3.0.2 version: 3.4.1 + w3ds-gateway: + specifier: workspace:* + version: link:../../packages/w3ds-gateway devDependencies: '@eslint/compat': specifier: ^1.2.5 @@ -737,6 +740,15 @@ importers: packages/typescript-config: {} + packages/w3ds-gateway: + devDependencies: + typescript: + specifier: ~5.6.2 + version: 5.6.3 + vitest: + specifier: ^3.0.9 + version: 3.2.4(@types/debug@4.1.12)(@types/node@24.10.13)(@vitest/browser@3.2.4)(jiti@2.6.1)(jsdom@19.0.0(bufferutil@4.1.0))(lightningcss@1.30.2)(sass@1.97.3)(terser@5.46.0)(tsx@4.21.0)(yaml@2.8.2) + packages/wallet-sdk: dependencies: jose: diff --git a/test.html b/test.html new file mode 100644 index 000000000..469f6e397 --- /dev/null +++ b/test.html @@ -0,0 +1,69 @@ + + + + + + W3DS Gateway Test + + + +

W3DS Gateway Test

+ + +
+ + + + + + + + From ce1f2d95390989237c994b60d73a6389ece585d2 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 26 Feb 2026 19:48:41 +0300 Subject: [PATCH 2/6] chore: remove w3ds-gateway dependency from lockfile --- pnpm-lock.yaml | 3 --- 1 file changed, 3 deletions(-) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7f7f43fd8..eb64d7cf8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -157,9 +157,6 @@ importers: tailwind-merge: specifier: ^3.0.2 version: 3.4.1 - w3ds-gateway: - specifier: workspace:* - version: link:../../packages/w3ds-gateway devDependencies: '@eslint/compat': specifier: ^1.2.5 From a0be299c608317a919ccf911edf49e4bb3566ede Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Thu, 26 Feb 2026 20:40:00 +0300 Subject: [PATCH 3/6] feat: introduce REGISTRY_PLATFORM_KEY_ORDER constant and refactor related code --- packages/w3ds-gateway/src/capabilities.ts | 20 ++++++++++++++++++++ packages/w3ds-gateway/src/index.ts | 1 + packages/w3ds-gateway/src/modal.ts | 11 +++-------- packages/w3ds-gateway/src/notifications.ts | 2 +- packages/w3ds-gateway/src/resolver.ts | 22 +++------------------- 5 files changed, 28 insertions(+), 28 deletions(-) diff --git a/packages/w3ds-gateway/src/capabilities.ts b/packages/w3ds-gateway/src/capabilities.ts index 160590421..851d737e9 100644 --- a/packages/w3ds-gateway/src/capabilities.ts +++ b/packages/w3ds-gateway/src/capabilities.ts @@ -314,6 +314,26 @@ export const PLATFORM_ENV_KEYS: Record = { emover: "PUBLIC_EMOVER_BASE_URL", // no _URL var }; +/** + * The order in which the Registry's `GET /platforms` endpoint returns platform URLs. + * The Registry returns a positional array — this constant maps each index to its + * platform key so both the resolver and the modal stay in sync with a single + * source of truth. + */ +export const REGISTRY_PLATFORM_KEY_ORDER: readonly string[] = [ + "pictique", + "blabsy", + "group-charter", + "cerberus", + "evoting", + "dreamsync", + "ereputation", + "ecurrency", + "emover", + "esigner", + "file-manager", +] as const; + /** * Default development platform CLIENT URLs. * These are the frontend URLs where users actually browse. diff --git a/packages/w3ds-gateway/src/index.ts b/packages/w3ds-gateway/src/index.ts index e18c16240..5a70fd962 100644 --- a/packages/w3ds-gateway/src/index.ts +++ b/packages/w3ds-gateway/src/index.ts @@ -30,6 +30,7 @@ export type { SchemaId } from "./schemas.js"; export { PLATFORM_CAPABILITIES, PLATFORM_ENV_KEYS, + REGISTRY_PLATFORM_KEY_ORDER, configurePlatformUrls, getPlatformUrls, } from "./capabilities.js"; diff --git a/packages/w3ds-gateway/src/modal.ts b/packages/w3ds-gateway/src/modal.ts index c3e61d53c..109960efa 100644 --- a/packages/w3ds-gateway/src/modal.ts +++ b/packages/w3ds-gateway/src/modal.ts @@ -37,6 +37,7 @@ import { PLATFORM_CAPABILITIES, getPlatformUrls, + REGISTRY_PLATFORM_KEY_ORDER, } from "./capabilities.js"; import { SchemaLabels } from "./schemas.js"; import { PLATFORM_ICONS, FALLBACK_ICON } from "./icons.js"; @@ -67,15 +68,9 @@ async function fetchRegistryPlatforms( if (!response.ok) return {}; const urls: (string | null)[] = await response.json(); - const keyOrder = [ - "pictique", "blabsy", "group-charter", "cerberus", "evoting", - "dreamsync", "ereputation", "ecurrency", "emover", "esigner", - "file-manager", - ]; - const result: Record = {}; - for (let i = 0; i < keyOrder.length && i < urls.length; i++) { - if (urls[i]) result[keyOrder[i]] = urls[i]!; + for (let i = 0; i < REGISTRY_PLATFORM_KEY_ORDER.length && i < urls.length; i++) { + if (urls[i]) result[REGISTRY_PLATFORM_KEY_ORDER[i]] = urls[i]!; } return result; } catch { diff --git a/packages/w3ds-gateway/src/notifications.ts b/packages/w3ds-gateway/src/notifications.ts index 210a928f5..cb9bc80f4 100644 --- a/packages/w3ds-gateway/src/notifications.ts +++ b/packages/w3ds-gateway/src/notifications.ts @@ -126,7 +126,7 @@ export function buildGatewayLink(options: GatewayLinkOptions): string { `Open ${SchemaLabels[options.schemaId as SchemaId] ?? "content"}`; const attrs: string[] = [ - `href="${uri}"`, + `href="${escapeAttr(uri)}"`, `class="w3ds-gateway-link"`, `data-ename="${escapeAttr(options.ename)}"`, `data-schema-id="${escapeAttr(options.schemaId)}"`, diff --git a/packages/w3ds-gateway/src/resolver.ts b/packages/w3ds-gateway/src/resolver.ts index e7db4bf3d..27a30f202 100644 --- a/packages/w3ds-gateway/src/resolver.ts +++ b/packages/w3ds-gateway/src/resolver.ts @@ -9,6 +9,7 @@ import { PLATFORM_CAPABILITIES, getPlatformUrls, + REGISTRY_PLATFORM_KEY_ORDER, } from "./capabilities.js"; import { SchemaLabels } from "./schemas.js"; import type { SchemaId } from "./schemas.js"; @@ -53,28 +54,11 @@ async function fetchRegistryPlatforms( const urls: (string | null)[] = await response.json(); - // The Registry returns an ordered array of URLs matching this order: - // Pictique, Blabsy, Group Charter, Cerberus, eVoting, - // DreamSync, eReputation, eCurrency, eMoving, eSigner, File Manager - const keyOrder = [ - "pictique", - "blabsy", - "group-charter", - "cerberus", - "evoting", - "dreamsync", - "ereputation", - "ecurrency", - "emover", - "esigner", - "file-manager", - ]; - const result: Record = {}; - for (let i = 0; i < keyOrder.length && i < urls.length; i++) { + for (let i = 0; i < REGISTRY_PLATFORM_KEY_ORDER.length && i < urls.length; i++) { const url = urls[i]; if (url) { - result[keyOrder[i]] = url; + result[REGISTRY_PLATFORM_KEY_ORDER[i]] = url; } } return result; From ac9e8811b31507534581a0ecceaaebce8cabffc7 Mon Sep 17 00:00:00 2001 From: Bekiboo Date: Fri, 27 Feb 2026 09:05:21 +0300 Subject: [PATCH 4/6] feat: update platform URL configuration for local development --- packages/w3ds-gateway/src/capabilities.ts | 30 +++++++++-------------- test.html | 16 ++++++++++++ 2 files changed, 28 insertions(+), 18 deletions(-) diff --git a/packages/w3ds-gateway/src/capabilities.ts b/packages/w3ds-gateway/src/capabilities.ts index 851d737e9..4d630d59b 100644 --- a/packages/w3ds-gateway/src/capabilities.ts +++ b/packages/w3ds-gateway/src/capabilities.ts @@ -335,26 +335,20 @@ export const REGISTRY_PLATFORM_KEY_ORDER: readonly string[] = [ ] as const; /** - * Default development platform CLIENT URLs. - * These are the frontend URLs where users actually browse. - * Platforms call `configurePlatformUrls()` at startup to override - * with production URLs from their env. + * Default platform URLs — intentionally empty. * - * Ports sourced from .env.example and platform package.json configs. + * Platforms must call `configurePlatformUrls()` at startup with URLs read from + * their env. There are no localhost fallbacks here by design: silent localhost + * leaks in production are harder to debug than an explicit empty result. + * + * For local development, pass the dev URLs directly: + * ```ts + * if (import.meta.env.DEV) { + * configurePlatformUrls({ pictique: "http://localhost:5173", ... }); + * } + * ``` */ -const DEFAULT_PLATFORM_URLS: Record = { - pictique: "http://localhost:5173", // SvelteKit default — PUBLIC_PICTIQUE_URL - blabsy: "http://localhost:8080", // next dev -p 8080 — PUBLIC_BLABSY_URL - "file-manager": "http://localhost:5174", // SvelteKit — needs --port 5174 to avoid conflicts - esigner: "http://localhost:5175", // SvelteKit — needs --port 5175 - evoting: "http://localhost:3001", // next dev — PUBLIC_EVOTING_URL - dreamsync: "http://localhost:5176", // Vite React — needs --port 5176 - ecurrency: "http://localhost:9888", // Vite React — explicit port in vite.config.ts - ereputation: "http://localhost:5178", // Vite React — needs --port 5178 - cerberus: "http://localhost:6666", // API-only — PUBLIC_CERBERUS_BASE_URL - "group-charter": "http://localhost:3000", // next dev — default port - emover: "http://localhost:3006", // next dev -p 3006 -}; +const DEFAULT_PLATFORM_URLS: Record = {}; let _platformUrls: Record = { ...DEFAULT_PLATFORM_URLS }; diff --git a/test.html b/test.html index 469f6e397..3fbd43152 100644 --- a/test.html +++ b/test.html @@ -32,8 +32,24 @@

W3DS Gateway Test

>