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(`
roleself
`); + rows.push(`
peers${node.peer_count || 0}
`); + rows.push(`
signals 24h${node.signals_count_24h || 0}
`); + } else if (n.distance >= 2) { + rows.push(`
distance2 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(`
tipclick to walk →
`); + } else { + const s = n.stats || {}; + rows.push(`
statustrusted
`); + 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(`
tipclick 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(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 + + + + + + + + + + + + +
    + + psyc + operations cockpit + + + PUBLIC · TRANSPARENT + +
    + NN-sc — Security/Control +
    +
    + +
    +
    +
    +

    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 }} + +

    +
    + +
    +
    +
    direct peers
    +
    vouches out
    +
    vouches in
    +
    signals (24h)
    +
    corroborations (24h)
    +
    translog entries
    +
    log integrity
    +
    +
    + + + raw log JSON + signed payload +
    +
    + +
    +
    +

    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). +

    + +
    +
    +
    + + + +
    + + self + trusted + transitive + + drag · scroll to zoom · click a peer to walk to its explorer +
    + +
    +
    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.