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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 19:16:06 -07:00
parent 751a2c8e7b
commit 5a33293a23
11 changed files with 2138 additions and 10 deletions

View File

@ -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 {
</div>`;
}
// ── Delegations tab ──
private renderDelegations(): string {
return `
<div class="delegations-layout">
<div class="delegations-col">
<folk-delegation-manager space="${this.space}"></folk-delegation-manager>
</div>
<div class="delegations-col">
<folk-trust-sankey space="${this.space}"></folk-trust-sankey>
</div>
</div>`;
}
// ── 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) {

View File

@ -0,0 +1,535 @@
/**
* <folk-delegation-manager> 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<string, string> = {
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<string, string> {
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<string, unknown>) {
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 `
<div class="authority-row">
<div class="authority-header">
<span class="authority-icon">${icon}</span>
<span class="authority-name">${authority}</span>
<span class="authority-pct">${pct}% delegated</span>
${inboundCount > 0 ? `<span class="authority-inbound">${inboundCount} received</span>` : ""}
<button class="btn-add" data-add-authority="${authority}" title="Add delegation">+</button>
</div>
<div class="authority-bar-track">
<div class="authority-bar-fill" style="width:${pct}%;background:${pct > 90 ? '#ef4444' : '#a78bfa'}"></div>
</div>
${delegations.length > 0 ? `
<div class="delegation-list">
${delegations.map(d => `
<div class="delegation-item ${d.state === 'paused' ? 'paused' : ''}">
<span class="delegation-name">${this.esc(this.getUserName(d.delegateDid))}</span>
<span class="delegation-weight">${Math.round(d.weight * 100)}%</span>
<span class="delegation-state">${d.state}</span>
${d.state === 'active' ? `
<button class="btn-sm btn-pause" data-pause="${d.id}" title="Pause">||</button>
` : d.state === 'paused' ? `
<button class="btn-sm btn-resume" data-resume="${d.id}" title="Resume">\u25B6</button>
` : ""}
<button class="btn-sm btn-revoke" data-revoke="${d.id}" title="Revoke">\u2715</button>
</div>`).join("")}
</div>` : ""}
</div>`;
}
private renderModal(): string {
if (!this.showModal) return "";
const currentTotal = this.getWeightForAuthority(this.modalAuthority);
const maxWeight = Math.round((1.0 - currentTotal) * 100);
return `
<div class="modal-overlay" id="modal-overlay">
<div class="modal">
<div class="modal-header">
<span class="modal-title">${this.editingId ? "Edit" : "New"} Delegation</span>
<button class="modal-close" id="modal-close">\u2715</button>
</div>
<div class="modal-body">
<label class="field-label">Authority</label>
<select class="field-select" id="modal-authority">
${AUTHORITIES.map(a => `<option value="${a}" ${this.modalAuthority === a ? "selected" : ""}>${AUTHORITY_ICONS[a]} ${a}</option>`).join("")}
</select>
<label class="field-label">Delegate</label>
<select class="field-select" id="modal-delegate">
<option value="">Select a member...</option>
${this.users.map(u => `<option value="${u.did}" ${this.modalDelegate === u.did ? "selected" : ""}>${this.esc(u.displayName || u.username)}</option>`).join("")}
</select>
<label class="field-label">Weight: ${this.modalWeight}%</label>
<input type="range" min="1" max="${Math.max(maxWeight, 1)}" value="${this.modalWeight}" class="field-slider" id="modal-weight">
<div class="field-hint">Available: ${maxWeight}% of ${this.modalAuthority}</div>
<label class="field-label">Re-delegation depth: ${this.modalMaxDepth}</label>
<input type="range" min="0" max="5" value="${this.modalMaxDepth}" class="field-slider" id="modal-depth">
<label class="field-check">
<input type="checkbox" id="modal-retain" ${this.modalRetainAuthority ? "checked" : ""}>
Retain authority alongside delegate
</label>
${this.error ? `<div class="modal-error">${this.esc(this.error)}</div>` : ""}
</div>
<div class="modal-footer">
<button class="btn-cancel" id="modal-cancel">Cancel</button>
<button class="btn-confirm" id="modal-confirm">${this.editingId ? "Update" : "Delegate"}</button>
</div>
</div>
</div>`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.dm-header { display: flex; align-items: center; gap: 10px; margin-bottom: 16px; }
.dm-title { font-size: 15px; font-weight: 600; }
.dm-subtitle { font-size: 12px; color: var(--rs-text-muted); }
.authority-row { margin-bottom: 16px; }
.authority-header {
display: flex; align-items: center; gap: 8px; margin-bottom: 6px;
}
.authority-icon { font-size: 16px; }
.authority-name { font-size: 13px; font-weight: 600; text-transform: capitalize; flex: 1; }
.authority-pct { font-size: 12px; color: var(--rs-text-muted); }
.authority-inbound {
font-size: 11px; color: #a78bfa; background: rgba(167,139,250,0.1);
padding: 2px 6px; border-radius: 4px;
}
.btn-add {
width: 24px; height: 24px; border: 1px solid var(--rs-input-border); border-radius: 6px;
background: var(--rs-input-bg); color: var(--rs-text-primary); cursor: pointer;
font-size: 14px; display: flex; align-items: center; justify-content: center;
}
.btn-add:hover { border-color: #a78bfa; color: #a78bfa; }
.authority-bar-track {
height: 6px; background: var(--rs-bg-surface-raised, #1a1a2e); border-radius: 3px;
overflow: hidden; margin-bottom: 8px;
}
.authority-bar-fill {
height: 100%; border-radius: 3px; transition: width 0.3s;
}
.delegation-list { display: flex; flex-direction: column; gap: 4px; }
.delegation-item {
display: flex; align-items: center; gap: 8px; padding: 6px 10px;
background: var(--rs-bg-surface); border: 1px solid var(--rs-border); border-radius: 8px;
font-size: 12px;
}
.delegation-item.paused { opacity: 0.6; }
.delegation-name { flex: 1; font-weight: 500; }
.delegation-weight { color: #a78bfa; font-weight: 600; min-width: 36px; text-align: right; }
.delegation-state {
font-size: 10px; text-transform: uppercase; color: var(--rs-text-muted);
padding: 1px 5px; border-radius: 3px; background: var(--rs-bg-surface-raised, #1a1a2e);
}
.btn-sm {
width: 22px; height: 22px; border: none; border-radius: 4px;
background: transparent; color: var(--rs-text-muted); cursor: pointer;
font-size: 11px; display: flex; align-items: center; justify-content: center;
}
.btn-sm:hover { background: var(--rs-bg-hover); }
.btn-revoke:hover { color: #ef4444; }
.btn-pause:hover { color: #f59e0b; }
.btn-resume:hover { color: #22c55e; }
.modal-overlay {
position: fixed; inset: 0; background: rgba(0,0,0,0.5); z-index: 100;
display: flex; align-items: center; justify-content: center;
}
.modal {
background: var(--rs-bg-surface); border: 1px solid var(--rs-border-strong);
border-radius: 12px; width: 380px; max-width: 95vw;
}
.modal-header {
display: flex; align-items: center; padding: 14px 16px;
border-bottom: 1px solid var(--rs-border);
}
.modal-title { font-size: 14px; font-weight: 600; flex: 1; }
.modal-close {
background: none; border: none; color: var(--rs-text-muted); cursor: pointer; font-size: 16px;
}
.modal-body { padding: 16px; display: flex; flex-direction: column; gap: 12px; }
.modal-footer {
display: flex; gap: 8px; justify-content: flex-end; padding: 12px 16px;
border-top: 1px solid var(--rs-border);
}
.field-label { font-size: 12px; font-weight: 600; color: var(--rs-text-muted); }
.field-select, .field-input {
width: 100%; padding: 8px 10px; border: 1px solid var(--rs-input-border);
border-radius: 8px; background: var(--rs-input-bg); color: var(--rs-input-text);
font-size: 13px; outline: none;
}
.field-select:focus, .field-input:focus { border-color: #a78bfa; }
.field-slider {
width: 100%; accent-color: #a78bfa;
}
.field-hint { font-size: 11px; color: var(--rs-text-muted); }
.field-check {
display: flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--rs-text-secondary); cursor: pointer;
}
.field-check input { accent-color: #a78bfa; }
.modal-error { font-size: 12px; color: #ef4444; padding: 6px 10px; background: rgba(239,68,68,0.1); border-radius: 6px; }
.btn-cancel {
padding: 6px 14px; border: 1px solid var(--rs-border); border-radius: 8px;
background: transparent; color: var(--rs-text-muted); cursor: pointer; font-size: 13px;
}
.btn-confirm {
padding: 6px 14px; border: none; border-radius: 8px;
background: #a78bfa; color: #fff; cursor: pointer; font-size: 13px; font-weight: 600;
}
.btn-confirm:hover { background: #8b5cf6; }
.loading { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.error-msg { font-size: 12px; color: #ef4444; margin-bottom: 10px; }
.section-title {
font-size: 11px; font-weight: 600; color: var(--rs-text-muted); text-transform: uppercase;
letter-spacing: 0.05em; margin: 16px 0 8px;
}
</style>
${this.loading ? `<div class="loading">Loading delegations...</div>` : `
<div class="dm-header">
<span class="dm-title">My Delegations</span>
<span class="dm-subtitle">Outbound</span>
</div>
${this.error && !this.showModal ? `<div class="error-msg">${this.esc(this.error)}</div>` : ""}
${AUTHORITIES.map(a => this.renderAuthorityBar(a)).join("")}
${this.inbound.length > 0 ? `
<div class="section-title">Received Delegations</div>
${this.inbound.filter(d => d.state === "active").map(d => `
<div class="delegation-item">
<span class="delegation-name">${this.esc(this.getUserName(d.delegatorDid))}</span>
<span class="delegation-weight">${Math.round(d.weight * 100)}%</span>
<span class="delegation-state">${d.authority}</span>
</div>
`).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);

View File

@ -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<string>();
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(`<line x1="${sp.x}" y1="${sp.y}" x2="${tp.x}" y2="${tp.y}" stroke="#a78bfa" stroke-width="${strokeWidth}" opacity="0.6" marker-end="url(#arrow-delegation)"/>`);
}
// 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 `
<svg id="graph-svg" width="100%" height="100%">
<defs>
<marker id="arrow-delegation" viewBox="0 0 10 10" refX="10" refY="5" markerWidth="6" markerHeight="6" orient="auto-start-reverse">
<path d="M 0 0 L 10 5 L 0 10 z" fill="#a78bfa" opacity="0.7"/>
</marker>
</defs>
<g id="canvas-transform" transform="translate(${this.canvasPanX},${this.canvasPanY}) scale(${this.canvasZoom})">
<g id="cluster-layer">${clustersSvg}</g>
<g id="edge-layer">${edgesSvg.join("")}</g>
@ -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 {
<div class="toolbar">
<input class="search-input" type="text" placeholder="Search nodes..." id="search-input" value="${this.esc(this.searchQuery)}">
${(["all", "person", "company", "opportunity"] as const).map(f => {
const labels: Record<string, string> = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities" };
${(["all", "person", "company", "opportunity", "rspace_user"] as const).map(f => {
const labels: Record<string, string> = { all: "All", person: "People", company: "Organizations", opportunity: "Opportunities", rspace_user: "Members" };
return `<button class="filter-btn ${this.filter === f ? "active" : ""}" data-filter="${f}">${labels[f]}</button>`;
}).join("")}
<button class="filter-btn ${this.trustMode ? "active" : ""}" id="trust-toggle" title="Toggle trust-weighted view">Trust</button>
</div>
${this.trustMode ? `
<div class="authority-bar">
${DELEGATION_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${a}</button>`).join("")}
</div>` : ""}
<div class="graph-canvas" id="graph-canvas">
${this.nodes.length > 0 ? this.renderGraphSVG() : `
<div class="placeholder">
@ -668,8 +731,10 @@ class FolkGraphViewer extends HTMLElement {
<div class="legend">
<div class="legend-item"><span class="legend-dot dot-person"></span> People</div>
<div class="legend-item"><span class="legend-dot dot-company"></span> Organizations</div>
${this.trustMode ? `<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Members</div>` : ""}
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" style="stroke:var(--rs-text-muted)" stroke-width="2"/></svg> Works at</div>
<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#c084fc" stroke-width="2" stroke-dasharray="4 2"/></svg> Point of contact</div>
${this.trustMode ? `<div class="legend-item"><svg width="20" height="10"><line x1="0" y1="5" x2="20" y2="5" stroke="#a78bfa" stroke-width="2"/></svg> Delegates to</div>` : ""}
</div>
${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;

View File

@ -0,0 +1,398 @@
/**
* <folk-trust-sankey> 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<string, string> {
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<string>();
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<string, string>();
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 `<div class="empty">No delegation flows for ${this.authority}${this.timeSliderValue < 100 ? " at this time" : ""}.</div>`;
}
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<string, number>();
const rightPositions = new Map<string, number>();
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(`
<path d="${path}" fill="none" stroke="${FLOW_COLOR}" stroke-width="${thickness}" opacity="0.3"/>
<path d="${path}" fill="none" stroke="url(#flow-gradient)" stroke-width="${thickness}" opacity="0.6"/>
`);
// Animated particles
if (this.animationEnabled) {
const duration = 3 + Math.random() * 2;
const delay = Math.random() * duration;
particles.push(`
<circle r="${Math.max(2, thickness * 0.4)}" fill="${FLOW_COLOR}" opacity="0.8">
<animateMotion dur="${duration}s" begin="${delay}s" repeatCount="indefinite" path="${path}"/>
</circle>
`);
}
}
// 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 `
<rect x="${leftX}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#7c3aed" opacity="0.8"/>
<text x="${leftX - 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="end" font-weight="500">${this.esc(name)}</text>
<text x="${leftX - 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="end">${Math.round(total * 100)}%</text>
`;
}).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 `
<rect x="${rightX - nodeW}" y="${y - h/2}" width="${nodeW}" height="${h}" rx="3" fill="#a78bfa" opacity="0.8"/>
<text x="${rightX + 6}" y="${y + 4}" fill="var(--rs-text-primary)" font-size="11" text-anchor="start" font-weight="500">${this.esc(name)}</text>
<text x="${rightX + 6}" y="${y + 16}" fill="var(--rs-text-muted)" font-size="9" text-anchor="start">${Math.round(total * 100)}% received</text>
${sparkline ? `<g transform="translate(${rightX + 6}, ${y + 20})">${sparkline}</g>` : ""}
`;
}).join("");
return `
<svg width="100%" viewBox="0 0 ${W} ${H}" class="sankey-svg">
<defs>
<linearGradient id="flow-gradient" x1="0%" y1="0%" x2="100%" y2="0%">
<stop offset="0%" stop-color="#7c3aed" stop-opacity="0.6"/>
<stop offset="100%" stop-color="#a78bfa" stop-opacity="0.4"/>
</linearGradient>
</defs>
<g class="flow-layer">${flowPaths.join("")}</g>
<g class="particle-layer">${particles.join("")}</g>
<g class="left-nodes">${leftNodes}</g>
<g class="right-nodes">${rightNodes}</g>
</svg>
`;
}
/** 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 `<path d="${pathData}" fill="none" stroke="#a78bfa" stroke-width="1" opacity="0.6"/>`;
}
private render() {
this.shadow.innerHTML = `
<style>
:host { display: block; font-family: system-ui, -apple-system, sans-serif; color: var(--rs-text-primary); }
* { box-sizing: border-box; }
.sankey-header { display: flex; align-items: center; gap: 10px; margin-bottom: 12px; flex-wrap: wrap; }
.sankey-title { font-size: 15px; font-weight: 600; }
.authority-filter {
display: flex; gap: 4px; 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); }
.sankey-container {
background: var(--rs-canvas-bg, #0a0a0f); border: 1px solid var(--rs-border);
border-radius: 12px; padding: 16px; overflow-x: auto;
}
.sankey-svg { display: block; min-height: 200px; }
.time-slider {
display: flex; align-items: center; gap: 10px; margin-top: 12px;
}
.time-label { font-size: 11px; color: var(--rs-text-muted); min-width: 80px; }
.time-range { flex: 1; accent-color: #a78bfa; }
.time-value { font-size: 11px; color: var(--rs-text-muted); min-width: 30px; text-align: right; }
.controls { display: flex; align-items: center; gap: 10px; margin-top: 8px; }
.toggle-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;
}
.toggle-btn.active { border-color: #a78bfa; color: #a78bfa; }
.empty { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.loading { text-align: center; color: var(--rs-text-muted); padding: 40px; font-size: 13px; }
.legend { display: flex; gap: 16px; margin-top: 10px; flex-wrap: wrap; }
.legend-item { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--rs-text-muted); }
.legend-dot { width: 10px; height: 4px; border-radius: 2px; }
</style>
<div class="sankey-header">
<span class="sankey-title">Delegation Flows</span>
<div class="authority-filter">
${SANKEY_AUTHORITIES.map(a => `<button class="authority-btn ${this.authority === a ? "active" : ""}" data-authority="${a}">${a}</button>`).join("")}
</div>
</div>
${this.loading ? `<div class="loading">Loading flows...</div>` : `
<div class="sankey-container">
${this.renderSankey()}
</div>
<div class="time-slider">
<span class="time-label">History:</span>
<input type="range" class="time-range" id="time-slider" min="0" max="100" value="${this.timeSliderValue}">
<span class="time-value" id="time-value">${this.timeSliderValue === 100 ? "Now" : this.timeSliderValue + "%"}</span>
</div>
<div class="controls">
<button class="toggle-btn ${this.animationEnabled ? "active" : ""}" id="toggle-animation">
${this.animationEnabled ? "Pause" : "Play"} particles
</button>
</div>
<div class="legend">
<div class="legend-item"><span class="legend-dot" style="background:#7c3aed"></span> Delegators</div>
<div class="legend-item"><span class="legend-dot" style="background:#a78bfa"></span> Delegates</div>
<div class="legend-item"><span class="legend-dot" style="background:linear-gradient(90deg,#7c3aed,#a78bfa)"></span> Flow (width = weight)</div>
</div>
`}
`;
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);

View File

@ -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<string, number> }> };
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<string, number>();
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: `<folk-crm-view space="${space}"></folk-crm-view>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>`,
scripts: `<script type="module" src="/modules/rnetwork/folk-crm-view.js"></script>
<script type="module" src="/modules/rnetwork/folk-delegation-manager.js"></script>
<script type="module" src="/modules/rnetwork/folk-trust-sankey.js"></script>`,
styles: `<link rel="stylesheet" href="/modules/rnetwork/network.css">`,
}));
});

View File

@ -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;

View File

@ -1723,4 +1723,350 @@ export async function updatePushSubscriptionLastUsed(id: string): Promise<void>
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<StoredDelegation> {
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<StoredDelegation | null> {
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<StoredDelegation[]> {
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<StoredDelegation[]> {
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<StoredDelegation | null> {
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<boolean> {
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<number> {
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<StoredDelegation[]> {
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<number> {
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<string, unknown>;
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<string, unknown>;
}): Promise<StoredTrustEvent> {
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<StoredTrustEvent[]> {
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<StoredTrustEvent[]> {
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<void> {
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<Array<{ did: string; totalScore: number; directScore: number; transitiveScore: number }>> {
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<StoredTrustScore[]> {
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<Array<{
did: string;
username: string;
displayName: string | null;
avatarUrl: string | null;
role: string;
trustScores: Record<string, number>;
}>> {
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 };

View File

@ -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);

View File

@ -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(`

View File

@ -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<string, number>, // 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<string, DelegationEdge[]>();
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<string>([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<number> {
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<string, number>();
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<void> {
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<typeof setInterval> | 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;
}
}

View File

@ -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(