Compare commits

...

10 Commits

15 changed files with 2400 additions and 21 deletions

3
.gitignore vendored
View File

@@ -14,6 +14,9 @@ data/
.env
.env.local
# per-operator federation host list (SSH targets are sensitive)
scripts/hosts
# editors
.vscode/
.idea/

65
scripts/deploy-all.sh Executable file
View File

@@ -0,0 +1,65 @@
#!/usr/bin/env bash
# Deploy the current main commit to every federation host listed in
# scripts/hosts (one node per line: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL).
# Loops scripts/deploy.sh against each. Bails on first failure unless --keep-going.
set -euo pipefail
HOSTS_FILE="${PSYC_HOSTS_FILE:-$(dirname "$0")/hosts}"
KEEP_GOING=0
for arg in "$@"; do
case "$arg" in
--keep-going) KEEP_GOING=1 ;;
-h|--help)
echo "usage: $0 [--keep-going]"
echo " reads $HOSTS_FILE (override with PSYC_HOSTS_FILE=...)"
exit 0
;;
esac
done
if [[ ! -f "$HOSTS_FILE" ]]; then
echo "no hosts file at $HOSTS_FILE — copy scripts/hosts.example to scripts/hosts and edit" >&2
exit 2
fi
declare -a OK=() FAIL=()
while IFS= read -r line; do
# skip blanks + comments
[[ -z "${line// /}" || "${line# }" == \#* ]] && continue
# shellcheck disable=SC2206
parts=($line)
if [[ ${#parts[@]} -lt 4 ]]; then
echo "[deploy-all] skipping malformed line: $line" >&2
continue
fi
LABEL="${parts[0]}"
SSH_TARGET="${parts[1]}"
REMOTE_PATH="${parts[2]}"
PUBLIC_URL="${parts[3]}"
echo
echo "════════════════════════════════════════════════════════════════"
echo " deploying → $LABEL ($SSH_TARGET:$REMOTE_PATH$PUBLIC_URL)"
echo "════════════════════════════════════════════════════════════════"
if PSYC_PROD_HOST="$SSH_TARGET" \
PSYC_PROD_PATH="$REMOTE_PATH" \
PSYC_PROD_URL="$PUBLIC_URL" \
bash "$(dirname "$0")/deploy.sh"; then
OK+=("$LABEL")
else
FAIL+=("$LABEL")
if [[ $KEEP_GOING -ne 1 ]]; then
echo "[deploy-all] $LABEL failed — stopping. pass --keep-going to continue past failures." >&2
break
fi
fi
done < "$HOSTS_FILE"
echo
echo "════════════════════════════════════════════════════════════════"
echo " summary"
echo "════════════════════════════════════════════════════════════════"
echo " ok: ${OK[*]:-(none)}"
echo " failed: ${FAIL[*]:-(none)}"
[[ ${#FAIL[@]} -eq 0 ]]

6
scripts/hosts.example Normal file
View File

@@ -0,0 +1,6 @@
# Federation hosts — one node per line.
# Format: LABEL SSH_TARGET REMOTE_PATH PUBLIC_URL
# Lines starting with # are ignored. Copy to scripts/hosts and edit.
prod neuronetz@cloud.neuronetz.ai /home/neuronetz/docker-public/neuro-psyc https://psyc.neuronetz.ai
sto user@sto-host.example /path/to/neuro-psyc https://psyc.maschinen-stockert.de
bittomine user@bittomine-host.example /path/to/neuro-psyc https://psyc.bittomine.com

View File

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

View File

@@ -3,13 +3,19 @@
The cockpit venv has no torch; the fine-tuned model only runs inside the CUDA
container behind serve_model.py. This client reaches it over HTTP and degrades
gracefully — if the server is down, callers get None and fall back to rules.
Two backends are supported via PSYC_INFERENCE_MODE:
- "psyc" (default) — native serve_model.py, POST /infer
- "openai" — OpenAI-compatible / Ollama, POST /v1/chat/completions
A bearer token can be set via PSYC_INFERENCE_TOKEN; it is sent on every request
when present (psyc-native ignores it; api.neuronetz.ai requires it).
"""
from __future__ import annotations
import json
import os
from typing import Optional
from typing import Dict, Optional
import httpx
@@ -18,15 +24,31 @@ from psyc.lines.train import SEVERITY_INSTRUCTION, severity_features
from psyc.models import Case
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
INFERENCE_URL = os.environ.get("PSYC_INFERENCE_URL", "http://127.0.0.1:8771")
INFERENCE_TOKEN = os.environ.get("PSYC_INFERENCE_TOKEN", "")
INFERENCE_MODE = os.environ.get("PSYC_INFERENCE_MODE", "psyc").lower()
INFERENCE_MODEL = os.environ.get("PSYC_INFERENCE_MODEL", "psyc-v5")
_log = log.get(__name__)
def _auth_headers() -> Dict[str, str]:
"""Bearer header when a token is set, empty dict otherwise."""
return {"Authorization": f"Bearer {INFERENCE_TOKEN}"} if INFERENCE_TOKEN else {}
def server_adapter(timeout: float = 2.0) -> Optional[str]:
"""Return the adapter the server is running, or None if it is unreachable."""
try:
with httpx.Client(timeout=timeout) as client:
if INFERENCE_MODE == "openai":
# OpenAI/Ollama exposes GET /v1/models — first available id wins.
resp = client.get(f"{INFERENCE_URL}/v1/models", headers=_auth_headers())
resp.raise_for_status()
data = resp.json().get("data") or []
if data:
return str(data[0].get("id") or INFERENCE_MODEL)
return INFERENCE_MODEL
resp = client.get(f"{INFERENCE_URL}/healthz")
resp.raise_for_status()
return resp.json().get("adapter")
@@ -45,18 +67,44 @@ def adapter_name(timeout: float = 2.0) -> Optional[str]:
def model_severity(case: Case, timeout: float = 15.0) -> Optional[str]:
"""Ask the live model to classify case severity. None if the server is down."""
payload = {
"instruction": SEVERITY_INSTRUCTION,
"input": json.dumps(severity_features(case), ensure_ascii=False),
"max_new_tokens": 16,
}
features_json = json.dumps(severity_features(case), ensure_ascii=False)
try:
with httpx.Client(timeout=timeout) as client:
resp = client.post(f"{INFERENCE_URL}/infer", json=payload)
resp.raise_for_status()
output = str(resp.json().get("output", "")).strip().lower()
if INFERENCE_MODE == "openai":
payload = {
"model": INFERENCE_MODEL,
"messages": [
{"role": "system", "content": SEVERITY_INSTRUCTION},
{"role": "user", "content": features_json},
],
"max_tokens": 16,
"temperature": 0.0,
}
resp = client.post(
f"{INFERENCE_URL}/v1/chat/completions",
json=payload,
headers=_auth_headers(),
)
resp.raise_for_status()
choices = resp.json().get("choices") or []
if not choices:
return None
output = str(choices[0].get("message", {}).get("content", "")).strip().lower()
else:
payload = {
"instruction": SEVERITY_INSTRUCTION,
"input": features_json,
"max_new_tokens": 16,
}
resp = client.post(
f"{INFERENCE_URL}/infer",
json=payload,
headers=_auth_headers(),
)
resp.raise_for_status()
output = str(resp.json().get("output", "")).strip().lower()
except httpx.HTTPError as exc:
_log.info("inference.unavailable", error=str(exc))
return None
_log.info("inference.severity", case_id=case.case_id, model_answer=output)
_log.info("inference.severity", case_id=case.case_id, model_answer=output, mode=INFERENCE_MODE)
return output

View File

@@ -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,261 @@ 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; }
}
/* peer self-view section inside the detail panel — fetched cross-origin */
.fn-remote-sec { grid-column: 1 / -1; }
.fn-remote-status {
font-size: 11px; color: var(--muted); margin-left: 8px; font-weight: 400;
text-transform: lowercase; letter-spacing: 0.02em;
}
.fn-remote-meta {
display: flex; flex-wrap: wrap; gap: 12px 18px;
font-size: 12px; color: var(--muted); margin-bottom: 12px;
}
.fn-remote-meta b { color: var(--text); font-weight: 600; }
.fn-remote-meta code { font-size: 11px; color: var(--accent); }
.fn-remote-ok { color: rgba(74,222,128,0.95); }
.fn-remote-warn { color: rgba(251,191,36,0.95); }
.fn-remote-cols {
display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
gap: 14px; margin: 8px 0 12px;
}
.fn-remote-h { font-size: 11px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.06em; margin-bottom: 6px; }
.fn-remote-list { list-style: none; padding: 0; margin: 0; font-size: 12px; line-height: 1.55; }
.fn-remote-list li { padding: 2px 0; border-bottom: 1px dashed rgba(125,133,151,0.18); }
.fn-remote-list li:last-child { border-bottom: 0; }
.fn-remote-list code { font-size: 11px; color: var(--accent); }
.fn-remote-list .muted { color: var(--muted); margin-left: 6px; font-size: 11px; }
.fn-remote-actions { display: flex; gap: 8px; flex-wrap: wrap; margin-top: 6px; }

View File

@@ -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 =>
({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[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(`<div class="fn-tooltip-title">${esc(n.domain || n.label || n.fp.slice(0,12))}</div>`);
if (n.is_self) {
rows.push(`<div class="fn-tooltip-row"><span class="k">role</span><span class="v">self</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">peers</span><span class="v">${node.peer_count || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${node.signals_count_24h || 0}</span></div>`);
} else if (n.distance >= 2) {
rows.push(`<div class="fn-tooltip-row"><span class="k">distance</span><span class="v">2 hops (transitive)</span></div>`);
if (n.via) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
rows.push(`<div class="fn-tooltip-row"><span class="k">via</span><span class="v">${esc(via)}</span></div>`);
}
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
} else {
const s = n.stats || {};
rows.push(`<div class="fn-tooltip-row"><span class="k">status</span><span class="v">trusted</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">signals 24h</span><span class="v">${s.signal_count_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">quorum hits</span><span class="v">${s.quorum_contribution_24h || 0}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">last seen</span><span class="v">${esc(fmtAge(s.last_seen))}</span></div>`);
rows.push(`<div class="fn-tooltip-row"><span class="k">tip</span><span class="v">click to walk →</span></div>`);
}
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(`<span><span class="k">peers</span> <span class="v">${node.peer_count || 0}</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${node.signals_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">corroborations</span> <span class="v">${node.corroboration_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">translog</span> <span class="v">${node.translog_entry_count || 0} entries</span></span>`);
} else if (isTransitive) {
const parent = peerByFp[n.via];
const via = parent ? (parent.domain || shortFp(parent.fp)) : shortFp(n.via);
statsHtml.push(`<span><span class="k">distance</span> <span class="v">2 hops</span></span>`);
statsHtml.push(`<span><span class="k">learned via</span> <span class="v">${esc(via)}</span></span>`);
statsHtml.push(`<span><span class="k">stats</span> <span class="v">— (peer-side only)</span></span>`);
} else {
statsHtml.push(`<span><span class="k">status</span> <span class="v">trusted</span></span>`);
statsHtml.push(`<span><span class="k">signals 24h</span> <span class="v">${stats.signal_count_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">cases / iocs 24h</span> <span class="v">${stats.cases_24h || 0} / ${stats.iocs_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">quorum hits</span> <span class="v">${stats.quorum_contribution_24h || 0}</span></span>`);
statsHtml.push(`<span><span class="k">last seen</span> <span class="v">${esc(fmtAge(stats.last_seen))}</span></span>`);
}
const cta = peerHref
? `<a class="fe-walk-cta" href="${esc(peerHref)}">View this peer's federation <span aria-hidden="true">→</span></a>`
: `<span class="fe-walk-cta fe-walk-cta-disabled" title="no public domain on file for this peer">no public address known</span>`;
walkEl.innerHTML = `
<div class="fe-walk-card">
<div class="fe-walk-card-body">
<h3 class="fe-walk-card-title">${esc(n.domain || n.label || shortFp(n.fp))}</h3>
<div class="fe-walk-card-fp">${esc(n.fp)}</div>
<div class="fe-walk-card-stats">${statsHtml.join("")}</div>
</div>
${cta}
</div>`;
}
// ---------- inbound vouches list ------------------------------------
function renderVouchesIn() {
if (!vouchesInList) return;
if (vouchesInCountEl) vouchesInCountEl.textContent = String(vouchesIn.length);
if (!vouchesIn.length) {
vouchesInList.innerHTML = `<li class="fe-vouches-in-empty">no inbound vouches yet</li>`;
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 `<li>
<span class="fp">
<button type="button" class="fn-fp-jump" ${clickable}>${esc(label)}</button>
<code style="margin-left:8px;color:var(--muted);font-size:11px;">${esc(fp)}</code>
</span>
<span class="ts">${esc(v.issued_at || "")}</span>
</li>`;
}).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=<domain> 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);
}
}
})();

View File

@@ -581,6 +581,14 @@
</div>
</div>`;
// Placeholder for the peer's-own-view, populated by an async fetch below.
const remote = (!n.is_self && n.domain)
? `<div class="fn-detail-sec fn-remote-sec" data-remote-fp="${esc(n.fp)}">
<h4>peer's self-view <span class="fn-remote-status">fetching…</span></h4>
<div class="fn-remote-body">loading from <code>${esc(n.domain)}</code>…</div>
</div>`
: "";
const html = `
<div class="td-head">
<span class="td-kind ${kindCls}">${esc(kindLabel)}</span>
@@ -594,12 +602,20 @@
${vouches}
${quorum}
${translog}
${remote}
${actions}
</div>
`;
detailEl.innerHTML = html;
detailEl.classList.add("has-selection");
// Async-fetch the peer's own /federation/explore/data and render their
// self-view inline. CORS is set on that endpoint, so the browser can hit
// any psyc node directly. Falls back to /federation/network on older nodes.
if (!n.is_self && n.domain) {
fetchPeerSelfView(n.domain, n.fp);
}
const close = detailEl.querySelector(".td-close");
if (close) close.addEventListener("click", clearSelection);
@@ -628,6 +644,99 @@
detailEl.scrollIntoView({ behavior: "smooth", block: "nearest" });
}
// ---------- peer self-view (cross-origin fetch) ----------------------
// Each psyc node exposes /federation/explore/data (rich) and falls back to
// /federation/network (slim). Both carry CORS=*, so the browser can hit
// them from any cockpit page. The fetch is best-effort — if the peer is
// unreachable, blocked by CSP, or older, we render whatever we got.
async function fetchPeerSelfView(domain, expectedFp) {
const sec = detailEl.querySelector(`.fn-remote-sec[data-remote-fp="${expectedFp}"]`);
if (!sec) return;
const statusEl = sec.querySelector(".fn-remote-status");
const bodyEl = sec.querySelector(".fn-remote-body");
const base = `https://${domain}`;
let data = null;
let kind = "";
try {
const r = await fetch(`${base}/federation/explore/data`, { mode: "cors", cache: "no-store" });
if (r.ok) { data = await r.json(); kind = "explore"; }
} catch (e) { /* fall through */ }
if (!data) {
try {
const r = await fetch(`${base}/federation/network`, { mode: "cors", cache: "no-store" });
if (r.ok) { data = await r.json(); kind = "network"; }
} catch (e) { /* fall through */ }
}
if (!data) {
statusEl.textContent = "unreachable";
bodyEl.innerHTML = `<span class="muted">couldn't reach ${esc(domain)} — peer may be offline, blocking cross-origin, or on an older psyc.</span>`;
return;
}
// The detail panel may have moved on to another node by now — re-check
// the section is still in the DOM before mutating.
if (!detailEl.contains(sec)) return;
const gen = (data.generated_at || (data.node && data.node.generated_at) || "").slice(0, 19).replace("T", " ");
const declaredFp = data.fingerprint || (data.node && data.node.fingerprint) || "";
const peers = data.peers || [];
const vouchesOut = data.vouches_out || data.vouches || [];
const vouchesIn = data.vouches_in || [];
const transitive = data.transitive_peers || [];
const corr = (typeof data.corroboration_count_24h === "number") ? data.corroboration_count_24h : null;
const logHead = data.node && data.node.transparency_log_head_hash;
const logCount = data.node && data.node.translog_entry_count;
const fpMatch = declaredFp === expectedFp;
const fpBadge = fpMatch
? `<span class="fn-remote-ok">fingerprint matches</span>`
: `<span class="fn-remote-warn">fingerprint mismatch (${esc((declaredFp || "—").slice(0, 12))}…)</span>`;
const peersList = peers.length
? `<ul class="fn-remote-list">${peers.slice(0, 12).map(p => {
const fp = p.fingerprint || "";
const dom = p.domain || "—";
const sig24 = (typeof p.signal_count_24h === "number") ? ` · ${p.signal_count_24h} sig/24h` : "";
return `<li><code>${esc(shortFp(fp))}</code> <span class="muted">${esc(dom)}</span>${sig24}</li>`;
}).join("")}</ul>`
: `<span class="muted">no trusted peers exposed</span>`;
const vouchesOutList = vouchesOut.length
? `<ul class="fn-remote-list">${vouchesOut.slice(0, 12).map(v => {
const tfp = v.target_fingerprint || v.target_fp || "";
return `<li><code>${esc(shortFp(tfp))}</code></li>`;
}).join("")}</ul>`
: `<span class="muted">no outbound vouches</span>`;
const vouchesInList = vouchesIn.length
? `<ul class="fn-remote-list">${vouchesIn.slice(0, 12).map(v => {
const vfp = v.voucher_fingerprint || v.voucher_fp || "";
return `<li><code>${esc(shortFp(vfp))}</code></li>`;
}).join("")}</ul>`
: `<span class="muted">no inbound vouches</span>`;
statusEl.textContent = kind === "explore" ? "explore feed" : "network feed";
bodyEl.innerHTML = `
<div class="fn-remote-meta">
<span>generated <code>${esc(gen || "—")}</code></span>
${fpBadge}
${corr !== null ? `<span>corroborations 24h: <b>${corr}</b></span>` : ""}
${logCount !== undefined ? `<span>translog: <b>${logCount}</b> entries</span>` : ""}
${logHead ? `<span>head <code>${esc(String(logHead).slice(0, 12))}…</code></span>` : ""}
</div>
<div class="fn-remote-cols">
<div><div class="fn-remote-h">their peers <span class="muted">(${peers.length})</span></div>${peersList}</div>
<div><div class="fn-remote-h">vouches out <span class="muted">(${vouchesOut.length})</span></div>${vouchesOutList}</div>
<div><div class="fn-remote-h">vouches in <span class="muted">(${vouchesIn.length})</span></div>${vouchesInList}</div>
${transitive.length ? `<div><div class="fn-remote-h">they know <span class="muted">(${transitive.length})</span></div><ul class="fn-remote-list">${transitive.slice(0, 12).map(t => `<li><code>${esc(shortFp(t.fingerprint || ""))}</code></li>`).join("")}</ul></div>` : ""}
</div>
<div class="fn-remote-actions">
<a class="fn-action-btn" href="${esc(base)}/federation/explore" target="_blank" rel="noopener">open their explorer →</a>
<a class="fn-action-btn" href="${esc(base)}/federation/explore/data" target="_blank" rel="noopener">raw JSON</a>
</div>
`;
}
// ---------- idle animation ------------------------------------------
let energyBudget = 40;
function loop() {

View File

@@ -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-v9";
const STATIC_ASSETS = [
"/static/cockpit.css",
"/static/psyc-tokens.css",

View File

@@ -0,0 +1,145 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover">
<meta name="theme-color" content="#0f1115">
<meta name="apple-mobile-web-app-title" content="psyc · federation explorer">
<title>Federation Explorer — psyc</title>
<link rel="icon" type="image/png" href="/static/psyc-logo.png">
<link rel="apple-touch-icon" href="/static/psyc-logo.png">
<link rel="manifest" href="/static/manifest.json">
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
<link rel="stylesheet" href="/static/cockpit.css">
<link rel="stylesheet" href="/static/psyc-tokens.css">
<script defer src="https://analytics.neuronetz.ai/script.js" data-website-id="34c354e5-780e-4c42-a5ce-49b13ff3f088"></script>
<script>
if ("serviceWorker" in navigator) {
window.addEventListener("load", () => {
navigator.serviceWorker.register("/sw.js", { scope: "/" }).catch(() => {});
});
}
</script>
</head>
<body class="wide fe-page">
<header class="topbar fe-topbar">
<a class="brand" href="/federation/explore">
<img class="brand-icon" src="/static/psyc-logo.png" alt="psyc">
<span class="brand-sub">operations cockpit</span>
</a>
<span class="fe-badge" title="Public, no auth — this view is auditable by anyone">
<span class="fe-badge-dot"></span>PUBLIC · TRANSPARENT
</span>
<div class="family">
<img class="family-icon" src="/static/nn-sc-icon.png" alt="NN-sc — Security/Control" title="NN-sc · Security">
</div>
</header>
<main class="content">
<section class="panel fe-hero">
<div class="fe-hero-head">
<h1 class="fe-title">Federation Explorer</h1>
<p class="fe-sub" id="fe-node-domain">{{ domain or fingerprint }}</p>
</div>
<p class="fe-intro">
This is a <strong>public transparency view</strong> 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.
<strong>Click any peer in the graph to inspect it, then jump to that peer's own explorer.</strong>
</p>
<p class="fe-intro fe-intro-sub">
Self fingerprint:
<code class="fe-fp">{{ fingerprint }}</code>
<button type="button" class="fn-copy-btn" data-copy="{{ fingerprint }}">copy</button>
</p>
</section>
<section class="panel fe-kpi-panel">
<div class="fn-stats fe-kpis">
<div class="fn-stat"><div class="fn-stat-label">direct peers</div><div class="fn-stat-value" id="fe-kpi-peers"></div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches out</div><div class="fn-stat-value" id="fe-kpi-vouches-out"></div></div>
<div class="fn-stat"><div class="fn-stat-label">vouches in</div><div class="fn-stat-value" id="fe-kpi-vouches-in"></div></div>
<div class="fn-stat"><div class="fn-stat-label">signals (24h)</div><div class="fn-stat-value" id="fe-kpi-signals"></div></div>
<div class="fn-stat"><div class="fn-stat-label">corroborations (24h)</div><div class="fn-stat-value" id="fe-kpi-corroboration"></div></div>
<div class="fn-stat"><div class="fn-stat-label">translog entries</div><div class="fn-stat-value" id="fe-kpi-translog"></div></div>
<div class="fn-stat fe-kpi-verify"><div class="fn-stat-label">log integrity</div><div class="fn-stat-value" id="fe-kpi-verify"></div></div>
</div>
<div class="fe-verify-row">
<button type="button" id="fe-verify-btn" class="btn fe-verify-btn">Verify this log →</button>
<span id="fe-verify-result" class="fe-verify-result"></span>
<a class="fe-verify-link" href="/federation/log" target="_blank" rel="noopener">raw log JSON</a>
<a class="fe-verify-link" href="/federation/explore/data" target="_blank" rel="noopener">signed payload</a>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Trust network</h2>
<span class="count"><span id="fe-direct-count"></span> direct · <span id="fe-transitive-count"></span> transitive</span>
</div>
<p class="page-intro">
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: <em>vouches</em> (solid), <em>signals</em> (dashed, thickness ∝ 24h volume),
<em>knows</em> (dotted grey), <em>corroborate</em> (faint pulse — two peers reporting the same signal).
</p>
<div class="topo-stage fe-stage">
<div class="topo-toolbar">
<div class="topo-layouts" role="tablist">
<button type="button" class="topo-layout is-active" data-layout="force" title="organic force-directed">Force</button>
<button type="button" class="topo-layout" data-layout="hier" title="self on top, direct peers in a row, transitives below">Hierarchical</button>
<button type="button" class="topo-layout" data-layout="radial" title="self at center, peers on a ring, transitives outside">Radial</button>
</div>
<label class="topo-toggle"><input type="checkbox" id="fn-flow" checked> signal flow</label>
<span class="topo-legend"><span class="lg-swatch fn-lg-self"></span>self</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-trusted"></span>trusted</span>
<span class="topo-legend"><span class="lg-swatch fn-lg-unknown"></span>transitive</span>
<button type="button" id="fn-reset" class="btn">re-settle</button>
<span class="topo-hint">drag · scroll to zoom · click a peer to walk to its explorer</span>
</div>
<svg id="federation-network-graph" preserveAspectRatio="xMidYMid meet"></svg>
<div id="fn-tooltip" class="fn-tooltip"></div>
<div id="fn-loading" class="page-intro" style="text-align:center; margin-top:10px;">loading federation network…</div>
<div id="fn-error" class="gate-error" style="display:none;"></div>
</div>
<div id="fe-walk" class="fe-walk">
<p class="fe-walk-empty">Click any peer in the graph above to inspect it and walk to its federation view.</p>
</div>
</section>
<section class="panel fe-vouches-panel">
<div class="panel-head">
<h2>Who vouches for this node</h2>
<span class="count"><span id="fe-vouches-in-count"></span> inbound</span>
</div>
<p class="page-intro">
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.
</p>
<ul id="fe-vouches-in-list" class="fe-vouches-in-list">
<li class="fe-vouches-in-empty">no inbound vouches yet</li>
</ul>
</section>
</main>
<footer class="footer fe-footer">
psyc · defensive CTI · transparent by design ·
powered by <a href="https://neuronetz.ai" target="_blank" rel="noopener">neuronetz.ai</a>
</footer>
<script>
// The walk-to-peer param ("?peer=domain") tells the JS to focus that peer
// in the graph as soon as the data lands.
window.PSYC_EXPLORE = {
selfFingerprint: "{{ fingerprint }}",
selfDomain: "{{ domain }}",
focusPeer: "{{ peer }}"
};
</script>
<script src="/static/federation_explore.js" defer></script>
</body>
</html>

View File

@@ -6,6 +6,7 @@
<div class="hero-text">
<h1 class="hero-title">Defensive CTI in motion</h1>
<p class="hero-sub">What psyc has seen and done — at a glance.</p>
<p class="hero-meta"><a class="hero-explore" href="/federation/explore">Federation Explorer →</a> <span class="hero-explore-sub">public · auditable trust network</span></p>
</div>
<a class="hero-cta" href="/cases">All cases →</a>
</section>

View File

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

427
tests/test_explore_view.py Normal file
View File

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

151
tests/test_inference.py Normal file
View File

@@ -0,0 +1,151 @@
"""Tests for the inference client — both psyc-native and openai-compatible modes."""
from __future__ import annotations
import importlib
import json
from typing import Any, Dict
import httpx
import pytest
from psyc.cockpit import inference
from psyc.models import Case, Classification, Confidence, Severity, TLP, Observables, Evidence, Victim
def _reload_with_env(monkeypatch, **env: str) -> Any:
for k, v in env.items():
monkeypatch.setenv(k, v)
return importlib.reload(inference)
def _case() -> Case:
return Case(
case_id="C-T-1",
summary="test",
source_type="test",
source_ref="",
observed_at="2026-01-01T00:00:00+00:00",
ingested_at="2026-01-01T00:00:00+00:00",
classification=Classification(tlp=TLP.GREEN, severity=Severity.HIGH),
confidence=Confidence(level="medium", source_reliability="B", information_credibility="2"),
observables=Observables(),
evidence=Evidence(),
source_metadata={},
victim=Victim(),
)
def test_no_auth_header_when_token_unset(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="")
assert mod._auth_headers() == {}
def test_bearer_header_when_token_set(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_TOKEN="abc123")
assert mod._auth_headers() == {"Authorization": "Bearer abc123"}
def test_psyc_mode_server_adapter(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x")
seen: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["method"] = request.method
return httpx.Response(200, json={"adapter": "/data/adapters/psyc-v5/final"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() == "/data/adapters/psyc-v5/final"
assert seen["url"].endswith("/healthz")
def test_openai_mode_server_adapter(monkeypatch):
mod = _reload_with_env(
monkeypatch,
PSYC_INFERENCE_MODE="openai",
PSYC_INFERENCE_URL="https://api.example",
PSYC_INFERENCE_TOKEN="t0k",
PSYC_INFERENCE_MODEL="psyc-v5",
)
seen: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
seen["url"] = str(request.url)
seen["auth"] = request.headers.get("authorization")
return httpx.Response(200, json={"data": [{"id": "llama3"}, {"id": "mistral"}]})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() == "llama3"
assert seen["url"].endswith("/v1/models")
assert seen["auth"] == "Bearer t0k"
def test_openai_mode_severity_request_shape(monkeypatch):
mod = _reload_with_env(
monkeypatch,
PSYC_INFERENCE_MODE="openai",
PSYC_INFERENCE_URL="https://api.example",
PSYC_INFERENCE_TOKEN="t0k",
PSYC_INFERENCE_MODEL="psyc-v5",
)
sent: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
sent["url"] = str(request.url)
sent["auth"] = request.headers.get("authorization")
sent["body"] = json.loads(request.content.decode())
return httpx.Response(200, json={"choices": [{"message": {"content": "HIGH"}}]})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
result = mod.model_severity(_case())
assert result == "high"
assert sent["url"].endswith("/v1/chat/completions")
assert sent["auth"] == "Bearer t0k"
assert sent["body"]["model"] == "psyc-v5"
assert sent["body"]["messages"][0]["role"] == "system"
assert sent["body"]["messages"][1]["role"] == "user"
assert sent["body"]["max_tokens"] == 16
def test_psyc_mode_severity_unchanged(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="psyc", PSYC_INFERENCE_URL="http://x", PSYC_INFERENCE_TOKEN="")
sent: Dict[str, Any] = {}
def handler(request: httpx.Request) -> httpx.Response:
sent["url"] = str(request.url)
sent["auth"] = request.headers.get("authorization")
sent["body"] = json.loads(request.content.decode())
return httpx.Response(200, json={"output": "MEDIUM"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.model_severity(_case()) == "medium"
assert sent["url"].endswith("/infer")
assert sent["auth"] is None
assert "instruction" in sent["body"]
assert "max_new_tokens" in sent["body"]
def test_server_adapter_returns_none_on_http_error(monkeypatch):
mod = _reload_with_env(monkeypatch, PSYC_INFERENCE_MODE="openai", PSYC_INFERENCE_URL="https://api.example")
def handler(request: httpx.Request) -> httpx.Response:
return httpx.Response(401, json={"error": "unauthorized"})
transport = httpx.MockTransport(handler)
real_client = httpx.Client
monkeypatch.setattr(httpx, "Client", lambda **kw: real_client(transport=transport, **{k: v for k, v in kw.items() if k != "transport"}))
assert mod.server_adapter() is None

View File

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