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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
127 changes: 127 additions & 0 deletions packages/w3ds-gateway/README.md
Original file line number Diff line number Diff line change
@@ -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",
});
// → '<a href="w3ds-gateway://resolve?ename=..." class="w3ds-gateway-link" ...>Choose app to view document</a>'
```

## Embeddable Web Component

Drop `<w3ds-gateway-chooser>` into any platform — works in Svelte, React, or plain HTML:

```html
<script type="module">
import "w3ds-gateway/modal";
</script>

<w3ds-gateway-chooser
ename="@user-uuid"
schema-id="550e8400-e29b-41d4-a716-446655440001"
entity-id="post-123"
registry-url="https://registry.w3ds.metastate.foundation"
></w3ds-gateway-chooser>

<script>
document.querySelector("w3ds-gateway-chooser").open();
</script>
```

### 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
39 changes: 39 additions & 0 deletions packages/w3ds-gateway/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
202 changes: 202 additions & 0 deletions packages/w3ds-gateway/src/__tests__/modal.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
});
Loading