diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py
index 02c9a96..ae915a5 100644
--- a/src/psyc/cockpit/federation_routes.py
+++ b/src/psyc/cockpit/federation_routes.py
@@ -34,6 +34,20 @@ _PUBLIC_PEERS_TTL = 60.0
_PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
_PUBLIC_NETWORK_TTL = 60.0
+# Explore-view cache. The builder fans out to trusted peers' explore feeds
+# for the distance-2 snapshot, so a polled hit must NEVER trigger that walk.
+_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None}
+_EXPLORE_TTL = 60.0
+
+
+# Headers we slap on every public endpoint so other psyc nodes' explore
+# pages can fetch them cross-origin from the browser.
+_CORS_HEADERS: Dict[str, str] = {
+ "Access-Control-Allow-Origin": "*",
+ "Access-Control-Allow-Methods": "GET, OPTIONS",
+ "Access-Control-Allow-Headers": "Content-Type",
+}
+
def _admin_ok(request: Request) -> bool:
return bool(request.session.get("admin_ok"))
@@ -63,6 +77,30 @@ def _cached_public_network() -> Dict[str, Any]:
return _PUBLIC_NETWORK_CACHE["payload"]
+def _cached_explore(domain: Optional[str]) -> Dict[str, Any]:
+ """Cached explore payload. Re-uses the cache when the host domain matches.
+
+ Domain is recorded into the payload's `node.domain` field, so a fresh
+ cache slot per host avoids serving the wrong reflected name.
+ """
+ now = time.time()
+ cached_domain = _EXPLORE_CACHE.get("domain")
+ if (
+ _EXPLORE_CACHE["payload"] is None
+ or (now - _EXPLORE_CACHE["ts"]) > _EXPLORE_TTL
+ or cached_domain != domain
+ ):
+ _EXPLORE_CACHE["payload"] = network_view.build_explore_view(node_domain=domain)
+ _EXPLORE_CACHE["ts"] = now
+ _EXPLORE_CACHE["domain"] = domain
+ return _EXPLORE_CACHE["payload"]
+
+
+def _public_json(payload: Any) -> JSONResponse:
+ """JSONResponse with the public-CORS header set."""
+ return JSONResponse(payload, headers=_CORS_HEADERS)
+
+
def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
"""Mount all federation routes onto `app`."""
@@ -180,20 +218,25 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
@app.get("/federation/info")
def federation_info() -> JSONResponse:
- return JSONResponse({
+ return _public_json({
"fingerprint": federation.node_fingerprint(),
"version": federation.FEED_VERSION,
"feed": federation.FEED_PATH,
"key": "/federation/key",
+ "explore": "/federation/explore",
})
@app.get("/federation/key", response_class=PlainTextResponse)
def federation_key() -> PlainTextResponse:
- return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
+ return PlainTextResponse(
+ federation.public_key_pem(),
+ media_type="text/plain",
+ headers=_CORS_HEADERS,
+ )
@app.get("/federation/feed")
def federation_feed() -> JSONResponse:
- return JSONResponse(_cached_feed())
+ return _public_json(_cached_feed())
@app.get("/federation/peers/public")
def federation_peers_public() -> JSONResponse:
@@ -202,7 +245,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
Only trusted peers leak; unknown + blocked are internal state and must
never appear here.
"""
- return JSONResponse(_cached_public_peers())
+ return _public_json(_cached_public_peers())
@app.get("/federation/network")
def federation_network_public() -> JSONResponse:
@@ -212,14 +255,14 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
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())
+ return _public_json(_cached_public_network())
# ---------- public vouches + transparency log --------------------
@app.get("/federation/vouches")
def federation_vouches() -> JSONResponse:
"""Vouches WE have issued. Peers fetch this to learn who we trust."""
- return JSONResponse({
+ return _public_json({
"fingerprint": federation.node_fingerprint(),
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
})
@@ -228,7 +271,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
def federation_log() -> JSONResponse:
"""Last 100 transparency-log entries, newest first."""
entries = translog.recent(limit=100)
- return JSONResponse({
+ return _public_json({
"count": len(entries),
"entries": [e.model_dump(mode="json") for e in entries],
})
@@ -240,8 +283,41 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
head = translog.head()
head_hash = head.entry_hash if head else None
if isinstance(result, Err):
- return JSONResponse({"error": result.reason, "head_hash": head_hash}, status_code=409)
- return JSONResponse({"verified": result.value, "head_hash": head_hash})
+ return JSONResponse(
+ {"error": result.reason, "head_hash": head_hash},
+ status_code=409,
+ headers=_CORS_HEADERS,
+ )
+ return _public_json({"verified": result.value, "head_hash": head_hash})
+
+ # ---------- public explore page + data ---------------------------
+
+ @app.get("/federation/explore", response_class=HTMLResponse)
+ def federation_explore_page(request: Request) -> HTMLResponse:
+ """Public transparency view — anyone can verify this network."""
+ host = request.url.hostname or ""
+ peer_param = request.query_params.get("peer", "").strip()
+ return TEMPLATES.TemplateResponse(
+ request,
+ "federation_explore.html",
+ {
+ "fingerprint": federation.node_fingerprint(),
+ "domain": host,
+ "peer": peer_param,
+ },
+ )
+
+ @app.get("/federation/explore/data")
+ def federation_explore_data(request: Request) -> JSONResponse:
+ """Signed public explorer payload — peer counts, vouches, transitives.
+
+ Public, no auth, CORS-enabled. Cached so a polled hit never triggers
+ the distance-2 fan-out. See `network_view.build_explore_view` for the
+ no-leak contract.
+ """
+ host = request.url.hostname or None
+ payload = _cached_explore(host)
+ return _public_json(payload)
# ---------- admin: vouches page ---------------------------------
diff --git a/src/psyc/cockpit/static/cockpit.css b/src/psyc/cockpit/static/cockpit.css
index a6d5934..0dab773 100644
--- a/src/psyc/cockpit/static/cockpit.css
+++ b/src/psyc/cockpit/static/cockpit.css
@@ -748,6 +748,18 @@ body.wide .net-grid { grid-template-columns: repeat(auto-fill, minmax(320px, 1fr
text-shadow: 0 0 22px var(--accent-glow);
}
.hero-sub { margin: 6px 0 0; color: var(--muted); font-size: 13px; }
+.hero-meta { margin: 10px 0 0; font-size: 12px; display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
+.hero-explore {
+ color: var(--accent);
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ text-decoration: none;
+ border: 1px solid var(--border);
+ padding: 3px 9px;
+ border-radius: 999px;
+ letter-spacing: 0.04em;
+}
+.hero-explore:hover { border-color: var(--accent); box-shadow: 0 0 10px var(--accent-glow); }
+.hero-explore-sub { color: var(--muted); font-size: 11px; font-family: ui-monospace, Menlo, Consolas, monospace; }
.hero-cta {
display: inline-flex; align-items: center; gap: 6px;
padding: 9px 16px; border: 1px solid var(--accent); border-radius: 8px;
@@ -1554,3 +1566,235 @@ body.wide #federation-network-graph { height: 720px; }
color: var(--muted); font-size: 12px; font-style: italic;
text-align: center; padding: 22px 0;
}
+
+/* ===================================================================
+ * federation explorer — public transparency page
+ * Public-facing variant of the admin federation network UI. Reuses the
+ * fn-* graph classes; fe-* is just the chrome around it.
+ * =================================================================== */
+
+.fe-page { background: var(--bg); }
+.fe-topbar { gap: 18px; }
+.fe-topbar .nav-toggle, .fe-topbar .nav { display: none; }
+.fe-badge {
+ display: inline-flex; align-items: center; gap: 8px;
+ padding: 4px 12px;
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 10.5px;
+ color: var(--accent);
+ background: rgba(30,200,255,0.08);
+ border: 1px solid var(--accent);
+ border-radius: 999px;
+ letter-spacing: 0.10em;
+ text-transform: uppercase;
+ box-shadow: 0 0 12px var(--accent-glow);
+ margin-left: auto;
+}
+.fe-badge-dot {
+ width: 7px; height: 7px; border-radius: 50%;
+ background: var(--accent);
+ box-shadow: 0 0 8px var(--accent);
+ animation: fe-pulse 1.8s ease-in-out infinite;
+}
+@keyframes fe-pulse {
+ 0%, 100% { opacity: 1; transform: scale(1); }
+ 50% { opacity: 0.4; transform: scale(1.4); }
+}
+
+.fe-hero { padding: 28px 32px; }
+.fe-hero-head { margin-bottom: 14px; }
+.fe-title {
+ margin: 0;
+ font-family: var(--font-display);
+ font-size: 32px;
+ font-weight: 700;
+ letter-spacing: 0.02em;
+ color: var(--text);
+}
+.fe-title::before {
+ content: "⌖ ";
+ color: var(--accent);
+ text-shadow: 0 0 12px var(--accent-glow);
+}
+.fe-sub {
+ margin: 6px 0 0;
+ color: var(--accent);
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 13px;
+ word-break: break-all;
+ text-shadow: 0 0 8px var(--accent-glow);
+}
+.fe-intro {
+ margin: 14px 0 0;
+ max-width: 920px;
+ color: var(--text);
+ line-height: 1.55;
+ font-size: 14px;
+}
+.fe-intro strong { color: var(--accent); font-weight: 600; }
+.fe-intro-sub { color: var(--muted); font-size: 12px; margin-top: 10px; }
+.fe-fp {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 11px;
+ background: var(--panel);
+ border: 1px solid var(--border);
+ border-radius: 3px;
+ padding: 2px 6px;
+ color: var(--accent);
+ word-break: break-all;
+}
+
+.fe-kpi-panel { padding: 18px 22px; }
+.fe-kpis { gap: 14px; }
+.fe-kpis .fn-stat { min-width: 130px; }
+.fe-kpi-verify .fn-stat-value { font-size: 18px; }
+.fe-kpi-verify .fn-stat-value.fe-verify-ok { color: var(--green); text-shadow: 0 0 10px rgba(74,222,128,0.45); }
+.fe-kpi-verify .fn-stat-value.fe-verify-bad { color: var(--red); text-shadow: 0 0 10px rgba(248,113,113,0.45); }
+
+.fe-verify-row {
+ display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
+ margin-top: 14px;
+ padding-top: 14px;
+ border-top: 1px dashed var(--border);
+}
+.fe-verify-btn {
+ font-family: var(--font-display);
+ letter-spacing: 0.08em;
+ text-transform: uppercase;
+ font-size: 11px;
+ font-weight: 600;
+}
+.fe-verify-result {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 12px;
+ color: var(--muted);
+}
+.fe-verify-result.fe-verify-ok { color: var(--green); }
+.fe-verify-result.fe-verify-bad { color: var(--red); }
+.fe-verify-link {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 11px;
+ color: var(--muted);
+ text-decoration: none;
+ border-bottom: 1px dotted var(--border);
+}
+.fe-verify-link:hover { color: var(--accent); border-color: var(--accent); }
+
+.fe-stage { margin-top: 8px; }
+
+.fe-walk {
+ margin-top: 16px;
+ padding: 14px 18px;
+ background: var(--panel-2);
+ border: 1px solid var(--border);
+ border-radius: 6px;
+ min-height: 56px;
+}
+.fe-walk-empty {
+ margin: 0; color: var(--muted);
+ font-style: italic; font-size: 13px;
+}
+.fe-walk-card {
+ display: grid;
+ grid-template-columns: 1fr auto;
+ gap: 16px;
+ align-items: center;
+}
+.fe-walk-card-body { min-width: 0; }
+.fe-walk-card-title {
+ margin: 0 0 4px;
+ font-family: var(--font-display);
+ font-size: 16px;
+ font-weight: 600;
+ color: var(--text);
+ word-break: break-all;
+}
+.fe-walk-card-fp {
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 11px;
+ color: var(--muted);
+ word-break: break-all;
+}
+.fe-walk-card-stats {
+ display: flex; flex-wrap: wrap; gap: 8px;
+ margin-top: 10px;
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 11px;
+}
+.fe-walk-card-stats .k { color: var(--muted); }
+.fe-walk-card-stats .v { color: var(--accent); }
+.fe-walk-card-stats > span {
+ padding: 2px 8px;
+ border: 1px solid var(--border);
+ border-radius: 999px;
+ background: var(--panel);
+}
+.fe-walk-cta {
+ display: inline-flex; align-items: center; gap: 8px;
+ padding: 10px 18px;
+ font-family: var(--font-display);
+ font-size: 13px;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--bg);
+ background: var(--accent);
+ border: 1px solid var(--accent);
+ border-radius: 5px;
+ text-decoration: none;
+ box-shadow: 0 0 14px var(--accent-glow);
+ white-space: nowrap;
+}
+.fe-walk-cta:hover {
+ background: #66daff;
+ text-decoration: none;
+}
+.fe-walk-cta-disabled {
+ color: var(--muted);
+ background: transparent;
+ border-color: var(--border);
+ box-shadow: none;
+ cursor: not-allowed;
+}
+
+.fe-vouches-panel .fe-vouches-in-list {
+ list-style: none;
+ margin: 0; padding: 0;
+}
+.fe-vouches-in-list li {
+ display: grid;
+ grid-template-columns: minmax(0, 1fr) auto;
+ gap: 12px;
+ align-items: center;
+ padding: 8px 10px;
+ border-bottom: 1px dashed var(--border);
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ font-size: 12px;
+}
+.fe-vouches-in-list li:last-child { border-bottom: 0; }
+.fe-vouches-in-list .fp { color: var(--accent); word-break: break-all; }
+.fe-vouches-in-list .ts { color: var(--muted); font-size: 11px; }
+.fe-vouches-in-empty {
+ color: var(--muted); font-style: italic;
+ display: block !important;
+ text-align: center;
+ padding: 18px 0;
+}
+
+.fe-footer {
+ margin-top: 36px;
+ text-align: center;
+ color: var(--muted);
+ font-size: 11px;
+ font-family: ui-monospace, Menlo, Consolas, monospace;
+ letter-spacing: 0.05em;
+}
+
+@media (max-width: 720px) {
+ .fe-hero { padding: 18px 16px; }
+ .fe-title { font-size: 24px; }
+ .fe-walk-card {
+ grid-template-columns: 1fr;
+ }
+ .fe-walk-cta { width: 100%; justify-content: center; }
+}
diff --git a/src/psyc/cockpit/static/federation_explore.js b/src/psyc/cockpit/static/federation_explore.js
new file mode 100644
index 0000000..20be78a
--- /dev/null
+++ b/src/psyc/cockpit/static/federation_explore.js
@@ -0,0 +1,780 @@
+/* psyc — federation explorer (public, transparency view).
+ *
+ * Forked from federation_network.js, adapted for the public surface:
+ * • data source is /federation/explore/data (signed, CORS-enabled)
+ * • clicking a peer opens a walk-to-peer card with a primary CTA
+ * that full-page-navigates to that peer's own /federation/explore
+ * • the transparency log can be re-verified live from the page
+ * • inbound vouches (who vouches for THIS node) get their own section
+ * • severity/IOC-type breakdowns are intentionally NOT surfaced —
+ * those stay admin-only to avoid sector-leaking via the public page
+ *
+ * Pure vanilla JS, no deps. Mirrors topology.js's proven force-sim loop.
+ */
+
+(function () {
+ "use strict";
+
+ const svg = document.getElementById("federation-network-graph");
+ const loadingEl = document.getElementById("fn-loading");
+ const errorEl = document.getElementById("fn-error");
+ const tooltipEl = document.getElementById("fn-tooltip");
+ const walkEl = document.getElementById("fe-walk");
+ const directCountEl = document.getElementById("fe-direct-count");
+ const transitiveCountEl = document.getElementById("fe-transitive-count");
+ const kpiPeers = document.getElementById("fe-kpi-peers");
+ const kpiVouchesOut = document.getElementById("fe-kpi-vouches-out");
+ const kpiVouchesIn = document.getElementById("fe-kpi-vouches-in");
+ const kpiSignals = document.getElementById("fe-kpi-signals");
+ const kpiCorroboration = document.getElementById("fe-kpi-corroboration");
+ const kpiTranslog = document.getElementById("fe-kpi-translog");
+ const kpiVerify = document.getElementById("fe-kpi-verify");
+ const verifyBtn = document.getElementById("fe-verify-btn");
+ const verifyResult = document.getElementById("fe-verify-result");
+ const vouchesInList = document.getElementById("fe-vouches-in-list");
+ const vouchesInCountEl = document.getElementById("fe-vouches-in-count");
+ const settings = window.PSYC_EXPLORE || {};
+
+ if (!svg) return;
+
+ // ---------- shared escape -----------------------------------------------
+ function esc(s) {
+ return String(s == null ? "" : s).replace(/[&<>"']/g, c =>
+ ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }[c]));
+ }
+ function shortFp(fp) {
+ if (!fp) return "—";
+ if (fp.length >= 16) return fp.slice(0, 8) + "…" + fp.slice(-8);
+ return fp;
+ }
+ function fmtAge(iso) {
+ if (!iso) return "—";
+ const ts = new Date(iso);
+ if (isNaN(ts.getTime())) return "—";
+ const secs = Math.floor((Date.now() - ts.getTime()) / 1000);
+ if (secs < 0) return "just now";
+ if (secs < 60) return secs + "s ago";
+ if (secs < 3600) return Math.floor(secs / 60) + "m ago";
+ if (secs < 86400) return Math.floor(secs / 3600) + "h ago";
+ return Math.floor(secs / 86400) + "d ago";
+ }
+
+ fetch("/federation/explore/data", { credentials: "omit" })
+ .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 explore payload: " + err.message;
+ }
+ });
+
+ // ---------- verify button — fetch /federation/log/verify ---------------
+ if (verifyBtn) {
+ verifyBtn.addEventListener("click", () => {
+ verifyBtn.disabled = true;
+ verifyResult.textContent = "verifying…";
+ verifyResult.classList.remove("fe-verify-ok", "fe-verify-bad");
+ fetch("/federation/log/verify", { credentials: "omit" })
+ .then(r => r.json().then(b => ({ status: r.status, body: b })))
+ .then(({ status, body }) => {
+ if (status === 200 && body.verified != null) {
+ verifyResult.textContent = "✓ verified " + body.verified + " entries · head " + (body.head_hash || "").slice(0, 12) + "…";
+ verifyResult.classList.add("fe-verify-ok");
+ if (kpiVerify) {
+ kpiVerify.textContent = "✓ ok";
+ kpiVerify.classList.add("fe-verify-ok");
+ kpiVerify.classList.remove("fe-verify-bad");
+ }
+ } else {
+ verifyResult.textContent = "✗ " + (body.error || "chain invalid");
+ verifyResult.classList.add("fe-verify-bad");
+ if (kpiVerify) {
+ kpiVerify.textContent = "✗ broken";
+ kpiVerify.classList.add("fe-verify-bad");
+ kpiVerify.classList.remove("fe-verify-ok");
+ }
+ }
+ })
+ .catch(err => {
+ verifyResult.textContent = "✗ fetch failed: " + err.message;
+ verifyResult.classList.add("fe-verify-bad");
+ })
+ .finally(() => { verifyBtn.disabled = false; });
+ });
+ }
+
+ function render(data) {
+ const node = data.node || {};
+ const selfFp = data.fingerprint || node.fingerprint || "";
+ const peersData = data.peers || [];
+ const transitiveData = data.transitive_peers || [];
+ const vouchesIn = data.vouches_in || [];
+ const vouchesOut = data.vouches_out || data.vouches || [];
+
+ // ---------- KPI strip ------------------------------------------------
+ if (kpiPeers) kpiPeers.textContent = String(node.peer_count != null ? node.peer_count : peersData.length);
+ if (kpiVouchesOut) kpiVouchesOut.textContent = String(node.vouches_out_count != null ? node.vouches_out_count : vouchesOut.length);
+ if (kpiVouchesIn) kpiVouchesIn.textContent = String(node.vouches_in_count != null ? node.vouches_in_count : vouchesIn.length);
+ if (kpiSignals) kpiSignals.textContent = String(node.signals_count_24h || 0);
+ if (kpiCorroboration) kpiCorroboration.textContent = String(node.corroboration_count_24h || 0);
+ if (kpiTranslog) kpiTranslog.textContent = String(node.translog_entry_count || 0);
+ if (kpiVerify) kpiVerify.textContent = "unverified";
+
+ if (directCountEl) directCountEl.textContent = String(peersData.length);
+ if (transitiveCountEl) transitiveCountEl.textContent = String(transitiveData.length);
+
+ // ---------- node + edge model ----------------------------------------
+ // The explore payload doesn't ship edges directly; we derive them from
+ // the vouches + per-peer signal counts so the graph reads the same way
+ // the admin view does.
+ const peerByFp = Object.create(null);
+ const nodes = [];
+
+ // Self at the center.
+ const selfNode = {
+ id: selfFp, fp: selfFp,
+ domain: settings.selfDomain || node.domain || "",
+ label: settings.selfDomain || (node.domain || "self"),
+ status: "self",
+ is_self: true,
+ distance: 0,
+ stats: null,
+ r: 38,
+ intensity: 1,
+ x: 0, y: 0, vx: 0, vy: 0, fixed: false,
+ };
+ nodes.push(selfNode);
+ peerByFp[selfFp] = selfNode;
+
+ // Max signal count for log-intensity normalization.
+ let maxSig = 0;
+ for (const p of peersData) {
+ if ((p.signal_count_24h || 0) > maxSig) maxSig = p.signal_count_24h || 0;
+ }
+
+ for (const p of peersData) {
+ const fp = p.fingerprint;
+ if (!fp || fp === selfFp) continue;
+ const sig = p.signal_count_24h || 0;
+ let intensity = 1;
+ if (maxSig > 0) {
+ const num = Math.log2(sig + 1);
+ const den = Math.log2(maxSig + 1) || 1;
+ intensity = 0.20 + 0.80 * (num / den);
+ }
+ const n = {
+ id: fp, fp,
+ domain: p.domain || "",
+ label: p.domain || shortFp(fp),
+ status: "trusted",
+ is_self: false,
+ distance: 1,
+ stats: p,
+ r: 16,
+ intensity,
+ x: 0, y: 0, vx: 0, vy: 0, fixed: false,
+ };
+ nodes.push(n);
+ peerByFp[fp] = n;
+ }
+
+ for (const t of transitiveData) {
+ const fp = t.fingerprint;
+ if (!fp || peerByFp[fp]) continue;
+ const n = {
+ id: fp, fp,
+ domain: t.domain || "",
+ label: t.domain || shortFp(fp),
+ status: "unknown",
+ is_self: false,
+ distance: 2,
+ stats: null,
+ via: t.via_peer_fingerprint || "",
+ r: 9,
+ intensity: 0.7,
+ x: 0, y: 0, vx: 0, vy: 0, fixed: false,
+ };
+ nodes.push(n);
+ peerByFp[fp] = n;
+ }
+
+ // Edges. Per-peer signal counts → signal edges; outbound vouches →
+ // vouch edges; vouches_in → bidirectional vouch edges; transitive
+ // "via" → knows edges.
+ const edges = [];
+ for (const p of peersData) {
+ const fp = p.fingerprint;
+ if (!fp || fp === selfFp) continue;
+ if ((p.signal_count_24h || 0) > 0) {
+ edges.push({
+ source: fp, target: selfFp, kind: "signal",
+ weight: p.signal_count_24h, label: p.signal_count_24h + " signals/24h",
+ bidirectional: false,
+ });
+ }
+ }
+ // Outbound vouches.
+ const outbound = new Set();
+ for (const v of vouchesOut) {
+ const tgt = v.target_fingerprint;
+ if (!tgt || !peerByFp[tgt]) continue;
+ outbound.add(tgt);
+ edges.push({
+ source: selfFp, target: tgt, kind: "vouch",
+ weight: 1, label: "vouched", bidirectional: false,
+ });
+ }
+ // Inbound vouches — collapse onto existing outbound where possible.
+ for (const v of vouchesIn) {
+ const src = v.voucher_fingerprint;
+ if (!src || !peerByFp[src]) continue;
+ if (outbound.has(src)) {
+ const existing = edges.find(e => e.kind === "vouch"
+ && e.source === selfFp && e.target === src);
+ if (existing) {
+ existing.bidirectional = true;
+ existing.label = "vouched ↔";
+ continue;
+ }
+ }
+ edges.push({
+ source: src, target: selfFp, kind: "vouch",
+ weight: 1, label: "vouches us", bidirectional: false,
+ });
+ }
+ // Transitive "knows" edges.
+ for (const t of transitiveData) {
+ const parent = t.via_peer_fingerprint;
+ if (!parent || !peerByFp[parent] || !peerByFp[t.fingerprint]) continue;
+ edges.push({
+ source: parent, target: t.fingerprint, kind: "knows",
+ weight: 0.5, label: "knows", bidirectional: false,
+ });
+ }
+
+ // ---------- 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;
+ });
+ })();
+
+ 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 = peerByFp[e.source], b = peerByFp[e.target];
+ if (!a || !b) continue;
+ 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));
+ }
+ }
+ for (let i = 0; i < 280; i++) tick();
+
+ // ---------- render SVG groups ----------------------------------------
+ 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);
+ grp.dataset.source = e.source;
+ grp.dataset.target = e.target;
+ const ln = document.createElementNS(ns, "line");
+ ln.setAttribute("class", "fn-edge");
+ 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"), grp };
+ });
+
+ 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;
+
+ let shape;
+ if (n.is_self) {
+ const sz = n.r;
+ shape = document.createElementNS(ns, "rect");
+ shape.setAttribute("x", -sz); shape.setAttribute("y", -sz);
+ shape.setAttribute("width", sz * 2); shape.setAttribute("height", sz * 2);
+ shape.setAttribute("rx", 10); shape.setAttribute("ry", 10);
+ g.appendChild(shape);
+ } else {
+ shape = document.createElementNS(ns, "circle");
+ shape.setAttribute("r", n.r);
+ shape.setAttribute("fill-opacity", n.intensity.toFixed(2));
+ g.appendChild(shape);
+ }
+
+ 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);
+
+ if (n.stats) {
+ const badge = document.createElementNS(ns, "text");
+ badge.setAttribute("class", "fn-stat-badge");
+ badge.setAttribute("dy", n.r + 36);
+ badge.textContent =
+ "↓ " + (n.stats.signal_count_24h || 0) +
+ " · ⚡ " + (n.stats.quorum_contribution_24h || 0);
+ g.appendChild(badge);
+ }
+ }
+
+ const title = document.createElementNS(ns, "title");
+ title.textContent = (n.is_self ? "self · " : "") + (n.domain || n.label || n.fp);
+ g.appendChild(title);
+
+ nodesG.appendChild(g);
+ return g;
+ });
+
+ function paint() {
+ for (let i = 0; i < edges.length; i++) {
+ const e = edges[i];
+ const a = peerByFp[e.source], b = peerByFp[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();
+
+ // ---------- tooltip --------------------------------------------------
+ function showTooltip(n, clientX, clientY) {
+ if (!tooltipEl) return;
+ const rows = [];
+ rows.push(`
${esc(n.domain || n.label || n.fp.slice(0,12))}
`);
+ if (n.is_self) {
+ rows.push(`role self
`);
+ rows.push(`peers ${node.peer_count || 0}
`);
+ rows.push(`signals 24h ${node.signals_count_24h || 0}
`);
+ } else if (n.distance >= 2) {
+ rows.push(`distance 2 hops (transitive)
`);
+ if (n.via) {
+ const parent = peerByFp[n.via];
+ const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
+ rows.push(`via ${esc(via)}
`);
+ }
+ rows.push(`tip click to walk →
`);
+ } else {
+ const s = n.stats || {};
+ rows.push(`status trusted
`);
+ rows.push(`signals 24h ${s.signal_count_24h || 0}
`);
+ rows.push(`quorum hits ${s.quorum_contribution_24h || 0}
`);
+ rows.push(`last seen ${esc(fmtAge(s.last_seen))}
`);
+ rows.push(`tip click to walk →
`);
+ }
+ tooltipEl.innerHTML = rows.join("");
+ tooltipEl.classList.add("is-visible");
+ positionTooltip(clientX, clientY);
+ }
+ function positionTooltip(clientX, clientY) {
+ if (!tooltipEl) return;
+ const parent = svg.parentElement;
+ if (!parent) return;
+ const rect = parent.getBoundingClientRect();
+ let x = clientX - rect.left + 14;
+ let y = clientY - rect.top + 14;
+ const tw = tooltipEl.offsetWidth || 240;
+ const th = tooltipEl.offsetHeight || 100;
+ if (x + tw > parent.clientWidth - 4) x = clientX - rect.left - tw - 14;
+ if (y + th > parent.clientHeight - 4) y = clientY - rect.top - th - 14;
+ tooltipEl.style.left = x + "px";
+ tooltipEl.style.top = y + "px";
+ }
+ function hideTooltip() {
+ if (tooltipEl) tooltipEl.classList.remove("is-visible");
+ }
+
+ // ---------- drag + click + hover ------------------------------------
+ let dragging = null, dragOffset = { x: 0, y: 0 };
+ let pressedNode = null, pressedAt = null, moved = false;
+ let energyBudget = 40;
+ function svgPoint(clientX, clientY) {
+ const pt = svg.createSVGPoint(); pt.x = clientX; pt.y = clientY;
+ return pt.matrixTransform(svg.getScreenCTM().inverse());
+ }
+ nodeEls.forEach((g, i) => {
+ const n = nodes[i];
+ g.addEventListener("mousedown", ev => {
+ ev.preventDefault();
+ pressedNode = n;
+ pressedAt = { x: ev.clientX, y: ev.clientY };
+ moved = false;
+ dragging = n;
+ 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");
+ });
+ g.addEventListener("mouseenter", ev => showTooltip(n, ev.clientX, ev.clientY));
+ g.addEventListener("mousemove", ev => positionTooltip(ev.clientX, ev.clientY));
+ g.addEventListener("mouseleave", hideTooltip);
+ });
+ 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;
+ });
+
+ // ---------- walk-to-peer card ---------------------------------------
+ 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");
+ renderWalk(n);
+ }
+ function jumpToFp(fp) {
+ const t = peerByFp[fp];
+ if (!t) return;
+ selectNode(t);
+ // Scroll the graph stage into view so the user sees the highlight.
+ svg.scrollIntoView({ behavior: "smooth", block: "center" });
+ }
+
+ function vouchersFor(fp) {
+ // Inbound vouches naming `fp`. Right now we only have inbound vouches
+ // for SELF in the public payload; for any other peer we don't see
+ // who-vouches-for-them from this page.
+ if (fp === selfFp) return vouchesIn.map(v => v.voucher_fingerprint);
+ return [];
+ }
+
+ function renderWalk(n) {
+ if (!walkEl) return;
+ const isSelf = n.is_self;
+ const isTransitive = n.distance >= 2;
+ const stats = n.stats || {};
+
+ const targetDomain = n.domain || (isSelf ? settings.selfDomain : "");
+ const peerHref = targetDomain
+ ? `https://${targetDomain}/federation/explore`
+ : "";
+
+ const statsHtml = [];
+ if (isSelf) {
+ statsHtml.push(`peers ${node.peer_count || 0} `);
+ statsHtml.push(`signals 24h ${node.signals_count_24h || 0} `);
+ statsHtml.push(`corroborations ${node.corroboration_count_24h || 0} `);
+ statsHtml.push(`translog ${node.translog_entry_count || 0} entries `);
+ } else if (isTransitive) {
+ const parent = peerByFp[n.via];
+ const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
+ statsHtml.push(`distance 2 hops `);
+ statsHtml.push(`learned via ${esc(via)} `);
+ statsHtml.push(`stats — (peer-side only) `);
+ } else {
+ statsHtml.push(`status trusted `);
+ statsHtml.push(`signals 24h ${stats.signal_count_24h || 0} `);
+ statsHtml.push(`cases / iocs 24h ${stats.cases_24h || 0} / ${stats.iocs_24h || 0} `);
+ statsHtml.push(`quorum hits ${stats.quorum_contribution_24h || 0} `);
+ statsHtml.push(`last seen ${esc(fmtAge(stats.last_seen))} `);
+ }
+
+ const cta = peerHref
+ ? `View this peer's federation → `
+ : `no public address known `;
+
+ walkEl.innerHTML = `
+
+
+
${esc(n.domain || n.label || shortFp(n.fp))}
+
${esc(n.fp)}
+
${statsHtml.join("")}
+
+ ${cta}
+
`;
+ }
+
+ // ---------- inbound vouches list ------------------------------------
+ function renderVouchesIn() {
+ if (!vouchesInList) return;
+ if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
+ if (!vouchesIn.length) {
+ vouchesInList.innerHTML = `no inbound vouches yet `;
+ return;
+ }
+ const items = vouchesIn.map(v => {
+ const fp = v.voucher_fingerprint || "";
+ const peer = peerByFp[fp];
+ const label = peer ? (peer.domain || shortFp(fp)) : shortFp(fp);
+ const clickable = peer ? `data-jump="${esc(fp)}"` : `data-jump=""`;
+ return `
+
+ ${esc(label)}
+ ${esc(fp)}
+
+ ${esc(v.issued_at || "")}
+ `;
+ }).join("");
+ vouchesInList.innerHTML = items;
+ vouchesInList.querySelectorAll(".fn-fp-jump").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const fp = btn.getAttribute("data-jump") || "";
+ if (fp) jumpToFp(fp);
+ });
+ });
+ }
+ renderVouchesIn();
+
+ // ---------- copy buttons on the static page -------------------------
+ document.querySelectorAll(".fn-copy-btn").forEach(btn => {
+ btn.addEventListener("click", () => {
+ const v = btn.getAttribute("data-copy") || "";
+ if (!v) return;
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(v).catch(() => {});
+ }
+ const t = btn.textContent;
+ btn.textContent = "copied";
+ setTimeout(() => { btn.textContent = t; }, 1100);
+ });
+ });
+
+ // ---------- idle animation ------------------------------------------
+ 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 -----------------------------
+ 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";
+ const selfNodeRef = nodes.find(n => n.is_self);
+ if (selfNodeRef) { selfNodeRef.x = W / 2; selfNodeRef.y = H / 2; selfNodeRef.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 + resize -------------------------------------
+ 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;
+ });
+
+ // ---------- focus-peer query param ----------------------------------
+ // ?peer= auto-selects that peer in the graph so deep links work.
+ if (settings.focusPeer) {
+ const target = nodes.find(n => n.domain && n.domain === settings.focusPeer);
+ if (target) selectNode(target);
+ }
+ }
+})();
diff --git a/src/psyc/cockpit/static/sw.js b/src/psyc/cockpit/static/sw.js
index 84e5686..d35ba60 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-v7";
+const CACHE_VERSION = "psyc-v8";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",
diff --git a/src/psyc/cockpit/templates/federation_explore.html b/src/psyc/cockpit/templates/federation_explore.html
new file mode 100644
index 0000000..f38fe22
--- /dev/null
+++ b/src/psyc/cockpit/templates/federation_explore.html
@@ -0,0 +1,145 @@
+
+
+
+
+
+
+
+ Federation Explorer — psyc
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Federation Explorer
+
{{ domain or fingerprint }}
+
+
+ This is a public transparency view of the psyc federation this node sits inside.
+ Anyone on the internet can verify the trust network — who has vouched for whom,
+ which signals are corroborated, and that the transparency log hasn't been rewritten.
+ Click any peer in the graph to inspect it, then jump to that peer's own explorer.
+
+
+ Self fingerprint:
+ {{ fingerprint }}
+ copy
+
+
+
+
+
+
+
+
Trust network
+ … direct · … transitive
+
+
+ Self at the center, directly-trusted peers around it,
+ peers-of-peers (learned from each trusted peer's signed feed) on the outer ring.
+ Edges: vouches (solid), signals (dashed, thickness ∝ 24h volume),
+ knows (dotted grey), corroborate (faint pulse — two peers reporting the same signal).
+
+
+
+
+
+
+
loading federation network…
+
+
+
+
+
Click any peer in the graph above to inspect it and walk to its federation view.
+
+
+
+
+
+
Who vouches for this node
+ … inbound
+
+
+ Each entry is a signed vouch naming this node as target, issued by a peer we currently trust.
+ Click a fingerprint to highlight that peer in the graph above.
+
+
+ no inbound vouches yet
+
+
+
+
+
+
+
+
+
+
diff --git a/src/psyc/cockpit/templates/home.html b/src/psyc/cockpit/templates/home.html
index 9e87006..2d7b8a9 100644
--- a/src/psyc/cockpit/templates/home.html
+++ b/src/psyc/cockpit/templates/home.html
@@ -6,6 +6,7 @@
Defensive CTI in motion
What psyc has seen and done — at a glance.
+
Federation Explorer → public · auditable trust network
All cases →
diff --git a/src/psyc/lines/network_view.py b/src/psyc/lines/network_view.py
index d15359a..d8ba344 100644
--- a/src/psyc/lines/network_view.py
+++ b/src/psyc/lines/network_view.py
@@ -38,6 +38,7 @@ _log = log.get(__name__)
SIGNAL_WINDOW_HOURS = 24
TRANSITIVE_CACHE_TTL = 300.0 # 5 minutes
TRANSITIVE_FETCH_TIMEOUT = 4.0
+EXPLORE_FETCH_TIMEOUT = 4.0
# ---------- data model --------------------------------------------------
@@ -773,3 +774,256 @@ def build_admin_view(include_transitive: bool = True) -> Dict[str, Any]:
"stats": view.stats,
"generated_at": view.generated_at.isoformat(),
}
+
+
+# ---------- public explore payload --------------------------------------
+#
+# "Transparent security" view: the same shape a peer would see at
+# /federation/network, plus per-peer counts (NEVER values), inbound vouches,
+# and a thin distance-2 snapshot — enough for a public visitor to draw the
+# mesh and walk to any peer's own explore page. Everything is signed.
+
+EXPLORE_TRANSITIVE_CAP = 50 # cap on distinct distance-2 fps to keep payload bounded
+
+
+def _explore_peer_stats(
+ peer_fp: str,
+ now: datetime,
+ signals_by_peer: Dict[str, List[Dict[str, Any]]],
+ all_signal_counts: Dict[str, int],
+ quorum_cache: Dict[str, bool],
+) -> Dict[str, Any]:
+ """Per-peer COUNTS only — no IOC values, no case summaries, no raw_json.
+
+ Counts split by signal_type (cases vs iocs) are safe to expose since
+ the magnitude of "how chatty is this peer" is already implicit in the
+ 24h signal count. We deliberately omit severity + ioc_type breakdowns
+ here — those could hint at the target sector.
+ """
+ rows = signals_by_peer.get(peer_fp, [])
+ cases_24h = 0
+ iocs_24h = 0
+ last_seen_iso = ""
+ seen_hashes: set = set()
+ quorum_contribution_24h = 0
+ for row in rows:
+ st = row.get("signal_type") or ""
+ if st == "case":
+ cases_24h += 1
+ elif st == "ioc":
+ iocs_24h += 1
+ h = row.get("signal_hash") or ""
+ if h and h not in seen_hashes:
+ seen_hashes.add(h)
+ if h not in quorum_cache:
+ quorum_cache[h] = federation.is_quorum_met(h)
+ if quorum_cache[h]:
+ quorum_contribution_24h += 1
+ if rows:
+ # recent_signals returns newest-first → first row is latest.
+ last_seen_iso = str(rows[0].get("received_at") or "")
+ return {
+ "signal_count_24h": len(rows),
+ "signal_count_total": all_signal_counts.get(peer_fp, 0),
+ "cases_24h": cases_24h,
+ "iocs_24h": iocs_24h,
+ "quorum_contribution_24h": quorum_contribution_24h,
+ "last_seen": last_seen_iso or None,
+ }
+
+
+def _fetch_peer_explore(domain: str, timeout: float = EXPLORE_FETCH_TIMEOUT) -> Optional[Dict[str, Any]]:
+ """GET /federation/explore/data on a peer. Returns dict on success.
+
+ Mirrors `_fetch_peer_network`'s failure semantics: one slow/broken peer
+ must never abort the explore walk.
+ """
+ if not domain:
+ return None
+ url = f"https://{domain}/federation/explore/data"
+ 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
+ _log.info("network_view.explore.transitive.skip", domain=domain, reason=str(exc)[:120])
+ return None
+ if not isinstance(data, dict):
+ return None
+ return data
+
+
+def _explore_transitive_peers(
+ trusted_peers: List[Tuple[str, Optional[str]]],
+ own_fp: str,
+ own_peer_fps: set,
+) -> List[Dict[str, Any]]:
+ """Distance-2 fps learned from trusted peers' explore/data feeds.
+
+ Returns [{fingerprint, via_peer_fingerprint, domain}] entries. Capped at
+ `EXPLORE_TRANSITIVE_CAP` to keep the public payload bounded — first peer
+ to introduce a fingerprint wins so the via attribution stays stable.
+ """
+ seen: set = set(own_peer_fps)
+ seen.add(own_fp)
+ out: List[Dict[str, Any]] = []
+ for parent_fp, parent_domain in trusted_peers:
+ if not parent_domain or len(out) >= EXPLORE_TRANSITIVE_CAP:
+ continue
+ data = _fetch_peer_explore(parent_domain)
+ if not data:
+ # Fall back to the older /federation/network endpoint — older
+ # psyc nodes won't have /federation/explore/data yet.
+ data = _fetch_peer_network(parent_domain)
+ if not data:
+ continue
+ 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 in seen:
+ continue
+ seen.add(fp)
+ out.append({
+ "fingerprint": fp,
+ "domain": pp.get("domain") or None,
+ "via_peer_fingerprint": parent_fp,
+ })
+ if len(out) >= EXPLORE_TRANSITIVE_CAP:
+ break
+ return out
+
+
+def build_explore_view(node_domain: Optional[str] = None) -> Dict[str, Any]:
+ """Signed public explorer payload for /federation/explore/data.
+
+ Extends `build_public_view` with:
+ * `node` — headline stats about THIS node (counts only)
+ * `peers[].*_count_24h` — per-peer chatter levels (no values leak)
+ * `vouches_in` — who has vouched for us (we only include vouchers
+ whose peer we currently trust, so signatures don't
+ leak unknown identities)
+ * `transitive_peers` — distance-2 fingerprints learned from each
+ trusted peer's public explore/network feed.
+ Cached aggressively (mirrors transitive cache).
+ * `corroboration_count_24h` — # distinct signal_hashes seen from ≥2
+ peers in the 24h window.
+
+ The whole payload (sans signature) is Ed25519-signed over canonical JSON.
+ No IOC values, case_ids, raw_json, severity or ioc-type breakdowns are
+ included — anything that could leak the target sector or who reported
+ what stays inside `build_admin_view`.
+ """
+ our_fp = federation.node_fingerprint()
+ now = datetime.now(timezone.utc)
+
+ # Reuse the 24h signal bucket scan + all-time count + quorum cache.
+ signals_by_peer, fresh_signals = _index_signals_24h(now)
+ all_signal_counts = _all_signals_by_peer_count()
+ quorum_cache: Dict[str, bool] = {}
+
+ # Build the trusted-peer rows (the only ones we expose), with public-safe
+ # stats. Unknown + blocked never leak — see `build_public_view`.
+ peer_rows: List[Dict[str, Any]] = []
+ trusted_peers_for_walk: List[Tuple[str, Optional[str]]] = []
+ trusted_fps: set = set()
+ for p in federation.list_peers():
+ if p.status != "trusted":
+ continue
+ trusted_fps.add(p.fingerprint)
+ trusted_peers_for_walk.append((p.fingerprint, p.domain))
+ stats = _explore_peer_stats(
+ peer_fp=p.fingerprint,
+ now=now,
+ signals_by_peer=signals_by_peer,
+ all_signal_counts=all_signal_counts,
+ quorum_cache=quorum_cache,
+ )
+ peer_rows.append({
+ "domain": p.domain,
+ "fingerprint": p.fingerprint,
+ "first_seen": p.discovered_at,
+ **stats,
+ })
+
+ # Vouches WE've issued — same shape as build_public_view + signature.
+ vouches_out: List[Dict[str, Any]] = []
+ for v in federation.our_vouches():
+ vouches_out.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,
+ "signature": v.signature,
+ })
+
+ # Vouches IN — only those naming us as target where we trust the voucher.
+ # We don't surface vouches from unknown identities: doing so would let any
+ # stranger forge an inbound vouch and show up here.
+ vouches_in: List[Dict[str, Any]] = []
+ for row in db.list_vouches():
+ if (row.get("target_fingerprint") or "") != our_fp:
+ continue
+ voucher_fp = row.get("voucher_fingerprint") or ""
+ if voucher_fp == our_fp:
+ continue
+ if voucher_fp not in trusted_fps:
+ continue
+ vouches_in.append({
+ "voucher_fingerprint": voucher_fp,
+ "target_fingerprint": our_fp,
+ "issued_at": row.get("issued_at") or "",
+ "expires_at": row.get("expires_at") or None,
+ "signature": row.get("signature") or "",
+ })
+
+ # Transitive snapshot. The aim is "one fetch surfaces N hops" — distance-2
+ # fingerprints learned from each trusted peer's own explore/network feed.
+ transitive_peers = _explore_transitive_peers(
+ trusted_peers_for_walk, our_fp, trusted_fps,
+ )
+
+ # Corroboration: # distinct hashes seen from ≥2 distinct peers in 24h.
+ by_hash: Dict[str, set] = {}
+ for row in fresh_signals:
+ h = row.get("signal_hash") or ""
+ if not h:
+ continue
+ by_hash.setdefault(h, set()).add(row.get("peer_fingerprint") or "")
+ corroboration_count_24h = sum(1 for fps in by_hash.values() if len(fps) >= 2)
+
+ # Transparency log headline numbers — chain head + length, never bodies.
+ head_entry = translog.head()
+ translog_head_hash = head_entry.entry_hash if head_entry else None
+ translog_entry_count = int(head_entry.id) if head_entry else 0
+
+ node_block: Dict[str, Any] = {
+ "fingerprint": our_fp,
+ "domain": node_domain,
+ "generated_at": now.isoformat(),
+ "transparency_log_head_hash": translog_head_hash,
+ "translog_entry_count": translog_entry_count,
+ "peer_count": len(peer_rows),
+ "vouches_out_count": len(vouches_out),
+ "vouches_in_count": len(vouches_in),
+ "corroboration_count_24h": corroboration_count_24h,
+ "signals_count_24h": sum(p["signal_count_24h"] for p in peer_rows),
+ }
+
+ payload: Dict[str, Any] = {
+ "version": federation.FEED_VERSION,
+ "fingerprint": our_fp,
+ "generated_at": now.isoformat(),
+ "node": node_block,
+ "peers": peer_rows,
+ "vouches": vouches_out, # kept for shape-compat with /federation/network
+ "vouches_out": vouches_out,
+ "vouches_in": vouches_in,
+ "transitive_peers": transitive_peers,
+ "corroboration_count_24h": corroboration_count_24h,
+ }
+ sig = federation.sign_payload(federation.canonical_json(payload))
+ payload["signature"] = base64.b64encode(sig).decode("ascii")
+ return payload
diff --git a/tests/test_explore_view.py b/tests/test_explore_view.py
new file mode 100644
index 0000000..4424586
--- /dev/null
+++ b/tests/test_explore_view.py
@@ -0,0 +1,427 @@
+"""Federation explore view — public transparency payload tests.
+
+Sibling to `test_network_view.py`; focused on the explore-only shape:
+shape contract, signature round-trip, no-leak invariants, transitive walk,
+inbound vouches filter, and the corroboration counter.
+"""
+
+from __future__ import annotations
+
+import base64
+import json
+from datetime import datetime, timedelta, timezone
+from typing import Any, Dict, List, Optional
+from unittest.mock import patch
+
+import pytest
+from fastapi import FastAPI
+from fastapi.templating import Jinja2Templates
+from fastapi.testclient import TestClient
+from sqlalchemy import create_engine
+from starlette.middleware.sessions import SessionMiddleware
+
+from psyc import db
+from psyc.cockpit import federation_routes
+from psyc.lines import federation, network_view, translog
+from psyc.lines.network_view import build_explore_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_explore_caches(monkeypatch):
+ """Prevent route-level cache bleed between tests."""
+ monkeypatch.setattr(network_view, "_TRANSITIVE_CACHE", {"ts": 0.0, "view": None})
+ federation_routes._FEED_CACHE["payload"] = None
+ federation_routes._PUBLIC_PEERS_CACHE["payload"] = None
+ federation_routes._PUBLIC_NETWORK_CACHE["payload"] = None
+ if hasattr(federation_routes, "_EXPLORE_CACHE"):
+ federation_routes._EXPLORE_CACHE["payload"] = 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
+
+
+def _silence_explore_fetch():
+ return patch.object(network_view, "_fetch_peer_explore", return_value=None)
+
+
+def _silence_network_fetch():
+ return patch.object(network_view, "_fetch_peer_network", return_value=None)
+
+
+# ---------- schema ------------------------------------------------------
+
+def test_explore_view_top_level_shape(fresh_db, fed_dir):
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view(node_domain="me.example")
+ for key in (
+ "version", "fingerprint", "generated_at",
+ "node", "peers", "vouches", "vouches_out", "vouches_in",
+ "transitive_peers", "corroboration_count_24h", "signature",
+ ):
+ assert key in payload, f"missing {key}"
+ node = payload["node"]
+ for key in (
+ "fingerprint", "domain", "generated_at",
+ "transparency_log_head_hash", "translog_entry_count",
+ "peer_count", "vouches_out_count", "vouches_in_count",
+ "corroboration_count_24h", "signals_count_24h",
+ ):
+ assert key in node, f"missing node.{key}"
+ assert node["domain"] == "me.example"
+ assert node["fingerprint"] == federation.node_fingerprint()
+
+
+def test_explore_peer_carries_public_safe_stats(fresh_db, fed_dir):
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("peer.example", fp, pem, status="trusted")
+ now_iso = datetime.now(timezone.utc).isoformat()
+ for i in range(3):
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="ioc",
+ signal_id=f"1.2.3.{i}",
+ signal_hash=f"hash-{i}",
+ received_at=now_iso,
+ raw_json=json.dumps({"type": "ip", "value": f"1.2.3.{i}"}),
+ ))
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ assert len(payload["peers"]) == 1
+ p = payload["peers"][0]
+ # Public-safe stats present.
+ for key in (
+ "signal_count_24h", "signal_count_total",
+ "cases_24h", "iocs_24h",
+ "quorum_contribution_24h", "last_seen",
+ ):
+ assert key in p
+ assert p["signal_count_24h"] == 3
+ assert p["iocs_24h"] == 3
+ assert p["cases_24h"] == 0
+ # Sensitive fields are not surfaced per-peer.
+ assert "severity_breakdown" not in p
+ assert "ioc_type_breakdown" not in p
+ assert "recent_translog" not in p
+
+
+# ---------- signature round-trip ---------------------------------------
+
+def test_explore_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)
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ assert "signature" in payload
+ 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
+
+
+# ---------- no-leak invariants -----------------------------------------
+
+def test_explore_view_has_no_ioc_values_or_case_ids_or_raw_json(fresh_db, fed_dir):
+ """Public payload must not expose IOC values, case_ids in raw form, or raw_json.
+
+ This is the core transparency-vs-leakage contract: anyone can see who's
+ talking to whom and how much, but never what they're saying.
+ """
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp, pem, status="trusted")
+ now_iso = datetime.now(timezone.utc).isoformat()
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="ioc",
+ signal_id="evil-domain-do-not-leak.com",
+ signal_hash="ioc-hash-leak",
+ received_at=now_iso,
+ raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
+ ))
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="case",
+ signal_id="CASE-SECRET-42",
+ signal_hash="case-hash-leak",
+ received_at=now_iso,
+ raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
+ ))
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ flat = json.dumps(payload, default=str)
+ # IOC values.
+ assert "evil-domain-do-not-leak.com" not in flat
+ # Case ids (raw).
+ assert "CASE-SECRET-42" not in flat
+ # raw_json shape never serialized.
+ assert "raw_json" not in flat
+ # Sector-leaking breakdowns.
+ assert "severity_breakdown" not in flat
+ assert "ioc_type_breakdown" not in flat
+
+
+# ---------- transitive peers --------------------------------------------
+
+def test_explore_transitive_peers_populated_from_peer_responses(fresh_db, fed_dir):
+ direct_fp, direct_pem = _make_peer_pubkey()
+ federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
+ far_a, _ = _make_peer_pubkey()
+ far_b, _ = _make_peer_pubkey()
+ fake_payload: Dict[str, Any] = {
+ "fingerprint": direct_fp,
+ "peers": [
+ {"fingerprint": far_a, "domain": "far-a.example"},
+ {"fingerprint": far_b, "domain": "far-b.example"},
+ ],
+ "vouches": [],
+ }
+ with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
+ payload = build_explore_view()
+ tps = payload["transitive_peers"]
+ fps = {t["fingerprint"] for t in tps}
+ assert far_a in fps
+ assert far_b in fps
+ via_fps = {t["via_peer_fingerprint"] for t in tps}
+ assert via_fps == {direct_fp}
+
+
+def test_explore_transitive_peers_falls_back_to_network_endpoint(fresh_db, fed_dir):
+ """If a peer doesn't have /federation/explore/data (older node), fall back
+ to /federation/network — the public-view shape is the same {fingerprint, peers}."""
+ direct_fp, direct_pem = _make_peer_pubkey()
+ federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
+ far_fp, _ = _make_peer_pubkey()
+ fallback_payload: Dict[str, Any] = {
+ "fingerprint": direct_fp,
+ "peers": [{"fingerprint": far_fp, "domain": "far.example"}],
+ "vouches": [],
+ }
+ with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
+ patch.object(network_view, "_fetch_peer_network", return_value=fallback_payload):
+ payload = build_explore_view()
+ assert any(t["fingerprint"] == far_fp for t in payload["transitive_peers"])
+
+
+def test_explore_transitive_peers_dedupe_against_direct(fresh_db, fed_dir):
+ """If a transitive fp is already a direct peer, don't duplicate it."""
+ direct_fp, direct_pem = _make_peer_pubkey()
+ federation.register_peer("direct.example", direct_fp, direct_pem, status="trusted")
+ fake_payload = {
+ "fingerprint": direct_fp,
+ # Direct peer's own fp echoed back — must be deduped.
+ "peers": [{"fingerprint": direct_fp, "domain": "direct.example"}],
+ "vouches": [],
+ }
+ with patch.object(network_view, "_fetch_peer_explore", return_value=fake_payload):
+ payload = build_explore_view()
+ assert payload["transitive_peers"] == []
+
+
+# ---------- vouches_in --------------------------------------------------
+
+def test_explore_vouches_in_filters_to_target_self_and_trusted_vouchers(fresh_db, fed_dir):
+ """vouches_in includes ONLY entries naming us as target whose voucher we trust."""
+ our_fp = federation.node_fingerprint()
+ fp_trusted, pem_t = _make_peer_pubkey()
+ fp_unknown, pem_u = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp_trusted, pem_t, status="trusted")
+ federation.register_peer("unknown.example", fp_unknown, pem_u, status="unknown")
+ now = datetime.now(timezone.utc).isoformat()
+ # Trusted peer vouches for us.
+ db.upsert_vouch(dict(
+ voucher_fingerprint=fp_trusted,
+ target_fingerprint=our_fp,
+ issued_at=now,
+ expires_at=None,
+ signature="trusted-sig",
+ ))
+ # Unknown peer also "vouches" for us — must NOT leak.
+ db.upsert_vouch(dict(
+ voucher_fingerprint=fp_unknown,
+ target_fingerprint=our_fp,
+ issued_at=now,
+ expires_at=None,
+ signature="rogue-sig",
+ ))
+ # Vouch naming someone else — must NOT appear in vouches_in.
+ other_fp, _ = _make_peer_pubkey()
+ db.upsert_vouch(dict(
+ voucher_fingerprint=fp_trusted,
+ target_fingerprint=other_fp,
+ issued_at=now,
+ expires_at=None,
+ signature="other-sig",
+ ))
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ vouchers = {v["voucher_fingerprint"] for v in payload["vouches_in"]}
+ assert vouchers == {fp_trusted}
+ # And the rogue signature is not anywhere in the payload.
+ assert "rogue-sig" not in json.dumps(payload, default=str)
+
+
+# ---------- corroboration counter --------------------------------------
+
+def test_explore_corroboration_count_matches_distinct_shared_hashes(fresh_db, fed_dir):
+ fp_a, pem_a = _make_peer_pubkey()
+ fp_b, pem_b = _make_peer_pubkey()
+ federation.register_peer("a.example", fp_a, pem_a, status="trusted")
+ federation.register_peer("b.example", fp_b, pem_b, status="trusted")
+ now_iso = datetime.now(timezone.utc).isoformat()
+ # Two shared hashes between A and B.
+ for h in ("shared-1", "shared-2"):
+ for fp in (fp_a, fp_b):
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="ioc",
+ signal_id="x",
+ signal_hash=h,
+ received_at=now_iso,
+ raw_json="{}",
+ ))
+ # One solo hash — must NOT count.
+ db.record_signal(dict(
+ peer_fingerprint=fp_a,
+ signal_type="ioc",
+ signal_id="solo",
+ signal_hash="solo-hash",
+ received_at=now_iso,
+ raw_json="{}",
+ ))
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ assert payload["corroboration_count_24h"] == 2
+ assert payload["node"]["corroboration_count_24h"] == 2
+
+
+# ---------- transparency log headline ----------------------------------
+
+def test_explore_node_translog_headline_reflects_chain(fresh_db, fed_dir):
+ translog.append("vouch", {"foo": "bar"})
+ translog.append("signal", {"x": 1})
+ with _silence_explore_fetch(), _silence_network_fetch():
+ payload = build_explore_view()
+ node = payload["node"]
+ assert node["translog_entry_count"] == 2
+ assert isinstance(node["transparency_log_head_hash"], str)
+ assert len(node["transparency_log_head_hash"]) == 64 # hex sha256
+
+
+# ---------- HTTP routes -------------------------------------------------
+
+def _mk_app() -> FastAPI:
+ app = FastAPI()
+ app.add_middleware(SessionMiddleware, secret_key="test-secret")
+ import tempfile
+ from pathlib import Path as _Path
+ # We need real templates for /federation/explore HTML response.
+ here = _Path(__file__).resolve().parent.parent / "src" / "psyc" / "cockpit" / "templates"
+ templates = Jinja2Templates(directory=str(here))
+ federation_routes.register(app, templates)
+ return app
+
+
+def test_federation_explore_endpoint_returns_html(fresh_db, fed_dir):
+ with _silence_explore_fetch(), _silence_network_fetch():
+ client = TestClient(_mk_app())
+ r = client.get("/federation/explore")
+ assert r.status_code == 200
+ assert "text/html" in r.headers.get("content-type", "")
+ # Banner + page title are present.
+ body = r.text
+ assert "Federation Explorer" in body
+
+
+def test_federation_explore_data_returns_signed_json(fresh_db, fed_dir):
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp, pem, status="trusted")
+ with _silence_explore_fetch(), _silence_network_fetch():
+ client = TestClient(_mk_app())
+ r = client.get("/federation/explore/data")
+ assert r.status_code == 200
+ data = r.json()
+ assert "signature" in data
+ assert "node" in data
+ sig = base64.b64decode(data["signature"])
+ unsigned = {k: v for k, v in data.items() if k != "signature"}
+ assert federation.verify_payload(
+ federation.canonical_json(unsigned),
+ sig,
+ federation.public_key_pem(),
+ ) is True
+
+
+def test_federation_explore_data_has_cors_header(fresh_db, fed_dir):
+ """Other psyc nodes' explore pages need to fetch this from the browser."""
+ with _silence_explore_fetch(), _silence_network_fetch():
+ client = TestClient(_mk_app())
+ r = client.get("/federation/explore/data")
+ assert r.status_code == 200
+ assert r.headers.get("access-control-allow-origin") == "*"
+
+
+def test_federation_info_has_explore_and_cors(fresh_db, fed_dir):
+ client = TestClient(_mk_app())
+ r = client.get("/federation/info")
+ assert r.status_code == 200
+ data = r.json()
+ assert data.get("explore") == "/federation/explore"
+ assert r.headers.get("access-control-allow-origin") == "*"
+
+
+def test_existing_public_endpoints_have_cors_header(fresh_db, fed_dir):
+ """All public endpoints must be cross-origin fetchable for the explorer."""
+ client = TestClient(_mk_app())
+ for path in (
+ "/federation/key",
+ "/federation/feed",
+ "/federation/vouches",
+ "/federation/log",
+ "/federation/log/verify",
+ "/federation/peers/public",
+ "/federation/network",
+ ):
+ r = client.get(path)
+ assert r.status_code in (200, 409), f"{path} status {r.status_code}"
+ assert r.headers.get("access-control-allow-origin") == "*", f"{path} missing CORS"
diff --git a/tests/test_network_view.py b/tests/test_network_view.py
index 49fde86..d7f3027 100644
--- a/tests/test_network_view.py
+++ b/tests/test_network_view.py
@@ -18,6 +18,7 @@ from psyc.lines.network_view import (
NetworkNode,
NetworkView,
build_admin_view,
+ build_explore_view,
build_local_view,
build_public_view,
build_transitive_view,
@@ -623,6 +624,49 @@ def test_admin_view_recent_translog_per_peer(fresh_db, fed_dir):
assert set(row.keys()) == {"id", "entry_type", "timestamp", "hash"}
+def test_explore_view_omits_ioc_values_case_ids_and_raw_json(fresh_db, fed_dir):
+ """The public explore payload must NEVER expose IOC values, case_ids, or raw_json.
+
+ This is the load-bearing transparency-vs-leakage contract that lives at
+ the network-view layer — anyone can audit who's talking to whom and how
+ much, but never *what* they're saying.
+ """
+ fp, pem = _make_peer_pubkey()
+ federation.register_peer("trusted.example", fp, pem, status="trusted")
+ now_iso = datetime.now(timezone.utc).isoformat()
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="ioc",
+ signal_id="evil-domain-do-not-leak.com",
+ signal_hash="ioc-hash-leak",
+ received_at=now_iso,
+ raw_json=json.dumps({"type": "domain", "value": "evil-domain-do-not-leak.com"}),
+ ))
+ db.record_signal(dict(
+ peer_fingerprint=fp,
+ signal_type="case",
+ signal_id="CASE-SECRET-42",
+ signal_hash="case-hash-leak",
+ received_at=now_iso,
+ raw_json=json.dumps({"severity": "critical", "case_id": "CASE-SECRET-42"}),
+ ))
+ with patch.object(network_view, "_fetch_peer_explore", return_value=None), \
+ patch.object(network_view, "_fetch_peer_network", return_value=None):
+ payload = build_explore_view()
+ flat = json.dumps(payload, default=str)
+ assert "evil-domain-do-not-leak.com" not in flat
+ assert "CASE-SECRET-42" not in flat
+ assert "raw_json" not in flat
+ # Sector-leaking breakdowns must not appear either.
+ assert "severity_breakdown" not in flat
+ assert "ioc_type_breakdown" not in flat
+ # And peer rows carry only public-safe counts.
+ for p in payload.get("peers", []):
+ assert "severity_breakdown" not in p
+ assert "ioc_type_breakdown" not in p
+ assert "recent_translog" not in p
+
+
def test_public_view_still_has_no_stats(fresh_db, fed_dir):
"""Public payload must not surface admin-only enrichments — sensitive.