""" backend/api/blackboard.py — S-BB: Blackboard condiviso cross-sessione via Upstash Redis REST. Riusa UPSTASH_REDIS_REST_URL e UPSTASH_REDIS_REST_TOKEN già configurati in backend/api/llm_cache.py — zero setup aggiuntivo. TTL: 600s (10 minuti) — session-scoped. Endpoint: POST /api/blackboard/{session_id}/write — scrive una entry (MACHINE auth) GET /api/blackboard/{session_id}/read — legge tutte le entry (pubblico) DEL /api/blackboard/{session_id} — pulisce il blackboard (MACHINE auth) GAP-BLACKBOARD-NOAUTH fix: write e delete protetti con require_role(MACHINE). La lettura rimane pubblica (solo findings, nessun segreto) ma con struttura che non espone dati sensibili — i valori nel blackboard sono findings agente. Pattern: i sub-agenti scrivono via frontend in-memory (agentBlackboard.ts); il backend persiste su Upstash per cross-tab/cross-reload continuity. """ import os import json import httpx import asyncio from fastapi import APIRouter, Depends from pydantic import BaseModel from .auth_guard import require_role, AuthRole router = APIRouter(prefix="/api/blackboard", tags=["blackboard"]) _URL = os.getenv("UPSTASH_REDIS_REST_URL", "") _TOKEN = os.getenv("UPSTASH_REDIS_REST_TOKEN", "") _TTL = 600 # 10 minuti class BBEntry(BaseModel): agentId: str key: str value: str severity: str = "info" ts: float = 0.0 async def _redis_post(command: list) -> dict | None: """Esegue un comando Redis via Upstash REST API.""" if not _URL or not _TOKEN: return None try: async with httpx.AsyncClient(timeout=2.0) as c: r = await c.post( _URL, json=command, headers={ "Authorization": f"Bearer {_TOKEN}", "Content-Type": "application/json", }, ) return r.json() if r.is_success else None except Exception: return None async def _redis_scan(pattern: str) -> list[str]: """Scansiona le chiavi Redis con SCAN paginato via POST (Upstash REST API). P40-H: la versione precedente usava cursor fisso "0" → solo il primo batch di 100 chiavi veniva letto. Con >100 entry Redis per sessione le chiavi extra venivano omesse silenziosamente. Fix: loop cursor finché cursor == "0". FIX G3: la versione GET usava URL-encoding del pattern (: → %3A, * → %2A). Upstash router path-decodifica %3A → ':' ma il pattern risultante veniva spezzato alla prima '/' → SCAN trovava 0 chiavi su pattern con ':'. La versione POST invia il pattern come stringa JSON → nessun encoding → corretto. """ all_keys: list[str] = [] cursor = "0" while True: result = await _redis_post(["SCAN", cursor, "MATCH", pattern, "COUNT", "100"]) if not result or "result" not in result: break scan_result = result["result"] # Upstash SCAN ritorna [next_cursor, [keys]] o [[next_cursor, [keys]]] (pipeline) if not isinstance(scan_result, list) or len(scan_result) < 2: break cursor = str(scan_result[0]) keys = scan_result[1] if isinstance(keys, list): all_keys.extend(keys) # cursor "0" segnala fine iterazione if cursor == "0": break return all_keys @router.post("/{session_id}/write") async def bb_write( session_id: str, entry: BBEntry, _auth: AuthRole = Depends(require_role(AuthRole.MACHINE)), ): """GAP-BLACKBOARD-NOAUTH fix: scrittura protetta con require_role(MACHINE). Richiede X-Internal-Token header (aggiunto dal CF Worker su route non-public). Senza auth, un attaccante poteva avvelenare il contesto dei sub-agenti. """ rkey = f"bb:{session_id}:{entry.agentId}:{entry.key}" payload = json.dumps({ "agentId": entry.agentId, "key": entry.key, "value": entry.value, "severity": entry.severity, "ts": entry.ts, }) # SET key value EX ttl await _redis_post(["SET", rkey, payload, "EX", _TTL]) return {"ok": True} @router.get("/{session_id}/read") async def bb_read(session_id: str): """Legge tutte le entries del blackboard per la sessione. Pubblico (solo findings).""" keys = await _redis_scan(f"bb:{session_id}:*") if not keys: return {"entries": [], "session_id": session_id} mget = await _redis_post(["MGET"] + keys) entries = [] if mget and "result" in mget: for v in mget["result"]: if v: try: entries.append(json.loads(v)) except Exception: pass return {"entries": entries, "session_id": session_id} @router.delete("/{session_id}") async def bb_clear( session_id: str, _auth: AuthRole = Depends(require_role(AuthRole.MACHINE)), ): """GAP-BLACKBOARD-NOAUTH fix: delete protetto con require_role(MACHINE). Senza auth, un attaccante poteva cancellare il blackboard di sessioni attive → VALIDATOR cieco, self-healing disabilitato silenziosamente. """ keys = await _redis_scan(f"bb:{session_id}:*") if keys: await _redis_post(["DEL"] + keys) return {"ok": True, "deleted": len(keys)}