From 5a33293a23d121c9c55055afc33481aa9b05e358 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 19:16:06 -0700 Subject: [PATCH] feat(rnetwork): implement delegative trust flows for liquid democracy Person-to-person delegation within spaces across 5 authority verticals (voting, moderation, curation, treasury, membership). Trust engine recomputes scores every 5 min with time decay, transitive BFS, and 50% per-hop discount. Graph viewer shows trust-weighted node sizing with authority selector. New Delegations tab in CRM with management UI and Sankey flow visualization. Schema: delegations, trust_events, trust_scores tables API: delegation CRUD, trust scores, events, user directory Frontend: folk-delegation-manager, folk-trust-sankey, graph trust mode Co-Authored-By: Claude Opus 4.6 --- modules/rnetwork/components/folk-crm-view.ts | 25 +- .../components/folk-delegation-manager.ts | 535 ++++++++++++++++++ .../rnetwork/components/folk-graph-viewer.ts | 93 ++- .../rnetwork/components/folk-trust-sankey.ts | 398 +++++++++++++ modules/rnetwork/mod.ts | 106 +++- server/notification-service.ts | 4 +- src/encryptid/db.ts | 346 +++++++++++ src/encryptid/schema.sql | 65 +++ src/encryptid/server.ts | 277 +++++++++ src/encryptid/trust-engine.ts | 259 +++++++++ vite.config.ts | 40 ++ 11 files changed, 2138 insertions(+), 10 deletions(-) create mode 100644 modules/rnetwork/components/folk-delegation-manager.ts create mode 100644 modules/rnetwork/components/folk-trust-sankey.ts create mode 100644 src/encryptid/trust-engine.ts diff --git a/modules/rnetwork/components/folk-crm-view.ts b/modules/rnetwork/components/folk-crm-view.ts index 9ed42e0..b900feb 100644 --- a/modules/rnetwork/components/folk-crm-view.ts +++ b/modules/rnetwork/components/folk-crm-view.ts @@ -48,7 +48,7 @@ interface CrmGraphEdge { type: "works_at" | "point_of_contact"; } -type Tab = "pipeline" | "contacts" | "companies" | "graph"; +type Tab = "pipeline" | "contacts" | "companies" | "graph" | "delegations"; const PIPELINE_STAGES = [ "INCOMING", @@ -109,6 +109,7 @@ class FolkCrmView extends HTMLElement { { target: '[data-tab="contacts"]', title: "Contacts", message: "View and search your contact directory. Click a contact to see their details.", advanceOnClick: true }, { target: '#crm-search', title: "Search", message: "Search across contacts and companies by name, email, or city. Results filter in real time.", advanceOnClick: false }, { target: '[data-tab="graph"]', title: "Relationship Graph", message: "Visualise connections between people and companies as an interactive network graph.", advanceOnClick: true }, + { target: '[data-tab="delegations"]', title: "Delegations", message: "Manage delegative trust — assign voting, moderation, and other authority to community members. View flows as a Sankey diagram.", advanceOnClick: true }, ]; constructor() { @@ -651,6 +652,19 @@ class FolkCrmView extends HTMLElement { `; } + // ── Delegations tab ── + private renderDelegations(): string { + return ` +
+
+ +
+
+ +
+
`; + } + // ── Main render ── private render() { const tabs: { id: Tab; label: string; count: number }[] = [ @@ -658,6 +672,7 @@ class FolkCrmView extends HTMLElement { { id: "contacts", label: "Contacts", count: this.people.length }, { id: "companies", label: "Companies", count: this.companies.length }, { id: "graph", label: "Graph", count: 0 }, + { id: "delegations", label: "Delegations", count: 0 }, ]; let content = ""; @@ -671,6 +686,7 @@ class FolkCrmView extends HTMLElement { case "contacts": content = this.renderContacts(); break; case "companies": content = this.renderCompanies(); break; case "graph": content = this.renderGraphTab(); break; + case "delegations": content = this.renderDelegations(); break; } } @@ -792,7 +808,14 @@ class FolkCrmView extends HTMLElement { .graph-legend-item { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--rs-text-muted); } .graph-legend-dot { width: 10px; height: 10px; border-radius: 50%; flex-shrink: 0; } + /* ── Delegations tab ── */ + .delegations-layout { + display: grid; grid-template-columns: 1fr 1fr; gap: 20px; + } + .delegations-col { min-width: 0; } + @media (max-width: 900px) { + .delegations-layout { grid-template-columns: 1fr; } .pipeline-grid { grid-template-columns: repeat(2, 1fr); } } @media (max-width: 600px) { diff --git a/modules/rnetwork/components/folk-delegation-manager.ts b/modules/rnetwork/components/folk-delegation-manager.ts new file mode 100644 index 0000000..c37d41b --- /dev/null +++ b/modules/rnetwork/components/folk-delegation-manager.ts @@ -0,0 +1,535 @@ +/** + * — per-vertical delegation management UI. + * + * Shows bars for each authority vertical (voting, moderation, curation, treasury, membership) + * with percentage allocated and delegate avatars. Supports create, edit, revoke. + * Weight sum validated client-side before submission. + */ + +interface Delegation { + id: string; + delegatorDid: string; + delegateDid: string; + authority: string; + weight: number; + maxDepth: number; + retainAuthority: boolean; + spaceSlug: string; + state: string; + customScope: string | null; + expiresAt: number | null; + createdAt: number; + updatedAt: number; +} + +interface SpaceUser { + did: string; + username: string; + displayName: string | null; + avatarUrl: string | null; + role: string; +} + +const AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const; +const AUTHORITY_ICONS: Record = { + voting: "\u{1F5F3}\uFE0F", + moderation: "\u{1F6E1}\uFE0F", + curation: "\u2728", + treasury: "\u{1F4B0}", + membership: "\u{1F465}", +}; + +class FolkDelegationManager extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private outbound: Delegation[] = []; + private inbound: Delegation[] = []; + private users: SpaceUser[] = []; + private loading = true; + private error = ""; + private showModal = false; + private modalAuthority = "voting"; + private modalDelegate = ""; + private modalWeight = 50; + private modalMaxDepth = 3; + private modalRetainAuthority = true; + private editingId: string | null = null; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.render(); + this.loadData(); + } + + private getAuthBase(): string { + return this.getAttribute("auth-url") || ""; + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rnetwork/); + return match ? match[0] : ""; + } + + private getAuthHeaders(): Record { + const token = localStorage.getItem("encryptid_session"); + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; + } + + private async loadData() { + const authBase = this.getAuthBase(); + const apiBase = this.getApiBase(); + const headers = this.getAuthHeaders(); + + try { + const [outRes, inRes, usersRes] = await Promise.all([ + fetch(`${authBase}/api/delegations/from?space=${encodeURIComponent(this.space)}`, { headers }), + fetch(`${authBase}/api/delegations/to?space=${encodeURIComponent(this.space)}`, { headers }), + fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`), + ]); + + if (outRes.ok) { + const d = await outRes.json(); + this.outbound = d.delegations || []; + } + if (inRes.ok) { + const d = await inRes.json(); + this.inbound = d.delegations || []; + } + if (usersRes.ok) { + const d = await usersRes.json(); + this.users = d.users || []; + } + } catch { + this.error = "Failed to load delegation data"; + } + + this.loading = false; + this.render(); + } + + private getWeightForAuthority(authority: string): number { + return this.outbound + .filter(d => d.authority === authority && d.state === "active") + .reduce((sum, d) => sum + d.weight, 0); + } + + private getDelegationsForAuthority(authority: string): Delegation[] { + return this.outbound.filter(d => d.authority === authority && d.state !== "revoked"); + } + + private getInboundForAuthority(authority: string): Delegation[] { + return this.inbound.filter(d => d.authority === authority && d.state === "active"); + } + + private getUserName(did: string): string { + const u = this.users.find(u => u.did === did); + return u?.displayName || u?.username || did.slice(0, 16) + "..."; + } + + private async createDelegation() { + if (!this.modalDelegate) return; + const headers = { ...this.getAuthHeaders(), "Content-Type": "application/json" }; + const authBase = this.getAuthBase(); + + try { + const body = JSON.stringify({ + delegateDid: this.modalDelegate, + authority: this.modalAuthority, + weight: this.modalWeight / 100, + spaceSlug: this.space, + maxDepth: this.modalMaxDepth, + retainAuthority: this.modalRetainAuthority, + }); + + const res = await fetch(`${authBase}/api/delegations`, { method: "POST", headers, body }); + const data = await res.json(); + + if (!res.ok) { + this.error = data.error || "Failed to create delegation"; + this.render(); + return; + } + + this.showModal = false; + this.editingId = null; + this.error = ""; + await this.loadData(); + } catch { + this.error = "Network error creating delegation"; + this.render(); + } + } + + private async updateDelegation(id: string, updates: Record) { + const headers = { ...this.getAuthHeaders(), "Content-Type": "application/json" }; + const authBase = this.getAuthBase(); + + try { + const res = await fetch(`${authBase}/api/delegations/${id}`, { + method: "PATCH", + headers, + body: JSON.stringify(updates), + }); + + if (!res.ok) { + const data = await res.json(); + this.error = data.error || "Failed to update delegation"; + this.render(); + return; + } + await this.loadData(); + } catch { + this.error = "Network error"; + this.render(); + } + } + + private async revokeDelegation(id: string) { + const headers = this.getAuthHeaders(); + const authBase = this.getAuthBase(); + + try { + const res = await fetch(`${authBase}/api/delegations/${id}`, { method: "DELETE", headers }); + if (!res.ok) { + const data = await res.json(); + this.error = data.error || "Failed to revoke delegation"; + this.render(); + return; + } + await this.loadData(); + } catch { + this.error = "Network error"; + this.render(); + } + } + + private renderAuthorityBar(authority: string): string { + const total = this.getWeightForAuthority(authority); + const pct = Math.round(total * 100); + const delegations = this.getDelegationsForAuthority(authority); + const inboundCount = this.getInboundForAuthority(authority).length; + const icon = AUTHORITY_ICONS[authority] || ""; + + return ` +
+
+ ${icon} + ${authority} + ${pct}% delegated + ${inboundCount > 0 ? `${inboundCount} received` : ""} + +
+
+
+
+ ${delegations.length > 0 ? ` +
+ ${delegations.map(d => ` +
+ ${this.esc(this.getUserName(d.delegateDid))} + ${Math.round(d.weight * 100)}% + ${d.state} + ${d.state === 'active' ? ` + + ` : d.state === 'paused' ? ` + + ` : ""} + +
`).join("")} +
` : ""} +
`; + } + + private renderModal(): string { + if (!this.showModal) return ""; + + const currentTotal = this.getWeightForAuthority(this.modalAuthority); + const maxWeight = Math.round((1.0 - currentTotal) * 100); + + return ` + `; + } + + private render() { + this.shadow.innerHTML = ` + + + ${this.loading ? `
Loading delegations...
` : ` +
+ My Delegations + Outbound +
+ + ${this.error && !this.showModal ? `
${this.esc(this.error)}
` : ""} + + ${AUTHORITIES.map(a => this.renderAuthorityBar(a)).join("")} + + ${this.inbound.length > 0 ? ` +
Received Delegations
+ ${this.inbound.filter(d => d.state === "active").map(d => ` +
+ ${this.esc(this.getUserName(d.delegatorDid))} + ${Math.round(d.weight * 100)}% + ${d.authority} +
+ `).join("")} + ` : ""} + `} + + ${this.renderModal()} + `; + + this.attachListeners(); + } + + private attachListeners() { + // Add delegation buttons + this.shadow.querySelectorAll("[data-add-authority]").forEach(el => { + el.addEventListener("click", () => { + this.modalAuthority = (el as HTMLElement).dataset.addAuthority!; + this.modalDelegate = ""; + this.modalWeight = 50; + this.modalMaxDepth = 3; + this.modalRetainAuthority = true; + this.editingId = null; + this.error = ""; + + // Clamp default weight to available + const currentTotal = this.getWeightForAuthority(this.modalAuthority); + const max = Math.round((1.0 - currentTotal) * 100); + this.modalWeight = Math.min(50, max); + + this.showModal = true; + this.render(); + }); + }); + + // Pause/Resume/Revoke buttons + this.shadow.querySelectorAll("[data-pause]").forEach(el => { + el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.pause!, { state: "paused" })); + }); + this.shadow.querySelectorAll("[data-resume]").forEach(el => { + el.addEventListener("click", () => this.updateDelegation((el as HTMLElement).dataset.resume!, { state: "active" })); + }); + this.shadow.querySelectorAll("[data-revoke]").forEach(el => { + el.addEventListener("click", () => { + if (confirm("Revoke this delegation?")) { + this.revokeDelegation((el as HTMLElement).dataset.revoke!); + } + }); + }); + + // Modal listeners + if (this.showModal) { + this.shadow.getElementById("modal-close")?.addEventListener("click", () => { + this.showModal = false; + this.error = ""; + this.render(); + }); + this.shadow.getElementById("modal-overlay")?.addEventListener("click", (e) => { + if ((e.target as HTMLElement).id === "modal-overlay") { + this.showModal = false; + this.error = ""; + this.render(); + } + }); + this.shadow.getElementById("modal-cancel")?.addEventListener("click", () => { + this.showModal = false; + this.error = ""; + this.render(); + }); + this.shadow.getElementById("modal-confirm")?.addEventListener("click", () => { + this.createDelegation(); + }); + this.shadow.getElementById("modal-authority")?.addEventListener("change", (e) => { + this.modalAuthority = (e.target as HTMLSelectElement).value; + const currentTotal = this.getWeightForAuthority(this.modalAuthority); + const max = Math.round((1.0 - currentTotal) * 100); + this.modalWeight = Math.min(this.modalWeight, max); + this.render(); + }); + this.shadow.getElementById("modal-delegate")?.addEventListener("change", (e) => { + this.modalDelegate = (e.target as HTMLSelectElement).value; + }); + this.shadow.getElementById("modal-weight")?.addEventListener("input", (e) => { + this.modalWeight = parseInt((e.target as HTMLInputElement).value); + const label = this.shadow.querySelector('.field-label:nth-of-type(3)'); + // live update shown via next render + }); + this.shadow.getElementById("modal-depth")?.addEventListener("input", (e) => { + this.modalMaxDepth = parseInt((e.target as HTMLInputElement).value); + }); + this.shadow.getElementById("modal-retain")?.addEventListener("change", (e) => { + this.modalRetainAuthority = (e.target as HTMLInputElement).checked; + }); + } + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-delegation-manager", FolkDelegationManager); diff --git a/modules/rnetwork/components/folk-graph-viewer.ts b/modules/rnetwork/components/folk-graph-viewer.ts index 5a53626..f23229a 100644 --- a/modules/rnetwork/components/folk-graph-viewer.ts +++ b/modules/rnetwork/components/folk-graph-viewer.ts @@ -9,11 +9,12 @@ interface GraphNode { id: string; name: string; - type: "person" | "company" | "opportunity"; + type: "person" | "company" | "opportunity" | "rspace_user"; workspace: string; role?: string; location?: string; description?: string; + trustScore?: number; } interface GraphEdge { @@ -21,8 +22,12 @@ interface GraphEdge { target: string; type: string; label?: string; + weight?: number; } +const DELEGATION_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const; +type DelegationAuthority = typeof DELEGATION_AUTHORITIES[number]; + class FolkGraphViewer extends HTMLElement { private shadow: ShadowRoot; private space = ""; @@ -30,10 +35,12 @@ class FolkGraphViewer extends HTMLElement { private info: any = null; private nodes: GraphNode[] = []; private edges: GraphEdge[] = []; - private filter: "all" | "person" | "company" | "opportunity" = "all"; + private filter: "all" | "person" | "company" | "opportunity" | "rspace_user" = "all"; private searchQuery = ""; private error = ""; private selectedNode: GraphNode | null = null; + private trustMode = false; + private authority: DelegationAuthority = "voting"; // Canvas state private canvasZoom = 1; @@ -75,10 +82,11 @@ class FolkGraphViewer extends HTMLElement { private async loadData() { const base = this.getApiBase(); try { + const trustParam = this.trustMode ? `?trust=true&authority=${this.authority}` : ""; const [wsRes, infoRes, graphRes] = await Promise.all([ fetch(`${base}/api/workspaces`), fetch(`${base}/api/info`), - fetch(`${base}/api/graph`), + fetch(`${base}/api/graph${trustParam}`), ]); if (wsRes.ok) this.workspaces = await wsRes.json(); if (infoRes.ok) this.info = await infoRes.json(); @@ -92,6 +100,14 @@ class FolkGraphViewer extends HTMLElement { requestAnimationFrame(() => this.fitView()); } + /** Reload graph with trust data for selected authority */ + private async reloadWithAuthority(authority: DelegationAuthority) { + this.authority = authority; + this.trustMode = true; + this.layoutDirty = true; + await this.loadData(); + } + /** Map server /api/graph response to client GraphNode/GraphEdge format */ private importGraph(graph: { nodes?: any[]; edges?: any[] }) { if (!graph.nodes?.length) return; @@ -268,9 +284,21 @@ class FolkGraphViewer extends HTMLElement { } private getTrustScore(nodeId: string): number { + const node = this.nodes.find(n => n.id === nodeId); + if (node?.trustScore != null) return Math.round(node.trustScore * 100); + // Fallback: edge-count heuristic return Math.min(100, this.edges.filter(e => e.source === nodeId || e.target === nodeId).length * 20); } + /** Get node radius based on trust score (8px min, 30px max for users) */ + private getNodeRadius(node: GraphNode): number { + if (node.type === "company") return 22; + if (node.trustScore != null && this.trustMode) { + return 8 + (node.trustScore * 22); // 8px min → 30px max + } + return 12; + } + private getConnectedNodes(nodeId: string): GraphNode[] { const connIds = new Set(); for (const e of this.edges) { @@ -471,13 +499,25 @@ class FolkGraphViewer extends HTMLElement { } } + // Render delegation edges (purple, with arrows) + for (const edge of this.edges) { + if (edge.type !== "delegates_to") continue; + const sp = positions[edge.source]; + const tp = positions[edge.target]; + if (!sp || !tp) continue; + if (!filteredIds.has(edge.source) || !filteredIds.has(edge.target)) continue; + const strokeWidth = 1 + (edge.weight || 0.5) * 3; + edgesSvg.push(``); + } + // Render nodes const nodesSvg = filtered.map(node => { const pos = positions[node.id]; if (!pos) return ""; const isOrg = node.type === "company"; - const color = isOrg ? (orgColors[node.id] || "#22c55e") : "#3b82f6"; - const radius = isOrg ? 22 : 12; + const isUser = node.type === "rspace_user"; + const color = isOrg ? (orgColors[node.id] || "#22c55e") : isUser ? "#a78bfa" : "#3b82f6"; + const radius = this.getNodeRadius(node); const isSelected = this.selectedNode?.id === node.id; let label = this.esc(node.name); @@ -509,6 +549,11 @@ class FolkGraphViewer extends HTMLElement { return ` + + + + + ${clustersSvg} ${edgesSvg.join("")} @@ -597,6 +642,17 @@ class FolkGraphViewer extends HTMLElement { .demo-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; background: #f59e0b22; color: #f59e0b; font-size: 11px; font-weight: 600; margin-left: 8px; } + .authority-bar { + display: flex; gap: 4px; margin-bottom: 10px; flex-wrap: wrap; + } + .authority-btn { + padding: 4px 10px; border-radius: 6px; border: 1px solid var(--rs-input-border); + background: var(--rs-input-bg); color: var(--rs-text-muted); cursor: pointer; + font-size: 11px; text-transform: capitalize; + } + .authority-btn:hover { border-color: var(--rs-border-strong); } + .authority-btn.active { border-color: #a78bfa; color: #a78bfa; background: rgba(167, 139, 250, 0.1); } + .graph-node:hover circle:first-child { filter: brightness(1.2); } .detail-panel { @@ -646,12 +702,19 @@ class FolkGraphViewer extends HTMLElement {
- ${(["all", "person", "company", "opportunity"] as const).map(f => { - const labels: Record = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" }; + ${(["all", "person", "company", "opportunity", "rspace_user"] as const).map(f => { + const labels: Record = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities", rspace_user: "Members" }; return ``; }).join("")} +
+ ${this.trustMode ? ` +
+ ${DELEGATION_AUTHORITIES.map(a => ``).join("")} +
` : ""} + +
${this.nodes.length > 0 ? this.renderGraphSVG() : `
@@ -668,8 +731,10 @@ class FolkGraphViewer extends HTMLElement {
People
Organizations
+ ${this.trustMode ? `
Members
` : ""}
Works at
Point of contact
+ ${this.trustMode ? `
Delegates to
` : ""}
${this.workspaces.length > 0 ? ` @@ -708,6 +773,20 @@ class FolkGraphViewer extends HTMLElement { }, 200); }); + // Trust toggle + this.shadow.getElementById("trust-toggle")?.addEventListener("click", () => { + this.trustMode = !this.trustMode; + this.loadData(); + }); + + // Authority buttons + this.shadow.querySelectorAll("[data-authority]").forEach(el => { + el.addEventListener("click", () => { + const authority = (el as HTMLElement).dataset.authority as DelegationAuthority; + this.reloadWithAuthority(authority); + }); + }); + // Close detail panel this.shadow.getElementById("close-detail")?.addEventListener("click", () => { this.selectedNode = null; diff --git a/modules/rnetwork/components/folk-trust-sankey.ts b/modules/rnetwork/components/folk-trust-sankey.ts new file mode 100644 index 0000000..f1f33ef --- /dev/null +++ b/modules/rnetwork/components/folk-trust-sankey.ts @@ -0,0 +1,398 @@ +/** + * — Sankey diagram of delegation trust flows. + * + * Left column: delegators. Right column: delegates. + * Bezier curves between them, width proportional to delegation weight. + * Animated flow particles, authority filter, time slider for history playback, + * and per-flow trend sparklines. + */ + +interface DelegationFlow { + id: string; + fromDid: string; + fromName: string; + toDid: string; + toName: string; + authority: string; + weight: number; + state: string; + createdAt: number; +} + +interface TrustEvent { + id: string; + sourceDid: string; + targetDid: string; + eventType: string; + authority: string | null; + weightDelta: number | null; + createdAt: number; +} + +const SANKEY_AUTHORITIES = ["voting", "moderation", "curation", "treasury", "membership"] as const; +const FLOW_COLOR = "#a78bfa"; + +class FolkTrustSankey extends HTMLElement { + private shadow: ShadowRoot; + private space = ""; + private authority = "voting"; + private flows: DelegationFlow[] = []; + private events: TrustEvent[] = []; + private loading = true; + private error = ""; + private timeSliderValue = 100; // 0-100, percentage of history + private animationEnabled = true; + + constructor() { + super(); + this.shadow = this.attachShadow({ mode: "open" }); + } + + connectedCallback() { + this.space = this.getAttribute("space") || "demo"; + this.authority = this.getAttribute("authority") || "voting"; + this.render(); + this.loadData(); + } + + private getAuthBase(): string { + return this.getAttribute("auth-url") || ""; + } + + private getApiBase(): string { + const path = window.location.pathname; + const match = path.match(/^(\/[^/]+)?\/rnetwork/); + return match ? match[0] : ""; + } + + private getAuthHeaders(): Record { + const token = localStorage.getItem("encryptid_session"); + if (!token) return {}; + return { Authorization: `Bearer ${token}` }; + } + + private async loadData() { + const authBase = this.getAuthBase(); + const headers = this.getAuthHeaders(); + + try { + // Fetch outbound delegations for all users in the space + // We use the trust scores endpoint to get the flow data + const [outRes, inRes] = await Promise.all([ + fetch(`${authBase}/api/delegations/from?space=${encodeURIComponent(this.space)}`, { headers }), + fetch(`${authBase}/api/delegations/to?space=${encodeURIComponent(this.space)}`, { headers }), + ]); + + const allFlows: DelegationFlow[] = []; + const seen = new Set(); + + for (const res of [outRes, inRes]) { + if (res.ok) { + const data = await res.json(); + for (const d of data.delegations || []) { + if (seen.has(d.id)) continue; + seen.add(d.id); + allFlows.push({ + id: d.id, + fromDid: d.delegatorDid, + fromName: d.delegatorDid.slice(0, 12) + "...", + toDid: d.delegateDid, + toName: d.delegateDid.slice(0, 12) + "...", + authority: d.authority, + weight: d.weight, + state: d.state, + createdAt: d.createdAt, + }); + } + } + } + this.flows = allFlows; + + // Load user names + const apiBase = this.getApiBase(); + const usersRes = await fetch(`${apiBase}/api/users?space=${encodeURIComponent(this.space)}`); + if (usersRes.ok) { + const userData = await usersRes.json(); + const nameMap = new Map(); + for (const u of userData.users || []) { + nameMap.set(u.did, u.displayName || u.username); + } + for (const f of this.flows) { + if (nameMap.has(f.fromDid)) f.fromName = nameMap.get(f.fromDid)!; + if (nameMap.has(f.toDid)) f.toName = nameMap.get(f.toDid)!; + } + } + } catch { + this.error = "Failed to load delegation data"; + } + + this.loading = false; + this.render(); + } + + private getFilteredFlows(): DelegationFlow[] { + let filtered = this.flows.filter(f => f.authority === this.authority && f.state === "active"); + + // Time slider: filter flows created before the time cutoff + if (this.timeSliderValue < 100 && filtered.length > 0) { + const times = filtered.map(f => f.createdAt).sort((a, b) => a - b); + const earliest = times[0]; + const latest = Date.now(); + const cutoff = earliest + (latest - earliest) * (this.timeSliderValue / 100); + filtered = filtered.filter(f => f.createdAt <= cutoff); + } + + return filtered; + } + + private renderSankey(): string { + const flows = this.getFilteredFlows(); + if (flows.length === 0) { + return `
No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.
`; + } + + const W = 600, H = Math.max(300, flows.length * 40 + 60); + const leftX = 120, rightX = W - 120; + const nodeW = 16; + + // Collect unique delegators and delegates + const delegators = [...new Set(flows.map(f => f.fromDid))]; + const delegates = [...new Set(flows.map(f => f.toDid))]; + + // Position nodes vertically + const leftH = H - 40; + const rightH = H - 40; + const leftPositions = new Map(); + const rightPositions = new Map(); + + delegators.forEach((did, i) => { + leftPositions.set(did, 20 + (leftH * (i + 0.5)) / delegators.length); + }); + delegates.forEach((did, i) => { + rightPositions.set(did, 20 + (rightH * (i + 0.5)) / delegates.length); + }); + + // Build SVG + const flowPaths: string[] = []; + const particles: string[] = []; + + for (let i = 0; i < flows.length; i++) { + const f = flows[i]; + const y1 = leftPositions.get(f.fromDid)!; + const y2 = rightPositions.get(f.toDid)!; + const thickness = Math.max(2, f.weight * 20); + const midX = (leftX + nodeW + rightX - nodeW) / 2; + + // Bezier path + const path = `M ${leftX + nodeW} ${y1} C ${midX} ${y1}, ${midX} ${y2}, ${rightX - nodeW} ${y2}`; + flowPaths.push(` + + + `); + + // Animated particles + if (this.animationEnabled) { + const duration = 3 + Math.random() * 2; + const delay = Math.random() * duration; + particles.push(` + + + + `); + } + } + + // Left nodes (delegators) + const leftNodes = delegators.map(did => { + const y = leftPositions.get(did)!; + const name = flows.find(f => f.fromDid === did)?.fromName || did.slice(0, 8); + const total = flows.filter(f => f.fromDid === did).reduce((s, f) => s + f.weight, 0); + const h = Math.max(12, total * 40); + return ` + + ${this.esc(name)} + ${Math.round(total * 100)}% + `; + }).join(""); + + // Right nodes (delegates) + const rightNodes = delegates.map(did => { + const y = rightPositions.get(did)!; + const name = flows.find(f => f.toDid === did)?.toName || did.slice(0, 8); + const total = flows.filter(f => f.toDid === did).reduce((s, f) => s + f.weight, 0); + const h = Math.max(12, total * 40); + + // Sparkline: recent weight changes (last 30 days) + const sparkline = this.renderSparkline(did, 30); + + return ` + + ${this.esc(name)} + ${Math.round(total * 100)}% received + ${sparkline ? `${sparkline}` : ""} + `; + }).join(""); + + return ` + + + + + + + + ${flowPaths.join("")} + ${particles.join("")} + ${leftNodes} + ${rightNodes} + + `; + } + + /** Render a tiny sparkline SVG (60x16) showing recent weight trend */ + private renderSparkline(did: string, days: number): string { + // Use delegation creation timestamps as data points + const now = Date.now(); + const cutoff = now - days * 24 * 60 * 60 * 1000; + const relevant = this.flows.filter(f => f.toDid === did && f.authority === this.authority && f.createdAt > cutoff); + if (relevant.length < 2) return ""; + + // Build cumulative weight over time + const sorted = [...relevant].sort((a, b) => a.createdAt - b.createdAt); + const points: Array<{ t: number; w: number }> = []; + let cumulative = 0; + for (const f of sorted) { + cumulative += f.weight; + points.push({ t: f.createdAt, w: cumulative }); + } + + const w = 50, h = 12; + const tMin = cutoff, tMax = now; + const wMax = Math.max(...points.map(p => p.w), 0.01); + + const pathData = points.map((p, i) => { + const x = ((p.t - tMin) / (tMax - tMin)) * w; + const y = h - (p.w / wMax) * h; + return `${i === 0 ? "M" : "L"} ${x.toFixed(1)} ${y.toFixed(1)}`; + }).join(" "); + + return ``; + } + + private render() { + this.shadow.innerHTML = ` + + +
+ Delegation Flows +
+ ${SANKEY_AUTHORITIES.map(a => ``).join("")} +
+
+ + ${this.loading ? `
Loading flows...
` : ` +
+ ${this.renderSankey()} +
+ +
+ History: + + ${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"} +
+ +
+ +
+ +
+
Delegators
+
Delegates
+
Flow (width = weight)
+
+ `} + `; + + this.attachListeners(); + } + + private attachListeners() { + // Authority filter + this.shadow.querySelectorAll("[data-authority]").forEach(el => { + el.addEventListener("click", () => { + this.authority = (el as HTMLElement).dataset.authority!; + this.render(); + }); + }); + + // Time slider + this.shadow.getElementById("time-slider")?.addEventListener("input", (e) => { + this.timeSliderValue = parseInt((e.target as HTMLInputElement).value); + const label = this.shadow.getElementById("time-value"); + if (label) label.textContent = this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"; + // Debounce re-render + clearTimeout((this as any)._sliderTimer); + (this as any)._sliderTimer = setTimeout(() => this.render(), 100); + }); + + // Toggle animation + this.shadow.getElementById("toggle-animation")?.addEventListener("click", () => { + this.animationEnabled = !this.animationEnabled; + this.render(); + }); + } + + private esc(s: string): string { + const d = document.createElement("div"); + d.textContent = s || ""; + return d.innerHTML; + } +} + +customElements.define("folk-trust-sankey", FolkTrustSankey); diff --git a/modules/rnetwork/mod.ts b/modules/rnetwork/mod.ts index 1ce16b2..c05ba3f 100644 --- a/modules/rnetwork/mod.ts +++ b/modules/rnetwork/mod.ts @@ -126,6 +126,63 @@ routes.get("/api/companies", async (c) => { return c.json({ companies }); }); +// ── API: Users — EncryptID user directory with trust metadata ── +routes.get("/api/users", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + + try { + const res = await fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(dataSpace)}`, { + signal: AbortSignal.timeout(5000), + }); + if (!res.ok) return c.json({ users: [], error: "User directory unavailable" }); + const data = await res.json() as { users: unknown[] }; + return c.json(data); + } catch { + return c.json({ users: [], error: "EncryptID unreachable" }); + } +}); + +// ── API: Trust scores for graph visualization ── +routes.get("/api/trust", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const authority = c.req.query("authority") || "voting"; + const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + + try { + const res = await fetch( + `${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(dataSpace)}&authority=${encodeURIComponent(authority)}`, + { signal: AbortSignal.timeout(5000) }, + ); + if (!res.ok) return c.json({ scores: [], authority, error: "Trust scores unavailable" }); + return c.json(await res.json()); + } catch { + return c.json({ scores: [], authority, error: "EncryptID unreachable" }); + } +}); + +// ── API: Delegations for graph edges ── +routes.get("/api/delegations", async (c) => { + const space = c.req.param("space") || "demo"; + const dataSpace = (c.get("effectiveSpace" as any) as string) || space; + const authority = c.req.query("authority"); + const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + + // Proxy delegation data for the space - uses internal API + try { + const url = new URL(`${ENCRYPTID_URL}/api/trust/scores`); + url.searchParams.set("space", dataSpace); + if (authority) url.searchParams.set("authority", authority); + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }); + if (!res.ok) return c.json({ delegations: [], error: "Delegations unavailable" }); + return c.json(await res.json()); + } catch { + return c.json({ delegations: [], error: "EncryptID unreachable" }); + } +}); + // ── API: Graph — transform entities to node/edge format ── routes.get("/api/graph", async (c) => { const space = c.req.param("space") || "demo"; @@ -231,6 +288,51 @@ routes.get("/api/graph", async (c) => { } } + // If trust=true, merge EncryptID user nodes + delegation edges + const includeTrust = c.req.query("trust") === "true"; + const authority = c.req.query("authority") || "voting"; + + if (includeTrust) { + const ENCRYPTID_URL = process.env.ENCRYPTID_INTERNAL_URL || "http://encryptid:3000"; + try { + const [usersRes, scoresRes] = await Promise.all([ + fetch(`${ENCRYPTID_URL}/api/users/directory?space=${encodeURIComponent(dataSpace)}`, { signal: AbortSignal.timeout(5000) }), + fetch(`${ENCRYPTID_URL}/api/trust/scores?space=${encodeURIComponent(dataSpace)}&authority=${encodeURIComponent(authority)}`, { signal: AbortSignal.timeout(5000) }), + ]); + + if (usersRes.ok) { + const userData = await usersRes.json() as { users: Array<{ did: string; username: string; displayName: string | null; trustScores: Record }> }; + for (const u of userData.users || []) { + if (!nodeIds.has(u.did)) { + const trustScore = u.trustScores?.[authority] ?? 0; + nodes.push({ + id: u.did, + label: u.displayName || u.username, + type: "rspace_user" as any, + data: { trustScore, authority, role: "member" }, + }); + nodeIds.add(u.did); + } + } + } + + if (scoresRes.ok) { + const scoreData = await scoresRes.json() as { scores: Array<{ did: string; totalScore: number }> }; + // Build trust score lookup for node sizing + const trustMap = new Map(); + for (const s of scoreData.scores || []) { + trustMap.set(s.did, s.totalScore); + } + // Annotate existing nodes with trust scores + for (const node of nodes) { + if (trustMap.has(node.id)) { + (node.data as any).trustScore = trustMap.get(node.id); + } + } + } + } catch { /* trust enrichment is best-effort */ } + } + const result = { nodes, edges, demo: false }; graphCaches.set(dataSpace, { data: result, ts: Date.now() }); c.header("Cache-Control", "public, max-age=60"); @@ -284,7 +386,9 @@ routes.get("/crm", (c) => { spaceSlug: space, modules: getModuleInfoList(), body: ``, - scripts: ``, + scripts: ` + +`, styles: ``, })); }); diff --git a/server/notification-service.ts b/server/notification-service.ts index c409ba0..7c09837 100644 --- a/server/notification-service.ts +++ b/server/notification-service.ts @@ -50,7 +50,9 @@ export type NotificationEventType = | 'guardian_invite' | 'guardian_accepted' | 'recovery_initiated' | 'recovery_approved' | 'device_linked' | 'security_alert' // Social - | 'mention' | 'ping_user'; + | 'mention' | 'ping_user' + // Delegation + | 'delegation_received' | 'delegation_revoked' | 'delegation_expired'; export interface NotifyOptions { userDid: string; diff --git a/src/encryptid/db.ts b/src/encryptid/db.ts index fa9599a..9ff07c0 100644 --- a/src/encryptid/db.ts +++ b/src/encryptid/db.ts @@ -1723,4 +1723,350 @@ export async function updatePushSubscriptionLastUsed(id: string): Promise await sql`UPDATE push_subscriptions SET last_used = NOW() WHERE id = ${id}`; } +// ============================================================================ +// DELEGATIONS (person-to-person liquid democracy) +// ============================================================================ + +export type DelegationAuthority = 'voting' | 'moderation' | 'curation' | 'treasury' | 'membership' | 'custom'; +export type DelegationState = 'active' | 'paused' | 'revoked'; + +export interface StoredDelegation { + id: string; + delegatorDid: string; + delegateDid: string; + authority: DelegationAuthority; + weight: number; + maxDepth: number; + retainAuthority: boolean; + spaceSlug: string; + state: DelegationState; + customScope: string | null; + expiresAt: number | null; + createdAt: number; + updatedAt: number; +} + +function mapDelegationRow(r: any): StoredDelegation { + return { + id: r.id, + delegatorDid: r.delegator_did, + delegateDid: r.delegate_did, + authority: r.authority, + weight: parseFloat(r.weight), + maxDepth: r.max_depth, + retainAuthority: r.retain_authority, + spaceSlug: r.space_slug, + state: r.state, + customScope: r.custom_scope || null, + expiresAt: r.expires_at ? new Date(r.expires_at).getTime() : null, + createdAt: new Date(r.created_at).getTime(), + updatedAt: new Date(r.updated_at).getTime(), + }; +} + +export async function createDelegation(d: { + id: string; + delegatorDid: string; + delegateDid: string; + authority: DelegationAuthority; + weight: number; + maxDepth?: number; + retainAuthority?: boolean; + spaceSlug: string; + customScope?: string; + expiresAt?: number; +}): Promise { + const rows = await sql` + INSERT INTO delegations (id, delegator_did, delegate_did, authority, weight, max_depth, retain_authority, space_slug, custom_scope, expires_at) + VALUES (${d.id}, ${d.delegatorDid}, ${d.delegateDid}, ${d.authority}, ${d.weight}, + ${d.maxDepth ?? 3}, ${d.retainAuthority ?? true}, ${d.spaceSlug}, + ${d.customScope || null}, ${d.expiresAt ? new Date(d.expiresAt).toISOString() : null}) + RETURNING * + `; + return mapDelegationRow(rows[0]); +} + +export async function getDelegation(id: string): Promise { + const rows = await sql`SELECT * FROM delegations WHERE id = ${id}`; + return rows.length ? mapDelegationRow(rows[0]) : null; +} + +export async function listDelegationsFrom(delegatorDid: string, spaceSlug: string): Promise { + const rows = await sql` + SELECT * FROM delegations + WHERE delegator_did = ${delegatorDid} AND space_slug = ${spaceSlug} AND state != 'revoked' + ORDER BY authority, created_at + `; + return rows.map(mapDelegationRow); +} + +export async function listDelegationsTo(delegateDid: string, spaceSlug: string): Promise { + const rows = await sql` + SELECT * FROM delegations + WHERE delegate_did = ${delegateDid} AND space_slug = ${spaceSlug} AND state != 'revoked' + ORDER BY authority, created_at + `; + return rows.map(mapDelegationRow); +} + +export async function updateDelegation(id: string, updates: { + weight?: number; + state?: DelegationState; + maxDepth?: number; + retainAuthority?: boolean; + expiresAt?: number | null; +}): Promise { + const sets: string[] = []; + const vals: any[] = []; + if (updates.weight !== undefined) { sets.push('weight'); vals.push(updates.weight); } + if (updates.state !== undefined) { sets.push('state'); vals.push(updates.state); } + if (updates.maxDepth !== undefined) { sets.push('max_depth'); vals.push(updates.maxDepth); } + if (updates.retainAuthority !== undefined) { sets.push('retain_authority'); vals.push(updates.retainAuthority); } + if (updates.expiresAt !== undefined) { sets.push('expires_at'); vals.push(updates.expiresAt ? new Date(updates.expiresAt).toISOString() : null); } + if (sets.length === 0) return getDelegation(id); + + const rows = await sql` + UPDATE delegations SET + ${sql(Object.fromEntries(sets.map((s, i) => [s, vals[i]])))} + , updated_at = NOW() + WHERE id = ${id} AND state != 'revoked' + RETURNING * + `; + return rows.length ? mapDelegationRow(rows[0]) : null; +} + +export async function revokeDelegation(id: string): Promise { + const result = await sql` + UPDATE delegations SET state = 'revoked', updated_at = NOW() + WHERE id = ${id} AND state != 'revoked' + `; + return result.count > 0; +} + +/** Get total weight delegated by a user for a given authority in a space */ +export async function getTotalDelegatedWeight(delegatorDid: string, authority: string, spaceSlug: string): Promise { + const rows = await sql` + SELECT COALESCE(SUM(weight), 0) as total + FROM delegations + WHERE delegator_did = ${delegatorDid} AND authority = ${authority} + AND space_slug = ${spaceSlug} AND state = 'active' + `; + return parseFloat(rows[0].total); +} + +/** Get all active delegations in a space for a given authority (for trust computation) */ +export async function listActiveDelegations(spaceSlug: string, authority?: string): Promise { + const rows = authority + ? await sql` + SELECT * FROM delegations + WHERE space_slug = ${spaceSlug} AND authority = ${authority} AND state = 'active' + AND (expires_at IS NULL OR expires_at > NOW()) + ` + : await sql` + SELECT * FROM delegations + WHERE space_slug = ${spaceSlug} AND state = 'active' + AND (expires_at IS NULL OR expires_at > NOW()) + `; + return rows.map(mapDelegationRow); +} + +/** Clean expired delegations (mark as revoked) */ +export async function cleanExpiredDelegations(): Promise { + const result = await sql` + UPDATE delegations SET state = 'revoked', updated_at = NOW() + WHERE state = 'active' AND expires_at IS NOT NULL AND expires_at < NOW() + `; + return result.count; +} + +// ============================================================================ +// TRUST EVENTS +// ============================================================================ + +export type TrustEventType = + | 'delegation_created' | 'delegation_increased' | 'delegation_decreased' + | 'delegation_revoked' | 'delegation_paused' | 'delegation_resumed' + | 'endorsement' | 'flag' | 'collaboration' | 'guardian_link'; + +export interface StoredTrustEvent { + id: string; + sourceDid: string; + targetDid: string; + eventType: TrustEventType; + authority: string | null; + weightDelta: number | null; + spaceSlug: string; + metadata: Record; + createdAt: number; +} + +function mapTrustEventRow(r: any): StoredTrustEvent { + return { + id: r.id, + sourceDid: r.source_did, + targetDid: r.target_did, + eventType: r.event_type, + authority: r.authority || null, + weightDelta: r.weight_delta != null ? parseFloat(r.weight_delta) : null, + spaceSlug: r.space_slug, + metadata: r.metadata || {}, + createdAt: new Date(r.created_at).getTime(), + }; +} + +export async function logTrustEvent(event: { + id: string; + sourceDid: string; + targetDid: string; + eventType: TrustEventType; + authority?: string; + weightDelta?: number; + spaceSlug: string; + metadata?: Record; +}): Promise { + const rows = await sql` + INSERT INTO trust_events (id, source_did, target_did, event_type, authority, weight_delta, space_slug, metadata) + VALUES (${event.id}, ${event.sourceDid}, ${event.targetDid}, ${event.eventType}, + ${event.authority || null}, ${event.weightDelta ?? null}, ${event.spaceSlug}, + ${JSON.stringify(event.metadata || {})}) + RETURNING * + `; + return mapTrustEventRow(rows[0]); +} + +export async function getTrustEvents(did: string, spaceSlug: string, limit = 50): Promise { + const rows = await sql` + SELECT * FROM trust_events + WHERE (source_did = ${did} OR target_did = ${did}) AND space_slug = ${spaceSlug} + ORDER BY created_at DESC + LIMIT ${limit} + `; + return rows.map(mapTrustEventRow); +} + +export async function getTrustEventsSince(spaceSlug: string, since: number): Promise { + const rows = await sql` + SELECT * FROM trust_events + WHERE space_slug = ${spaceSlug} AND created_at >= ${new Date(since).toISOString()} + ORDER BY created_at ASC + `; + return rows.map(mapTrustEventRow); +} + +// ============================================================================ +// TRUST SCORES (materialized) +// ============================================================================ + +export interface StoredTrustScore { + sourceDid: string; + targetDid: string; + authority: string; + spaceSlug: string; + score: number; + directWeight: number; + transitiveWeight: number; + lastComputed: number; +} + +function mapTrustScoreRow(r: any): StoredTrustScore { + return { + sourceDid: r.source_did, + targetDid: r.target_did, + authority: r.authority, + spaceSlug: r.space_slug, + score: parseFloat(r.score), + directWeight: parseFloat(r.direct_weight), + transitiveWeight: parseFloat(r.transitive_weight), + lastComputed: new Date(r.last_computed).getTime(), + }; +} + +export async function upsertTrustScore(score: { + sourceDid: string; + targetDid: string; + authority: string; + spaceSlug: string; + score: number; + directWeight: number; + transitiveWeight: number; +}): Promise { + await sql` + INSERT INTO trust_scores (source_did, target_did, authority, space_slug, score, direct_weight, transitive_weight, last_computed) + VALUES (${score.sourceDid}, ${score.targetDid}, ${score.authority}, ${score.spaceSlug}, + ${score.score}, ${score.directWeight}, ${score.transitiveWeight}, NOW()) + ON CONFLICT (source_did, target_did, authority, space_slug) DO UPDATE SET + score = EXCLUDED.score, + direct_weight = EXCLUDED.direct_weight, + transitive_weight = EXCLUDED.transitive_weight, + last_computed = NOW() + `; +} + +/** Get aggregated trust scores — total trust received by each user for an authority in a space */ +export async function getAggregatedTrustScores(spaceSlug: string, authority: string): Promise> { + const rows = await sql` + SELECT target_did, + SUM(score) as total_score, + SUM(direct_weight) as direct_score, + SUM(transitive_weight) as transitive_score + FROM trust_scores + WHERE space_slug = ${spaceSlug} AND authority = ${authority} + GROUP BY target_did + ORDER BY total_score DESC + `; + return rows.map(r => ({ + did: r.target_did, + totalScore: parseFloat(r.total_score), + directScore: parseFloat(r.direct_score), + transitiveScore: parseFloat(r.transitive_score), + })); +} + +/** Get trust scores for a specific user across all authorities */ +export async function getTrustScoresByAuthority(did: string, spaceSlug: string): Promise { + const rows = await sql` + SELECT * FROM trust_scores + WHERE target_did = ${did} AND space_slug = ${spaceSlug} + ORDER BY authority + `; + return rows.map(mapTrustScoreRow); +} + +/** List all users with trust metadata for a space (user directory) */ +export async function listAllUsersWithTrust(spaceSlug: string): Promise; +}>> { + const rows = await sql` + SELECT u.did, u.username, u.display_name, u.avatar_url, sm.role, + COALESCE( + json_object_agg(ts.authority, ts.total) FILTER (WHERE ts.authority IS NOT NULL), + '{}' + ) as trust_scores + FROM space_members sm + JOIN users u ON u.did = sm.user_did + LEFT JOIN ( + SELECT target_did, authority, SUM(score) as total + FROM trust_scores + WHERE space_slug = ${spaceSlug} + GROUP BY target_did, authority + ) ts ON ts.target_did = sm.user_did + WHERE sm.space_slug = ${spaceSlug} + GROUP BY u.did, u.username, u.display_name, u.avatar_url, sm.role + ORDER BY u.username + `; + return rows.map(r => ({ + did: r.did, + username: r.username, + displayName: r.display_name || null, + avatarUrl: r.avatar_url || null, + role: r.role, + trustScores: typeof r.trust_scores === 'string' ? JSON.parse(r.trust_scores) : (r.trust_scores || {}), + })); +} + export { sql }; diff --git a/src/encryptid/schema.sql b/src/encryptid/schema.sql index fa8f32c..1182346 100644 --- a/src/encryptid/schema.sql +++ b/src/encryptid/schema.sql @@ -366,3 +366,68 @@ DO $$ BEGIN UNIQUE (user_id, address_hash); EXCEPTION WHEN duplicate_table THEN NULL; END $$; + +-- ============================================================================ +-- DELEGATIVE TRUST (person-to-person liquid democracy) +-- ============================================================================ + +-- Delegations: person-to-person authority delegation within a space +CREATE TABLE IF NOT EXISTS delegations ( + id TEXT PRIMARY KEY, + delegator_did TEXT NOT NULL, + delegate_did TEXT NOT NULL, + authority TEXT NOT NULL CHECK (authority IN ('voting', 'moderation', 'curation', 'treasury', 'membership', 'custom')), + weight REAL NOT NULL CHECK (weight > 0 AND weight <= 1), + max_depth INTEGER NOT NULL DEFAULT 3, + retain_authority BOOLEAN NOT NULL DEFAULT TRUE, + space_slug TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'active' CHECK (state IN ('active', 'paused', 'revoked')), + custom_scope TEXT, + expires_at TIMESTAMPTZ, + created_at TIMESTAMPTZ DEFAULT NOW(), + updated_at TIMESTAMPTZ DEFAULT NOW(), + UNIQUE (delegator_did, delegate_did, authority, space_slug), + CHECK (delegator_did != delegate_did) +); + +CREATE INDEX IF NOT EXISTS idx_delegations_delegator ON delegations(delegator_did, space_slug); +CREATE INDEX IF NOT EXISTS idx_delegations_delegate ON delegations(delegate_did, space_slug); +CREATE INDEX IF NOT EXISTS idx_delegations_space ON delegations(space_slug, authority); +CREATE INDEX IF NOT EXISTS idx_delegations_expires ON delegations(expires_at) WHERE expires_at IS NOT NULL; + +-- Trust events: append-only log of trust-relevant actions +CREATE TABLE IF NOT EXISTS trust_events ( + id TEXT PRIMARY KEY, + source_did TEXT NOT NULL, + target_did TEXT NOT NULL, + event_type TEXT NOT NULL CHECK (event_type IN ( + 'delegation_created', 'delegation_increased', 'delegation_decreased', + 'delegation_revoked', 'delegation_paused', 'delegation_resumed', + 'endorsement', 'flag', 'collaboration', 'guardian_link' + )), + authority TEXT, + weight_delta REAL, + space_slug TEXT NOT NULL, + metadata JSONB DEFAULT '{}', + created_at TIMESTAMPTZ DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_trust_events_source ON trust_events(source_did, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_trust_events_target ON trust_events(target_did, created_at DESC); +CREATE INDEX IF NOT EXISTS idx_trust_events_space ON trust_events(space_slug, created_at DESC); + +-- Trust scores: materialized aggregation of delegation + transitive trust +CREATE TABLE IF NOT EXISTS trust_scores ( + source_did TEXT NOT NULL, + target_did TEXT NOT NULL, + authority TEXT NOT NULL, + space_slug TEXT NOT NULL, + score REAL NOT NULL DEFAULT 0, + direct_weight REAL NOT NULL DEFAULT 0, + transitive_weight REAL NOT NULL DEFAULT 0, + last_computed TIMESTAMPTZ DEFAULT NOW(), + PRIMARY KEY (source_did, target_did, authority, space_slug) +); + +CREATE INDEX IF NOT EXISTS idx_trust_scores_target ON trust_scores(target_did, authority, space_slug); +CREATE INDEX IF NOT EXISTS idx_trust_scores_space ON trust_scores(space_slug, authority); diff --git a/src/encryptid/server.ts b/src/encryptid/server.ts index eaee22d..fbf68fb 100644 --- a/src/encryptid/server.ts +++ b/src/encryptid/server.ts @@ -101,6 +101,19 @@ import { deleteLinkedWallet, linkedWalletExists, consumeChallenge, + createDelegation, + getDelegation, + listDelegationsFrom, + listDelegationsTo, + updateDelegation, + revokeDelegation, + getTotalDelegatedWeight, + cleanExpiredDelegations, + logTrustEvent, + getTrustEvents, + getAggregatedTrustScores, + getTrustScoresByAuthority, + listAllUsersWithTrust, sql, } from './db.js'; import { @@ -111,6 +124,7 @@ import { aliasExists, } from './mailcow.js'; import { notify } from '../../server/notification-service'; +import { startTrustEngine } from './trust-engine.js'; // ============================================================================ // CONFIGURATION @@ -6847,6 +6861,265 @@ app.get('/', (c) => { `); }); +// ============================================================================ +// DELEGATION ROUTES (person-to-person liquid democracy) +// ============================================================================ + +const VALID_AUTHORITIES = ['voting', 'moderation', 'curation', 'treasury', 'membership', 'custom']; + +// POST /api/delegations — create a new delegation +app.post('/api/delegations', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const { delegateDid, authority, weight, spaceSlug, maxDepth, retainAuthority, customScope, expiresAt } = await c.req.json(); + + if (!delegateDid || !authority || !spaceSlug || weight == null) { + return c.json({ error: 'delegateDid, authority, weight, and spaceSlug are required' }, 400); + } + + if (!VALID_AUTHORITIES.includes(authority)) { + return c.json({ error: `authority must be one of: ${VALID_AUTHORITIES.join(', ')}` }, 400); + } + + const w = parseFloat(weight); + if (isNaN(w) || w <= 0 || w > 1) { + return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400); + } + + const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`; + + if (delegateDid === delegatorDid) { + return c.json({ error: 'Cannot delegate to yourself' }, 400); + } + + // Check weight sum doesn't exceed 1.0 + const currentTotal = await getTotalDelegatedWeight(delegatorDid, authority, spaceSlug); + if (currentTotal + w > 1.0 + 0.001) { // small epsilon for float + return c.json({ error: `Total delegation weight would exceed 100% (current: ${Math.round(currentTotal * 100)}%, requested: ${Math.round(w * 100)}%)` }, 400); + } + + try { + const id = crypto.randomUUID(); + const delegation = await createDelegation({ + id, + delegatorDid, + delegateDid, + authority, + weight: w, + maxDepth: maxDepth ?? 3, + retainAuthority: retainAuthority ?? true, + spaceSlug, + customScope, + expiresAt, + }); + + // Log trust event + await logTrustEvent({ + id: crypto.randomUUID(), + sourceDid: delegatorDid, + targetDid: delegateDid, + eventType: 'delegation_created', + authority, + weightDelta: w, + spaceSlug, + }); + + // Notify delegate + try { + await notify({ + userDid: delegateDid, + category: 'social', + eventType: 'delegation_received', + title: 'New delegation received', + body: `${claims.username || 'Someone'} delegated ${Math.round(w * 100)}% ${authority} authority to you`, + spaceSlug, + actorDid: delegatorDid, + actorUsername: claims.username, + }); + } catch { /* notification delivery is best-effort */ } + + return c.json({ success: true, delegation }); + } catch (err: any) { + if (err.message?.includes('unique') || err.code === '23505') { + return c.json({ error: 'Delegation already exists for this authority in this space' }, 409); + } + console.error('[delegations] Create error:', err.message); + return c.json({ error: 'Failed to create delegation' }, 500); + } +}); + +// GET /api/delegations/from — outbound delegations for the authenticated user +app.get('/api/delegations/from', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`; + const delegations = await listDelegationsFrom(delegatorDid, spaceSlug); + return c.json({ delegations }); +}); + +// GET /api/delegations/to — inbound delegations for the authenticated user +app.get('/api/delegations/to', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const delegateDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`; + const delegations = await listDelegationsTo(delegateDid, spaceSlug); + return c.json({ delegations }); +}); + +// PATCH /api/delegations/:id — update weight/state +app.patch('/api/delegations/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const id = c.req.param('id'); + const existing = await getDelegation(id); + if (!existing) return c.json({ error: 'Delegation not found' }, 404); + + const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`; + if (existing.delegatorDid !== delegatorDid) { + return c.json({ error: 'Only the delegator can modify a delegation' }, 403); + } + + const { weight, state, maxDepth, retainAuthority, expiresAt } = await c.req.json(); + + // Validate weight sum if weight is being changed + if (weight != null) { + const w = parseFloat(weight); + if (isNaN(w) || w <= 0 || w > 1) { + return c.json({ error: 'weight must be between 0 (exclusive) and 1 (inclusive)' }, 400); + } + const currentTotal = await getTotalDelegatedWeight(delegatorDid, existing.authority, existing.spaceSlug); + const adjustedTotal = currentTotal - existing.weight + w; + if (adjustedTotal > 1.0 + 0.001) { + return c.json({ error: `Total delegation weight would exceed 100%` }, 400); + } + } + + const updated = await updateDelegation(id, { + weight: weight != null ? parseFloat(weight) : undefined, + state, + maxDepth, + retainAuthority, + expiresAt, + }); + + if (!updated) return c.json({ error: 'Update failed' }, 500); + + // Log trust event + const eventType = state === 'paused' ? 'delegation_paused' + : state === 'active' ? 'delegation_resumed' + : weight != null && weight > existing.weight ? 'delegation_increased' + : weight != null && weight < existing.weight ? 'delegation_decreased' + : null; + + if (eventType) { + await logTrustEvent({ + id: crypto.randomUUID(), + sourceDid: delegatorDid, + targetDid: existing.delegateDid, + eventType, + authority: existing.authority, + weightDelta: weight != null ? parseFloat(weight) - existing.weight : null, + spaceSlug: existing.spaceSlug, + }); + } + + return c.json({ success: true, delegation: updated }); +}); + +// DELETE /api/delegations/:id — revoke a delegation +app.delete('/api/delegations/:id', async (c) => { + const claims = await verifyTokenFromRequest(c.req.header('Authorization')); + if (!claims) return c.json({ error: 'Unauthorized' }, 401); + + const id = c.req.param('id'); + const existing = await getDelegation(id); + if (!existing) return c.json({ error: 'Delegation not found' }, 404); + + const delegatorDid = claims.did || `did:key:${(claims.sub as string).slice(0, 32)}`; + if (existing.delegatorDid !== delegatorDid) { + return c.json({ error: 'Only the delegator can revoke a delegation' }, 403); + } + + const revoked = await revokeDelegation(id); + if (!revoked) return c.json({ error: 'Revoke failed' }, 500); + + // Log trust event + await logTrustEvent({ + id: crypto.randomUUID(), + sourceDid: delegatorDid, + targetDid: existing.delegateDid, + eventType: 'delegation_revoked', + authority: existing.authority, + weightDelta: -existing.weight, + spaceSlug: existing.spaceSlug, + }); + + // Notify delegate + try { + await notify({ + userDid: existing.delegateDid, + category: 'social', + eventType: 'delegation_revoked', + title: 'Delegation revoked', + body: `${claims.username || 'Someone'} revoked their ${existing.authority} delegation to you`, + spaceSlug: existing.spaceSlug, + actorDid: delegatorDid, + actorUsername: claims.username, + }); + } catch { /* best-effort */ } + + return c.json({ success: true }); +}); + +// GET /api/trust/scores — aggregated trust scores for visualization +app.get('/api/trust/scores', async (c) => { + const authority = c.req.query('authority') || 'voting'; + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const scores = await getAggregatedTrustScores(spaceSlug, authority); + return c.json({ scores, authority, space: spaceSlug }); +}); + +// GET /api/trust/scores/:did — trust profile for one user +app.get('/api/trust/scores/:did', async (c) => { + const did = c.req.param('did'); + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const scores = await getTrustScoresByAuthority(did, spaceSlug); + return c.json({ did, scores, space: spaceSlug }); +}); + +// GET /api/trust/events/:did — event history for one user +app.get('/api/trust/events/:did', async (c) => { + const did = c.req.param('did'); + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const events = await getTrustEvents(did, spaceSlug); + return c.json({ did, events, space: spaceSlug }); +}); + +// GET /api/users/directory — all users for rNetwork with trust metadata +app.get('/api/users/directory', async (c) => { + const spaceSlug = c.req.query('space'); + if (!spaceSlug) return c.json({ error: 'space query parameter required' }, 400); + + const users = await listAllUsersWithTrust(spaceSlug); + return c.json({ users, space: spaceSlug }); +}); + // ============================================================================ // DATABASE INITIALIZATION & SERVER START // ============================================================================ @@ -6879,6 +7152,9 @@ app.get('/', (c) => { } catch (err) { console.error('EncryptID: Failed to seed OIDC clients:', (err as Error).message); } + + // Start trust engine background job (recomputes scores every 5 min) + startTrustEngine(); })(); // Clean expired challenges, recovery tokens, fund claims, and OIDC codes every 10 minutes @@ -6888,6 +7164,7 @@ setInterval(() => { cleanExpiredFundClaims().catch(() => {}); cleanExpiredOidcCodes().catch(() => {}); cleanExpiredIdentityInvites().catch(() => {}); + cleanExpiredDelegations().catch(() => {}); }, 10 * 60 * 1000); console.log(` diff --git a/src/encryptid/trust-engine.ts b/src/encryptid/trust-engine.ts new file mode 100644 index 0000000..46aeca8 --- /dev/null +++ b/src/encryptid/trust-engine.ts @@ -0,0 +1,259 @@ +/** + * Trust Dynamics Engine — computes trust scores from delegation graphs. + * + * Algorithm: + * 1. Direct weight from active delegations + * 2. Time-decayed based on last trust event (half-life = 30 days) + * 3. Transitive trust via BFS through delegation chains (50% discount per hop) + * 4. Floor: trust never reaches zero (min 0.01) + * 5. Composite: clamp(direct + transitive, 0.01, 1.0) + * + * Runs as a background job every 5 minutes (same pattern as cleanExpiredChallenges). + */ + +import { + listActiveDelegations, + getTrustEventsSince, + upsertTrustScore, + cleanExpiredDelegations, + logTrustEvent, + type StoredDelegation, + type DelegationAuthority, +} from './db.js'; + +// ── Configuration ── + +export const TRUST_CONFIG = { + growthRate: 0.05, // 5% per positive event + decayRate: 0.10, // 10% per negative event (2x growth) + timeDecayHalfLife: 30, // days — trust halves with no activity + minTrust: 0.01, // trust never reaches zero + maxChainDepth: 3, // default transitive delegation limit + transitiveDiscount: 0.5, // each hop reduces trust 50% + recomputeIntervalMs: 5 * 60 * 1000, // 5 minutes +}; + +// ── Types ── + +interface DelegationEdge { + delegatorDid: string; + delegateDid: string; + weight: number; + maxDepth: number; +} + +interface ComputedScore { + sourceDid: string; + targetDid: string; + authority: string; + spaceSlug: string; + directWeight: number; + transitiveWeight: number; + score: number; +} + +// ── Core Computation ── + +/** + * Compute trust scores for all delegation relationships in a space+authority. + * Returns scores for every (source → target) pair that has nonzero trust. + */ +export function computeTrustScores( + delegations: StoredDelegation[], + authority: string, + spaceSlug: string, + lastEventTimes?: Map, // key: "source:target" → timestamp +): ComputedScore[] { + const now = Date.now(); + const halfLifeMs = TRUST_CONFIG.timeDecayHalfLife * 24 * 60 * 60 * 1000; + + // Build adjacency list: delegator → [{ delegate, weight, maxDepth }] + const edges = new Map(); + for (const d of delegations) { + if (d.authority !== authority) continue; + const list = edges.get(d.delegatorDid) || []; + list.push({ + delegatorDid: d.delegatorDid, + delegateDid: d.delegateDid, + weight: d.weight, + maxDepth: d.maxDepth, + }); + edges.set(d.delegatorDid, list); + } + + const scores: ComputedScore[] = []; + + // For each delegator, compute direct + transitive trust to all reachable delegates + edges.forEach((directEdges, delegator) => { + // BFS through delegation chains from this delegator + const visited = new Set([delegator]); + // queue: [targetDid, accumulatedWeight, hopsRemaining] + const queue: Array<[string, number, number]> = []; + + // Seed with direct delegations + for (const edge of directEdges) { + if (visited.has(edge.delegateDid)) continue; + + // Apply time decay to direct weight + const eventKey = `${delegator}:${edge.delegateDid}`; + const lastEvent = lastEventTimes?.get(eventKey) ?? now; + const daysSince = (now - lastEvent) / (24 * 60 * 60 * 1000); + const decayFactor = Math.pow(0.5, daysSince / TRUST_CONFIG.timeDecayHalfLife); + const decayedWeight = Math.max(TRUST_CONFIG.minTrust, edge.weight * decayFactor); + + visited.add(edge.delegateDid); + + // Record direct score + const directScore: ComputedScore = { + sourceDid: delegator, + targetDid: edge.delegateDid, + authority, + spaceSlug, + directWeight: decayedWeight, + transitiveWeight: 0, + score: Math.max(TRUST_CONFIG.minTrust, Math.min(1, decayedWeight)), + }; + scores.push(directScore); + + // Enqueue for transitive exploration + if (edge.maxDepth > 1) { + queue.push([edge.delegateDid, decayedWeight, edge.maxDepth - 1]); + } + } + + // BFS for transitive trust + while (queue.length > 0) { + const [currentDid, parentWeight, remainingDepth] = queue.shift()!; + const transitiveEdges = edges.get(currentDid); + if (!transitiveEdges || remainingDepth <= 0) continue; + + for (const edge of transitiveEdges) { + if (visited.has(edge.delegateDid)) continue; + visited.add(edge.delegateDid); + + const transitiveWeight = parentWeight * edge.weight * TRUST_CONFIG.transitiveDiscount; + if (transitiveWeight < TRUST_CONFIG.minTrust * 0.1) continue; // prune tiny values + + // Check if we already have a direct score for this target + const existing = scores.find( + s => s.sourceDid === delegator && s.targetDid === edge.delegateDid && s.authority === authority + ); + + if (existing) { + existing.transitiveWeight += transitiveWeight; + existing.score = Math.max(TRUST_CONFIG.minTrust, Math.min(1, existing.directWeight + existing.transitiveWeight)); + } else { + scores.push({ + sourceDid: delegator, + targetDid: edge.delegateDid, + authority, + spaceSlug, + directWeight: 0, + transitiveWeight, + score: Math.max(TRUST_CONFIG.minTrust, Math.min(1, transitiveWeight)), + }); + } + + // Continue BFS if depth allows + if (remainingDepth > 1) { + queue.push([edge.delegateDid, transitiveWeight, remainingDepth - 1]); + } + } + } + }); + + return scores; +} + +// ── Background Recomputation ── + +const AUTHORITIES: DelegationAuthority[] = ['voting', 'moderation', 'curation', 'treasury', 'membership', 'custom']; + +/** + * Recompute all trust scores for a single space. + * Called by the background job for each space that has delegations. + */ +export async function recomputeSpaceTrustScores(spaceSlug: string): Promise { + let totalScores = 0; + + for (const authority of AUTHORITIES) { + const delegations = await listActiveDelegations(spaceSlug, authority); + if (delegations.length === 0) continue; + + // Build last-event-time map from recent events (last 90 days) + const since = Date.now() - 90 * 24 * 60 * 60 * 1000; + const events = await getTrustEventsSince(spaceSlug, since); + const lastEventTimes = new Map(); + for (const evt of events) { + const key = `${evt.sourceDid}:${evt.targetDid}`; + const existing = lastEventTimes.get(key) || 0; + if (evt.createdAt > existing) lastEventTimes.set(key, evt.createdAt); + } + + const scores = computeTrustScores(delegations, authority, spaceSlug, lastEventTimes); + + // Upsert all computed scores + for (const s of scores) { + await upsertTrustScore(s); + } + totalScores += scores.length; + } + + return totalScores; +} + +/** + * Recompute trust scores across all spaces that have active delegations. + * This is the main entry point for the background job. + */ +export async function recomputeAllTrustScores(): Promise { + try { + // Get distinct space slugs from active delegations + const allDelegations = await listActiveDelegations('__all__'); // Won't match anything + // Instead, query distinct spaces directly + const { sql } = await import('./db.js'); + const spaces = await sql` + SELECT DISTINCT space_slug FROM delegations WHERE state = 'active' + `; + + for (const { space_slug } of spaces) { + try { + const count = await recomputeSpaceTrustScores(space_slug); + if (count > 0) { + console.log(`[trust-engine] Recomputed ${count} scores for space "${space_slug}"`); + } + } catch (err) { + console.error(`[trust-engine] Error recomputing space "${space_slug}":`, (err as Error).message); + } + } + } catch (err) { + console.error('[trust-engine] Recomputation failed:', (err as Error).message); + } +} + +// ── Background Job ── + +let recomputeInterval: ReturnType | null = null; + +export function startTrustEngine(): void { + if (recomputeInterval) return; + console.log('[trust-engine] Starting background recomputation (every 5 min)'); + + recomputeInterval = setInterval(async () => { + await cleanExpiredDelegations(); + await recomputeAllTrustScores(); + }, TRUST_CONFIG.recomputeIntervalMs); + + // Run once on startup (delayed 30s to let DB stabilize) + setTimeout(async () => { + await cleanExpiredDelegations(); + await recomputeAllTrustScores(); + }, 30_000); +} + +export function stopTrustEngine(): void { + if (recomputeInterval) { + clearInterval(recomputeInterval); + recomputeInterval = null; + } +} diff --git a/vite.config.ts b/vite.config.ts index 0a69d8e..29273b0 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -671,6 +671,46 @@ export default defineConfig({ }, }); + // Build delegation manager component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rnetwork/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rnetwork"), + lib: { + entry: resolve(__dirname, "modules/rnetwork/components/folk-delegation-manager.ts"), + formats: ["es"], + fileName: () => "folk-delegation-manager.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-delegation-manager.js", + }, + }, + }, + }); + + // Build trust sankey component + await build({ + configFile: false, + root: resolve(__dirname, "modules/rnetwork/components"), + build: { + emptyOutDir: false, + outDir: resolve(__dirname, "dist/modules/rnetwork"), + lib: { + entry: resolve(__dirname, "modules/rnetwork/components/folk-trust-sankey.ts"), + formats: ["es"], + fileName: () => "folk-trust-sankey.js", + }, + rollupOptions: { + output: { + entryFileNames: "folk-trust-sankey.js", + }, + }, + }, + }); + // Copy network CSS mkdirSync(resolve(__dirname, "dist/modules/rnetwork"), { recursive: true }); copyFileSync(