From a10203d8f1e67f915d86fd130f68e21b399f4b2c Mon Sep 17 00:00:00 2001 From: m17hr1l Date: Sun, 7 Jun 2026 01:12:25 +0200 Subject: [PATCH] stage-exp-b explore: public routes + CORS on existing public endpoints --- src/psyc/cockpit/federation_routes.py | 94 ++++++++++++++++++++++++--- 1 file changed, 85 insertions(+), 9 deletions(-) diff --git a/src/psyc/cockpit/federation_routes.py b/src/psyc/cockpit/federation_routes.py index 02c9a96..ae915a5 100644 --- a/src/psyc/cockpit/federation_routes.py +++ b/src/psyc/cockpit/federation_routes.py @@ -34,6 +34,20 @@ _PUBLIC_PEERS_TTL = 60.0 _PUBLIC_NETWORK_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None} _PUBLIC_NETWORK_TTL = 60.0 +# Explore-view cache. The builder fans out to trusted peers' explore feeds +# for the distance-2 snapshot, so a polled hit must NEVER trigger that walk. +_EXPLORE_CACHE: Dict[str, Any] = {"ts": 0.0, "payload": None, "domain": None} +_EXPLORE_TTL = 60.0 + + +# Headers we slap on every public endpoint so other psyc nodes' explore +# pages can fetch them cross-origin from the browser. +_CORS_HEADERS: Dict[str, str] = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type", +} + def _admin_ok(request: Request) -> bool: return bool(request.session.get("admin_ok")) @@ -63,6 +77,30 @@ def _cached_public_network() -> Dict[str, Any]: return _PUBLIC_NETWORK_CACHE["payload"] +def _cached_explore(domain: Optional[str]) -> Dict[str, Any]: + """Cached explore payload. Re-uses the cache when the host domain matches. + + Domain is recorded into the payload's `node.domain` field, so a fresh + cache slot per host avoids serving the wrong reflected name. + """ + now = time.time() + cached_domain = _EXPLORE_CACHE.get("domain") + if ( + _EXPLORE_CACHE["payload"] is None + or (now - _EXPLORE_CACHE["ts"]) > _EXPLORE_TTL + or cached_domain != domain + ): + _EXPLORE_CACHE["payload"] = network_view.build_explore_view(node_domain=domain) + _EXPLORE_CACHE["ts"] = now + _EXPLORE_CACHE["domain"] = domain + return _EXPLORE_CACHE["payload"] + + +def _public_json(payload: Any) -> JSONResponse: + """JSONResponse with the public-CORS header set.""" + return JSONResponse(payload, headers=_CORS_HEADERS) + + def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: """Mount all federation routes onto `app`.""" @@ -180,20 +218,25 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: @app.get("/federation/info") def federation_info() -> JSONResponse: - return JSONResponse({ + return _public_json({ "fingerprint": federation.node_fingerprint(), "version": federation.FEED_VERSION, "feed": federation.FEED_PATH, "key": "/federation/key", + "explore": "/federation/explore", }) @app.get("/federation/key", response_class=PlainTextResponse) def federation_key() -> PlainTextResponse: - return PlainTextResponse(federation.public_key_pem(), media_type="text/plain") + return PlainTextResponse( + federation.public_key_pem(), + media_type="text/plain", + headers=_CORS_HEADERS, + ) @app.get("/federation/feed") def federation_feed() -> JSONResponse: - return JSONResponse(_cached_feed()) + return _public_json(_cached_feed()) @app.get("/federation/peers/public") def federation_peers_public() -> JSONResponse: @@ -202,7 +245,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: Only trusted peers leak; unknown + blocked are internal state and must never appear here. """ - return JSONResponse(_cached_public_peers()) + return _public_json(_cached_public_peers()) @app.get("/federation/network") def federation_network_public() -> JSONResponse: @@ -212,14 +255,14 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: so a fetcher can reconstruct the local web-of-trust shape. Trusted peers only — never unknown or blocked. Signal hashes are deliberately omitted. """ - return JSONResponse(_cached_public_network()) + return _public_json(_cached_public_network()) # ---------- public vouches + transparency log -------------------- @app.get("/federation/vouches") def federation_vouches() -> JSONResponse: """Vouches WE have issued. Peers fetch this to learn who we trust.""" - return JSONResponse({ + return _public_json({ "fingerprint": federation.node_fingerprint(), "vouches": [v.model_dump(mode="json") for v in federation.our_vouches()], }) @@ -228,7 +271,7 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: def federation_log() -> JSONResponse: """Last 100 transparency-log entries, newest first.""" entries = translog.recent(limit=100) - return JSONResponse({ + return _public_json({ "count": len(entries), "entries": [e.model_dump(mode="json") for e in entries], }) @@ -240,8 +283,41 @@ def register(app: FastAPI, TEMPLATES: Jinja2Templates) -> None: head = translog.head() head_hash = head.entry_hash if head else None if isinstance(result, Err): - return JSONResponse({"error": result.reason, "head_hash": head_hash}, status_code=409) - return JSONResponse({"verified": result.value, "head_hash": head_hash}) + return JSONResponse( + {"error": result.reason, "head_hash": head_hash}, + status_code=409, + headers=_CORS_HEADERS, + ) + return _public_json({"verified": result.value, "head_hash": head_hash}) + + # ---------- public explore page + data --------------------------- + + @app.get("/federation/explore", response_class=HTMLResponse) + def federation_explore_page(request: Request) -> HTMLResponse: + """Public transparency view — anyone can verify this network.""" + host = request.url.hostname or "" + peer_param = request.query_params.get("peer", "").strip() + return TEMPLATES.TemplateResponse( + request, + "federation_explore.html", + { + "fingerprint": federation.node_fingerprint(), + "domain": host, + "peer": peer_param, + }, + ) + + @app.get("/federation/explore/data") + def federation_explore_data(request: Request) -> JSONResponse: + """Signed public explorer payload — peer counts, vouches, transitives. + + Public, no auth, CORS-enabled. Cached so a polled hit never triggers + the distance-2 fan-out. See `network_view.build_explore_view` for the + no-leak contract. + """ + host = request.url.hostname or None + payload = _cached_explore(host) + return _public_json(payload) # ---------- admin: vouches page ---------------------------------