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 import typer
from psyc import db, log from psyc import db, log
from psyc.lines import federation from psyc.lines import federation, translog
from psyc.result import Err from psyc.result import Err
@@ -133,3 +133,80 @@ def register(typer_app: typer.Typer) -> None:
db.init_db() db.init_db()
federation.remove_peer(domain) federation.remove_peer(domain)
typer.echo(f"removed {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 fastapi.templating import Jinja2Templates
from psyc import db, log 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__) _log = log.get(__name__)
@@ -122,4 +123,183 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
def federation_feed() -> JSONResponse: def federation_feed() -> JSONResponse:
return JSONResponse(_cached_feed()) 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") _log.info("federation.routes.registered")

View File

@@ -8,7 +8,7 @@
<span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span> <span class="count">{{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}</span>
</div> </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="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="card" style="margin-bottom:14px;">
<div class="lg-sub">node fingerprint</div> <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 %}