diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py
new file mode 100644
index 0000000..d402f38
--- /dev/null
+++ b/src/psyc/cockpit/federation_routes.py
@@ -0,0 +1,125 @@
+"""Federation cockpit routes — admin page, public feed/key/info endpoints.
+
+Wired into the FastAPI app by app.py via a single `register(app, TEMPLATES)`
+call so the federation surface stays self-contained.
+"""
+
+from __future__ import annotations
+
+import json
+import time
+from typing import Any, Dict, Optional, Tuple
+
+from fastapi import FastAPI, Form, HTTPException, Request
+from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse, RedirectResponse
+from fastapi.templating import Jinja2Templates
+
+from psyc import db, log
+from psyc.lines import federation
+
+
+_log = log.get(__name__)
+
+# Tiny in-memory cache for the signed feed — peers may poll, recomputing
+# canonical JSON + signature on every hit would be wasteful.
+_FEED_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None}
+_FEED_TTL = 60.0
+
+
+def _admin_ok(request: Request) -> bool:
+ return bool(request.session.get("admin_ok"))
+
+
+def _cached_feed() -> Dict[str, Any]:
+ now = time.time()
+ if _FEED_CACHE["payload"] is None or (now - _FEED_CACHE["ts"]) > _FEED_TTL:
+ _FEED_CACHE["payload"] = federation.build_signed_feed()
+ _FEED_CACHE["ts"] = now
+ return _FEED_CACHE["payload"]
+
+
+def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None:
+ """Mount all federation routes onto `app`."""
+
+ @app.get("/admin/federation", response_class=HTMLResponse)
+ def admin_federation(request: Request) -> HTMLResponse:
+ if not _admin_ok(request):
+ return RedirectResponse("/admin", status_code=303)
+ host = request.url.hostname or "your-node.example"
+ suggested = request.query_params.get("domain", host)
+ rec = federation.dns_record(suggested)
+ peers = federation.list_peers()
+ signals = db.recent_signals(limit=20)
+ return TEMPLATES.TemplateResponse(
+ request,
+ "admin_federation.html",
+ {
+ "fingerprint": federation.node_fingerprint(),
+ "pubkey_pem": federation.public_key_pem(),
+ "suggested_domain": suggested,
+ "dns": rec,
+ "peers": peers,
+ "signals": signals,
+ },
+ )
+
+ @app.post("/admin/federation/peers/add")
+ def admin_federation_add_peer(
+ request: Request,
+ domain: str = Form(...),
+ fingerprint: str = Form(...),
+ pubkey_pem: str = Form(...),
+ status: str = Form("unknown"),
+ ) -> RedirectResponse:
+ if not _admin_ok(request):
+ raise HTTPException(status_code=403, detail="admin session required")
+ try:
+ federation.register_peer(domain.strip(), fingerprint.strip(), pubkey_pem.strip(), status=status)
+ except Exception as exc:
+ _log.warning("federation.peer.add.error", domain=domain, error=str(exc))
+ return RedirectResponse("/admin/federation", status_code=303)
+
+ @app.post("/admin/federation/peers/{domain}/status")
+ def admin_federation_set_status(
+ request: Request,
+ domain: str,
+ status: str = Form(...),
+ ) -> RedirectResponse:
+ if not _admin_ok(request):
+ raise HTTPException(status_code=403, detail="admin session required")
+ try:
+ federation.set_peer_status(domain, status)
+ except ValueError as exc:
+ _log.warning("federation.peer.status.bad", domain=domain, status=status, error=str(exc))
+ return RedirectResponse("/admin/federation", status_code=303)
+
+ @app.post("/admin/federation/peers/{domain}/remove")
+ def admin_federation_remove(
+ request: Request,
+ domain: str,
+ ) -> RedirectResponse:
+ if not _admin_ok(request):
+ raise HTTPException(status_code=403, detail="admin session required")
+ federation.remove_peer(domain)
+ return RedirectResponse("/admin/federation", status_code=303)
+
+ # ---------- public endpoints --------------------------------------
+
+ @app.get("/federation/info")
+ def federation_info() -> JSONResponse:
+ return JSONResponse({
+ "fingerprint": federation.node_fingerprint(),
+ "version": federation.FEED_VERSION,
+ "feed": federation.FEED_PATH,
+ "key": "/federation/key",
+ })
+
+ @app.get("/federation/key", response_class=PlainTextResponse)
+ def federation_key() -> PlainTextResponse:
+ return PlainTextResponse(federation.public_key_pem(), media_type="text/plain")
+
+ @app.get("/federation/feed")
+ def federation_feed() -> JSONResponse:
+ return JSONResponse(_cached_feed())
+
+ _log.info("federation.routes.registered")
diff --git a/src/psyc/cockpit/templates/admin_federation.html b/src/psyc/cockpit/templates/admin_federation.html
new file mode 100644
index 0000000..b9493bd
--- /dev/null
+++ b/src/psyc/cockpit/templates/admin_federation.html
@@ -0,0 +1,132 @@
+{% extends "base.html" %}
+{% block title %}Federation — psyc admin{% endblock %}
+{% block content %}
+
+
+
+
Federation Identity
+ {{ peers|length }} peer{{ '' if peers|length == 1 else 's' }}
+
+ 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.
+ ← back to admin
+
+
+
node fingerprint
+
{{ fingerprint }}
+
+ public key (PEM)
+ {{ pubkey_pem }}
+
+
+
+
+
+
+
Publish via DNS
+ SRV + TXT records
+
+ Paste these into your zone file. Once they're live, any peer that knows your domain can discover the node and pin the right key without out-of-band coordination.
+
+
+
+ {{ dns.human_instructions }}
+
+
+
+
+
Known Peers
+ {{ peers|length }} registered
+
+ Trusted peers' feeds are signature-verified on every poll. Blocked peers are recorded but ignored. Unknown peers are kept for review — nothing flows from them until you set them trusted.
+
+ {% if peers %}
+
+ | Domain | Fingerprint | Status | Discovered | Last seen | |
+
+ {% for p in peers %}
+
+ | {{ p.domain }} |
+ {{ p.fingerprint[:8] }}…{{ p.fingerprint[-8:] }} |
+
+ {% if p.status == 'trusted' %}
+ trusted
+ {% elif p.status == 'blocked' %}
+ blocked
+ {% else %}
+ unknown
+ {% endif %}
+ |
+ {{ (p.discovered_at or '')[:16] | replace('T', ' ') }} |
+ {{ ((p.last_seen or '')[:16] | replace('T', ' ')) or '—' }} |
+
+
+
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+ (no peers yet — add one below)
+ {% endif %}
+
+
+
+
+
Add Peer
+
+ Pin a peer's identity manually: their domain, their fingerprint (from their DNS TXT record), and the public key they publish at /federation/key.
+
+
+
+
+
+
Recent Signals
+ last {{ signals|length }} of buffer
+
+ Verified federation signals from peers — case + IOC reports awaiting quorum. The signal buffer is what later quorum logic will count over.
+
+ {% if signals %}
+
+ | Received | Peer | Type | Id | Hash |
+
+ {% for s in signals %}
+
+ | {{ (s.received_at or '')[:19] | replace('T', ' ') }} |
+ {{ s.peer_fingerprint[:8] }}… |
+ {{ s.signal_type }} |
+ {{ s.signal_id[:48] }} |
+ {{ s.signal_hash[:16] }}… |
+
+ {% endfor %}
+
+
+ {% else %}
+ (no signals received yet — quorum stage will populate this)
+ {% endif %}
+
+
+{% endblock %}