stage-vouch-d federation: cockpit pages + CLI + public endpoints

Public JSON endpoints (no auth):
- GET /federation/vouches — our_vouches() so peers can pull our trust
- GET /federation/log — last 100 transparency-log entries
- GET /federation/log/verify — re-walks the chain, returns
  {verified, head_hash} or 409 with {error, head_hash}

Admin pages (TOTP-gated):
- /admin/federation/vouches — issued list, issue form, revoke
  buttons, per-peer quorum-met table
- /admin/federation/log — chain verification status + last 200 entries
- /admin/federation/quorum — config form + per-peer eligibility +
  per-signal-hash distinct-eligible-peer counts

CLI: fed-vouch / fed-unvouch / fed-vouches / fed-quorum-set / fed-log /
fed-log-verify, plus existing fed-* commands untouched.

The base /admin/federation page now links to the three new sub-pages.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m17hr1l
2026-06-06 21:11:03 +02:00
parent eadd1aea3b
commit 0e56fa70af
6 changed files with 525 additions and 3 deletions

View File

@@ -13,7 +13,7 @@ import httpx
import typer
from psyc import db, log
from psyc.lines import federation
from psyc.lines import federation, translog
from psyc.result import Err
@@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None:
db.init_db()
federation.remove_peer(domain)
typer.echo(f"removed {domain}")
# ---------- vouching + quorum --------------------------------------
@typer_app.command("fed-vouch")
def fed_vouch(
target_fp: str = typer.Argument(..., help="target peer fingerprint (32 hex)"),
ttl_days: int = typer.Option(90, "--ttl-days", help="vouch lifetime in days"),
) -> None:
"""Issue a signed vouch for `target_fp`. Persists locally + rides our feed."""
db.init_db()
v = federation.issue_vouch(target_fp.strip(), ttl_days=ttl_days)
typer.echo(f"vouched: {v.target_fingerprint} (expires {v.expires_at})")
@typer_app.command("fed-unvouch")
def fed_unvouch(target_fp: str = typer.Argument(...)) -> None:
"""Revoke OUR vouch for `target_fp`."""
db.init_db()
federation.revoke_vouch(target_fp.strip())
typer.echo(f"revoked vouch for {target_fp}")
@typer_app.command("fed-vouches")
def fed_vouches() -> None:
"""List vouches WE have issued."""
db.init_db()
rows = federation.our_vouches()
if not rows:
typer.echo("(no vouches issued)")
return
for v in rows:
exp = v.expires_at.isoformat() if v.expires_at else ""
typer.echo(f" {v.target_fingerprint} issued={v.issued_at.isoformat()[:16]} expires={exp[:16]}")
@typer_app.command("fed-quorum-set")
def fed_quorum_set(
trust: Optional[int] = typer.Option(None, "--trust", help="trust_min_vouchers"),
k: Optional[int] = typer.Option(None, "--k", help="signal_quorum_k"),
) -> None:
"""Update quorum thresholds. Either flag is optional — only changed values overwrite."""
db.init_db()
cfg = federation.quorum_config()
if trust is not None:
cfg.trust_min_vouchers = max(1, int(trust))
if k is not None:
cfg.signal_quorum_k = max(1, int(k))
federation.set_quorum_config(cfg)
typer.echo(f"quorum: trust_min_vouchers={cfg.trust_min_vouchers} signal_quorum_k={cfg.signal_quorum_k}")
# ---------- transparency log --------------------------------------
@typer_app.command("fed-log")
def fed_log(
limit: int = typer.Option(20, "--limit", help="number of entries to show"),
) -> None:
"""Print recent transparency-log entries (newest first)."""
db.init_db()
rows = translog.recent(limit=limit)
if not rows:
typer.echo("(transparency log empty)")
return
for e in rows:
typer.echo(
f" id={e.id:5d} {e.entry_type:6s} {e.timestamp[:19]} hash={e.entry_hash[:16]}"
)
@typer_app.command("fed-log-verify")
def fed_log_verify() -> None:
"""Re-walk the chain locally and report verification status."""
db.init_db()
result = translog.verify_chain()
head = translog.head()
head_hash = head.entry_hash if head else "(empty)"
if isinstance(result, Err):
typer.echo(f" ✗ broken: {result.reason}", err=True)
typer.echo(f" head_hash: {head_hash}")
raise typer.Exit(1)
typer.echo(f" ✓ verified {result.value} entries")
typer.echo(f" head_hash: {head_hash}")

View File

@@ -15,7 +15,8 @@ from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, Red
from fastapi.templating import Jinja2Templates
from psyc import db, log
from psyc.lines import federation
from psyc.lines import federation, translog
from psyc.result import Err
_log = log.get(__name__)
@@ -122,4 +123,183 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
def federation_feed() -> JSONResponse:
return JSONResponse(_cached_feed())
# ---------- 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({
"fingerprint": federation.node_fingerprint(),
"vouches": [v.model_dump(mode="json") for v in federation.our_vouches()],
})
@app.get("/federation/log")
def federation_log() -> JSONResponse:
"""Last 100 transparency-log entries, newest first."""
entries = translog.recent(limit=100)
return JSONResponse({
"count": len(entries),
"entries": [e.model_dump(mode="json") for e in entries],
})
@app.get("/federation/log/verify")
def federation_log_verify() -> JSONResponse:
"""Re-walk the chain locally and report status. Auditors poll this."""
result = translog.verify_chain()
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})
# ---------- admin: vouches page ---------------------------------
@app.get("/admin/federation/vouches", response_class=HTMLResponse)
def admin_federation_vouches(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
peers = federation.list_peers()
cfg = federation.quorum_config()
ours = federation.our_vouches()
# Per-peer view: vouches naming each peer and whether quorum is met.
peer_rows = []
for p in peers:
vouches = federation.vouches_for(p.fingerprint)
peer_rows.append({
"peer": p,
"vouches": vouches,
"vouched": federation.is_vouched(p.fingerprint),
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
})
return TEMPLATES.TemplateResponse(
request,
"admin_federation_vouches.html",
{
"fingerprint": federation.node_fingerprint(),
"our_vouches": ours,
"peer_rows": peer_rows,
"cfg": cfg,
},
)
@app.post("/admin/federation/vouches/issue")
def admin_federation_vouch_issue(
request: Request,
target_fingerprint: str = Form(...),
ttl_days: int = Form(90),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
federation.issue_vouch(target_fingerprint.strip(), ttl_days=ttl_days)
except Exception as exc:
_log.warning("federation.vouch.issue.error", error=str(exc))
return RedirectResponse("/admin/federation/vouches", status_code=303)
@app.post("/admin/federation/vouches/revoke")
def admin_federation_vouch_revoke(
request: Request,
target_fingerprint: str = Form(...),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
federation.revoke_vouch(target_fingerprint.strip())
return RedirectResponse("/admin/federation/vouches", status_code=303)
# ---------- admin: transparency log page ------------------------
@app.get("/admin/federation/log", response_class=HTMLResponse)
def admin_federation_log(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
result = translog.verify_chain()
head = translog.head()
entries = translog.recent(limit=200)
verify_status: Dict[str, Any]
if isinstance(result, Err):
verify_status = {"ok": False, "reason": result.reason}
else:
verify_status = {"ok": True, "verified": result.value}
return TEMPLATES.TemplateResponse(
request,
"admin_federation_log.html",
{
"verify_status": verify_status,
"head_hash": head.entry_hash if head else "",
"head_id": head.id if head else 0,
"entries": entries,
},
)
# ---------- admin: quorum config + per-peer/per-hash view -------
@app.get("/admin/federation/quorum", response_class=HTMLResponse)
def admin_federation_quorum(request: Request) -> HTMLResponse:
if not _admin_ok(request):
return RedirectResponse("/admin", status_code=303)
cfg = federation.quorum_config()
peers = federation.list_peers()
peer_rows = [
{
"peer": p,
"vouched": federation.is_vouched(p.fingerprint),
"eligible": federation.peer_is_listening_eligible(p.fingerprint),
}
for p in peers
]
# Group buffered signals by signal_hash and count distinct eligible peers.
signal_rows: Dict[str, Dict[str, Any]] = {}
for s in db.recent_signals(limit=500):
h = s.get("signal_hash") or ""
entry = signal_rows.setdefault(h, {
"signal_hash": h,
"signal_type": s.get("signal_type") or "",
"signal_id": s.get("signal_id") or "",
"peers": set(),
"latest": s.get("received_at") or "",
})
entry["peers"].add(s.get("peer_fingerprint") or "")
hash_summary = []
for h, row in signal_rows.items():
distinct_eligible = sum(
1 for fp in row["peers"] if federation.peer_is_listening_eligible(fp)
)
hash_summary.append({
"signal_hash": h,
"signal_type": row["signal_type"],
"signal_id": row["signal_id"],
"distinct_peers": len(row["peers"]),
"distinct_eligible": distinct_eligible,
"quorum_met": distinct_eligible >= cfg.signal_quorum_k,
"latest": row["latest"],
})
hash_summary.sort(key=lambda r: r["latest"], reverse=True)
return TEMPLATES.TemplateResponse(
request,
"admin_federation_quorum.html",
{
"cfg": cfg,
"peer_rows": peer_rows,
"hash_summary": hash_summary,
},
)
@app.post("/admin/federation/quorum/save")
def admin_federation_quorum_save(
request: Request,
trust_min_vouchers: int = Form(...),
signal_quorum_k: int = Form(...),
) -> RedirectResponse:
if not _admin_ok(request):
raise HTTPException(status_code=403, detail="admin session required")
try:
cfg = federation.QuorumConfig(
trust_min_vouchers=max(1, int(trust_min_vouchers)),
signal_quorum_k=max(1, int(signal_quorum_k)),
)
federation.set_quorum_config(cfg)
except Exception as exc:
_log.warning("federation.quorum.save.error", error=str(exc))
return RedirectResponse("/admin/federation/quorum", status_code=303)
_log.info("federation.routes.registered")

View File

@@ -8,7 +8,7 @@
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
</div>
<p class="page-intro">This node's Ed25519 identity. The fingerprint goes into a DNS TXT record so other psyc nodes can discover this one. The public key lets them verify any feed we publish — the private key never leaves this box.</p>
<p class="back"><a href="/admin">← back to admin</a></p>
<p class="back"><a href="/admin">← back to admin</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div>

View File

@@ -0,0 +1,63 @@
{% extends "base.html" %}
{% block title %}Transparency Log — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Transparency Log</h1>
<span class="count">id @ {{ head_id }}</span>
</div>
<p class="page-intro">Every signal we accept from a peer is appended to a signed merkle chain. Each entry references the previous entry's hash, so tampering with any historical row invalidates every entry after. Auditors can re-walk and detect a bad peer historically — even one we trusted at the time.</p>
<p class="back"><a href="/admin/federation">← back to federation</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/federation/log/verify">public verify endpoint</a></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">chain verification</div>
{% if verify_status.ok %}
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">verified</span> &nbsp; {{ verify_status.verified }} entries walked, no breaks</div>
{% else %}
<div style="margin:6px 0;"><span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">BROKEN</span> &nbsp; {{ verify_status.reason }}</div>
{% endif %}
{% if head_hash %}
<div class="lg-sub" style="margin-top:8px;">head hash</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:13px; word-break:break-all; color:var(--accent);">{{ head_hash }}</div>
{% endif %}
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Recent Entries</h2>
<span class="count">{{ entries|length }} of last 200</span>
</div>
<p class="page-intro">Newest first. Hashes are truncated for display — full values are at <code>/federation/log</code>.</p>
{% if entries %}
<table class="ledger">
<thead><tr><th>id</th><th>When</th><th>Type</th><th>Peer / target</th><th>Signal id</th><th>Hash</th></tr></thead>
<tbody>
{% for e in entries %}
<tr class="ledger-row">
<td>{{ e.id }}</td>
<td class="lg-ts">{{ (e.timestamp or '')[:19] | replace('T', ' ') }}</td>
<td><span class="sev-badge">{{ e.entry_type }}</span></td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">
{% if e.entry_type == 'signal' %}
{{ (e.entry_data.peer_fingerprint or '')[:8] }}…
{% elif e.entry_type == 'vouch' %}
{{ (e.entry_data.voucher_fingerprint or '')[:8] }}…→{{ (e.entry_data.target_fingerprint or '')[:8] }}…
{% else %}
{% endif %}
</td>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ ((e.entry_data.signal_id or e.entry_data.target_fingerprint or '') | string)[:32] }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ e.entry_hash[:16] }}…</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(chain empty — no signals appended yet)</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,92 @@
{% extends "base.html" %}
{% block title %}Quorum Config — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Quorum Configuration</h1>
<span class="count">trust={{ cfg.trust_min_vouchers }} k={{ cfg.signal_quorum_k }}</span>
</div>
<p class="page-intro"><strong>trust_min_vouchers</strong> — distinct trusted vouchers required to make a new peer listening-eligible. <strong>signal_quorum_k</strong> — distinct listening-eligible peers required to consider a signal_hash quorum-met. Both gates live in pulse_settings; raising them tightens trust, lowering them relaxes it.</p>
<p class="back"><a href="/admin/federation">← back to federation</a> &nbsp;·&nbsp; <a href="/admin/federation/vouches">vouches</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a></p>
<form method="post" action="/admin/federation/quorum/save" style="display:grid; gap:10px; max-width:520px;">
<label class="lg-sub">trust_min_vouchers</label>
<input type="number" name="trust_min_vouchers" value="{{ cfg.trust_min_vouchers }}" min="1" max="50" class="lookup-input" required>
<label class="lg-sub">signal_quorum_k</label>
<input type="number" name="signal_quorum_k" value="{{ cfg.signal_quorum_k }}" min="1" max="50" class="lookup-input" required>
<button type="submit" class="btn btn-enforce">save config</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Per-Peer Listening Eligibility</h2>
<span class="count">{{ peer_rows|length }}</span>
</div>
<p class="page-intro">A peer's feed gets ingested only when its fingerprint is eligible (directly trusted or vouched into trust).</p>
{% if peer_rows %}
<table class="ledger">
<thead><tr><th>Domain</th><th>Fingerprint</th><th>Status</th><th>Vouched</th><th>Eligible</th></tr></thead>
<tbody>
{% for row in peer_rows %}
<tr class="ledger-row">
<td><strong>{{ row.peer.domain }}</strong></td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</td>
<td>
{% if row.peer.status == 'trusted' %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
{% elif row.peer.status == 'blocked' %}
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">blocked</span>
{% else %}
<span class="sev-badge">{{ row.peer.status }}</span>
{% endif %}
</td>
<td>{% if row.vouched %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>{% else %}<span class="sev-badge">no</span>{% endif %}</td>
<td>{% if row.eligible %}<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>{% else %}<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no peers registered yet)</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Signal Hashes in Buffer</h2>
<span class="count">{{ hash_summary|length }} hashes</span>
</div>
<p class="page-intro">Distinct eligible-peer counts per signal hash. Quorum is met when count ≥ {{ cfg.signal_quorum_k }}.</p>
{% if hash_summary %}
<table class="ledger">
<thead><tr><th>Latest</th><th>Type</th><th>Signal id</th><th>Hash</th><th>Distinct peers</th><th>Eligible</th><th>Quorum</th></tr></thead>
<tbody>
{% for r in hash_summary %}
<tr class="ledger-row">
<td class="lg-ts">{{ (r.latest or '')[:19] | replace('T', ' ') }}</td>
<td><span class="sev-badge">{{ r.signal_type }}</span></td>
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_id[:48] }}</td>
<td class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:11.5px;">{{ r.signal_hash[:16] }}…</td>
<td>{{ r.distinct_peers }}</td>
<td>{{ r.distinct_eligible }}</td>
<td>
{% if r.quorum_met %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">met</span>
{% else %}
<span class="sev-badge">below</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no signals in buffer yet)</p>
{% endif %}
</section>
{% endblock %}

View File

@@ -0,0 +1,110 @@
{% extends "base.html" %}
{% block title %}Federation Vouches — psyc admin{% endblock %}
{% block content %}
<section class="panel">
<div class="panel-head">
<h1>Web of Trust</h1>
<span class="count">{{ our_vouches|length }} issued</span>
</div>
<p class="page-intro">A vouch is an Ed25519-signed assertion that we trust another node's fingerprint. Peers gossip our vouches with their feeds, so trust accumulates: once {{ cfg.trust_min_vouchers }} of our trusted peers vouches for a new fingerprint, it becomes <em>listening-eligible</em> — its signed feeds get ingested.</p>
<p class="back"><a href="/admin/federation">← back to federation</a> &nbsp;·&nbsp; <a href="/admin/federation/quorum">quorum config</a> &nbsp;·&nbsp; <a href="/admin/federation/log">transparency log</a></p>
<div class="card" style="margin-bottom:14px;">
<div class="lg-sub">our fingerprint</div>
<div style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:14px; word-break:break-all; color:var(--accent);">{{ fingerprint }}</div>
</div>
</section>
<section class="panel">
<div class="panel-head">
<h2>Vouches We've Issued</h2>
<span class="count">{{ our_vouches|length }}</span>
</div>
<p class="page-intro">We've signed these — peers that fetch our feed will see them and may extend trust accordingly.</p>
{% if our_vouches %}
<table class="ledger">
<thead><tr><th>Target fingerprint</th><th>Issued</th><th>Expires</th><th></th></tr></thead>
<tbody>
{% for v in our_vouches %}
<tr class="ledger-row">
<td style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace; font-size:12px;">{{ v.target_fingerprint }}</td>
<td class="lg-ts">{{ v.issued_at.isoformat()[:16] | replace('T', ' ') }}</td>
<td class="lg-ts">{{ v.expires_at.isoformat()[:16] | replace('T', ' ') if v.expires_at else '—' }}</td>
<td>
<form method="post" action="/admin/federation/vouches/revoke" class="queue-action"
onsubmit="return confirm('Revoke vouch for {{ v.target_fingerprint[:8] }}…?');">
<input type="hidden" name="target_fingerprint" value="{{ v.target_fingerprint }}">
<button type="submit" class="btn btn-reject">revoke</button>
</form>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no vouches issued yet)</p>
{% endif %}
</section>
<section class="panel">
<div class="panel-head">
<h2>Issue a Vouch</h2>
</div>
<p class="page-intro">Vouch for a peer's fingerprint. Trusted peers see this and may treat the target as listening-eligible.</p>
<form method="post" action="/admin/federation/vouches/issue" style="display:grid; gap:10px; max-width:680px;">
<input type="text" name="target_fingerprint" placeholder="target fingerprint (32 hex chars)" class="lookup-input" maxlength="64" required style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">
<input type="number" name="ttl_days" value="90" min="1" max="3650" class="lookup-input" placeholder="ttl days">
<button type="submit" class="btn btn-enforce">+ issue vouch</button>
</form>
</section>
<section class="panel">
<div class="panel-head">
<h2>Per-Peer Quorum Status</h2>
<span class="count">{{ peer_rows|length }} peers</span>
</div>
<p class="page-intro">Threshold: {{ cfg.trust_min_vouchers }} distinct trusted vouchers required to make a non-trusted peer listening-eligible.</p>
{% if peer_rows %}
<table class="ledger">
<thead><tr><th>Peer</th><th>Status</th><th>Vouches</th><th>Quorum met</th><th>Eligible</th></tr></thead>
<tbody>
{% for row in peer_rows %}
<tr class="ledger-row">
<td><strong>{{ row.peer.domain }}</strong><br><span class="lg-sub" style="font-family:ui-monospace,SFMono-Regular,Menlo,monospace;">{{ row.peer.fingerprint[:8] }}…{{ row.peer.fingerprint[-8:] }}</span></td>
<td>
{% if row.peer.status == 'trusted' %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">trusted</span>
{% elif row.peer.status == 'blocked' %}
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">blocked</span>
{% else %}
<span class="sev-badge">{{ row.peer.status }}</span>
{% endif %}
</td>
<td>{{ row.vouches|length }}</td>
<td>
{% if row.vouched %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">yes</span>
{% else %}
<span class="sev-badge">no</span>
{% endif %}
</td>
<td>
{% if row.eligible %}
<span class="sev-badge" style="background:rgba(74,222,128,0.10); color:var(--green); border-color:var(--green);">listening</span>
{% else %}
<span class="sev-badge" style="background:rgba(248,113,113,0.10); color:var(--red); border-color:var(--red);">muted</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="page-intro">(no peers registered yet)</p>
{% endif %}
</section>
{% endblock %}