diff --git a/src/psyc/_federation_cli.py b/src/psyc/_federation_cli.py
index e36bfd7..b51223c 100644
--- a/src/psyc/_federation_cli.py
+++ b/src/psyc/_federation_cli.py
@@ -13,7 +13,7 @@ import httpx
import typer
from psyc import db, log
-from psyc.lines import discovery, federation, pulse, translog
+from psyc.lines import discovery, federation, network_view, pulse, translog
from psyc.result import Err, Ok
@@ -274,6 +274,58 @@ def register(typer_app: typer.Typer) -> None:
f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}…"
)
+ # ---------- network view ------------------------------------------
+
+ @typer_app.command("fed-network")
+ def fed_network() -> None:
+ """Print the local federation network view — nodes, vouches, stats."""
+ db.init_db()
+ view = network_view.build_local_view()
+
+ # Nodes table.
+ typer.echo("NODES")
+ typer.echo(f" {'fingerprint':<34} {'label':<32} {'status':<9} dist")
+ for n in view.nodes:
+ fp = f"{n.fingerprint[:8]}…{n.fingerprint[-8:]}" if len(n.fingerprint) >= 16 else n.fingerprint
+ label = (n.label or "")[:30]
+ typer.echo(f" {fp:<34} {label:<32} {n.status:<9} {n.distance}")
+
+ # Vouches breakdown.
+ our_fp = view.nodes[0].fingerprint
+ vouch_out = [e for e in view.edges if e.kind == "vouch" and e.source_fingerprint == our_fp]
+ vouch_in = [e for e in view.edges if e.kind == "vouch" and e.target_fingerprint == our_fp]
+ bidir = [e for e in vouch_out if e.bidirectional]
+
+ typer.echo("")
+ typer.echo("VOUCHES")
+ if not vouch_out and not vouch_in and not bidir:
+ typer.echo(" (no vouches)")
+ else:
+ for e in vouch_out:
+ arrow = "↔" if e.bidirectional else "→"
+ fp = f"{e.target_fingerprint[:8]}…{e.target_fingerprint[-8:]}"
+ typer.echo(f" us {arrow} {fp}")
+ for e in vouch_in:
+ fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}"
+ typer.echo(f" {fp} → us")
+
+ # Signal edges.
+ sig_edges = [e for e in view.edges if e.kind == "signal"]
+ typer.echo("")
+ typer.echo("SIGNALS (24h)")
+ if not sig_edges:
+ typer.echo(" (no signals)")
+ else:
+ for e in sig_edges:
+ fp = f"{e.source_fingerprint[:8]}…{e.source_fingerprint[-8:]}"
+ typer.echo(f" from {fp}: {int(e.weight)}")
+
+ # Stats footer.
+ typer.echo("")
+ typer.echo("STATS")
+ for k, v in view.stats.items():
+ typer.echo(f" {k:<32} {v}")
+
@typer_app.command("fed-log-verify")
def fed_log_verify() -> None:
"""Re-walk the chain locally and report verification status."""
diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py
index 90bdcb0..02c9a96 100644
--- a/src/psyc/cockpit/federation_routes.py
+++ b/src/psyc/cockpit/federation_routes.py
@@ -15,7 +15,7 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
from fastapi.templating import Jinja2Templates
from psyc import db, log
-from psyc.lines import discovery, federation, pulse, translog
+from psyc.lines import discovery, federation, network_view, pulse, translog
from psyc.result import Err
@@ -30,6 +30,10 @@ _FEED_TTL = 60.0
_PUBLIC_PEERS_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_PUBLIC_PEERS_TTL = 60.0
+# And again for the public federation-network payload (signed JSON view).
+_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
+_PUBLIC_NETWORK_TTL = 60.0
+
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))
@@ -51,6 +55,14 @@ def _cached_public_peers() -> Any:
return _PUBLIC_PEERS_CACHE["payload"]
+def _cached_public_network() -> Dict[str, Any]:
+ now = time.time()
+ if _PUBLIC_NETWORK_CACHE["payload"] is None or (now - _PUBLIC_NETWORK_CACHE["ts"]) > _PUBLIC_NETWORK_TTL:
+ _PUBLIC_NETWORK_CACHE["payload"] = network_view.build_public_view()
+ _PUBLIC_NETWORK_CACHE["ts"] = now
+ return _PUBLIC_NETWORK_CACHE["payload"]
+
+
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""Mount all federation routes onto `app`."""
@@ -192,6 +204,16 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""
return JSONResponse(_cached_public_peers())
+ @app.get("/federation/network")
+ def federation_network_public() -> JSONResponse:
+ """Signed federation-network attestation — for transitive-view fetchers.
+
+ Mirrors /federation/peers/public in spirit but adds our outbound vouches
+ so a fetcher can reconstruct the local web-of-trust shape. Trusted peers
+ only — never unknown or blocked. Signal hashes are deliberately omitted.
+ """
+ return JSONResponse(_cached_public_network())
+
# ---------- public vouches + transparency log --------------------
@app.get("/federation/vouches")
@@ -300,6 +322,38 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
},
)
+ # ---------- admin: federation network view ----------------------
+
+ @app.get("/admin/federation/network", response_class=HTMLResponse)
+ def admin_federation_network(request: Request) -> HTMLResponse:
+ """Cockpit page — force-directed federation map. Data lives at /data."""
+ if not _admin_ok(request):
+ return RedirectResponse("/admin", status_code=303)
+ # Build local stats up front so the header card renders even if the
+ # JS data-endpoint fetch fails (defensive — never give the operator a
+ # blank page).
+ view = network_view.build_local_view()
+ return TEMPLATES.TemplateResponse(
+ request,
+ "admin_federation_network.html",
+ {
+ "fingerprint": federation.node_fingerprint(),
+ "stats": view.stats,
+ },
+ )
+
+ @app.get("/admin/federation/network/data")
+ def admin_federation_network_data(request: Request) -> JSONResponse:
+ """Full admin view — includes unknown/blocked peers + transitive peers.
+
+ Public /federation/network filters those out; this surface does not,
+ because it sits behind the admin gate and the operator needs to see
+ the real shape of the federation including the parts being ignored.
+ """
+ if not _admin_ok(request):
+ raise HTTPException(status_code=403, detail="admin session required")
+ return JSONResponse(network_view.build_admin_view(include_transitive=True))
+
# ---------- admin: quorum config + per-peer/per-hash view -------
@app.get("/admin/federation/quorum", response_class=HTMLResponse)
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index 920ceb9..35125f7 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -1166,3 +1166,81 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
.stat-country { border-color: rgba(251,191,36,0.4); color: var(--amber); }
.stat-confidence { border-color: rgba(167,139,250,0.4); color: #c4b5fd; }
.stat-family { border-color: rgba(248,113,113,0.4); color: var(--red); }
+
+/* ── federation network graph ──────────────────────────────── */
+.fn-stats { display: flex; flex-wrap: wrap; gap: 10px; margin: 8px 0 18px; }
+.fn-stat {
+ flex: 1; min-width: 120px;
+ background: var(--panel-2); border: 1px solid var(--border); border-radius: 6px;
+ padding: 10px 14px;
+}
+.fn-stat-label { color: var(--muted); font-size: 11px; text-transform: uppercase; letter-spacing: 0.06em; }
+.fn-stat-value { font-family: var(--font-display); font-size: 22px; font-weight: 700; color: var(--accent); margin-top: 4px; text-shadow: 0 0 12px var(--accent-glow); }
+
+#federation-network-graph { display: block; width: 100%; height: 620px; cursor: grab; }
+#federation-network-graph:active { cursor: grabbing; }
+
+body.wide #federation-network-graph { height: 720px; }
+
+.fn-edge { stroke: rgba(125,133,151,0.45); stroke-width: 1.2; fill: none; }
+.fn-edge-label { fill: var(--muted); font-size: 9px; text-anchor: middle; font-family: ui-monospace, Menlo, monospace; opacity: 0.7; pointer-events: none; }
+
+.fn-kind-vouch .fn-edge { stroke: rgba(74,222,128,0.7); stroke-width: 1.8; }
+.fn-kind-vouch .fn-edge-label { fill: rgba(160,240,190,0.85); font-weight: 600; }
+.fn-kind-signal .fn-edge { stroke: rgba(30,200,255,0.65); stroke-dasharray: 5 4; }
+.fn-kind-signal .fn-edge-label { fill: rgba(170, 220, 255, 0.85); }
+.fn-kind-knows .fn-edge { stroke: rgba(125,133,151,0.32); stroke-dasharray: 2 4; }
+.fn-kind-knows .fn-edge-label { display: none; }
+
+.fn-edge.alive { animation: fn-flow 1.6s linear infinite; }
+.fn-edge.dim { opacity: 0.55; }
+@keyframes fn-flow { to { stroke-dashoffset: -54; } }
+#federation-network-graph.flow-off .fn-edge.alive { animation: none; }
+@media (prefers-reduced-motion: reduce) { .fn-edge.alive { animation: none; } }
+
+.fn-node { cursor: grab; }
+.fn-node.dragging { cursor: grabbing; }
+.fn-node circle, .fn-node rect { transition: filter 0.15s; }
+.fn-node:hover circle, .fn-node:hover rect { filter: drop-shadow(0 0 10px var(--accent)); }
+
+/* Self — accent-glowing rounded square. */
+.fn-self rect {
+ fill: rgba(30,200,255,0.18); stroke: var(--accent); stroke-width: 2;
+ filter: drop-shadow(0 0 14px var(--accent-glow));
+}
+.fn-self .fn-label { fill: var(--accent); font-weight: 700; letter-spacing: 0.10em; font-size: 13px; }
+
+/* Direct peers (distance=1). Status drives color. */
+.fn-status-trusted circle { fill: rgba(74,222,128,0.12); stroke: var(--green); stroke-width: 2; }
+.fn-status-vouched circle { fill: rgba(167,139,250,0.12); stroke: #a78bfa; stroke-width: 1.8; stroke-dasharray: 4 3; }
+.fn-status-unknown circle { fill: rgba(125,133,151,0.10); stroke: var(--muted); stroke-width: 1.6; }
+.fn-status-blocked circle { fill: rgba(248,113,113,0.10); stroke: var(--red); stroke-width: 1.6; }
+
+/* Transitive (distance=2) — fade and shrink the stroke. */
+.fn-distance-2 circle { opacity: 0.78; stroke-width: 1.2; }
+.fn-distance-2 .fn-label { fill: var(--muted); font-size: 9.5px; }
+.fn-distance-2 .fn-sublabel { display: none; }
+
+.fn-label, .fn-sublabel { text-anchor: middle; pointer-events: none; font-family: var(--font-display); }
+.fn-label { fill: var(--text); font-size: 11px; }
+.fn-sublabel { fill: var(--muted); font-size: 9px; font-family: ui-monospace, Menlo, monospace; letter-spacing: 0.04em; }
+
+.fn-node.selected circle, .fn-node.selected rect {
+ filter: drop-shadow(0 0 14px var(--accent));
+}
+.fn-node.selected .fn-label { fill: #eaf6ff; font-weight: 700; }
+
+/* Legend swatches. */
+.lg-swatch { display: inline-block; width: 10px; height: 10px; border-radius: 50%; border: 1.5px solid; vertical-align: -1px; }
+.fn-lg-self { border-color: var(--accent); background: rgba(30,200,255,0.18); }
+.fn-lg-trusted { border-color: var(--green); background: rgba(74,222,128,0.18); }
+.fn-lg-vouched { border-color: #a78bfa; background: rgba(167,139,250,0.18); }
+.fn-lg-unknown { border-color: var(--muted); background: rgba(125,133,151,0.18); }
+.fn-lg-blocked { border-color: var(--red); background: rgba(248,113,113,0.18); }
+
+/* Detail status badge tinting. */
+.fn-status-badge-self { color: var(--accent); border-color: var(--accent); background: rgba(30,200,255,0.10); }
+.fn-status-badge-trusted { color: var(--green); border-color: var(--green); background: rgba(74,222,128,0.10); }
+.fn-status-badge-vouched { color: #c4b5fd; border-color: #a78bfa; background: rgba(167,139,250,0.10); }
+.fn-status-badge-unknown { color: var(--muted); border-color: var(--muted); }
+.fn-status-badge-blocked { color: var(--red); border-color: var(--red); background: rgba(248,113,113,0.10); }
diff --git a/src/psyc/cockpit/static/federation_network.js b/src/psyc/cockpit/static/federation_network.js
new file mode 100644
index 0000000..2c1dbda
--- /dev/null
+++ b/src/psyc/cockpit/static/federation_network.js
@@ -0,0 +1,510 @@
+/* psyc — federation network force-directed graph.
+ *
+ * Self at center, direct peers around it, transitive peers (distance=2) on
+ * the outer ring. Edges: vouch (solid), signal (dashed, animated, thickness
+ * ∝ weight), knows (dotted grey).
+ *
+ * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop so
+ * the two pages feel familiar; once both are stable the shared engine can
+ * factor out into a force_graph.js module — for now, a copy keeps the diff
+ * narrow.
+ */
+
+(function () {
+ "use strict";
+
+ const svg = document.getElementById("federation-network-graph");
+ const detailEl = document.getElementById("fn-detail");
+ const loadingEl = document.getElementById("fn-loading");
+ const errorEl = document.getElementById("fn-error");
+ const transitiveCountEl = document.getElementById("fn-transitive-count");
+ if (!svg) return;
+
+ // Fetch data, then build the graph. The /data endpoint includes transitive
+ // peers (mid-cost cached server-side at 5 min TTL).
+ fetch("/admin/federation/network/data", { credentials: "same-origin" })
+ .then(r => {
+ if (!r.ok) throw new Error("HTTP " + r.status);
+ return r.json();
+ })
+ .then(data => {
+ if (loadingEl) loadingEl.style.display = "none";
+ render(data);
+ })
+ .catch(err => {
+ if (loadingEl) loadingEl.style.display = "none";
+ if (errorEl) {
+ errorEl.style.display = "block";
+ errorEl.textContent = "✗ failed to load network data: " + err.message;
+ }
+ });
+
+ function render(data) {
+ const selfFp = data.self_fingerprint || "";
+ const nodesData = data.nodes || [];
+ const edgesData = data.edges || [];
+
+ if (transitiveCountEl) {
+ const n = nodesData.filter(n => (n.distance || 0) >= 2).length;
+ transitiveCountEl.textContent = String(n);
+ }
+
+ // ---------- build node + edge sim objects -----------------------------
+ const nodes = [];
+ const nodeByFp = Object.create(null);
+ for (const nd of nodesData) {
+ const isSelf = !!nd.is_self;
+ const dist = Number(nd.distance || 0);
+ const r = isSelf ? 38 : (dist >= 2 ? 9 : 16);
+ const n = {
+ id: nd.fingerprint,
+ fp: nd.fingerprint,
+ domain: nd.domain || "",
+ label: nd.label || nd.fingerprint.slice(0, 8),
+ status: nd.status || "unknown",
+ is_self: isSelf,
+ distance: dist,
+ r,
+ x: 0, y: 0, vx: 0, vy: 0, fixed: false,
+ tooltip: buildTooltip(nd),
+ };
+ nodes.push(n);
+ nodeByFp[n.id] = n;
+ }
+
+ const edges = [];
+ for (const ed of edgesData) {
+ const a = nodeByFp[ed.source_fingerprint];
+ const b = nodeByFp[ed.target_fingerprint];
+ if (!a || !b) continue;
+ edges.push({
+ source: a.id,
+ target: b.id,
+ kind: ed.kind || "knows",
+ weight: Number(ed.weight || 1),
+ label: ed.label || "",
+ bidirectional: !!ed.bidirectional,
+ });
+ }
+
+ function buildTooltip(nd) {
+ const lines = [];
+ lines.push((nd.is_self ? "self · " : "") + (nd.domain || nd.label || nd.fingerprint));
+ lines.push("fp: " + nd.fingerprint);
+ lines.push("status: " + (nd.status || "unknown"));
+ lines.push("distance: " + (nd.distance || 0));
+ return lines.join("\n");
+ }
+
+ // ---------- viewport + seeding ---------------------------------------
+ function viewport() {
+ const W = svg.clientWidth || 900;
+ const H = parseInt(getComputedStyle(svg).height, 10) || 560;
+ return { W, H };
+ }
+ let { W, H } = viewport();
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+
+ (function seed() {
+ const cx = W / 2, cy = H / 2;
+ nodes.forEach((n, i) => {
+ if (n.is_self) {
+ n.x = cx; n.y = cy;
+ return;
+ }
+ const ring = n.distance >= 2 ? Math.min(W, H) * 0.40 : Math.min(W, H) * 0.22;
+ const ang = (i / Math.max(1, nodes.length)) * Math.PI * 2;
+ n.x = cx + ring * Math.cos(ang) + (Math.random() - 0.5) * 20;
+ n.y = cy + ring * Math.sin(ang) + (Math.random() - 0.5) * 20;
+ });
+ })();
+
+ // Force-sim params — same shape as topology.js, slightly softer springs
+ // so the two rings settle visibly.
+ const REPULSION = 1500;
+ const SPRING_K = 0.035;
+ const SPRING_REST_BASE = 110;
+ const DAMP = 0.82;
+ const CENTER_PULL = 0.005;
+
+ function tick() {
+ for (let i = 0; i < nodes.length; i++) {
+ const a = nodes[i];
+ for (let j = i + 1; j < nodes.length; j++) {
+ const b = nodes[j];
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const d2 = dx * dx + dy * dy + 0.1;
+ const d = Math.sqrt(d2);
+ const f = REPULSION / d2;
+ const fx = (dx / d) * f, fy = (dy / d) * f;
+ if (!a.fixed) { a.vx -= fx; a.vy -= fy; }
+ if (!b.fixed) { b.vx += fx; b.vy += fy; }
+ }
+ }
+ for (const e of edges) {
+ const a = nodeByFp[e.source], b = nodeByFp[e.target];
+ if (!a || !b) continue;
+ // "knows" edges (distance-2) rest longer so transitive bands stay clear.
+ const rest = e.kind === "knows" ? SPRING_REST_BASE + 60 : SPRING_REST_BASE;
+ const dx = b.x - a.x, dy = b.y - a.y;
+ const d = Math.sqrt(dx * dx + dy * dy) + 0.1;
+ const f = (d - rest) * SPRING_K;
+ const fx = (dx / d) * f, fy = (dy / d) * f;
+ if (!a.fixed) { a.vx += fx; a.vy += fy; }
+ if (!b.fixed) { b.vx -= fx; b.vy -= fy; }
+ }
+ for (const n of nodes) {
+ if (n.fixed) { n.vx = 0; n.vy = 0; continue; }
+ n.vx += (W / 2 - n.x) * CENTER_PULL;
+ n.vy += (H / 2 - n.y) * CENTER_PULL;
+ n.vx *= DAMP; n.vy *= DAMP;
+ n.x += n.vx; n.y += n.vy;
+ n.x = Math.max(n.r, Math.min(W - n.r, n.x));
+ n.y = Math.max(n.r, Math.min(H - n.r, n.y));
+ }
+ }
+
+ // Pre-settle so the first frame isn't a glob.
+ for (let i = 0; i < 280; i++) tick();
+
+ // ---------- render ----------------------------------------------------
+ const ns = "http://www.w3.org/2000/svg";
+ const edgesG = document.createElementNS(ns, "g");
+ const nodesG = document.createElementNS(ns, "g");
+ edgesG.setAttribute("class", "fn-edges");
+ nodesG.setAttribute("class", "fn-nodes");
+ svg.appendChild(edgesG);
+ svg.appendChild(nodesG);
+
+ const edgeEls = edges.map(e => {
+ const grp = document.createElementNS(ns, "g");
+ grp.setAttribute("class", "fn-edge-grp fn-kind-" + e.kind);
+ const ln = document.createElementNS(ns, "line");
+ ln.setAttribute("class", "fn-edge");
+ // Signal weight controls stroke width; cap at 5px so a noisy peer
+ // doesn't blot out the layout.
+ if (e.kind === "signal") {
+ const w = Math.min(5, 1 + Math.log2(e.weight + 1) * 0.8);
+ ln.setAttribute("stroke-width", w.toFixed(2));
+ }
+ grp.appendChild(ln);
+ if (e.label) {
+ const lbl = document.createElementNS(ns, "text");
+ lbl.setAttribute("class", "fn-edge-label");
+ lbl.textContent = e.label;
+ grp.appendChild(lbl);
+ }
+ edgesG.appendChild(grp);
+ return { line: ln, label: grp.querySelector("text") };
+ });
+
+ function _classFor(n) {
+ if (n.is_self) return "fn-node fn-self";
+ const dist = n.distance >= 2 ? " fn-distance-2" : " fn-distance-1";
+ return "fn-node fn-status-" + n.status + dist;
+ }
+
+ const nodeEls = nodes.map(n => {
+ const g = document.createElementNS(ns, "g");
+ g.setAttribute("class", _classFor(n));
+ g.dataset.fp = n.fp;
+
+ if (n.is_self) {
+ const sz = n.r;
+ const rect = document.createElementNS(ns, "rect");
+ rect.setAttribute("x", -sz); rect.setAttribute("y", -sz);
+ rect.setAttribute("width", sz * 2); rect.setAttribute("height", sz * 2);
+ rect.setAttribute("rx", 10); rect.setAttribute("ry", 10);
+ g.appendChild(rect);
+ } else {
+ const c = document.createElementNS(ns, "circle");
+ c.setAttribute("r", n.r);
+ g.appendChild(c);
+ }
+
+ const text = document.createElementNS(ns, "text");
+ text.setAttribute("class", "fn-label");
+ text.setAttribute("dy", n.r + 13);
+ text.textContent = n.label;
+ g.appendChild(text);
+
+ if (!n.is_self) {
+ const sub = document.createElementNS(ns, "text");
+ sub.setAttribute("class", "fn-sublabel");
+ sub.setAttribute("dy", n.r + 24);
+ sub.textContent = n.fp.slice(0, 8) + "…";
+ g.appendChild(sub);
+ }
+
+ const title = document.createElementNS(ns, "title");
+ title.textContent = n.tooltip;
+ g.appendChild(title);
+
+ nodesG.appendChild(g);
+ return g;
+ });
+
+ function paint() {
+ for (let i = 0; i < edges.length; i++) {
+ const e = edges[i];
+ const a = nodeByFp[e.source], b = nodeByFp[e.target];
+ if (!a || !b) continue;
+ const els = edgeEls[i];
+ els.line.setAttribute("x1", a.x); els.line.setAttribute("y1", a.y);
+ els.line.setAttribute("x2", b.x); els.line.setAttribute("y2", b.y);
+ if (els.label) {
+ const mx = (a.x + b.x) / 2, my = (a.y + b.y) / 2;
+ els.label.setAttribute("x", mx); els.label.setAttribute("y", my - 3);
+ }
+ }
+ for (let i = 0; i < nodes.length; i++) {
+ nodeEls[i].setAttribute("transform", `translate(${nodes[i].x},${nodes[i].y})`);
+ }
+ }
+ paint();
+
+ // ---------- drag + click --------------------------------------------
+ let dragging = null, dragOffset = { x: 0, y: 0 };
+ let pressedNode = null, pressedAt = null, moved = false;
+ function svgPoint(clientX, clientY) {
+ const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
+ return pt.matrixTransform(svg.getScreenCTM().inverse());
+ }
+ nodeEls.forEach((g, i) => {
+ g.addEventListener("mousedown", ev => {
+ ev.preventDefault();
+ pressedNode = nodes[i];
+ pressedAt = { x: ev.clientX, y: ev.clientY };
+ moved = false;
+ dragging = nodes[i];
+ const p = svgPoint(ev.clientX, ev.clientY);
+ dragOffset.x = p.x - dragging.x; dragOffset.y = p.y - dragging.y;
+ if (currentLayout === "force") dragging.fixed = true;
+ g.classList.add("dragging");
+ });
+ });
+ document.addEventListener("mousemove", ev => {
+ if (pressedAt) {
+ const dx = ev.clientX - pressedAt.x, dy = ev.clientY - pressedAt.y;
+ if (dx * dx + dy * dy > 16) moved = true;
+ }
+ if (!dragging) return;
+ const p = svgPoint(ev.clientX, ev.clientY);
+ dragging.x = p.x - dragOffset.x; dragging.y = p.y - dragOffset.y;
+ dragging.vx = 0; dragging.vy = 0;
+ energyBudget = 80;
+ });
+ document.addEventListener("mouseup", () => {
+ if (dragging) {
+ const g = nodesG.querySelector(`[data-fp="${CSS.escape(dragging.fp)}"]`);
+ if (g) g.classList.remove("dragging");
+ if (currentLayout === "force") dragging.fixed = false;
+ dragging = null;
+ }
+ if (pressedNode && !moved) selectNode(pressedNode);
+ pressedNode = null; pressedAt = null;
+ });
+ svg.addEventListener("click", ev => {
+ if (!ev.target.closest(".fn-node")) clearSelection();
+ });
+
+ // ---------- detail panel --------------------------------------------
+ function esc(s) {
+ return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+ }
+
+ function selectNode(n) {
+ nodeEls.forEach(el => el.classList.remove("selected"));
+ const me = nodesG.querySelector(`[data-fp="${CSS.escape(n.fp)}"]`);
+ if (me) me.classList.add("selected");
+ renderDetail(n);
+ }
+
+ function clearSelection() {
+ nodeEls.forEach(el => el.classList.remove("selected"));
+ if (detailEl) detailEl.innerHTML = '
Click any node in the graph above to inspect it.
';
+ }
+
+ function countEdges(fp) {
+ let vouchOut = 0, vouchIn = 0, signalsIn = 0, knows = 0;
+ for (const e of edges) {
+ if (e.kind === "vouch" && e.source === fp) vouchOut++;
+ else if (e.kind === "vouch" && e.target === fp) vouchIn++;
+ else if (e.kind === "signal" && e.target === fp) signalsIn += e.weight;
+ else if (e.kind === "signal" && e.source === fp) signalsIn += e.weight;
+ else if (e.kind === "knows" && (e.source === fp || e.target === fp)) knows++;
+ }
+ return { vouchOut, vouchIn, signalsIn, knows };
+ }
+
+ function renderDetail(n) {
+ if (!detailEl) return;
+ const stats = countEdges(n.fp);
+ const kindLabel = n.is_self ? "SELF" : (n.distance >= 2 ? "TRANSITIVE" : "DIRECT PEER");
+ const kindCls = n.is_self ? "td-kind-host" : (n.distance >= 2 ? "td-kind-cont" : "td-kind-net");
+ const statusBadge = `${esc(n.status)} `;
+ const jumpBack = (!n.is_self && n.domain)
+ ? `→ open peer in /admin/federation
`
+ : "";
+ const html = `
+
+ ${esc(kindLabel)}
+
${esc(n.domain || n.label || n.fp.slice(0, 12))}
+ ${statusBadge}
+ ×
+
+
+ Fingerprint ${esc(n.fp)}
+ Domain ${n.domain ? esc(n.domain) : "—"}
+ Distance ${esc(n.distance)} hop${n.distance === 1 ? "" : "s"}
+ Vouches out: ${stats.vouchOut} · in: ${stats.vouchIn}
+ Signals (24h) ${stats.signalsIn ? stats.signalsIn.toFixed(0) : "0"}
+ Knows-edges ${stats.knows}
+
+ ${jumpBack}
+ `;
+ detailEl.innerHTML = html;
+ detailEl.classList.add("has-selection");
+ const close = detailEl.querySelector(".td-close");
+ if (close) close.addEventListener("click", clearSelection);
+ detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
+ }
+
+ // ---------- idle animation ------------------------------------------
+ let energyBudget = 40;
+ function loop() {
+ let moving = false;
+ for (const n of nodes) {
+ if (Math.abs(n.vx) > 0.04 || Math.abs(n.vy) > 0.04) { moving = true; break; }
+ }
+ if (moving || energyBudget > 0 || dragging) {
+ tick(); paint();
+ if (energyBudget > 0) energyBudget--;
+ }
+ requestAnimationFrame(loop);
+ }
+ loop();
+
+ // ---------- edge liveness + flow toggle -----------------------------
+ // Signal edges always flow (we just saw N signals in 24h). Vouch edges
+ // are static. Knows edges fade.
+ edges.forEach((e, i) => {
+ const ln = edgeEls[i].line;
+ if (e.kind === "signal") ln.classList.add("alive");
+ if (e.kind === "knows") ln.classList.add("dim");
+ });
+ const flowToggle = document.getElementById("fn-flow");
+ function applyFlowToggle() {
+ svg.classList.toggle("flow-off", !(flowToggle && flowToggle.checked));
+ }
+ applyFlowToggle();
+ if (flowToggle) flowToggle.addEventListener("change", applyFlowToggle);
+
+ // ---------- layout modes --------------------------------------------
+ function unfix() { for (const n of nodes) n.fixed = false; }
+ function clearVel() { for (const n of nodes) { n.vx = 0; n.vy = 0; } }
+
+ function applyForce() {
+ unfix();
+ for (const n of nodes) {
+ if (n.is_self) { n.x = W / 2; n.y = H / 2; n.fixed = true; continue; }
+ n.vx = (Math.random() - 0.5) * 5;
+ n.vy = (Math.random() - 0.5) * 5;
+ }
+ energyBudget = 300;
+ }
+
+ function applyHierarchical() {
+ const self = nodes.find(n => n.is_self);
+ const direct = nodes.filter(n => !n.is_self && n.distance === 1);
+ const transitive = nodes.filter(n => n.distance >= 2);
+ if (self) { self.x = W / 2; self.y = 70; self.fixed = true; }
+ direct.forEach((n, i) => {
+ n.x = W * (i + 1) / (direct.length + 1);
+ n.y = H * 0.42;
+ n.fixed = true;
+ });
+ const tCount = transitive.length || 1;
+ transitive.forEach((n, i) => {
+ n.x = W * (i + 1) / (tCount + 1);
+ n.y = H * 0.78;
+ n.fixed = true;
+ });
+ clearVel(); paint();
+ }
+
+ function applyRadial() {
+ const self = nodes.find(n => n.is_self);
+ const direct = nodes.filter(n => !n.is_self && n.distance === 1);
+ const transitive = nodes.filter(n => n.distance >= 2);
+ const R1 = Math.min(W, H) * 0.22;
+ const R2 = Math.min(W, H) * 0.40;
+ if (self) { self.x = W / 2; self.y = H / 2; self.fixed = true; }
+ const dCount = direct.length || 1;
+ direct.forEach((n, i) => {
+ const a = (i / dCount) * Math.PI * 2 - Math.PI / 2;
+ n.x = W / 2 + R1 * Math.cos(a);
+ n.y = H / 2 + R1 * Math.sin(a);
+ n.fixed = true;
+ });
+ const tCount = transitive.length || 1;
+ transitive.forEach((n, i) => {
+ const a = (i / tCount) * Math.PI * 2 - Math.PI / 2;
+ n.x = W / 2 + R2 * Math.cos(a);
+ n.y = H / 2 + R2 * Math.sin(a);
+ n.fixed = true;
+ });
+ clearVel(); paint();
+ }
+
+ const LAYOUTS = { force: applyForce, hier: applyHierarchical, radial: applyRadial };
+ let currentLayout = "force";
+ // Force-mode bootstraps with self pinned at center — so the very first
+ // settle radiates outward naturally.
+ const selfNode = nodes.find(n => n.is_self);
+ if (selfNode) { selfNode.x = W / 2; selfNode.y = H / 2; selfNode.fixed = true; }
+
+ document.querySelectorAll(".topo-layout").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const mode = btn.dataset.layout;
+ if (!LAYOUTS[mode] || mode === currentLayout) return;
+ document.querySelectorAll(".topo-layout").forEach(b => b.classList.toggle("is-active", b === btn));
+ currentLayout = mode;
+ LAYOUTS[mode]();
+ });
+ });
+
+ const resetBtn = document.getElementById("fn-reset");
+ if (resetBtn) {
+ resetBtn.addEventListener("click", () => {
+ if (currentLayout === "force") {
+ for (const n of nodes) {
+ if (n.is_self) continue;
+ n.vx = (Math.random() - 0.5) * 6;
+ n.vy = (Math.random() - 0.5) * 6;
+ }
+ energyBudget = 200;
+ } else {
+ LAYOUTS[currentLayout]();
+ }
+ });
+ }
+
+ // Wheel zoom.
+ let zoom = 1, panX = 0, panY = 0;
+ svg.addEventListener("wheel", ev => {
+ ev.preventDefault();
+ const delta = ev.deltaY > 0 ? 1.1 : 0.9;
+ zoom = Math.max(0.3, Math.min(2.5, zoom * delta));
+ const vw = W / zoom, vh = H / zoom;
+ svg.setAttribute("viewBox", `${(W - vw) / 2 + panX} ${(H - vh) / 2 + panY} ${vw} ${vh}`);
+ }, { passive: false });
+ window.addEventListener("resize", () => {
+ const v = viewport();
+ W = v.W; H = v.H;
+ svg.setAttribute("viewBox", `0 0 ${W} ${H}`);
+ energyBudget = 60;
+ });
+ }
+})();
diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js
index 32c8dc1..61c9ee9 100644
--- a/src/psyc/cockpit/static/sw.js
+++ b/src/psyc/cockpit/static/sw.js
@@ -5,7 +5,7 @@
// This makes the cockpit installable as a PWA and survives flaky connections,
// without serving stale operational data behind the operator's back.
-const CACHE_VERSION = "psyc-v5";
+const CACHE_VERSION = "psyc-v6";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",
diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html
index d201be0..58e8fbe 100644
--- a/src/psyc/cockpit/templates/admin_federation.html
+++ b/src/psyc/cockpit/templates/admin_federation.html
@@ -8,7 +8,7 @@
{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}
This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.
- ← back to admin · discovery · vouches · quorum config · transparency log
+ ← back to admin · discovery · vouches · quorum config · transparency log · network
node fingerprint
diff --git a/src/psyc/cockpit/templates/admin_federation_network.html b/src/psyc/cockpit/templates/admin_federation_network.html
new file mode 100644
index 0000000..98487cf
--- /dev/null
+++ b/src/psyc/cockpit/templates/admin_federation_network.html
@@ -0,0 +1,51 @@
+{% extends "base.html" %}
+{% block title %}Federation network — psyc admin{% endblock %}
+{% block body_class %}wide{% endblock %}
+{% block content %}
+
+
+
Federation Network
+ {{ stats.total_peers }} direct · … transitive
+
+ Force-directed map of the federation this node sits inside. Self at the center, directly-registered peers at distance 1, peers-of-peers (fetched from each trusted peer's /federation/network) at distance 2. Edges: vouches (solid), signals (dashed, thickness ∝ 24h volume), knows (dotted grey).
+ ← federation hub · discovery · vouches · quorum · log
+
+
+
direct peers
{{ stats.total_peers }}
+
vouched / trusted
{{ stats.vouched_peers }}
+
vouches issued
{{ stats.vouches_issued }}
+
signals (24h)
{{ stats.signals_buffered_24h }}
+
distinct hashes
{{ stats.distinct_signal_hashes_24h }}
+
quorum-met
{{ stats.quorum_met_count }}
+
+
+
+
+
+
loading federation network…
+
+
+
+
+
Click any node in the graph above to inspect it.
+
+
+ Self fingerprint: {{ fingerprint }}
+
+
+
+{% endblock %}
diff --git a/src/psyc/lines/network_view.py b/src/psyc/lines/network_view.py
new file mode 100644
index 0000000..f33fc14
--- /dev/null
+++ b/src/psyc/lines/network_view.py
@@ -0,0 +1,423 @@
+"""Network view — federation graph: self + peers + edges (vouches + signals).
+
+The federation/identity layer (psyc.lines.federation) gives us node identity
+and a peer registry; discovery walks DNS-SD; vouching adds the web-of-trust;
+the signal buffer records what peers have told us. This module is the
+*visualization primitive* — it stitches those into a node+edge graph the
+cockpit renders as a force-directed map of the federation we sit in.
+
+Three views:
+ * build_local_view() — what we know first-hand: self at center, direct
+ peers around us, edges = vouches + 24h signals.
+ * build_transitive_view() — local_view plus peers-of-peers, fetched from
+ each trusted peer's /federation/network endpoint.
+ Cached aggressively to avoid spamming peers.
+ * build_public_view() — JSON-safe payload to publish at /federation/network.
+ Only TRUSTED peers + our outbound vouches; signed.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+import threading
+import time
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional, Tuple
+
+import httpx
+from pydantic import BaseModel, Field
+
+from psyc import db, log
+from psyc.lines import federation
+
+
+_log = log.get(__name__)
+
+
+SIGNAL_WINDOW_HOURS = 24
+TRANSITIVE_CACHE_TTL = 300.0 # 5 minutes
+TRANSITIVE_FETCH_TIMEOUT = 4.0
+
+
+# ---------- data model --------------------------------------------------
+
+class NetworkNode(BaseModel):
+ """One vertex on the federation map.
+
+ `distance` is the topological hop count from self: 0 for self, 1 for
+ directly-registered peers, 2 for peers-of-peers discovered via the
+ transitive fetch. `status` is the trust label the UI colors by.
+ """
+ fingerprint: str
+ domain: Optional[str] = None
+ label: str
+ status: str # "self" | "trusted" | "vouched" | "unknown" | "blocked"
+ is_self: bool = False
+ distance: int = 1
+
+
+class NetworkEdge(BaseModel):
+ """One edge on the federation map.
+
+ `kind` drives stroke style in the UI: vouch = solid, signal = dashed
+ flow with thickness ∝ weight, knows = dotted grey transitive hint.
+ """
+ source_fingerprint: str
+ target_fingerprint: str
+ kind: str # "vouch" | "signal" | "knows"
+ weight: float = 1.0
+ label: str = ""
+ bidirectional: bool = False
+
+
+class NetworkView(BaseModel):
+ """Renderable snapshot — nodes + edges + headline stats."""
+ nodes: List[NetworkNode] = Field(default_factory=list)
+ edges: List[NetworkEdge] = Field(default_factory=list)
+ generated_at: datetime = Field(default_factory=lambda: datetime.now(timezone.utc))
+ stats: Dict[str, Any] = Field(default_factory=dict)
+
+
+# ---------- internal helpers --------------------------------------------
+
+def _short_fp(fp: str) -> str:
+ if len(fp) >= 16:
+ return f"{fp[:8]}…{fp[-8:]}"
+ return fp
+
+
+def _peer_status_label(peer: federation.Peer) -> str:
+ """Map peers.status + vouch-eligibility into the UI status taxonomy."""
+ if peer.status == "trusted":
+ return "trusted"
+ if peer.status == "blocked":
+ return "blocked"
+ # unknown — but if a quorum of trusted peers have vouched, surface as "vouched"
+ if federation.peer_is_listening_eligible(peer.fingerprint):
+ return "vouched"
+ return "unknown"
+
+
+def _signal_counts_24h() -> Tuple[Dict[str, int], int, int]:
+ """Per-peer signal count over the last `SIGNAL_WINDOW_HOURS`, plus totals.
+
+ Returns (counts_by_fingerprint, total_buffered, distinct_hashes).
+ """
+ cutoff = (datetime.now(timezone.utc) - timedelta(hours=SIGNAL_WINDOW_HOURS)).isoformat()
+ counts: Dict[str, int] = {}
+ distinct_hashes: set = set()
+ total = 0
+ # recent_signals gives newest first; one call is enough for a 24h window
+ # under any realistic volume — the buffer is small.
+ for row in db.recent_signals(limit=10_000):
+ received = str(row.get("received_at") or "")
+ if received < cutoff:
+ break
+ fp = row.get("peer_fingerprint") or ""
+ if not fp:
+ continue
+ counts[fp] = counts.get(fp, 0) + 1
+ distinct_hashes.add(row.get("signal_hash") or "")
+ total += 1
+ return counts, total, len(distinct_hashes)
+
+
+# ---------- local view --------------------------------------------------
+
+def build_local_view() -> NetworkView:
+ """Everything we know first-hand — self + direct peers + their edges to us.
+
+ Self sits at distance 0. Each row from `db.list_peers()` becomes a
+ distance-1 node, with `status` derived from `peers.status` (falling
+ back to "vouched" if a vouch quorum names them) or "blocked".
+
+ Edges:
+ * self -> peer vouch — for every peer we've issued a vouch for
+ * peer -> self vouch — for every received vouch naming our fp
+ * self -> peer signal — one per peer that has sent us ≥1 signal in 24h,
+ weight = count, label = "N signals"
+
+ Stats line up with what the admin page header shows.
+ """
+ our_fp = federation.node_fingerprint()
+ peers = federation.list_peers()
+ nodes: List[NetworkNode] = [
+ NetworkNode(
+ fingerprint=our_fp,
+ label="self",
+ status="self",
+ is_self=True,
+ distance=0,
+ )
+ ]
+ peer_by_fp: Dict[str, federation.Peer] = {}
+ for p in peers:
+ if p.fingerprint == our_fp:
+ # Defensive — should never happen, but don't double-render self.
+ continue
+ peer_by_fp[p.fingerprint] = p
+ nodes.append(NetworkNode(
+ fingerprint=p.fingerprint,
+ domain=p.domain,
+ label=p.domain or _short_fp(p.fingerprint),
+ status=_peer_status_label(p),
+ is_self=False,
+ distance=1,
+ ))
+
+ edges: List[NetworkEdge] = []
+
+ # Vouches WE have issued — self -> target.
+ out_vouch_targets: set = set()
+ for v in federation.our_vouches():
+ # Only render an edge if the target is in our peer registry
+ # OR is the federation broadcast target — but for the local map we
+ # only show edges to nodes we render, so skip strangers.
+ if v.target_fingerprint not in peer_by_fp:
+ continue
+ out_vouch_targets.add(v.target_fingerprint)
+ edges.append(NetworkEdge(
+ source_fingerprint=our_fp,
+ target_fingerprint=v.target_fingerprint,
+ kind="vouch",
+ weight=1.0,
+ label="vouched",
+ ))
+
+ # Vouches received — non-self vouches where target == our fp.
+ for v in federation.vouches_for(our_fp):
+ if v.voucher_fingerprint == our_fp:
+ continue
+ if v.voucher_fingerprint not in peer_by_fp:
+ # Voucher not in our peer table → no node to attach to. Skip;
+ # the local map is honest about what's renderable.
+ continue
+ # Mark bidirectional on the existing outbound vouch when applicable so
+ # the UI can collapse the two lines.
+ existing = next(
+ (e for e in edges
+ if e.kind == "vouch"
+ and e.source_fingerprint == our_fp
+ and e.target_fingerprint == v.voucher_fingerprint),
+ None,
+ )
+ if existing is not None:
+ existing.bidirectional = True
+ existing.label = "vouched ↔"
+ continue
+ edges.append(NetworkEdge(
+ source_fingerprint=v.voucher_fingerprint,
+ target_fingerprint=our_fp,
+ kind="vouch",
+ weight=1.0,
+ label="vouches us",
+ ))
+
+ # Signal edges — one per peer that has reported anything in 24h.
+ sig_counts, total_signals, distinct_hashes = _signal_counts_24h()
+ for fp, count in sig_counts.items():
+ if fp not in peer_by_fp:
+ # Signals only land from listening-eligible peers, but if the
+ # operator just demoted a peer the buffer can still contain
+ # rows referencing a now-deleted peer. Skip cleanly.
+ continue
+ edges.append(NetworkEdge(
+ source_fingerprint=fp,
+ target_fingerprint=our_fp,
+ kind="signal",
+ weight=float(count),
+ label=f"{count} signals/24h",
+ ))
+
+ # Quorum-met count over the signals we've seen in the window.
+ quorum_met = 0
+ seen_hashes: set = set()
+ for row in db.recent_signals(limit=10_000):
+ h = row.get("signal_hash") or ""
+ if not h or h in seen_hashes:
+ continue
+ seen_hashes.add(h)
+ if federation.is_quorum_met(h):
+ quorum_met += 1
+
+ vouched_peers = sum(
+ 1 for n in nodes
+ if not n.is_self and n.status in ("trusted", "vouched")
+ )
+ stats: Dict[str, Any] = {
+ "total_peers": len(peer_by_fp),
+ "vouched_peers": vouched_peers,
+ "signals_buffered_24h": total_signals,
+ "distinct_signal_hashes_24h": distinct_hashes,
+ "quorum_met_count": quorum_met,
+ "vouches_issued": len(out_vouch_targets),
+ }
+
+ return NetworkView(nodes=nodes, edges=edges, stats=stats)
+
+
+# ---------- transitive view ---------------------------------------------
+
+_TRANSITIVE_CACHE: Dict[str, Any] = {"ts": 0.0, "view": None}
+_TRANSITIVE_LOCK = threading.Lock()
+
+
+def _fetch_peer_network(domain: str, timeout: float = TRANSITIVE_FETCH_TIMEOUT) -> Optional[Dict[str, Any]]:
+ """GET /federation/network on a peer. Returns dict on success, None otherwise.
+
+ Failure modes are logged at info — one slow/broken peer must NEVER abort
+ the transitive walk.
+ """
+ if not domain:
+ return None
+ url = f"https://{domain}/federation/network"
+ try:
+ with httpx.Client(timeout=timeout) as client:
+ r = client.get(url)
+ r.raise_for_status()
+ data = r.json()
+ except Exception as exc: # noqa: BLE001 — every failure mode is "skip this peer"
+ _log.info("network_view.transitive.skip", domain=domain, reason=str(exc)[:120])
+ return None
+ if not isinstance(data, dict):
+ _log.info("network_view.transitive.bad_shape", domain=domain, kind=type(data).__name__)
+ return None
+ return data
+
+
+def build_transitive_view(force_refresh: bool = False) -> NetworkView:
+ """Local view + distance-2 nodes pulled from each trusted peer.
+
+ Cached for `TRANSITIVE_CACHE_TTL` seconds — peer fan-out is expensive.
+ Cache is opt-out via `force_refresh` (the admin "re-fetch" button).
+ """
+ now = time.time()
+ if not force_refresh:
+ with _TRANSITIVE_LOCK:
+ cached = _TRANSITIVE_CACHE.get("view")
+ cached_ts = float(_TRANSITIVE_CACHE.get("ts") or 0.0)
+ if cached is not None and (now - cached_ts) < TRANSITIVE_CACHE_TTL:
+ return cached
+
+ view = build_local_view()
+ our_fp = view.nodes[0].fingerprint
+ existing_fps: set = {n.fingerprint for n in view.nodes}
+
+ # Index direct peers by domain so we can attribute distance=2 to a parent.
+ direct_by_fp: Dict[str, NetworkNode] = {
+ n.fingerprint: n for n in view.nodes if not n.is_self
+ }
+ direct_trusted = [n for n in direct_by_fp.values() if n.status == "trusted" and n.domain]
+
+ transitive_count = 0
+ for parent in direct_trusted:
+ data = _fetch_peer_network(parent.domain or "")
+ if data is None:
+ continue
+ # The peer's payload mirrors build_public_view's shape:
+ # {fingerprint, peers: [{fingerprint, domain}], vouches: [{voucher, target}]}.
+ their_peers = data.get("peers") or []
+ for pp in their_peers:
+ if not isinstance(pp, dict):
+ continue
+ fp = str(pp.get("fingerprint") or "")
+ if not fp or fp == our_fp:
+ continue
+ if fp in existing_fps:
+ # Already a direct peer (or our self) — but still draw the
+ # "knows" edge from parent to it to express the topology.
+ view.edges.append(NetworkEdge(
+ source_fingerprint=parent.fingerprint,
+ target_fingerprint=fp,
+ kind="knows",
+ weight=0.5,
+ label="knows",
+ ))
+ continue
+ domain = str(pp.get("domain") or "") or None
+ view.nodes.append(NetworkNode(
+ fingerprint=fp,
+ domain=domain,
+ label=domain or _short_fp(fp),
+ status="unknown",
+ is_self=False,
+ distance=2,
+ ))
+ existing_fps.add(fp)
+ transitive_count += 1
+ view.edges.append(NetworkEdge(
+ source_fingerprint=parent.fingerprint,
+ target_fingerprint=fp,
+ kind="knows",
+ weight=0.5,
+ label="knows",
+ ))
+
+ view.stats["transitive_nodes"] = transitive_count
+
+ with _TRANSITIVE_LOCK:
+ _TRANSITIVE_CACHE["view"] = view
+ _TRANSITIVE_CACHE["ts"] = now
+ return view
+
+
+# ---------- public payload ----------------------------------------------
+
+def build_public_view() -> Dict[str, Any]:
+ """JSON-safe public payload returned from GET /federation/network.
+
+ Only TRUSTED peers leak — never unknown or blocked. Vouches we've
+ issued ride along so peers can reconstruct the WoT shape. The whole
+ payload is signed with our Ed25519 key (canonical-JSON, then base64).
+
+ Signal hashes are deliberately omitted — those are internal state and
+ would leak who's reporting what to whom.
+ """
+ our_fp = federation.node_fingerprint()
+ trusted_peers: List[Dict[str, Any]] = []
+ for p in federation.list_peers():
+ if p.status != "trusted":
+ continue
+ trusted_peers.append({
+ "domain": p.domain,
+ "fingerprint": p.fingerprint,
+ "first_seen": p.discovered_at,
+ })
+ vouches: List[Dict[str, Any]] = []
+ for v in federation.our_vouches():
+ vouches.append({
+ "voucher_fingerprint": v.voucher_fingerprint,
+ "target_fingerprint": v.target_fingerprint,
+ "issued_at": v.issued_at.isoformat(),
+ "expires_at": v.expires_at.isoformat() if v.expires_at else None,
+ })
+
+ payload: Dict[str, Any] = {
+ "version": federation.FEED_VERSION,
+ "fingerprint": our_fp,
+ "generated_at": datetime.now(timezone.utc).isoformat(),
+ "peers": trusted_peers,
+ "vouches": vouches,
+ }
+ sig = federation.sign_payload(federation.canonical_json(payload))
+ payload["signature"] = base64.b64encode(sig).decode("ascii")
+ return payload
+
+
+# ---------- admin-only payload (data endpoint) --------------------------
+
+def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
+ """Full view for the admin data endpoint — JSON-serializable.
+
+ Unlike `build_public_view`, this DOES include unknown + blocked peers
+ and recent signal hashes — it's only ever served behind admin auth.
+ """
+ view = build_transitive_view() if include_transitive else build_local_view()
+ return {
+ "self_fingerprint": view.nodes[0].fingerprint,
+ "nodes": [n.model_dump() for n in view.nodes],
+ "edges": [e.model_dump() for e in view.edges],
+ "stats": view.stats,
+ "generated_at": view.generated_at.isoformat(),
+ }
diff --git a/tests/test_network_view.py b/tests/test_network_view.py
new file mode 100644
index 0000000..152da47
--- /dev/null
+++ b/tests/test_network_view.py
@@ -0,0 +1,334 @@
+"""Network view — local + transitive + public payload tests."""
+
+from __future__ import annotations
+
+import base64
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict
+from unittest.mock import patch
+
+import pytest
+from sqlalchemy import create_engine
+
+from psyc import db
+from psyc.lines import federation, network_view
+from psyc.lines.network_view import (
+ NetworkEdge,
+ NetworkNode,
+ NetworkView,
+ build_local_view,
+ build_public_view,
+ build_transitive_view,
+)
+
+
+# ---------- fixtures ----------------------------------------------------
+
+@pytest.fixture
+def fresh_db(tmp_path, monkeypatch):
+ test_db = tmp_path / "test.db"
+ eng = create_engine(f"sqlite:///{test_db}", future=True)
+ db._metadata.create_all(eng, checkfirst=True)
+ monkeypatch.setattr(db, "_engine", eng)
+ monkeypatch.setattr(db, "DB_PATH", test_db)
+ yield test_db
+
+
+@pytest.fixture
+def fed_dir(tmp_path, monkeypatch):
+ d = tmp_path / "federation"
+ monkeypatch.setattr(federation, "FED_DIR", d)
+ monkeypatch.setattr(federation, "PRIVATE_KEY_PATH", d / "node.key")
+ monkeypatch.setattr(federation, "PUBLIC_KEY_PATH", d / "node.pub")
+ yield d
+
+
+@pytest.fixture(autouse=True)
+def reset_transitive_cache(monkeypatch):
+ """Prevent cache bleed between tests."""
+ monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
+ yield
+
+
+def _make_peer_pubkey() -> tuple[str, str]:
+ """Return (fingerprint, pubkey_pem) for a synthetic peer keypair."""
+ import hashlib
+ from cryptography.hazmat.primitives import serialization
+ from cryptography.hazmat.primitives.asymmetric import ed25519
+ priv = ed25519.Ed25519PrivateKey.generate()
+ pub = priv.public_key()
+ pem = pub.public_bytes(
+ encoding=serialization.Encoding.PEM,
+ format=serialization.PublicFormat.SubjectPublicKeyInfo,
+ ).decode("ascii")
+ raw = pub.public_bytes(
+ encoding=serialization.Encoding.Raw,
+ format=serialization.PublicFormat.Raw,
+ )
+ fp = hashlib.sha256(raw).digest()[:16].hex()
+ return fp, pem
+
+
+# ---------- local view --------------------------------------------------
+
+def test_local_view_empty_registry_yields_only_self(fresh_db, fed_dir):
+ view = build_local_view()
+ assert isinstance(view, NetworkView)
+ assert len(view.nodes) == 1
+ self_node = view.nodes[0]
+ assert self_node.is_self is True
+ assert self_node.distance == 0
+ assert self_node.status == "self"
+ assert self_node.fingerprint == federation.node_fingerprint()
+ assert view.edges == []
+ assert view.stats["total_peers"] == 0
+ assert view.stats["vouched_peers"] == 0
+ assert view.stats["signals_buffered_24h"] == 0
+
+
+def test_local_view_one_trusted_peer_no_edges(fresh_db, fed_dir):
+ peer_fp, peer_pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
+ view = build_local_view()
+ assert len(view.nodes) == 2
+ peer_node = next(n for n in view.nodes if not n.is_self)
+ assert peer_node.fingerprint == peer_fp
+ assert peer_node.status == "trusted"
+ assert peer_node.distance == 1
+ assert peer_node.domain == "peer.example"
+ assert view.edges == []
+ assert view.stats["total_peers"] == 1
+ assert view.stats["vouched_peers"] == 1
+
+
+def test_local_view_outbound_vouch_creates_edge(fresh_db, fed_dir):
+ peer_fp, peer_pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
+ federation.issue_vouch(peer_fp, ttl_days=30)
+ view = build_local_view()
+ vouch_edges = [e for e in view.edges if e.kind == "vouch"]
+ assert len(vouch_edges) == 1
+ e = vouch_edges[0]
+ assert e.source_fingerprint == federation.node_fingerprint()
+ assert e.target_fingerprint == peer_fp
+ assert view.stats["vouches_issued"] == 1
+
+
+def test_local_view_inbound_vouch_creates_edge(fresh_db, fed_dir):
+ """Vouches received that name us as target → peer → self edge."""
+ peer_fp, peer_pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
+ # Insert a vouch where peer vouches FOR us, bypassing accept_vouch (which
+ # we don't need to exercise here — the question is render shape).
+ our_fp = federation.node_fingerprint()
+ now = datetime.now(timezone.utc)
+ db.upsert_vouch(dict(
+ voucher_fingerprint=peer_fp,
+ target_fingerprint=our_fp,
+ issued_at=now.isoformat(),
+ expires_at=(now + timedelta(days=30)).isoformat(),
+ signature="x" * 88,
+ ))
+ view = build_local_view()
+ vouch_edges = [e for e in view.edges if e.kind == "vouch"]
+ assert len(vouch_edges) == 1
+ e = vouch_edges[0]
+ assert e.source_fingerprint == peer_fp
+ assert e.target_fingerprint == our_fp
+
+
+def test_local_view_bidirectional_vouches_collapse(fresh_db, fed_dir):
+ peer_fp, peer_pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
+ federation.issue_vouch(peer_fp, ttl_days=30)
+ # And peer vouches back at us.
+ our_fp = federation.node_fingerprint()
+ now = datetime.now(timezone.utc)
+ db.upsert_vouch(dict(
+ voucher_fingerprint=peer_fp,
+ target_fingerprint=our_fp,
+ issued_at=now.isoformat(),
+ expires_at=(now + timedelta(days=30)).isoformat(),
+ signature="x" * 88,
+ ))
+ view = build_local_view()
+ vouch_edges = [e for e in view.edges if e.kind == "vouch"]
+ assert len(vouch_edges) == 1
+ assert vouch_edges[0].bidirectional is True
+
+
+def test_local_view_signal_edge_weight_is_24h_count(fresh_db, fed_dir):
+ peer_fp, peer_pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", peer_fp, peer_pem, status="trusted")
+ now_iso = datetime.now(timezone.utc).isoformat()
+ # Three signals from this peer within the window.
+ for i in range(3):
+ db.record_signal(dict(
+ peer_fingerprint=peer_fp,
+ signal_type="ioc",
+ signal_id=f"1.2.3.{i}",
+ signal_hash=f"hash-{i}",
+ received_at=now_iso,
+ raw_json="{}",
+ ))
+ # One stale signal outside the window — must be ignored.
+ stale = (datetime.now(timezone.utc) - timedelta(hours=48)).isoformat()
+ db.record_signal(dict(
+ peer_fingerprint=peer_fp,
+ signal_type="ioc",
+ signal_id="9.9.9.9",
+ signal_hash="stale",
+ received_at=stale,
+ raw_json="{}",
+ ))
+ view = build_local_view()
+ sig_edges = [e for e in view.edges if e.kind == "signal"]
+ assert len(sig_edges) == 1
+ assert sig_edges[0].weight == 3.0
+ assert sig_edges[0].source_fingerprint == peer_fp
+ assert sig_edges[0].target_fingerprint == federation.node_fingerprint()
+ assert view.stats["signals_buffered_24h"] == 3
+ assert view.stats["distinct_signal_hashes_24h"] == 3
+
+
+def test_local_view_blocked_peer_renders_with_blocked_status(fresh_db, fed_dir):
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("blocked.example", fp, pem, status="blocked")
+ view = build_local_view()
+ peer = next(n for n in view.nodes if not n.is_self)
+ assert peer.status == "blocked"
+
+
+# ---------- public view + signature round-trip --------------------------
+
+def test_public_view_excludes_unknown_and_blocked(fresh_db, fed_dir):
+ fp_t, pem_t = _make_peer_pubkey()
+ fp_u, pem_u = _make_peer_pubkey()
+ fp_b, pem_b = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp_t, pem_t, status="trusted")
+ federation.register_peer("unknown.example", fp_u, pem_u, status="unknown")
+ federation.register_peer("blocked.example", fp_b, pem_b, status="blocked")
+
+ payload = build_public_view()
+ fps = {p["fingerprint"] for p in payload["peers"]}
+ assert fp_t in fps
+ assert fp_u not in fps
+ assert fp_b not in fps
+
+
+def test_public_view_signature_round_trip(fresh_db, fed_dir):
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp, pem, status="trusted")
+ federation.issue_vouch(fp, ttl_days=30)
+ payload = build_public_view()
+
+ assert "signature" in payload
+ assert payload["fingerprint"] == federation.node_fingerprint()
+
+ sig = base64.b64decode(payload["signature"])
+ unsigned = {k: v for k, v in payload.items() if k != "signature"}
+ assert federation.verify_payload(
+ federation.canonical_json(unsigned),
+ sig,
+ federation.public_key_pem(),
+ ) is True
+
+ # Vouch we issued is in the payload.
+ targets = {v["target_fingerprint"] for v in payload["vouches"]}
+ assert fp in targets
+
+
+def test_public_view_omits_signals(fresh_db, fed_dir):
+ """Public payload must not leak who's reporting what."""
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp, pem, status="trusted")
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="ioc",
+ signal_id="1.2.3.4",
+ signal_hash="secret-hash",
+ received_at=datetime.now(timezone.utc).isoformat(),
+ raw_json="{}",
+ ))
+ payload = build_public_view()
+ # No signal-shaped fields anywhere in the payload.
+ flat = str(payload)
+ assert "secret-hash" not in flat
+ assert "signals" not in payload
+
+
+# ---------- transitive view ---------------------------------------------
+
+def test_transitive_view_adds_distance_2_nodes(fresh_db, fed_dir):
+ direct_fp, direct_pem = _make_peer_pubkey()
+ federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
+ # The peer reports two peers of its own.
+ far_fp_a, _ = _make_peer_pubkey()
+ far_fp_b, _ = _make_peer_pubkey()
+ fake_payload: Dict[str, Any] = {
+ "fingerprint": direct_fp,
+ "peers": [
+ {"fingerprint": far_fp_a, "domain": "far-a.example"},
+ {"fingerprint": far_fp_b, "domain": "far-b.example"},
+ ],
+ "vouches": [],
+ }
+ with patch.object(network_view, "_fetch_peer_network", return_value=fake_payload):
+ view = build_transitive_view(force_refresh=True)
+
+ distances = sorted(n.distance for n in view.nodes)
+ assert 0 in distances and 1 in distances and 2 in distances
+ transitive_fps = {n.fingerprint for n in view.nodes if n.distance == 2}
+ assert far_fp_a in transitive_fps
+ assert far_fp_b in transitive_fps
+ # "knows" edges from direct peer to each transitive.
+ knows = [e for e in view.edges if e.kind == "knows"]
+ assert len(knows) == 2
+ assert all(e.source_fingerprint == direct_fp for e in knows)
+ assert view.stats["transitive_nodes"] == 2
+
+
+def test_transitive_view_failed_fetch_does_not_abort(fresh_db, fed_dir):
+ fp_a, pem_a = _make_peer_pubkey()
+ fp_b, pem_b = _make_peer_pubkey()
+ federation.register_peer("peer-a.example", fp_a, pem_a, status="trusted")
+ federation.register_peer("peer-b.example", fp_b, pem_b, status="trusted")
+
+ far_fp, _ = _make_peer_pubkey()
+
+ def fake_fetch(domain, timeout=4.0):
+ if domain == "peer-a.example":
+ return None # simulate a fetch failure
+ return {
+ "fingerprint": fp_b,
+ "peers": [{"fingerprint": far_fp, "domain": "far.example"}],
+ "vouches": [],
+ }
+
+ with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
+ view = build_transitive_view(force_refresh=True)
+ # Direct nodes both present, transitive only from B.
+ assert any(n.fingerprint == fp_a for n in view.nodes)
+ assert any(n.fingerprint == fp_b for n in view.nodes)
+ assert any(n.fingerprint == far_fp and n.distance == 2 for n in view.nodes)
+ assert view.stats["transitive_nodes"] == 1
+
+
+def test_transitive_view_skips_only_unknown_peers(fresh_db, fed_dir):
+ """Unknown peers shouldn't be queried — fetcher only called for trusted."""
+ fp_unknown, pem_u = _make_peer_pubkey()
+ fp_trusted, pem_t = _make_peer_pubkey()
+ federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
+ federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
+
+ calls = []
+
+ def fake_fetch(domain, timeout=4.0):
+ calls.append(domain)
+ return None
+
+ with patch.object(network_view, "_fetch_peer_network", side_effect=fake_fetch):
+ build_transitive_view(force_refresh=True)
+
+ assert "trusted.example" in calls
+ assert "unknown.example" not in calls