= { ...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;
+ private _resolveRunId = 0;
+
+ static get observedAttributes() {
+ return ["ename", "schema-id", "entity-id", "registry-url", "open"];
+ }
+
+ constructor() {
+ super();
+ this.shadow = this.attachShadow({ mode: "open" });
+ }
+
+ connectedCallback() {
+ if (!this.backdrop) 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;
+ this._resolveRunId++; // invalidate any in-flight resolve
+ 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() {
+ if (this.backdrop) return;
+ 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 runId = ++this._resolveRunId;
+ 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);
+
+ if (runId !== this._resolveRunId || !this._isOpen) return;
+
+ // 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";
+
+ // Only allow http/https URLs — reject javascript:, data:, etc.
+ const safeHref = isSafeUrl(app.url) ? app.url : null;
+ if (safeHref) {
+ link.href = safeHref;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ } else {
+ link.setAttribute("role", "button");
+ link.setAttribute("aria-disabled", "true");
+ link.style.opacity = "0.4";
+ link.style.cursor = "not-allowed";
+ link.style.pointerEvents = "none";
+ }
+
+ 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 = ``;
+ } catch (err) {
+ if (runId !== this._resolveRunId || !this._isOpen) return;
+ 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..cb9bc80f4
--- /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="${escapeAttr(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..de85bb0eb
--- /dev/null
+++ b/packages/w3ds-gateway/src/resolver.ts
@@ -0,0 +1,197 @@
+/**
+ * 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,
+ REGISTRY_PLATFORM_KEY_ORDER,
+} from "./capabilities.js";
+import { isSafeUrl } from "./utils.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();
+
+ const result: Record = {};
+ for (let i = 0; i < REGISTRY_PLATFORM_KEY_ORDER.length && i < urls.length; i++) {
+ const url = urls[i];
+ if (url) {
+ result[REGISTRY_PLATFORM_KEY_ORDER[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] && isSafeUrl(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] && isSafeUrl(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/src/utils.ts b/packages/w3ds-gateway/src/utils.ts
new file mode 100644
index 000000000..a0087aa1e
--- /dev/null
+++ b/packages/w3ds-gateway/src/utils.ts
@@ -0,0 +1,16 @@
+/**
+ * W3DS Gateway — Shared Utilities
+ */
+
+/**
+ * Returns true only for http: and https: URLs.
+ * Rejects javascript:, data:, and any other scheme.
+ */
+export function isSafeUrl(url: string): boolean {
+ try {
+ const { protocol } = new URL(url);
+ return protocol === "http:" || protocol === "https:";
+ } catch {
+ return false;
+ }
+}
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..eb64d7cf8 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -737,6 +737,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..3fbd43152
--- /dev/null
+++ b/test.html
@@ -0,0 +1,85 @@
+
+
+
+
+
+ W3DS Gateway Test
+
+
+
+ W3DS Gateway Test
+
+
+
+
+
+
+
+
+
+
+