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:
parent
751a2c8e7b
commit
5a33293a23
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
@ -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">`,
|
||||
}));
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(`
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
Loading…
Reference in New Issue