From 403b94fa2c2251f85cf14b60291cb6acc65b5711 Mon Sep 17 00:00:00 2001 From: Yvv Date: Sat, 28 Feb 2026 15:12:50 +0100 Subject: [PATCH] Sprint 5 : integration et production -- securite, performance, API publique, documentation Backend: rate limiter, security headers, blockchain cache service avec RPC, public API (7 endpoints read-only), WebSocket auth + heartbeat, DB connection pooling, structured logging, health check DB. Frontend: API retry/timeout, WebSocket auth + heartbeat + typed events, notifications toast, mobile hamburger + drawer, error boundary, offline banner, loading skeletons, dashboard enrichi. Documentation: guides utilisateur complets (demarrage, vote, sanctuaire, FAQ 30+), guide deploiement, politique securite. 123 tests, 155 fichiers. Co-Authored-By: Claude Opus 4.6 --- .env.example | 26 ++ backend/app/config.py | 17 + backend/app/database.py | 9 +- backend/app/main.py | 113 ++++- backend/app/middleware/__init__.py | 0 backend/app/middleware/rate_limiter.py | 163 +++++++ backend/app/middleware/security_headers.py | 42 ++ backend/app/routers/public.py | 249 +++++++++++ backend/app/routers/websocket.py | 201 ++++++++- backend/app/services/blockchain_service.py | 315 +++++++++++--- backend/app/services/cache_service.py | 140 ++++++ backend/app/tests/test_cache_service.py | 215 ++++++++++ backend/app/tests/test_public_api.py | 222 ++++++++++ docs/content/dev/1.index.md | 62 ++- docs/content/dev/8.deployment.md | 405 ++++++++++++++++++ docs/content/dev/9.security.md | 278 ++++++++++++ docs/content/user/1.index.md | 47 +- docs/content/user/2.getting-started.md | 160 ++++++- docs/content/user/3.documents.md | 110 ++++- docs/content/user/5.voting.md | 327 +++++++++----- docs/content/user/7.sanctuary.md | 163 ++++--- docs/content/user/8.faq.md | 200 ++++++++- frontend/app/app.vue | 182 +++++++- .../app/components/common/ErrorBoundary.vue | 70 +++ .../app/components/common/LoadingSkeleton.vue | 81 ++++ .../app/components/common/OfflineBanner.vue | 95 ++++ frontend/app/composables/useApi.ts | 179 +++++++- frontend/app/composables/useNotifications.ts | 180 ++++++++ frontend/app/composables/useWebSocket.ts | 305 ++++++++++++- frontend/app/pages/index.vue | 258 +++++++++-- frontend/nuxt.config.ts | 14 +- 31 files changed, 4472 insertions(+), 356 deletions(-) create mode 100644 backend/app/middleware/__init__.py create mode 100644 backend/app/middleware/rate_limiter.py create mode 100644 backend/app/middleware/security_headers.py create mode 100644 backend/app/routers/public.py create mode 100644 backend/app/services/cache_service.py create mode 100644 backend/app/tests/test_cache_service.py create mode 100644 backend/app/tests/test_public_api.py create mode 100644 docs/content/dev/8.deployment.md create mode 100644 docs/content/dev/9.security.md create mode 100644 frontend/app/components/common/ErrorBoundary.vue create mode 100644 frontend/app/components/common/LoadingSkeleton.vue create mode 100644 frontend/app/components/common/OfflineBanner.vue create mode 100644 frontend/app/composables/useNotifications.ts diff --git a/.env.example b/.env.example index 82ad9dd..d96c423 100644 --- a/.env.example +++ b/.env.example @@ -7,14 +7,40 @@ DATABASE_URL=postgresql+asyncpg://glibredecision:change-me-in-production@localho # Backend SECRET_KEY=change-me-in-production-with-a-real-secret-key DEBUG=true + +# Environment: development, staging, production +ENVIRONMENT=development + +# Logging: DEBUG, INFO, WARNING, ERROR +LOG_LEVEL=INFO + +# CORS CORS_ORIGINS=["http://localhost:3002"] +# Database connection pool +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=10 + +# Auth / Sessions +SESSION_TTL_HOURS=24 +CHALLENGE_EXPIRE_SECONDS=300 + +# Rate limiting (requests per minute) +RATE_LIMIT_DEFAULT=60 +RATE_LIMIT_AUTH=10 +RATE_LIMIT_VOTE=30 + # Duniter V2 RPC DUNITER_RPC_URL=wss://gdev.p2p.legal/ws +DUNITER_RPC_TIMEOUT_SECONDS=10 + +# Blockchain cache TTL (seconds) +BLOCKCHAIN_CACHE_TTL_SECONDS=3600 # IPFS IPFS_API_URL=http://localhost:5001 IPFS_GATEWAY_URL=http://localhost:8080 +IPFS_TIMEOUT_SECONDS=30 # Frontend NUXT_PUBLIC_API_BASE=http://localhost:8002/api/v1 diff --git a/backend/app/config.py b/backend/app/config.py index 6d0d886..731de26 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -6,24 +6,41 @@ class Settings(BaseSettings): APP_NAME: str = "Glibredecision" DEBUG: bool = True + # Environment + ENVIRONMENT: str = "development" # development, staging, production + LOG_LEVEL: str = "INFO" + # Database DATABASE_URL: str = "postgresql+asyncpg://glibredecision:change-me-in-production@localhost:5432/glibredecision" + DATABASE_POOL_SIZE: int = 20 + DATABASE_MAX_OVERFLOW: int = 10 # Auth SECRET_KEY: str = "change-me-in-production-with-a-real-secret-key" CHALLENGE_EXPIRE_SECONDS: int = 300 TOKEN_EXPIRE_HOURS: int = 24 + SESSION_TTL_HOURS: int = 24 # Duniter V2 RPC DUNITER_RPC_URL: str = "wss://gdev.p2p.legal/ws" + DUNITER_RPC_TIMEOUT_SECONDS: int = 10 # IPFS IPFS_API_URL: str = "http://localhost:5001" IPFS_GATEWAY_URL: str = "http://localhost:8080" + IPFS_TIMEOUT_SECONDS: int = 30 # CORS CORS_ORIGINS: list[str] = ["http://localhost:3002"] + # Rate limiting (requests per minute) + RATE_LIMIT_DEFAULT: int = 60 + RATE_LIMIT_AUTH: int = 10 + RATE_LIMIT_VOTE: int = 30 + + # Blockchain cache + BLOCKCHAIN_CACHE_TTL_SECONDS: int = 3600 + # Paths BASE_DIR: Path = Path(__file__).resolve().parent.parent diff --git a/backend/app/database.py b/backend/app/database.py index 6ce56ec..e83e36a 100644 --- a/backend/app/database.py +++ b/backend/app/database.py @@ -3,7 +3,14 @@ from sqlalchemy.orm import DeclarativeBase from app.config import settings -engine = create_async_engine(settings.DATABASE_URL, echo=settings.DEBUG) +engine = create_async_engine( + settings.DATABASE_URL, + echo=settings.ENVIRONMENT == "development", + pool_size=settings.DATABASE_POOL_SIZE, + max_overflow=settings.DATABASE_MAX_OVERFLOW, + pool_pre_ping=True, + pool_recycle=3600, +) async_session = async_sessionmaker(engine, class_=AsyncSession, expire_on_commit=False) diff --git a/backend/app/main.py b/backend/app/main.py index a519155..677bacd 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,26 +1,93 @@ +import logging +import sys from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import Depends, FastAPI from fastapi.middleware.cors import CORSMiddleware +from sqlalchemy import text +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings -from app.database import init_db +from app.database import get_db, init_db +from app.middleware.rate_limiter import RateLimiterMiddleware +from app.middleware.security_headers import SecurityHeadersMiddleware from app.routers import auth, documents, decisions, votes, mandates, protocols, sanctuary, websocket +from app.routers import public + + +# ── Structured logging setup ─────────────────────────────────────────────── + + +def _setup_logging() -> None: + """Configure structured logging based on environment. + + - Production/staging: JSON-formatted log lines for log aggregation. + - Development: human-readable format with colors. + """ + log_level = getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO) + + if settings.ENVIRONMENT in ("production", "staging"): + # JSON formatter for structured logging + formatter = logging.Formatter( + '{"timestamp":"%(asctime)s","level":"%(levelname)s",' + '"logger":"%(name)s","message":"%(message)s"}', + datefmt="%Y-%m-%dT%H:%M:%S", + ) + else: + # Human-readable format for development + formatter = logging.Formatter( + "%(asctime)s | %(levelname)-8s | %(name)s | %(message)s", + datefmt="%H:%M:%S", + ) + + handler = logging.StreamHandler(sys.stdout) + handler.setFormatter(formatter) + + root_logger = logging.getLogger() + root_logger.setLevel(log_level) + root_logger.handlers.clear() + root_logger.addHandler(handler) + + # Reduce noise from third-party libraries + logging.getLogger("uvicorn.access").setLevel(logging.WARNING) + logging.getLogger("sqlalchemy.engine").setLevel( + logging.INFO if settings.ENVIRONMENT == "development" else logging.WARNING + ) + + +_setup_logging() +logger = logging.getLogger(__name__) + + +# ── Application lifespan ─────────────────────────────────────────────────── @asynccontextmanager async def lifespan(app: FastAPI): + logger.info( + "Demarrage %s (env=%s, log_level=%s)", + settings.APP_NAME, settings.ENVIRONMENT, settings.LOG_LEVEL, + ) await init_db() yield + logger.info("Arret %s", settings.APP_NAME) + + +# ── FastAPI application ─────────────────────────────────────────────────── app = FastAPI( title=settings.APP_NAME, description="Plateforme de decisions collectives pour la communaute Duniter/G1", - version="0.1.0", + version="0.5.0", lifespan=lifespan, ) + +# ── Middleware stack ────────────────────────────────────────────────────── +# Middleware is applied in reverse order: last added = first executed. +# Order: SecurityHeaders -> RateLimiter -> CORS -> Application + app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ORIGINS, @@ -29,6 +96,18 @@ app.add_middleware( allow_headers=["*"], ) +app.add_middleware( + RateLimiterMiddleware, + rate_limit_default=settings.RATE_LIMIT_DEFAULT, + rate_limit_auth=settings.RATE_LIMIT_AUTH, + rate_limit_vote=settings.RATE_LIMIT_VOTE, +) + +app.add_middleware(SecurityHeadersMiddleware) + + +# ── Routers ────────────────────────────────────────────────────────────── + app.include_router(auth.router, prefix="/api/v1/auth", tags=["auth"]) app.include_router(documents.router, prefix="/api/v1/documents", tags=["documents"]) app.include_router(decisions.router, prefix="/api/v1/decisions", tags=["decisions"]) @@ -37,8 +116,32 @@ app.include_router(mandates.router, prefix="/api/v1/mandates", tags=["mandates"] app.include_router(protocols.router, prefix="/api/v1/protocols", tags=["protocols"]) app.include_router(sanctuary.router, prefix="/api/v1/sanctuary", tags=["sanctuary"]) app.include_router(websocket.router, prefix="/api/v1/ws", tags=["websocket"]) +app.include_router(public.router, prefix="/api/v1/public", tags=["public"]) + + +# ── Health check ───────────────────────────────────────────────────────── @app.get("/api/health") -async def health(): - return {"status": "ok"} +async def health(db: AsyncSession = Depends(get_db)): + """Health check endpoint that verifies database connectivity. + + Returns status "ok" with database connection info if healthy, + or status "degraded" if the database is unreachable. + """ + try: + result = await db.execute(text("SELECT 1")) + result.scalar() + db_status = "connected" + except Exception as exc: + logger.warning("Health check: base de donnees inaccessible - %s", exc) + db_status = "disconnected" + + overall_status = "ok" if db_status == "connected" else "degraded" + + return { + "status": overall_status, + "environment": settings.ENVIRONMENT, + "database": db_status, + "version": "0.5.0", + } diff --git a/backend/app/middleware/__init__.py b/backend/app/middleware/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/app/middleware/rate_limiter.py b/backend/app/middleware/rate_limiter.py new file mode 100644 index 0000000..46f33f3 --- /dev/null +++ b/backend/app/middleware/rate_limiter.py @@ -0,0 +1,163 @@ +"""Rate limiter middleware: in-memory IP-based request throttling. + +Tracks requests per IP address using a sliding window approach. +Configurable limits per endpoint category (general, auth, vote). +Returns 429 Too Many Requests with Retry-After header when exceeded. +""" + +from __future__ import annotations + +import asyncio +import logging +import time +from collections import defaultdict + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import JSONResponse, Response + +logger = logging.getLogger(__name__) + +# Cleanup interval: remove expired entries every 5 minutes +_CLEANUP_INTERVAL_SECONDS = 300 + + +class RateLimiterMiddleware(BaseHTTPMiddleware): + """In-memory rate limiter middleware. + + Tracks request timestamps per IP and enforces configurable limits: + - General endpoints: ``rate_limit_default`` requests/min + - Auth endpoints (``/auth``): ``rate_limit_auth`` requests/min + - Vote endpoints (``/vote``): ``rate_limit_vote`` requests/min + + Adds standard rate-limit headers to all responses: + - ``X-RateLimit-Limit`` + - ``X-RateLimit-Remaining`` + - ``X-RateLimit-Reset`` + + Parameters + ---------- + app: + The ASGI application. + rate_limit_default: + Maximum requests per minute for general endpoints. + rate_limit_auth: + Maximum requests per minute for auth endpoints. + rate_limit_vote: + Maximum requests per minute for vote endpoints. + """ + + def __init__( + self, + app, + rate_limit_default: int = 60, + rate_limit_auth: int = 10, + rate_limit_vote: int = 30, + ) -> None: + super().__init__(app) + self.rate_limit_default = rate_limit_default + self.rate_limit_auth = rate_limit_auth + self.rate_limit_vote = rate_limit_vote + + # IP -> list of timestamps (epoch seconds) + self._requests: dict[str, list[float]] = defaultdict(list) + self._last_cleanup: float = time.time() + self._lock = asyncio.Lock() + + def _get_limit_for_path(self, path: str) -> int: + """Return the rate limit applicable to the given request path.""" + if "/auth" in path: + return self.rate_limit_auth + if "/vote" in path: + return self.rate_limit_vote + return self.rate_limit_default + + def _get_client_ip(self, request: Request) -> str: + """Extract the client IP from the request, respecting X-Forwarded-For.""" + forwarded = request.headers.get("x-forwarded-for") + if forwarded: + return forwarded.split(",")[0].strip() + return request.client.host if request.client else "unknown" + + async def _cleanup_old_entries(self) -> None: + """Remove request timestamps older than 60 seconds for all IPs.""" + now = time.time() + if now - self._last_cleanup < _CLEANUP_INTERVAL_SECONDS: + return + + async with self._lock: + cutoff = now - 60 + ips_to_delete: list[str] = [] + + for ip, timestamps in self._requests.items(): + self._requests[ip] = [t for t in timestamps if t > cutoff] + if not self._requests[ip]: + ips_to_delete.append(ip) + + for ip in ips_to_delete: + del self._requests[ip] + + self._last_cleanup = now + if ips_to_delete: + logger.debug("Nettoyage rate limiter: %d IPs supprimees", len(ips_to_delete)) + + async def dispatch(self, request: Request, call_next) -> Response: + """Check rate limit and either allow the request or return 429.""" + # Skip rate limiting for WebSocket upgrades + if request.headers.get("upgrade", "").lower() == "websocket": + return await call_next(request) + + # Trigger periodic cleanup + await self._cleanup_old_entries() + + client_ip = self._get_client_ip(request) + path = request.url.path + limit = self._get_limit_for_path(path) + now = time.time() + window_start = now - 60 + + async with self._lock: + # Filter to requests within the last 60 seconds + self._requests[client_ip] = [ + t for t in self._requests[client_ip] if t > window_start + ] + request_count = len(self._requests[client_ip]) + + if request_count >= limit: + # Calculate when the oldest request in the window expires + oldest = min(self._requests[client_ip]) if self._requests[client_ip] else now + retry_after = int(oldest + 60 - now) + 1 + retry_after = max(retry_after, 1) + + reset_at = int(oldest + 60) + + logger.warning( + "Rate limit depasse pour %s sur %s (%d/%d)", + client_ip, path, request_count, limit, + ) + + return JSONResponse( + status_code=429, + content={"detail": "Trop de requetes. Veuillez reessayer plus tard."}, + headers={ + "Retry-After": str(retry_after), + "X-RateLimit-Limit": str(limit), + "X-RateLimit-Remaining": "0", + "X-RateLimit-Reset": str(reset_at), + }, + ) + + # Record this request + self._requests[client_ip].append(now) + remaining = max(0, limit - request_count - 1) + reset_at = int(now + 60) + + # Process the request + response = await call_next(request) + + # Add rate limit headers to the response + response.headers["X-RateLimit-Limit"] = str(limit) + response.headers["X-RateLimit-Remaining"] = str(remaining) + response.headers["X-RateLimit-Reset"] = str(reset_at) + + return response diff --git a/backend/app/middleware/security_headers.py b/backend/app/middleware/security_headers.py new file mode 100644 index 0000000..f4a3159 --- /dev/null +++ b/backend/app/middleware/security_headers.py @@ -0,0 +1,42 @@ +"""Security headers middleware: adds hardening headers to all responses. + +Applies OWASP-recommended security headers to prevent common +web vulnerabilities (clickjacking, MIME sniffing, XSS, etc.). +""" + +from __future__ import annotations + +from starlette.middleware.base import BaseHTTPMiddleware +from starlette.requests import Request +from starlette.responses import Response + + +class SecurityHeadersMiddleware(BaseHTTPMiddleware): + """Add security headers to all HTTP responses. + + Headers applied: + - ``X-Content-Type-Options: nosniff`` + - ``X-Frame-Options: DENY`` + - ``X-XSS-Protection: 1; mode=block`` + - ``Referrer-Policy: strict-origin-when-cross-origin`` + - ``Content-Security-Policy: default-src 'self'`` + - ``Strict-Transport-Security: max-age=31536000; includeSubDomains`` + (only when the request was made over HTTPS) + """ + + async def dispatch(self, request: Request, call_next) -> Response: + response = await call_next(request) + + response.headers["X-Content-Type-Options"] = "nosniff" + response.headers["X-Frame-Options"] = "DENY" + response.headers["X-XSS-Protection"] = "1; mode=block" + response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin" + response.headers["Content-Security-Policy"] = "default-src 'self'" + + # Only add HSTS header for HTTPS requests + if request.url.scheme == "https": + response.headers["Strict-Transport-Security"] = ( + "max-age=31536000; includeSubDomains" + ) + + return response diff --git a/backend/app/routers/public.py b/backend/app/routers/public.py new file mode 100644 index 0000000..b67a942 --- /dev/null +++ b/backend/app/routers/public.py @@ -0,0 +1,249 @@ +"""Public API router: read-only endpoints for external consumption. + +All endpoints are accessible without authentication. +No mutations allowed -- strictly read-only. +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query, status +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.models.decision import Decision +from app.models.document import Document, DocumentItem +from app.models.sanctuary import SanctuaryEntry +from app.models.vote import VoteSession +from app.schemas.document import DocumentFullOut, DocumentItemOut, DocumentOut +from app.schemas.sanctuary import SanctuaryEntryOut +from app.services import document_service, sanctuary_service + +router = APIRouter() + + +# ── Documents (public, read-only) ───────────────────────────────────────── + + +@router.get( + "/documents", + response_model=list[DocumentOut], + tags=["public-documents"], + summary="Liste publique des documents actifs", +) +async def list_documents( + db: AsyncSession = Depends(get_db), + doc_type: str | None = Query(default=None, description="Filtrer par type de document"), + status_filter: str | None = Query(default=None, alias="status", description="Filtrer par statut"), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), +) -> list[DocumentOut]: + """Liste les documents de reference avec leurs items (lecture seule).""" + stmt = select(Document) + + if doc_type is not None: + stmt = stmt.where(Document.doc_type == doc_type) + if status_filter is not None: + stmt = stmt.where(Document.status == status_filter) + + stmt = stmt.order_by(Document.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + documents = result.scalars().all() + + out = [] + for doc in documents: + count_result = await db.execute( + select(func.count()).select_from(DocumentItem).where(DocumentItem.document_id == doc.id) + ) + items_count = count_result.scalar() or 0 + doc_out = DocumentOut.model_validate(doc) + doc_out.items_count = items_count + out.append(doc_out) + + return out + + +@router.get( + "/documents/{slug}", + response_model=DocumentFullOut, + tags=["public-documents"], + summary="Document complet avec ses items", +) +async def get_document( + slug: str, + db: AsyncSession = Depends(get_db), +) -> DocumentFullOut: + """Recupere un document avec tous ses items (texte complet, serialise).""" + doc = await document_service.get_document_with_items(slug, db) + if doc is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Document introuvable", + ) + return DocumentFullOut.model_validate(doc) + + +# ── Sanctuary (public, read-only) ───────────────────────────────────────── + + +@router.get( + "/sanctuary/entries", + response_model=list[SanctuaryEntryOut], + tags=["public-sanctuary"], + summary="Liste des entrees du sanctuaire", +) +async def list_sanctuary_entries( + db: AsyncSession = Depends(get_db), + entry_type: str | None = Query(default=None, description="Filtrer par type (document, decision, vote_result)"), + skip: int = Query(default=0, ge=0), + limit: int = Query(default=50, ge=1, le=200), +) -> list[SanctuaryEntryOut]: + """Liste les entrees du sanctuaire (archives verifiees).""" + stmt = select(SanctuaryEntry) + + if entry_type is not None: + stmt = stmt.where(SanctuaryEntry.entry_type == entry_type) + + stmt = stmt.order_by(SanctuaryEntry.created_at.desc()).offset(skip).limit(limit) + result = await db.execute(stmt) + entries = result.scalars().all() + + return [SanctuaryEntryOut.model_validate(e) for e in entries] + + +@router.get( + "/sanctuary/entries/{id}", + response_model=SanctuaryEntryOut, + tags=["public-sanctuary"], + summary="Entree du sanctuaire par ID", +) +async def get_sanctuary_entry( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), +) -> SanctuaryEntryOut: + """Recupere une entree du sanctuaire avec lien IPFS et ancrage on-chain.""" + result = await db.execute( + select(SanctuaryEntry).where(SanctuaryEntry.id == id) + ) + entry = result.scalar_one_or_none() + if entry is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Entree sanctuaire introuvable", + ) + return SanctuaryEntryOut.model_validate(entry) + + +@router.get( + "/sanctuary/verify/{id}", + tags=["public-sanctuary"], + summary="Verification d'integrite d'une entree", +) +async def verify_sanctuary_entry( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), +) -> dict: + """Verifie l'integrite d'une entree du sanctuaire (comparaison de hash).""" + try: + result = await sanctuary_service.verify_entry(id, db) + except ValueError as exc: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=str(exc), + ) + return result + + +# ── Votes (public, read-only) ───────────────────────────────────────────── + + +@router.get( + "/votes/sessions/{id}/result", + tags=["public-votes"], + summary="Resultat d'une session de vote", +) +async def get_vote_result( + id: uuid.UUID, + db: AsyncSession = Depends(get_db), +) -> dict: + """Recupere le resultat d'une session de vote (lecture seule, public).""" + result = await db.execute( + select(VoteSession).where(VoteSession.id == id) + ) + session = result.scalar_one_or_none() + if session is None: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Session de vote introuvable", + ) + + return { + "session_id": str(session.id), + "status": session.status, + "votes_for": session.votes_for, + "votes_against": session.votes_against, + "votes_total": session.votes_total, + "wot_size": session.wot_size, + "smith_size": session.smith_size, + "techcomm_size": session.techcomm_size, + "threshold_required": session.threshold_required, + "result": session.result, + "starts_at": session.starts_at.isoformat() if session.starts_at else None, + "ends_at": session.ends_at.isoformat() if session.ends_at else None, + "chain_recorded": session.chain_recorded, + "chain_tx_hash": session.chain_tx_hash, + } + + +# ── Platform status ─────────────────────────────────────────────────────── + + +@router.get( + "/status", + tags=["public-status"], + summary="Statut de la plateforme", +) +async def platform_status( + db: AsyncSession = Depends(get_db), +) -> dict: + """Statut general de la plateforme: compteurs de documents, decisions, votes actifs.""" + # Count documents + doc_count_result = await db.execute( + select(func.count()).select_from(Document) + ) + documents_count = doc_count_result.scalar() or 0 + + # Count decisions + decision_count_result = await db.execute( + select(func.count()).select_from(Decision) + ) + decisions_count = decision_count_result.scalar() or 0 + + # Count active vote sessions + active_votes_result = await db.execute( + select(func.count()).select_from(VoteSession).where(VoteSession.status == "open") + ) + active_votes_count = active_votes_result.scalar() or 0 + + # Count total vote sessions + total_votes_result = await db.execute( + select(func.count()).select_from(VoteSession) + ) + total_votes_count = total_votes_result.scalar() or 0 + + # Count sanctuary entries + sanctuary_count_result = await db.execute( + select(func.count()).select_from(SanctuaryEntry) + ) + sanctuary_count = sanctuary_count_result.scalar() or 0 + + return { + "platform": "Glibredecision", + "documents_count": documents_count, + "decisions_count": decisions_count, + "active_votes_count": active_votes_count, + "total_votes_count": total_votes_count, + "sanctuary_entries_count": sanctuary_count, + } diff --git a/backend/app/routers/websocket.py b/backend/app/routers/websocket.py index cb7a3d0..91bb559 100644 --- a/backend/app/routers/websocket.py +++ b/backend/app/routers/websocket.py @@ -1,14 +1,37 @@ -"""WebSocket router: live vote updates.""" +"""WebSocket router: live vote updates with authentication and heartbeat.""" from __future__ import annotations +import asyncio import json +import logging import uuid +from datetime import datetime, timezone from typing import Any from fastapi import APIRouter, WebSocket, WebSocketDisconnect +from sqlalchemy import select + +from app.database import async_session +from app.models.user import Session as UserSession router = APIRouter() +logger = logging.getLogger(__name__) + +# Heartbeat interval in seconds +_HEARTBEAT_INTERVAL = 30 + +# Valid notification event types +EVENT_TYPES = ( + "vote_submitted", + "vote_update", + "session_closed", + "tally_update", + "decision_advanced", + "mandate_updated", + "document_changed", + "sanctuary_archived", +) # ── Connection manager ────────────────────────────────────────────────────── @@ -20,6 +43,8 @@ class ConnectionManager: def __init__(self) -> None: # session_id -> list of connected websockets self._connections: dict[uuid.UUID, list[WebSocket]] = {} + # websocket -> authenticated identity_id (or None for anonymous) + self._authenticated: dict[WebSocket, uuid.UUID | None] = {} async def connect(self, websocket: WebSocket, session_id: uuid.UUID) -> None: """Accept a WebSocket connection and register it for a vote session.""" @@ -36,6 +61,7 @@ class ConnectionManager: ] if not self._connections[session_id]: del self._connections[session_id] + self._authenticated.pop(websocket, None) async def broadcast(self, session_id: uuid.UUID, data: dict[str, Any]) -> None: """Broadcast a message to all connections watching a given vote session.""" @@ -55,10 +81,127 @@ class ConnectionManager: for ws in dead: self.disconnect(ws, session_id) + async def broadcast_all(self, data: dict[str, Any]) -> None: + """Broadcast a message to all connected WebSockets across all sessions.""" + message = json.dumps(data, default=str) + for session_id in list(self._connections.keys()): + dead: list[WebSocket] = [] + for ws in self._connections.get(session_id, []): + try: + await ws.send_text(message) + except Exception: + dead.append(ws) + for ws in dead: + self.disconnect(ws, session_id) + + def set_authenticated(self, websocket: WebSocket, identity_id: uuid.UUID) -> None: + """Mark a WebSocket connection as authenticated.""" + self._authenticated[websocket] = identity_id + + def is_authenticated(self, websocket: WebSocket) -> bool: + """Check if a WebSocket connection is authenticated.""" + return self._authenticated.get(websocket) is not None + manager = ConnectionManager() +# ── Authentication helper ────────────────────────────────────────────────── + + +async def _validate_token(token: str) -> uuid.UUID | None: + """Validate a bearer token and return the associated identity_id. + + Uses the same token hashing as auth_service but without FastAPI + dependency injection (since WebSocket doesn't use Depends the same way). + + Parameters + ---------- + token: + The raw bearer token from the query parameter. + + Returns + ------- + uuid.UUID | None + The identity_id if valid, or None if invalid/expired. + """ + import hashlib + token_hash = hashlib.sha256(token.encode()).hexdigest() + + try: + async with async_session() as db: + result = await db.execute( + select(UserSession).where( + UserSession.token_hash == token_hash, + UserSession.expires_at > datetime.now(timezone.utc), + ) + ) + session = result.scalar_one_or_none() + if session is not None: + return session.identity_id + except Exception: + logger.warning("Erreur lors de la validation du token WebSocket", exc_info=True) + + return None + + +# ── Broadcast event helper (importable by other routers) ────────────────── + + +async def broadcast_event( + event_type: str, + payload: dict[str, Any], + session_id: uuid.UUID | None = None, +) -> None: + """Broadcast a notification event to connected WebSocket clients. + + This function is designed to be imported and called from other routers + (votes, decisions, mandates, etc.) to push real-time notifications. + + Parameters + ---------- + event_type: + One of the valid EVENT_TYPES. + payload: + The event data to send. + session_id: + If provided, broadcast only to clients watching this specific session. + If None, broadcast to all connected clients. + """ + data = { + "event": event_type, + "timestamp": datetime.now(timezone.utc).isoformat(), + **payload, + } + + if session_id is not None: + await manager.broadcast(session_id, data) + else: + await manager.broadcast_all(data) + + +# ── Heartbeat task ───────────────────────────────────────────────────────── + + +async def _heartbeat(websocket: WebSocket) -> None: + """Send periodic ping messages to keep the connection alive. + + Runs as a background task alongside the main message loop. + Sends a JSON ping every _HEARTBEAT_INTERVAL seconds. + """ + try: + while True: + await asyncio.sleep(_HEARTBEAT_INTERVAL) + try: + await websocket.send_text( + json.dumps({"event": "ping", "timestamp": datetime.now(timezone.utc).isoformat()}) + ) + except Exception: + break + except asyncio.CancelledError: + pass + + # ── WebSocket endpoint ────────────────────────────────────────────────────── @@ -66,23 +209,51 @@ manager = ConnectionManager() async def live_updates(websocket: WebSocket) -> None: """WebSocket endpoint for live vote session updates. - The client connects and sends a JSON message with the session_id - they want to subscribe to: + Authentication (optional): + Connect with ``?token=`` query parameter to authenticate. + If the token is valid, the connection is marked as authenticated. + If missing or invalid, the connection is accepted but unauthenticated. + + The client sends JSON messages: { "action": "subscribe", "session_id": "" } - - The server will then push vote update events to the client: - - { "event": "vote_update", "session_id": "...", "votes_for": N, "votes_against": N, "votes_total": N } - { "event": "session_closed", "session_id": "...", "result": "adopted|rejected" } - - The client can also unsubscribe: - { "action": "unsubscribe", "session_id": "" } + + The server pushes events: + + { "event": "vote_update", "session_id": "...", ... } + { "event": "session_closed", "session_id": "...", ... } + { "event": "vote_submitted", ... } + { "event": "decision_advanced", ... } + { "event": "mandate_updated", ... } + { "event": "document_changed", ... } + { "event": "sanctuary_archived", ... } + { "event": "ping", "timestamp": "..." } (heartbeat) """ + # Extract token from query parameters + token = websocket.query_params.get("token") + await websocket.accept() subscribed_sessions: set[uuid.UUID] = set() + # Authenticate if token provided + if token: + identity_id = await _validate_token(token) + if identity_id is not None: + manager.set_authenticated(websocket, identity_id) + await websocket.send_text( + json.dumps({"event": "authenticated", "identity_id": str(identity_id)}) + ) + logger.debug("WebSocket authentifie: identity=%s", identity_id) + else: + await websocket.send_text( + json.dumps({"event": "auth_failed", "detail": "Token invalide ou expire"}) + ) + logger.debug("Echec authentification WebSocket (token invalide)") + + # Start heartbeat task + heartbeat_task = asyncio.create_task(_heartbeat(websocket)) + try: while True: raw = await websocket.receive_text() @@ -138,3 +309,11 @@ async def live_updates(websocket: WebSocket) -> None: # Clean up all subscriptions for this client for session_id in subscribed_sessions: manager.disconnect(websocket, session_id) + logger.debug("WebSocket deconnecte, %d souscriptions nettoyees", len(subscribed_sessions)) + finally: + # Cancel the heartbeat task + heartbeat_task.cancel() + try: + await heartbeat_task + except asyncio.CancelledError: + pass diff --git a/backend/app/services/blockchain_service.py b/backend/app/services/blockchain_service.py index 667ac7b..11236e8 100644 --- a/backend/app/services/blockchain_service.py +++ b/backend/app/services/blockchain_service.py @@ -3,85 +3,302 @@ Provides functions to query WoT size, Smith sub-WoT size, and Technical Committee size from the Duniter V2 blockchain. -Currently stubbed with hardcoded values matching GDev test data. +Architecture: +1. Check database cache (via cache_service) +2. Try JSON-RPC call to Duniter node +3. Fall back to hardcoded GDev test values (with warning log) + +All public functions accept a db session for cache access. """ from __future__ import annotations +import logging -async def get_wot_size() -> int: +import httpx +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import settings +from app.services import cache_service + +logger = logging.getLogger(__name__) + +# Hardcoded fallback values from GDev snapshot +_FALLBACK_WOT_SIZE = 7224 +_FALLBACK_SMITH_SIZE = 20 +_FALLBACK_TECHCOMM_SIZE = 5 + +# Cache key prefixes +_CACHE_KEY_WOT = "blockchain:wot_size" +_CACHE_KEY_SMITH = "blockchain:smith_size" +_CACHE_KEY_TECHCOMM = "blockchain:techcomm_size" + + +async def _fetch_from_rpc(method: str, params: list | None = None) -> dict | None: + """Send a JSON-RPC POST request to the Duniter node. + + Uses the HTTP variant of the RPC URL. If DUNITER_RPC_URL starts with + ``wss://`` or ``ws://``, it is converted to ``https://`` or ``http://`` + for the HTTP JSON-RPC endpoint. + + Parameters + ---------- + method: + The RPC method name (e.g. ``"state_getStorage"``). + params: + Optional list of parameters for the RPC call. + + Returns + ------- + dict | None + The ``"result"`` field from the JSON-RPC response, or None on error. + """ + # Convert WebSocket URL to HTTP for JSON-RPC + rpc_url = settings.DUNITER_RPC_URL + if rpc_url.startswith("wss://"): + rpc_url = rpc_url.replace("wss://", "https://", 1) + elif rpc_url.startswith("ws://"): + rpc_url = rpc_url.replace("ws://", "http://", 1) + + # Strip /ws suffix if present + if rpc_url.endswith("/ws"): + rpc_url = rpc_url[:-3] + + payload = { + "jsonrpc": "2.0", + "id": 1, + "method": method, + "params": params or [], + } + + try: + async with httpx.AsyncClient( + timeout=settings.DUNITER_RPC_TIMEOUT_SECONDS + ) as client: + response = await client.post(rpc_url, json=payload) + response.raise_for_status() + data = response.json() + + if "error" in data: + logger.warning( + "Erreur RPC Duniter pour %s: %s", + method, data["error"], + ) + return None + + return data.get("result") + + except httpx.ConnectError: + logger.warning( + "Impossible de se connecter au noeud Duniter (%s)", rpc_url + ) + return None + except httpx.TimeoutException: + logger.warning( + "Timeout lors de l'appel RPC Duniter pour %s (%s)", + method, rpc_url, + ) + return None + except httpx.HTTPStatusError as exc: + logger.warning( + "Erreur HTTP Duniter RPC pour %s: %s", + method, exc.response.status_code, + ) + return None + except Exception: + logger.warning( + "Erreur inattendue lors de l'appel RPC Duniter pour %s", + method, + exc_info=True, + ) + return None + + +async def _fetch_membership_count(db: AsyncSession) -> int | None: + """Fetch WoT membership count from the Duniter RPC. + + Queries ``membership_membershipsCount`` via state RPC. + + Returns + ------- + int | None + The membership count, or None if the RPC call failed. + """ + # Try runtime API call for membership count + result = await _fetch_from_rpc("membership_membershipsCount", []) + if result is not None: + try: + count = int(result) + # Cache the result + await cache_service.set_cached( + _CACHE_KEY_WOT, + {"value": count}, + db, + ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS, + ) + return count + except (ValueError, TypeError): + logger.warning("Reponse RPC invalide pour membership count: %s", result) + return None + + +async def _fetch_smith_count(db: AsyncSession) -> int | None: + """Fetch Smith membership count from the Duniter RPC. + + Returns + ------- + int | None + The Smith member count, or None if the RPC call failed. + """ + result = await _fetch_from_rpc("smithMembers_smithMembersCount", []) + if result is not None: + try: + count = int(result) + await cache_service.set_cached( + _CACHE_KEY_SMITH, + {"value": count}, + db, + ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS, + ) + return count + except (ValueError, TypeError): + logger.warning("Reponse RPC invalide pour smith count: %s", result) + return None + + +async def _fetch_techcomm_count(db: AsyncSession) -> int | None: + """Fetch Technical Committee member count from the Duniter RPC. + + Returns + ------- + int | None + The TechComm member count, or None if the RPC call failed. + """ + result = await _fetch_from_rpc("technicalCommittee_members", []) + if result is not None: + try: + if isinstance(result, list): + count = len(result) + else: + count = int(result) + await cache_service.set_cached( + _CACHE_KEY_TECHCOMM, + {"value": count}, + db, + ttl_seconds=settings.BLOCKCHAIN_CACHE_TTL_SECONDS, + ) + return count + except (ValueError, TypeError): + logger.warning("Reponse RPC invalide pour techcomm count: %s", result) + return None + + +async def get_wot_size(db: AsyncSession) -> int: """Return the current number of WoT members. - TODO: Implement real RPC call using substrate-interface:: + Resolution order: + 1. Database cache (if not expired) + 2. Duniter RPC call + 3. Hardcoded fallback (7224, GDev snapshot) - from substrateinterface import SubstrateInterface - from app.config import settings - - substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL) - - # Query membership count - result = substrate.query( - module="Membership", - storage_function="MembershipCount", - ) - return int(result.value) + Parameters + ---------- + db: + Async database session (for cache access). Returns ------- int - Number of WoT members. Currently returns 7224 (GDev snapshot). + Number of WoT members. """ - # TODO: Replace with real substrate-interface RPC call - return 7224 + # 1. Try cache + cached = await cache_service.get_cached(_CACHE_KEY_WOT, db) + if cached is not None: + return cached["value"] + + # 2. Try RPC + rpc_value = await _fetch_membership_count(db) + if rpc_value is not None: + return rpc_value + + # 3. Fallback + logger.warning( + "Utilisation de la valeur WoT par defaut (%d) - " + "cache et RPC indisponibles", + _FALLBACK_WOT_SIZE, + ) + return _FALLBACK_WOT_SIZE -async def get_smith_size() -> int: +async def get_smith_size(db: AsyncSession) -> int: """Return the current number of Smith members (forgerons). - TODO: Implement real RPC call using substrate-interface:: + Resolution order: + 1. Database cache (if not expired) + 2. Duniter RPC call + 3. Hardcoded fallback (20, GDev snapshot) - from substrateinterface import SubstrateInterface - from app.config import settings - - substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL) - - # Query Smith membership count - result = substrate.query( - module="SmithMembers", - storage_function="SmithMembershipCount", - ) - return int(result.value) + Parameters + ---------- + db: + Async database session (for cache access). Returns ------- int - Number of Smith members. Currently returns 20 (GDev snapshot). + Number of Smith members. """ - # TODO: Replace with real substrate-interface RPC call - return 20 + # 1. Try cache + cached = await cache_service.get_cached(_CACHE_KEY_SMITH, db) + if cached is not None: + return cached["value"] + + # 2. Try RPC + rpc_value = await _fetch_smith_count(db) + if rpc_value is not None: + return rpc_value + + # 3. Fallback + logger.warning( + "Utilisation de la valeur Smith par defaut (%d) - " + "cache et RPC indisponibles", + _FALLBACK_SMITH_SIZE, + ) + return _FALLBACK_SMITH_SIZE -async def get_techcomm_size() -> int: +async def get_techcomm_size(db: AsyncSession) -> int: """Return the current number of Technical Committee members. - TODO: Implement real RPC call using substrate-interface:: + Resolution order: + 1. Database cache (if not expired) + 2. Duniter RPC call + 3. Hardcoded fallback (5, GDev snapshot) - from substrateinterface import SubstrateInterface - from app.config import settings - - substrate = SubstrateInterface(url=settings.DUNITER_RPC_URL) - - # Query TechComm member count - result = substrate.query( - module="TechnicalCommittee", - storage_function="Members", - ) - return len(result.value) if result.value else 0 + Parameters + ---------- + db: + Async database session (for cache access). Returns ------- int - Number of TechComm members. Currently returns 5 (GDev snapshot). + Number of TechComm members. """ - # TODO: Replace with real substrate-interface RPC call - return 5 + # 1. Try cache + cached = await cache_service.get_cached(_CACHE_KEY_TECHCOMM, db) + if cached is not None: + return cached["value"] + + # 2. Try RPC + rpc_value = await _fetch_techcomm_count(db) + if rpc_value is not None: + return rpc_value + + # 3. Fallback + logger.warning( + "Utilisation de la valeur TechComm par defaut (%d) - " + "cache et RPC indisponibles", + _FALLBACK_TECHCOMM_SIZE, + ) + return _FALLBACK_TECHCOMM_SIZE diff --git a/backend/app/services/cache_service.py b/backend/app/services/cache_service.py new file mode 100644 index 0000000..1a6e06c --- /dev/null +++ b/backend/app/services/cache_service.py @@ -0,0 +1,140 @@ +"""Cache service: blockchain data caching with TTL expiry. + +Uses the BlockchainCache model (PostgreSQL/JSONB) to cache +on-chain data like WoT size, Smith size, and TechComm size. +Avoids repeated RPC calls to the Duniter node. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timedelta, timezone + +from sqlalchemy import delete, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.models.cache import BlockchainCache + +logger = logging.getLogger(__name__) + + +async def get_cached(key: str, db: AsyncSession) -> dict | None: + """Retrieve a cached value by key if it has not expired. + + Parameters + ---------- + key: + The cache key to look up. + db: + Async database session. + + Returns + ------- + dict | None + The cached value as a dict, or None if missing/expired. + """ + result = await db.execute( + select(BlockchainCache).where( + BlockchainCache.cache_key == key, + BlockchainCache.expires_at > datetime.now(timezone.utc), + ) + ) + entry = result.scalar_one_or_none() + + if entry is None: + logger.debug("Cache miss pour la cle '%s'", key) + return None + + logger.debug("Cache hit pour la cle '%s'", key) + return entry.cache_value + + +async def set_cached( + key: str, + value: dict, + db: AsyncSession, + ttl_seconds: int = 3600, +) -> None: + """Store a value in the cache with the given TTL. + + If the key already exists, it is replaced (upsert). + + Parameters + ---------- + key: + The cache key. + value: + The value to store (must be JSON-serializable). + db: + Async database session. + ttl_seconds: + Time-to-live in seconds (default: 1 hour). + """ + now = datetime.now(timezone.utc) + expires_at = now + timedelta(seconds=ttl_seconds) + + # Check if key already exists + result = await db.execute( + select(BlockchainCache).where(BlockchainCache.cache_key == key) + ) + existing = result.scalar_one_or_none() + + if existing is not None: + existing.cache_value = value + existing.fetched_at = now + existing.expires_at = expires_at + logger.debug("Cache mis a jour pour la cle '%s' (TTL=%ds)", key, ttl_seconds) + else: + entry = BlockchainCache( + cache_key=key, + cache_value=value, + fetched_at=now, + expires_at=expires_at, + ) + db.add(entry) + logger.debug("Cache cree pour la cle '%s' (TTL=%ds)", key, ttl_seconds) + + await db.commit() + + +async def invalidate(key: str, db: AsyncSession) -> None: + """Remove a specific cache entry by key. + + Parameters + ---------- + key: + The cache key to invalidate. + db: + Async database session. + """ + await db.execute( + delete(BlockchainCache).where(BlockchainCache.cache_key == key) + ) + await db.commit() + logger.debug("Cache invalide pour la cle '%s'", key) + + +async def cleanup_expired(db: AsyncSession) -> int: + """Remove all expired cache entries. + + Parameters + ---------- + db: + Async database session. + + Returns + ------- + int + Number of entries removed. + """ + result = await db.execute( + delete(BlockchainCache).where( + BlockchainCache.expires_at <= datetime.now(timezone.utc) + ) + ) + await db.commit() + + count = result.rowcount + if count > 0: + logger.info("Nettoyage cache: %d entrees expirees supprimees", count) + return count diff --git a/backend/app/tests/test_cache_service.py b/backend/app/tests/test_cache_service.py new file mode 100644 index 0000000..dc23c7c --- /dev/null +++ b/backend/app/tests/test_cache_service.py @@ -0,0 +1,215 @@ +"""Tests for cache_service: get, set, invalidate, and cleanup. + +Uses mock database sessions to test cache logic in isolation +without requiring a real PostgreSQL connection. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timedelta, timezone +from unittest.mock import AsyncMock, MagicMock + +import pytest + +sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for cache service tests") + +from app.services.cache_service import ( # noqa: E402 + cleanup_expired, + get_cached, + invalidate, + set_cached, +) + + +# --------------------------------------------------------------------------- +# Helpers: mock objects for BlockchainCache entries +# --------------------------------------------------------------------------- + + +def _make_cache_entry( + key: str = "test:key", + value: dict | None = None, + expired: bool = False, +) -> MagicMock: + """Create a mock BlockchainCache entry.""" + entry = MagicMock() + entry.id = uuid.uuid4() + entry.cache_key = key + entry.cache_value = value or {"data": 42} + entry.fetched_at = datetime.now(timezone.utc) + + if expired: + entry.expires_at = datetime.now(timezone.utc) - timedelta(hours=1) + else: + entry.expires_at = datetime.now(timezone.utc) + timedelta(hours=1) + + return entry + + +def _make_async_db(entry: MagicMock | None = None) -> AsyncMock: + """Create a mock async database session. + + The session's execute() returns a result with scalar_one_or_none() + that returns the given entry (or None). + """ + db = AsyncMock() + + result = MagicMock() + result.scalar_one_or_none.return_value = entry + db.execute = AsyncMock(return_value=result) + db.commit = AsyncMock() + db.add = MagicMock() + + return db + + +# --------------------------------------------------------------------------- +# Tests: get_cached +# --------------------------------------------------------------------------- + + +class TestGetCached: + """Test cache_service.get_cached.""" + + @pytest.mark.asyncio + async def test_returns_none_for_missing_key(self): + """get_cached returns None when no entry exists for the key.""" + db = _make_async_db(entry=None) + result = await get_cached("nonexistent:key", db) + assert result is None + + @pytest.mark.asyncio + async def test_returns_value_for_valid_entry(self): + """get_cached returns the cached value when entry exists and is not expired.""" + entry = _make_cache_entry(key="blockchain:wot_size", value={"value": 7224}) + db = _make_async_db(entry=entry) + + result = await get_cached("blockchain:wot_size", db) + assert result == {"value": 7224} + + @pytest.mark.asyncio + async def test_returns_none_for_expired_entry(self): + """get_cached returns None when the entry is expired. + + Note: In the real implementation, the SQL query filters by expires_at. + Here we test that when the DB returns None (as it would for expired), + get_cached returns None. + """ + db = _make_async_db(entry=None) + result = await get_cached("expired:key", db) + assert result is None + + +# --------------------------------------------------------------------------- +# Tests: set_cached + get_cached roundtrip +# --------------------------------------------------------------------------- + + +class TestSetCached: + """Test cache_service.set_cached.""" + + @pytest.mark.asyncio + async def test_set_cached_creates_new_entry(self): + """set_cached creates a new entry when key does not exist.""" + db = _make_async_db(entry=None) + + await set_cached("new:key", {"value": 100}, db, ttl_seconds=3600) + + db.add.assert_called_once() + db.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_set_cached_updates_existing_entry(self): + """set_cached updates the value when key already exists.""" + existing = _make_cache_entry(key="existing:key", value={"value": 50}) + db = _make_async_db(entry=existing) + + await set_cached("existing:key", {"value": 200}, db, ttl_seconds=7200) + + # Should update in-place, not add new + db.add.assert_not_called() + assert existing.cache_value == {"value": 200} + db.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_set_cached_roundtrip(self): + """set_cached followed by get_cached returns the stored value.""" + # First call: set (no existing entry) + db_set = _make_async_db(entry=None) + await set_cached("roundtrip:key", {"data": "test"}, db_set, ttl_seconds=3600) + db_set.add.assert_called_once() + + # Extract the added entry from the mock + added_entry = db_set.add.call_args[0][0] + + # Second call: get (returns the added entry) + db_get = _make_async_db(entry=added_entry) + result = await get_cached("roundtrip:key", db_get) + assert result == {"data": "test"} + + +# --------------------------------------------------------------------------- +# Tests: invalidate +# --------------------------------------------------------------------------- + + +class TestInvalidate: + """Test cache_service.invalidate.""" + + @pytest.mark.asyncio + async def test_invalidate_removes_entry(self): + """invalidate executes a delete and commits.""" + db = AsyncMock() + db.execute = AsyncMock() + db.commit = AsyncMock() + + await invalidate("some:key", db) + + db.execute.assert_awaited_once() + db.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_invalidate_then_get_returns_none(self): + """After invalidation, get_cached returns None.""" + db = _make_async_db(entry=None) + result = await get_cached("invalidated:key", db) + assert result is None + + +# --------------------------------------------------------------------------- +# Tests: cleanup_expired +# --------------------------------------------------------------------------- + + +class TestCleanupExpired: + """Test cache_service.cleanup_expired.""" + + @pytest.mark.asyncio + async def test_cleanup_removes_expired_entries(self): + """cleanup_expired executes a delete for expired entries and returns count.""" + db = AsyncMock() + + # Mock the execute result with rowcount + exec_result = MagicMock() + exec_result.rowcount = 3 + db.execute = AsyncMock(return_value=exec_result) + db.commit = AsyncMock() + + count = await cleanup_expired(db) + assert count == 3 + db.execute.assert_awaited_once() + db.commit.assert_awaited_once() + + @pytest.mark.asyncio + async def test_cleanup_with_no_expired_entries(self): + """cleanup_expired returns 0 when no expired entries exist.""" + db = AsyncMock() + + exec_result = MagicMock() + exec_result.rowcount = 0 + db.execute = AsyncMock(return_value=exec_result) + db.commit = AsyncMock() + + count = await cleanup_expired(db) + assert count == 0 diff --git a/backend/app/tests/test_public_api.py b/backend/app/tests/test_public_api.py new file mode 100644 index 0000000..f85cb5b --- /dev/null +++ b/backend/app/tests/test_public_api.py @@ -0,0 +1,222 @@ +"""Tests for public API: basic schema validation and serialization. + +Uses mock database sessions to test the public router logic +without requiring a real PostgreSQL connection. +""" + +from __future__ import annotations + +import uuid +from datetime import datetime, timezone +from unittest.mock import MagicMock + +import pytest + +sqlalchemy = pytest.importorskip("sqlalchemy", reason="sqlalchemy required for public API tests") + +from app.schemas.document import DocumentFullOut, DocumentOut # noqa: E402 +from app.schemas.sanctuary import SanctuaryEntryOut # noqa: E402 + + +# --------------------------------------------------------------------------- +# Helpers: mock objects +# --------------------------------------------------------------------------- + + +def _make_document_mock( + doc_id: uuid.UUID | None = None, + slug: str = "licence-g1", + title: str = "Licence G1", + doc_type: str = "licence", + version: str = "2.0.0", + doc_status: str = "active", + description: str | None = "La licence monetaire", +) -> MagicMock: + """Create a mock Document for schema validation.""" + doc = MagicMock() + doc.id = doc_id or uuid.uuid4() + doc.slug = slug + doc.title = title + doc.doc_type = doc_type + doc.version = version + doc.status = doc_status + doc.description = description + doc.ipfs_cid = None + doc.chain_anchor = None + doc.created_at = datetime.now(timezone.utc) + doc.updated_at = datetime.now(timezone.utc) + doc.items = [] + return doc + + +def _make_item_mock( + item_id: uuid.UUID | None = None, + document_id: uuid.UUID | None = None, + position: str = "1", + item_type: str = "clause", + title: str | None = "Article 1", + current_text: str = "Texte de l'article", + sort_order: int = 0, +) -> MagicMock: + """Create a mock DocumentItem for schema validation.""" + item = MagicMock() + item.id = item_id or uuid.uuid4() + item.document_id = document_id or uuid.uuid4() + item.position = position + item.item_type = item_type + item.title = title + item.current_text = current_text + item.voting_protocol_id = None + item.sort_order = sort_order + item.created_at = datetime.now(timezone.utc) + item.updated_at = datetime.now(timezone.utc) + return item + + +def _make_sanctuary_entry_mock( + entry_id: uuid.UUID | None = None, + entry_type: str = "document", + reference_id: uuid.UUID | None = None, + title: str | None = "Licence G1 v2.0.0", + content_hash: str = "abc123def456", + ipfs_cid: str | None = "QmTestCid123", + chain_tx_hash: str | None = "0xdeadbeef", +) -> MagicMock: + """Create a mock SanctuaryEntry for schema validation.""" + entry = MagicMock() + entry.id = entry_id or uuid.uuid4() + entry.entry_type = entry_type + entry.reference_id = reference_id or uuid.uuid4() + entry.title = title + entry.content_hash = content_hash + entry.ipfs_cid = ipfs_cid + entry.chain_tx_hash = chain_tx_hash + entry.chain_block = 12345 if chain_tx_hash else None + entry.metadata_json = None + entry.created_at = datetime.now(timezone.utc) + return entry + + +# --------------------------------------------------------------------------- +# Tests: DocumentOut schema serialization +# --------------------------------------------------------------------------- + + +class TestDocumentOutSchema: + """Test DocumentOut schema validation from mock objects.""" + + def test_document_out_basic(self): + """DocumentOut validates from a mock document object.""" + doc = _make_document_mock() + out = DocumentOut.model_validate(doc) + + assert out.slug == "licence-g1" + assert out.title == "Licence G1" + assert out.doc_type == "licence" + assert out.version == "2.0.0" + assert out.status == "active" + # items_count is set explicitly after validation (not from model) + out.items_count = 0 + assert out.items_count == 0 + + def test_document_out_with_items_count(self): + """DocumentOut can have items_count set after validation.""" + doc = _make_document_mock() + out = DocumentOut.model_validate(doc) + out.items_count = 42 + + assert out.items_count == 42 + + def test_document_out_all_fields_present(self): + """All expected fields are present in the DocumentOut serialization.""" + doc = _make_document_mock() + out = DocumentOut.model_validate(doc) + data = out.model_dump() + + expected_fields = { + "id", "slug", "title", "doc_type", "version", "status", + "description", "ipfs_cid", "chain_anchor", "created_at", + "updated_at", "items_count", + } + assert expected_fields.issubset(set(data.keys())) + + +# --------------------------------------------------------------------------- +# Tests: DocumentFullOut schema serialization +# --------------------------------------------------------------------------- + + +class TestDocumentFullOutSchema: + """Test DocumentFullOut schema validation (document with items).""" + + def test_document_full_out_empty_items(self): + """DocumentFullOut works with an empty items list.""" + doc = _make_document_mock() + doc.items = [] + out = DocumentFullOut.model_validate(doc) + + assert out.slug == "licence-g1" + assert out.items == [] + + def test_document_full_out_with_items(self): + """DocumentFullOut includes items when present.""" + doc_id = uuid.uuid4() + doc = _make_document_mock(doc_id=doc_id) + item1 = _make_item_mock(document_id=doc_id, position="1", sort_order=0) + item2 = _make_item_mock(document_id=doc_id, position="2", sort_order=1) + doc.items = [item1, item2] + + out = DocumentFullOut.model_validate(doc) + + assert len(out.items) == 2 + assert out.items[0].position == "1" + assert out.items[1].position == "2" + + +# --------------------------------------------------------------------------- +# Tests: SanctuaryEntryOut schema serialization +# --------------------------------------------------------------------------- + + +class TestSanctuaryEntryOutSchema: + """Test SanctuaryEntryOut schema validation.""" + + def test_sanctuary_entry_out_basic(self): + """SanctuaryEntryOut validates from a mock entry.""" + entry = _make_sanctuary_entry_mock() + out = SanctuaryEntryOut.model_validate(entry) + + assert out.entry_type == "document" + assert out.content_hash == "abc123def456" + assert out.ipfs_cid == "QmTestCid123" + assert out.chain_tx_hash == "0xdeadbeef" + assert out.chain_block == 12345 + + def test_sanctuary_entry_out_without_ipfs(self): + """SanctuaryEntryOut works when IPFS CID is None.""" + entry = _make_sanctuary_entry_mock(ipfs_cid=None, chain_tx_hash=None) + out = SanctuaryEntryOut.model_validate(entry) + + assert out.ipfs_cid is None + assert out.chain_tx_hash is None + assert out.chain_block is None + + def test_sanctuary_entry_out_all_fields(self): + """All expected fields are present in SanctuaryEntryOut.""" + entry = _make_sanctuary_entry_mock() + out = SanctuaryEntryOut.model_validate(entry) + data = out.model_dump() + + expected_fields = { + "id", "entry_type", "reference_id", "title", + "content_hash", "ipfs_cid", "chain_tx_hash", + "chain_block", "metadata_json", "created_at", + } + assert expected_fields.issubset(set(data.keys())) + + def test_sanctuary_entry_types(self): + """Different entry_type values are accepted.""" + for entry_type in ("document", "decision", "vote_result"): + entry = _make_sanctuary_entry_mock(entry_type=entry_type) + out = SanctuaryEntryOut.model_validate(entry) + assert out.entry_type == entry_type diff --git a/docs/content/dev/1.index.md b/docs/content/dev/1.index.md index 3cf0d78..f388e00 100644 --- a/docs/content/dev/1.index.md +++ b/docs/content/dev/1.index.md @@ -5,13 +5,61 @@ description: Architecture, API et reference technique de Glibredecision # Documentation technique -Bienvenue dans la documentation technique de Glibredecision. +Bienvenue dans la documentation technique de Glibredecision, la plateforme de decisions collectives pour la communaute Duniter/G1. + +## Presentation + +Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter V2 de gerer des documents de reference modulaires sous vote permanent, prendre des decisions collectives multi-etapes, attribuer des mandats et archiver de maniere immuable les resultats via IPFS et la blockchain Duniter. + +## Stack technique + +| Couche | Technologie | +| ------------ | -------------------------------------------------- | +| Frontend | Nuxt 4 + Nuxt UI v3 + Pinia + UnoCSS | +| Backend | Python FastAPI + SQLAlchemy 2.0 (async) + Pydantic v2 | +| Base de donnees | PostgreSQL 16 (asyncpg) | +| Authentification | Duniter V2 Ed25519 challenge-response | +| Sanctuaire | IPFS (kubo) + hash on-chain (system.remark) | +| CI/CD | Woodpecker CI + Docker + Traefik | +| Temps reel | WebSocket pour les mises a jour de vote en direct | + +## Historique des sprints + +| Sprint | Contenu principal | Statut | +| ------ | ----------------- | ------ | +| Sprint 1 | Architecture, modeles de base (documents, items, versions, identites, protocoles, formules), API documents et authentification | Termine | +| Sprint 2 | Sanctuaire (IPFS + on-chain), gestion complete des items (CRUD, reorder), verification d'integrite, archivage de documents | Termine | +| Sprint 3 | Systeme de vote complet (sessions, votes signes, seuil WoT, criteres Smith/TechComm), meta-gouvernance, simulateur de formules, WebSocket temps reel | Termine | +| Sprint 4 | Decisions multi-etapes (workflow, avancement, vote lie), mandats (candidature, election, assignation, revocation), vote nuance | Termine | +| Sprint 5 | Stabilisation, documentation complete, deploiement production, audit securite | En cours | + +## Version et statut + +- **Version** : 1.0.0-rc +- **Statut** : Release candidate -- Sprint 5 (documentation et stabilisation) +- **Depot** : [git.duniter.org/tools/glibredecision](https://git.duniter.org/tools/glibredecision) ## Sections -- [Architecture](/dev/architecture) -- Vue d'ensemble de l'architecture -- [Reference API](/dev/api-reference) -- Endpoints et schemas -- [Schema de base de donnees](/dev/database-schema) -- Tables et relations -- [Formules](/dev/formulas) -- Formules mathematiques de seuil -- [Integration blockchain](/dev/blockchain-integration) -- Duniter V2, IPFS, on-chain -- [Contribution](/dev/contributing) -- Guide de contribution +### Architecture et conception + +- [Architecture](/dev/architecture) -- Vue d'ensemble de l'architecture, stack, flux de communication + +### Reference technique + +- [Reference API](/dev/api-reference) -- Tous les endpoints REST et WebSocket avec schemas +- [Schema de base de donnees](/dev/database-schema) -- Tables, colonnes, relations et diagramme + +### Domaines fonctionnels + +- [Formules](/dev/formulas) -- Formules mathematiques de seuil WoT, criteres Smith/TechComm, simulateur, meta-gouvernance +- [Integration blockchain](/dev/blockchain-integration) -- Duniter V2 RPC, IPFS, ancrage on-chain + +### Operations + +- [Deploiement](/dev/deployment) -- Docker, Traefik, migrations, sauvegarde, mise a jour, troubleshooting +- [Securite](/dev/security) -- Authentification, integrite des votes, rate limiting, en-tetes, audit + +### Contribution + +- [Guide de contribution](/dev/contributing) -- Installation locale, conventions, tests, processus de contribution diff --git a/docs/content/dev/8.deployment.md b/docs/content/dev/8.deployment.md new file mode 100644 index 0000000..a9e9fae --- /dev/null +++ b/docs/content/dev/8.deployment.md @@ -0,0 +1,405 @@ +--- +title: Deploiement +description: Guide de deploiement en production de Glibredecision +--- + +# Deploiement + +Ce guide couvre le deploiement complet de Glibredecision en production avec Docker, Traefik, PostgreSQL et IPFS. + +## Prerequis + +| Composant | Version minimale | Description | +| --------- | ---------------- | ----------- | +| Docker | 24+ | Moteur de conteneurs | +| Docker Compose | 2.20+ | Orchestration multi-conteneurs | +| Nom de domaine | -- | Domaine pointe vers le serveur (ex: `glibredecision.org`) | +| Certificat TLS | -- | Genere automatiquement par Traefik via Let's Encrypt | +| Traefik | 2.10+ | Reverse proxy avec terminaison TLS (reseau externe `traefik`) | +| Serveur | 2 vCPU, 4 Go RAM, 40 Go SSD | Ressources recommandees | + +### Reseau Traefik + +Le deploiement suppose qu'un reseau Docker externe `traefik` existe deja avec une instance Traefik configuree. Si ce n'est pas le cas, creez-le : + +```bash +docker network create traefik +``` + +Et deployez Traefik separement (voir section [Configuration Traefik](#configuration-traefik)). + +## Configuration .env + +Copiez le fichier `.env.example` et configurez chaque variable pour la production : + +```bash +cp .env.example .env +``` + +### Variables d'environnement + +| Variable | Description | Valeur par defaut | Production | +| -------- | ----------- | ----------------- | ---------- | +| `POSTGRES_DB` | Nom de la base de donnees | `glibredecision` | `glibredecision` | +| `POSTGRES_USER` | Utilisateur PostgreSQL | `glibredecision` | `glibredecision` | +| `POSTGRES_PASSWORD` | Mot de passe PostgreSQL | `change-me-in-production` | **Generer un mot de passe fort** (32+ caracteres) | +| `DATABASE_URL` | URL de connexion asyncpg | `postgresql+asyncpg://...@localhost:5432/...` | Construite automatiquement dans docker-compose | +| `SECRET_KEY` | Cle secrete pour les tokens de session | `change-me-in-production-with-a-real-secret-key` | **Generer une cle aleatoire** (`openssl rand -hex 64`) | +| `DEBUG` | Mode debug | `true` | **`false`** | +| `CORS_ORIGINS` | Origines CORS autorisees | `["http://localhost:3002"]` | `["https://glibredecision.org"]` | +| `DUNITER_RPC_URL` | URL du noeud Duniter V2 RPC | `wss://gdev.p2p.legal/ws` | URL du noeud de production | +| `IPFS_API_URL` | URL de l'API IPFS (kubo) | `http://localhost:5001` | `http://ipfs:5001` (interne Docker) | +| `IPFS_GATEWAY_URL` | URL de la passerelle IPFS | `http://localhost:8080` | `http://ipfs:8080` (interne Docker) | +| `NUXT_PUBLIC_API_BASE` | URL publique de l'API pour le frontend | `http://localhost:8002/api/v1` | `https://glibredecision.org/api/v1` | +| `DOMAIN` | Nom de domaine | `glibredecision.org` | Votre domaine | + +### Generer les secrets + +```bash +# Generer POSTGRES_PASSWORD +openssl rand -hex 32 + +# Generer SECRET_KEY +openssl rand -hex 64 +``` + +::callout{type="warning"} +Ne commitez jamais le fichier `.env` contenant les secrets de production. Ajoutez-le au `.gitignore`. +:: + +## Lancement avec docker-compose + +### Premier deploiement + +```bash +# Se placer dans le repertoire du projet +cd /opt/glibredecision + +# Cloner le depot +git clone https://git.duniter.org/tools/glibredecision.git . + +# Configurer l'environnement +cp .env.example .env +# Editer .env avec les valeurs de production + +# Construire et demarrer les services +docker compose -f docker/docker-compose.yml up -d --build + +# Verifier que tous les services sont sains +docker compose -f docker/docker-compose.yml ps +``` + +### Services deployes + +| Service | Description | Port interne | Expose | +| ------- | ----------- | ------------ | ------ | +| `postgres` | Base de donnees PostgreSQL 16 | 5432 | Non (interne uniquement) | +| `backend` | API FastAPI | 8002 | Via Traefik (`/api/*`) | +| `frontend` | Application Nuxt 4 | 3000 | Via Traefik (racine) | +| `ipfs` | Noeud IPFS kubo | 5001, 8080 | Non (interne uniquement) | + +### Verifier le deploiement + +```bash +# Statut des conteneurs +docker compose -f docker/docker-compose.yml ps + +# Logs d'un service specifique +docker compose -f docker/docker-compose.yml logs -f backend + +# Health check de l'API +curl -s https://glibredecision.org/api/health | jq . +``` + +## Migration de base de donnees (Alembic) + +Glibredecision utilise Alembic pour les migrations de schema PostgreSQL. + +### Appliquer les migrations + +```bash +# Executer les migrations dans le conteneur backend +docker compose -f docker/docker-compose.yml exec backend alembic upgrade head +``` + +### Verifier l'etat des migrations + +```bash +docker compose -f docker/docker-compose.yml exec backend alembic current +``` + +### Creer une nouvelle migration + +```bash +# En developpement uniquement +docker compose -f docker/docker-compose.yml exec backend alembic revision --autogenerate -m "description de la migration" +``` + +### Rollback d'une migration + +```bash +# Revenir d'une migration +docker compose -f docker/docker-compose.yml exec backend alembic downgrade -1 +``` + +## Seed des donnees initiales + +Apres la premiere migration, vous pouvez charger les donnees de seed (documents de reference initiaux) : + +```bash +# Executer le script de seed dans le conteneur backend +docker compose -f docker/docker-compose.yml exec backend python -m app.seed +``` + +Les donnees de seed incluent : + +- La Licence G1 avec ses items +- L'Engagement Forgeron v2.0.0 +- L'Engagement Comite Technique v2.0.0 +- Le template de processus de Runtime Upgrade +- Les protocoles de vote par defaut avec leurs formules + +## Configuration Traefik + +### Configuration minimale Traefik + +Si vous n'avez pas encore Traefik, voici une configuration minimale. Creez un fichier `docker-compose.traefik.yml` : + +```yaml +version: "3.9" + +services: + traefik: + image: traefik:v2.10 + restart: unless-stopped + command: + - "--api.dashboard=false" + - "--providers.docker=true" + - "--providers.docker.exposedbydefault=false" + - "--providers.docker.network=traefik" + - "--entrypoints.web.address=:80" + - "--entrypoints.websecure.address=:443" + - "--entrypoints.web.http.redirections.entryPoint.to=websecure" + - "--entrypoints.web.http.redirections.entryPoint.scheme=https" + - "--certificatesresolvers.letsencrypt.acme.tlschallenge=true" + - "--certificatesresolvers.letsencrypt.acme.email=admin@glibredecision.org" + - "--certificatesresolvers.letsencrypt.acme.storage=/letsencrypt/acme.json" + ports: + - "80:80" + - "443:443" + volumes: + - "/var/run/docker.sock:/var/run/docker.sock:ro" + - "letsencrypt:/letsencrypt" + networks: + - traefik + +volumes: + letsencrypt: + +networks: + traefik: + external: true +``` + +```bash +docker compose -f docker-compose.traefik.yml up -d +``` + +### Routage + +Le `docker-compose.yml` de Glibredecision configure automatiquement les labels Traefik : + +- **Frontend** : `Host(glibredecision.org)` sur le port 3000 +- **Backend** : `Host(glibredecision.org) && PathPrefix(/api)` sur le port 8002 +- Les deux utilisent le certificat Let's Encrypt (`certresolver=letsencrypt`) +- Redirection HTTP vers HTTPS automatique + +### Certificat TLS + +Le certificat est genere automatiquement par Traefik via Let's Encrypt (challenge TLS-ALPN-01). Assurez-vous que : + +1. Le domaine pointe vers l'adresse IP du serveur (enregistrement DNS A) +2. Les ports 80 et 443 sont ouverts dans le firewall +3. L'adresse email dans la configuration Traefik est valide + +## Monitoring + +### Health checks + +Le service PostgreSQL dispose d'un health check integre (`pg_isready`). Le backend expose un endpoint de sante : + +```bash +# Health check de l'API +curl -s https://glibredecision.org/api/health +# Reponse attendue : {"status": "healthy"} +``` + +### Logs + +```bash +# Suivre les logs de tous les services +docker compose -f docker/docker-compose.yml logs -f + +# Logs d'un service specifique +docker compose -f docker/docker-compose.yml logs -f backend +docker compose -f docker/docker-compose.yml logs -f frontend +docker compose -f docker/docker-compose.yml logs -f postgres + +# Derniers 100 lignes +docker compose -f docker/docker-compose.yml logs --tail=100 backend +``` + +### Metriques + +Surveillez les indicateurs suivants : + +| Indicateur | Commande | Seuil d'alerte | +| ---------- | -------- | --------------- | +| CPU/RAM conteneurs | `docker stats` | > 80% RAM | +| Espace disque | `df -h` | > 85% | +| Connexions PostgreSQL | `docker exec postgres psql -U glibredecision -c "SELECT count(*) FROM pg_stat_activity;"` | > 80 | +| Taille base de donnees | `docker exec postgres psql -U glibredecision -c "SELECT pg_size_pretty(pg_database_size('glibredecision'));"` | Information | +| Statut IPFS | `docker exec ipfs ipfs id` | Erreur | + +## Sauvegarde PostgreSQL + +### Sauvegarde manuelle + +```bash +# Dump complet de la base +docker compose -f docker/docker-compose.yml exec postgres \ + pg_dump -U glibredecision -Fc glibredecision > backup_$(date +%Y%m%d_%H%M%S).dump +``` + +### Restauration + +```bash +# Restaurer un dump +docker compose -f docker/docker-compose.yml exec -T postgres \ + pg_restore -U glibredecision -d glibredecision --clean < backup_20260228_120000.dump +``` + +### Sauvegarde automatique (cron) + +Ajoutez un crontab pour des sauvegardes quotidiennes : + +```bash +# Editer le crontab +crontab -e + +# Ajouter une sauvegarde quotidienne a 3h du matin +0 3 * * * cd /opt/glibredecision && docker compose -f docker/docker-compose.yml exec -T postgres pg_dump -U glibredecision -Fc glibredecision > /opt/backups/glibredecision_$(date +\%Y\%m\%d).dump && find /opt/backups -name "glibredecision_*.dump" -mtime +30 -delete +``` + +Cette commande : +1. Execute un dump quotidien a 3h du matin +2. Stocke les sauvegardes dans `/opt/backups/` +3. Supprime automatiquement les sauvegardes de plus de 30 jours + +## Mise a jour + +### Procedure standard + +```bash +cd /opt/glibredecision + +# 1. Tirer les dernieres images +docker compose -f docker/docker-compose.yml pull + +# 2. Redemarrer les services avec les nouvelles images +docker compose -f docker/docker-compose.yml up -d --remove-orphans + +# 3. Appliquer les migrations de base de donnees +docker compose -f docker/docker-compose.yml exec backend alembic upgrade head + +# 4. Nettoyer les anciennes images +docker image prune -f + +# 5. Verifier le deploiement +docker compose -f docker/docker-compose.yml ps +curl -s https://glibredecision.org/api/health +``` + +### Pipeline CI/CD (Woodpecker) + +La pipeline Woodpecker CI automatise le deploiement lors d'un push sur `main` : + +1. **test-backend** : execution des tests Python (`pytest`) +2. **test-frontend** : build du frontend Nuxt (`npm run build`) +3. **docker-backend** : construction et push de l'image Docker backend +4. **docker-frontend** : construction et push de l'image Docker frontend +5. **deploy** : connexion SSH au serveur, pull des nouvelles images, redemarrage + +Les secrets CI sont configures dans l'interface Woodpecker : + +| Secret | Description | +| ------ | ----------- | +| `docker_registry` | URL du registre Docker | +| `docker_username` | Identifiant du registre | +| `docker_password` | Mot de passe du registre | +| `deploy_host` | Adresse du serveur de production | +| `deploy_username` | Utilisateur SSH | +| `deploy_key` | Cle privee SSH | + +## Troubleshooting + +### Le backend ne demarre pas + +**Symptome** : Le conteneur backend redemoarre en boucle. + +```bash +# Verifier les logs +docker compose -f docker/docker-compose.yml logs backend + +# Causes courantes : +# 1. DATABASE_URL incorrecte -> verifier .env +# 2. PostgreSQL pas encore pret -> verifier le health check +# 3. Migrations non appliquees -> executer alembic upgrade head +``` + +### Erreur de connexion a PostgreSQL + +**Symptome** : `connection refused` ou `password authentication failed`. + +```bash +# Verifier que PostgreSQL est sain +docker compose -f docker/docker-compose.yml exec postgres pg_isready + +# Verifier les variables d'environnement +docker compose -f docker/docker-compose.yml exec backend env | grep DATABASE + +# Reinitialiser le mot de passe si necessaire (attention : destructif) +docker compose -f docker/docker-compose.yml down -v # supprime les volumes +docker compose -f docker/docker-compose.yml up -d # recree avec le nouveau mot de passe +``` + +### Certificat TLS non genere + +**Symptome** : Le site est inaccessible en HTTPS ou affiche un certificat invalide. + +1. Verifier que le DNS A/AAAA pointe vers le serveur : `dig glibredecision.org` +2. Verifier que les ports 80 et 443 sont ouverts : `ss -tlnp | grep -E '80|443'` +3. Consulter les logs Traefik : `docker logs traefik 2>&1 | grep -i acme` + +### IPFS ne repond pas + +**Symptome** : Erreur lors de l'archivage dans le Sanctuaire. + +```bash +# Verifier le statut du noeud IPFS +docker compose -f docker/docker-compose.yml exec ipfs ipfs id + +# Verifier l'espace disque disponible +docker compose -f docker/docker-compose.yml exec ipfs df -h /data/ipfs + +# Redemarrer le noeud IPFS +docker compose -f docker/docker-compose.yml restart ipfs +``` + +### Erreur WebSocket + +**Symptome** : L'indicateur de connexion temps reel est rouge. + +1. Verifier que Traefik supporte les WebSockets (actif par defaut) +2. Verifier le routage : le WebSocket est sur `/api/v1/ws` +3. Verifier les logs backend pour les erreurs de connexion WebSocket diff --git a/docs/content/dev/9.security.md b/docs/content/dev/9.security.md new file mode 100644 index 0000000..435d691 --- /dev/null +++ b/docs/content/dev/9.security.md @@ -0,0 +1,278 @@ +--- +title: Securite +description: Politique de securite et mesures de protection de Glibredecision +--- + +# Securite + +Ce document decrit les mesures de securite implementees dans Glibredecision pour proteger l'integrite de la plateforme, des votes et des donnees des utilisateurs. + +## Authentification Duniter V2 (Ed25519 challenge-response) + +### Principe + +Glibredecision n'utilise ni mot de passe ni systeme d'inscription classique. L'authentification repose entierement sur la cryptographie Ed25519 de la blockchain Duniter V2. + +### Flux challenge-response + +``` +Client Serveur + | | + |-- POST /auth/challenge ----------->| + | { address: "5Grw..." } | + | |-- Genere challenge (64 hex) + | |-- Stocke en memoire (TTL 5 min) + |<-------- { challenge: "a1b2..." } -| + | | + |-- Signe le challenge localement | + | (cle privee Ed25519) | + | | + |-- POST /auth/verify ------------->| + | { address, challenge, signature}| + | |-- Verifie signature Ed25519 + | |-- Verifie identite WoT + | |-- Cree/retrouve DuniterIdentity + | |-- Genere token de session + |<-------- { token: "..." } --------| +``` + +### Garanties de securite + +| Propriete | Mecanisme | +| --------- | --------- | +| **Cle privee jamais transmise** | Seule la signature est envoyee, pas la cle | +| **Anti-replay** | Chaque challenge est usage unique, expire apres 5 minutes | +| **Anti-interception** | HTTPS/TLS obligatoire en production | +| **Identite verifiee** | L'adresse SS58 est verifiee sur la blockchain via `substrate-interface` | + +### Verification de la signature + +```python +from substrateinterface import Keypair + +keypair = Keypair(ss58_address=address) +is_valid = keypair.verify(challenge_bytes, signature_bytes) +``` + +La cle publique est derivee de l'adresse SS58 sans aucun appel reseau. La verification est locale et instantanee. + +## Integrite des votes + +### Signature cryptographique des votes + +Chaque vote soumis est accompagne d'une **signature Ed25519** qui garantit son authenticite et son integrite. + +### Payload signe + +Le payload signe contient : + +```json +{ + "session_id": "uuid-de-la-session", + "vote_value": "for", + "timestamp": "2026-02-28T12:00:00Z" +} +``` + +Ce payload est signe par la cle privee du votant. La signature est stockee en base de donnees avec le vote, permettant une verification independante a tout moment. + +### Proprietes garanties + +| Propriete | Description | +| --------- | ----------- | +| **Authenticite** | Seul le proprietaire de l'adresse Duniter peut soumettre un vote en son nom | +| **Integrite** | Le payload ne peut pas etre modifie apres signature sans invalider la signature | +| **Non-repudiation** | Le votant ne peut pas nier avoir vote ; la preuve cryptographique est publique | +| **Transparence** | Les votes signes sont publics et verifiables par quiconque | + +### Verification d'un vote + +```python +from substrateinterface import Keypair + +keypair = Keypair(ss58_address=voter_address) +is_valid = keypair.verify(signed_payload.encode(), signature_bytes) +``` + +### Protection contre la modification + +Quand un votant modifie son vote, l'ancien vote est marque `is_active = false` mais conserve en base. Les deux votes (ancien et nouveau) conservent leur signature. L'historique complet est auditable. + +## Rate limiting + +### Limites par endpoint + +Pour prevenir les abus et les attaques par deni de service, des limites de taux sont appliquees : + +| Categorie | Endpoints | Limite | Fenetre | +| --------- | --------- | ------ | ------- | +| Authentification | `/auth/challenge`, `/auth/verify` | 10 requetes | 1 minute | +| Vote | `/votes/sessions/{id}/vote` | 5 requetes | 1 minute | +| Ecriture | `POST`, `PUT`, `DELETE` | 30 requetes | 1 minute | +| Lecture | `GET` | 200 requetes | 1 minute | +| WebSocket | `/ws` | 3 connexions | simultanees par IP | + +### Reponse en cas de depassement + +Le serveur retourne un code HTTP **429 Too Many Requests** avec un header `Retry-After` indiquant le temps d'attente en secondes. + +## En-tetes de securite + +Les en-tetes HTTP suivants sont configures en production : + +| En-tete | Valeur | Description | +| ------- | ------ | ----------- | +| `Strict-Transport-Security` | `max-age=63072000; includeSubDomains; preload` | Force HTTPS pendant 2 ans, inclut les sous-domaines | +| `Content-Security-Policy` | `default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self' wss:; img-src 'self' data: https://ipfs.io` | Restreint les sources de contenu autorisees | +| `X-Content-Type-Options` | `nosniff` | Empeche le navigateur de deviner le type MIME | +| `X-Frame-Options` | `DENY` | Empeche l'inclusion dans un iframe (anti-clickjacking) | +| `X-XSS-Protection` | `1; mode=block` | Active la protection XSS du navigateur | +| `Referrer-Policy` | `strict-origin-when-cross-origin` | Limite les informations de referer | +| `Permissions-Policy` | `camera=(), microphone=(), geolocation=()` | Desactive les APIs sensibles non necessaires | + +### CSP detaillee + +La Content Security Policy est configuree pour : + +- Autoriser les scripts uniquement depuis l'origine (`'self'`) +- Autoriser les styles inline (necessaires pour UnoCSS) +- Autoriser les connexions WebSocket (`wss:`) +- Autoriser les images depuis IPFS gateway (`https://ipfs.io`) pour l'affichage des contenus archives +- Bloquer tout le reste + +## Sanctuaire : triple preuve d'integrite + +### Chaine de verification + +``` +Contenu --> [SHA-256] --> hash + | + +---> [IPFS] --> CID (content-addressable) + | + +---> [system.remark on-chain] --> preuve horodatee + | + v + Triple preuve : + 1. Hash local = hash enregistre (integrite) + 2. CID IPFS = contenu identique (distribution) + 3. Remark on-chain = hash confirme (immutabilite temporelle) +``` + +### Garanties + +| Preuve | Ce qu'elle garantit | Point de defaillance | +| ------ | ------------------- | -------------------- | +| SHA-256 | Le contenu n'a pas ete modifie | Aucun (mathematique) | +| IPFS CID | Le contenu est distribue et adressable par contenu | Disponibilite des noeuds IPFS | +| On-chain remark | Le hash existait a la date du bloc | Securite de la blockchain Duniter | + +### Format du remark on-chain + +``` +glibredecision:sanctuary:{content_hash_sha256} +``` + +Le prefixe `glibredecision:sanctuary:` permet d'identifier les ancrages de Glibredecision parmi tous les remarks de la blockchain. + +## WebSocket : authentification et securite + +### Authentification par token + +La connexion WebSocket est authentifiee via le token de session obtenu lors du challenge-response : + +``` +ws://server/api/v1/ws?token={session_token} +``` + +Le serveur verifie le token avant d'accepter la connexion. Les connexions non authentifiees sont rejetees. + +### Protections WebSocket + +| Protection | Description | +| ---------- | ----------- | +| Token valide requis | Pas de connexion anonyme | +| Limite de connexions | Maximum 3 connexions simultanees par IP | +| Heartbeat | Ping/pong periodique pour detecter les connexions mortes | +| Taille maximale des messages | Limite a 64 Ko pour prevenir les abus | +| Broadcast read-only | Les clients recoivent les mises a jour mais ne peuvent pas modifier l'etat via WebSocket | + +## Session management + +### Tokens de session + +| Propriete | Valeur | +| --------- | ------ | +| Duree de vie (TTL) | 24 heures | +| Stockage serveur | Hash SHA-256 du token en base (table `sessions`) | +| Stockage client | Token en clair dans le `localStorage` du navigateur | +| Invalidation | Suppression de l'entree en base via `/auth/logout` | + +### Securite des tokens + +- Le token est genere avec un generateur cryptographiquement sur (CSPRNG) +- Seul le **hash** du token est stocke en base de donnees. Si la base est compromise, les tokens bruts ne sont pas exposes. +- A chaque requete authentifiee, le token fourni est hashe et compare au hash en base +- Les tokens expires sont nettoyes periodiquement + +### Deconnexion + +L'appel a `POST /auth/logout` invalide la session cote serveur en supprimant l'entree de la table `sessions`. Le client supprime le token de son `localStorage`. + +## Audit logging + +### Evenements traces + +Les actions suivantes sont enregistrees pour auditabilite : + +| Evenement | Donnees enregistrees | +| --------- | -------------------- | +| Authentification reussie | Adresse SS58, timestamp, IP | +| Authentification echouee | Adresse SS58, raison, timestamp, IP | +| Vote soumis | Session ID, voter ID, vote, signature, timestamp | +| Vote modifie | Session ID, voter ID, ancien vote (desactive), nouveau vote | +| Decision creee/avancee | Decision ID, auteur, action, timestamp | +| Document archive | Document ID, hash, CID, tx_hash | +| Session de vote cloturee | Session ID, resultat, seuils | +| Mandat assigne/revoque | Mandate ID, mandataire, action | + +### Conservation + +Les logs d'audit sont conserves de maniere permanente dans la base de donnees. Les votes et leurs signatures sont particulierement importants car ils constituent la preuve cryptographique des decisions collectives. + +## Signalement de vulnerabilite (Responsible disclosure) + +### Processus + +Si vous decouvrez une vulnerabilite de securite dans Glibredecision, merci de suivre cette procedure de divulgation responsable : + +1. **Ne divulguez pas publiquement** la vulnerabilite avant qu'un correctif soit disponible. +2. **Contactez l'equipe** via le canal securise indique sur le depot Git Duniter ou via le forum Duniter (message prive aux mainteneurs). +3. **Decrivez la vulnerabilite** avec autant de details que possible : + - Type de vulnerabilite (injection, XSS, CSRF, contournement d'authentification, etc.) + - Etapes pour reproduire + - Impact potentiel + - Suggestion de correctif (si applicable) +4. **Delai de correction** : l'equipe s'engage a accuser reception sous 48 heures et a fournir un correctif sous 14 jours pour les vulnerabilites critiques. +5. **Credit** : les chercheurs en securite qui signalent des vulnerabilites de maniere responsable seront credites dans le changelog (sauf s'ils preferent l'anonymat). + +### Perimetre + +Le perimetre de la politique de securite couvre : + +| Inclus | Exclus | +| ------ | ------ | +| API backend (FastAPI) | Infrastructure d'hebergement tiers | +| Frontend (Nuxt 4) | Blockchain Duniter V2 elle-meme | +| Authentification challenge-response | Extension Polkadot.js | +| Integrite des votes et signatures | Noeud IPFS (kubo) lui-meme | +| WebSocket | Traefik | +| Base de donnees (acces, injection) | -- | + +### Exclusions + +Les types de signalement suivants sont hors perimetre : + +- Deni de service par volume (DDoS) +- Ingenierie sociale +- Attaques physiques sur l'infrastructure +- Vulnerabilites dans les dependances tierces deja connues et trackees (utiliser les issues du depot) diff --git a/docs/content/user/1.index.md b/docs/content/user/1.index.md index 720095e..f7a961d 100644 --- a/docs/content/user/1.index.md +++ b/docs/content/user/1.index.md @@ -11,18 +11,39 @@ Bienvenue dans la documentation utilisateur de Glibredecision, la plateforme de Glibredecision est une plateforme de gouvernance decentralisee qui permet aux membres de la Toile de Confiance (WoT) Duniter de : -- Gerer des **documents de reference** modulaires (Licence G1, Engagements Forgeron, etc.) sous vote permanent -- Prendre des **decisions collectives** via des processus multi-etapes -- **Voter** avec un systeme de seuil adaptatif base sur la participation -- Attribuer des **mandats** a des membres de la communaute -- **Archiver de maniere immuable** les decisions adoptees via IPFS et la blockchain Duniter +- Gerer des **documents de reference** modulaires (Licence G1, Engagements Forgeron, Reglement du Comite Technique, etc.) sous vote permanent +- Prendre des **decisions collectives** via des processus multi-etapes (qualification, examen, vote, execution, rapport) +- **Voter** avec un systeme de seuil adaptatif base sur la participation : plus la participation est faible, plus le seuil est exigeant (mecanisme d'inertie) +- Attribuer des **mandats** a des membres de la communaute via des elections formelles +- **Archiver de maniere immuable** les decisions adoptees via IPFS et la blockchain Duniter (triple preuve SHA-256 + IPFS + on-chain) -## Sections +La plateforme est entierement transparente : tous les votes sont publics, signes cryptographiquement et verifiables par quiconque. -- [Premiers pas](/user/getting-started) -- Connexion et prise en main -- [Documents](/user/documents) -- Consulter et proposer des modifications aux documents de reference -- [Decisions](/user/decisions) -- Comprendre et participer aux processus decisionnels -- [Vote](/user/voting) -- Comment voter et comprendre les resultats -- [Mandats](/user/mandates) -- Mandats et responsabilites -- [Sanctuaire](/user/sanctuary) -- Archivage immuable et verification -- [FAQ](/user/faq) -- Questions frequentes +## Guides disponibles + +| Guide | Description | +| ----- | ----------- | +| [Premiers pas](/user/getting-started) | Prerequis, premiere connexion, tour de l'interface, premier vote | +| [Documents](/user/documents) | Consulter les documents de reference, proposer des modifications, vote permanent | +| [Decisions](/user/decisions) | Comprendre et participer aux processus decisionnels multi-etapes | +| [Vote](/user/voting) | Types de vote, formule de seuil expliquee, comment voter, jauge de seuil, simulateur | +| [Mandats](/user/mandates) | Elections, candidatures, assignation, reporting, revocation | +| [Sanctuaire](/user/sanctuary) | Archivage immuable, triple preuve, verification d'integrite | +| [FAQ](/user/faq) | Reponses aux questions frequentes sur l'authentification, le vote, les documents, la securite | + +## Par ou commencer ? + +1. **Nouveau sur Glibredecision ?** Commencez par le guide [Premiers pas](/user/getting-started) pour vous connecter et decouvrir l'interface. +2. **Vous voulez voter ?** Consultez le guide [Vote](/user/voting) pour comprendre les types de vote et la formule de seuil. +3. **Vous voulez proposer une modification ?** Le guide [Documents](/user/documents) explique comment proposer des modifications aux textes fondateurs. +4. **Une question ?** La [FAQ](/user/faq) repond aux questions les plus courantes. + +## Contribuer a la documentation + +Cette documentation est elle-meme un document en evolution. Si vous constatez une erreur, une imprecision ou un manque, vous pouvez : + +- Ouvrir une issue sur le [depot Git](https://git.duniter.org/tools/glibredecision) de Glibredecision +- Proposer une modification directement via une merge request +- En discuter sur le [forum Duniter](https://forum.duniter.org) + +La documentation est redigee en Markdown et fait partie du code source du projet (dossier `docs/`). Les contributions suivent le meme processus que le code : branche, merge request, revue. diff --git a/docs/content/user/2.getting-started.md b/docs/content/user/2.getting-started.md index f247e97..a22d7cb 100644 --- a/docs/content/user/2.getting-started.md +++ b/docs/content/user/2.getting-started.md @@ -5,6 +5,29 @@ description: Connexion et prise en main de Glibredecision # Premiers pas +Ce guide vous accompagne de votre premiere visite jusqu'a votre premier vote sur Glibredecision. + +## Prerequis + +Avant de commencer, assurez-vous de disposer de : + +| Prerequis | Description | Ou l'obtenir | +| --------- | ----------- | ------------ | +| **Navigateur moderne** | Chrome, Firefox, Edge ou Safari a jour | Mis a jour automatiquement | +| **Extension Polkadot.js** | Extension de signature pour Substrate/Duniter V2 | [polkadot.js.org/extension](https://polkadot.js.org/extension/) | +| **Identite Duniter V2** | Adresse SS58 avec une identite confirmee dans la Toile de Confiance | Via Cesium2 ou directement sur la blockchain Duniter V2 | + +::callout{type="info"} +Vous pouvez **consulter** les documents, decisions et resultats de vote sans aucune authentification. Seules les actions de participation (voter, proposer, creer) necessitent une identite Duniter. +:: + +### Installer l'extension Polkadot.js + +1. Rendez-vous sur [polkadot.js.org/extension](https://polkadot.js.org/extension/). +2. Installez l'extension pour votre navigateur (Chrome ou Firefox). +3. Creez ou importez votre compte Duniter V2 dans l'extension. +4. Assurez-vous que votre adresse SS58 est bien celle liee a votre identite Duniter. + ## Qui peut utiliser Glibredecision ? Glibredecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duniter V2. Pour utiliser pleinement la plateforme, vous devez posseder une identite Duniter avec une adresse SS58 valide. @@ -12,28 +35,60 @@ Glibredecision est ouvert a tous les membres de la Toile de Confiance (WoT) Duni - **Consultation** : tout visiteur peut consulter les documents, decisions et resultats de vote. - **Participation** (voter, proposer) : reservee aux membres authentifies via leur identite Duniter. -## Connexion +## Premiere connexion -La connexion utilise votre identite Duniter sans jamais transmettre votre cle privee : +La connexion utilise votre identite Duniter sans jamais transmettre votre cle privee. Le mecanisme est un **challenge-response** cryptographique : + +### Comment ca marche (en termes simples) + +Imaginez que la plateforme vous montre une phrase aleatoire et vous demande de la signer avec votre cle privee. C'est comme signer un document avec un stylo que vous seul possedez. La plateforme peut verifier que c'est bien votre signature grace a votre cle publique, sans jamais voir votre cle privee. + +### Etape par etape 1. Cliquez sur **Se connecter** dans la barre de navigation. 2. Saisissez votre **adresse Duniter** (format SS58, par exemple `5GrwvaEF...`). -3. La plateforme vous envoie un **challenge** (texte aleatoire a signer). -4. Signez le challenge avec votre cle privee Ed25519 (via votre portefeuille Duniter ou Cesium). -5. Soumettez la signature. La plateforme verifie que vous etes bien le proprietaire de l'adresse. -6. Vous etes connecte. Un jeton de session est stocke localement (valable 24h). +3. La plateforme vous envoie un **challenge** : un texte aleatoire de 64 caracteres hexadecimaux. +4. Votre extension Polkadot.js (ou votre portefeuille Cesium) vous demande d'autoriser la signature du challenge avec votre cle privee Ed25519. +5. Signez le challenge. Votre cle privee ne quitte jamais votre appareil. +6. Soumettez la signature. La plateforme verifie que vous etes bien le proprietaire de l'adresse. +7. Vous etes connecte. Un jeton de session est stocke localement (valable 24 heures). -## Navigation +::callout{type="tip"} +Votre cle privee n'est **jamais** transmise au serveur. Seule la signature du challenge est envoyee. C'est le principe fondamental de l'authentification par challenge-response Ed25519. +:: -L'interface est organisee autour de cinq sections principales : +## Tour de l'interface -| Section | Description | -| ------------ | ---------------------------------------------------- | -| Documents | Documents de reference de la communaute | -| Decisions | Processus decisionnels en cours et archives | -| Votes | Sessions de vote actives et resultats | -| Mandats | Mandats attribues aux membres | -| Sanctuaire | Archives immuables (IPFS + blockchain) | +### Barre de navigation + +La barre de navigation en haut de page contient : + +- **Logo Glibredecision** : retour a l'accueil +- **Menu principal** : acces aux cinq sections +- **Bouton de connexion** / **Votre profil** (si connecte) +- **Indicateur temps reel** : point colore indiquant l'etat de la connexion WebSocket + +### Sidebar (menu lateral) + +La sidebar sur la gauche donne acces aux cinq sections principales : + +| Section | Icone | Description | +| ------------ | ----- | ---------------------------------------------------- | +| Documents | -- | Documents de reference de la communaute | +| Decisions | -- | Processus decisionnels en cours et archives | +| Votes | -- | Sessions de vote actives et resultats | +| Mandats | -- | Mandats attribues aux membres | +| Sanctuaire | -- | Archives immuables (IPFS + blockchain) | + +### Dashboard (tableau de bord) + +Apres connexion, le tableau de bord affiche un apercu rapide de : + +- **Votes en cours** : les sessions de vote ouvertes auxquelles vous pouvez participer +- **Vos derniers votes** : historique de vos votes recents +- **Decisions actives** : les processus decisionnels en cours +- **Mandats** : les mandats actifs et en cours d'election +- **Activite recente** : flux des dernieres actions sur la plateforme ## Votre profil @@ -46,6 +101,81 @@ Apres connexion, votre profil affiche : Ces informations sont synchronisees depuis la blockchain Duniter V2 et determinent vos droits de vote. +## Premier vote : voter sur un item de document + +Voici comment participer a votre premier vote en quelques minutes : + +### 1. Trouver un vote ouvert + +- Depuis le tableau de bord, consultez la section **Votes en cours**. +- Ou rendez-vous dans la section **Votes** du menu et filtrez par statut "En cours". +- Les sessions ouvertes sont signalees par un badge vert. + +### 2. Lire le sujet + +- Cliquez sur la session de vote qui vous interesse. +- Lisez attentivement le texte soumis au vote (document, decision, modification d'item). +- Consultez le diff si c'est une modification : les ajouts sont en vert, les suppressions en rouge. + +### 3. Voter + +- **Vote binaire** : cliquez sur **Pour** (vert) ou **Contre** (rouge). +- **Vote nuance** : choisissez un niveau de 0 (CONTRE) a 5 (TOUT A FAIT) sur l'echelle proposee. +- Ajoutez un **commentaire** si vous souhaitez expliquer votre choix (optionnel mais recommande). + +### 4. Signer votre vote + +- Apres avoir choisi, la plateforme genere un payload contenant votre vote, l'identifiant de la session et un horodatage. +- Votre extension Polkadot.js vous demande de signer ce payload. +- Signez, puis cliquez sur **Soumettre**. + +### 5. Verification + +- Un message de confirmation apparait. +- Votre vote est visible dans la liste des votes de la session. +- La jauge de seuil se met a jour en temps reel pour refleter votre vote. + +::callout{type="info"} +Vous pouvez modifier votre vote a tout moment tant que la session est ouverte. L'ancien vote est conserve pour l'audit mais seul le dernier compte. +:: + +## Premiers pas avec les decisions + +Au-dela du vote, vous pouvez participer activement a la gouvernance : + +### Proposer une modification de document + +1. Ouvrez un document de reference dans la section **Documents**. +2. Selectionnez l'item (clause, regle, article) que vous souhaitez modifier. +3. Cliquez sur **Proposer une modification**. +4. Redigez votre nouveau texte et justifiez votre proposition. +5. Soumettez : un diff est genere automatiquement. + +Votre proposition sera examinee et soumise au vote par la communaute. + +### Creer une decision + +1. Rendez-vous dans la section **Decisions**. +2. Cliquez sur **Nouvelle decision**. +3. Renseignez le titre, la description et le contexte. +4. Choisissez le type de decision et le protocole de vote. +5. Definissez les etapes du processus. + +### Consulter le Sanctuaire + +Le Sanctuaire contient les archives immuables de toutes les decisions adoptees, documents archives et resultats de vote. Rendez-vous dans la section **Sanctuaire** pour explorer l'historique verifiable de la communaute. + ## Deconnexion Cliquez sur votre profil puis **Se deconnecter**. La session est invalidee cote serveur et le jeton local est supprime. + +## Pour aller plus loin + +| Guide | Description | +| ----- | ----------- | +| [Documents](/user/documents) | Tout savoir sur les documents de reference et leurs modifications | +| [Decisions](/user/decisions) | Comprendre les processus decisionnels multi-etapes | +| [Vote](/user/voting) | Comprendre les types de vote, la formule de seuil et le simulateur | +| [Mandats](/user/mandates) | Elections et responsabilites au sein de la communaute | +| [Sanctuaire](/user/sanctuary) | Archivage immuable et verification d'integrite | +| [FAQ](/user/faq) | Reponses aux questions frequentes | diff --git a/docs/content/user/3.documents.md b/docs/content/user/3.documents.md index 8e3977f..3d3a9f3 100644 --- a/docs/content/user/3.documents.md +++ b/docs/content/user/3.documents.md @@ -5,9 +5,11 @@ description: Guide des documents de reference sur Glibredecision # Documents de reference -## Principe +## Qu'est-ce qu'un document de reference ? -Les documents de reference sont les textes fondateurs de la communaute Duniter/G1. Ils sont **modulaires** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. +Un document de reference est un **texte fondateur** de la communaute Duniter/G1. Il peut s'agir d'une licence monetaire, d'un engagement que les membres s'engagent a respecter, d'un reglement interieur ou d'un texte constitutif. Ces documents definissent les regles, les valeurs et le fonctionnement de la communaute. + +Ce qui rend Glibredecision unique, c'est que ces documents sont **modulaires** et sous **vote permanent** : chaque document est compose d'items individuels (clauses, regles, verifications, preambules, sections) qui peuvent etre modifies independamment par proposition et vote. La communaute peut faire evoluer ses textes de maniere continue, sans procedure lourde ni periode speciale. ## Types de documents @@ -18,6 +20,38 @@ Les documents de reference sont les textes fondateurs de la communaute Duniter/G | Reglement | Reglement interieur d'un organe | Reglement du Comite Technique | | Constitution | Texte constitutif fondamental | -- | +## Structure : Document, Items et Versions + +Un document est organise selon une structure a trois niveaux : + +``` +Document (ex: Licence G1 v2.0.0) + | + +-- Item 1 (clause 1 : Preambule) + | |-- Version 1.0 (texte courant) + | |-- Version 1.1 (proposition en attente) + | + +-- Item 2 (regle 2.1 : Conditions d'adhesion) + | |-- Version 1.0 (texte courant) + | + +-- Item 3 (verification 3.1 : Distance rule) + |-- Version 1.0 (texte courant) + |-- Version 1.1 (proposition rejetee) + |-- Version 1.2 (proposition en vote) +``` + +### Document + +Le document est le conteneur. Il possede un titre, un type, un numero de version semantique (ex: `2.0.0`), un statut et une description. + +### Item + +Chaque item est une unite modulaire du document : une clause, une regle, une verification, un preambule ou une section. Chaque item a une position hierarchique (ex: "1", "1.1", "3.2") et un texte courant. + +### Version + +Chaque modification proposee a un item cree une nouvelle version. La version contient le texte propose, un diff automatique par rapport au texte courant, et la justification de l'auteur. Les versions suivent un cycle de vie : proposee, en vote, acceptee ou rejetee. + ## Consulter un document 1. Rendez-vous dans la section **Documents**. @@ -53,6 +87,14 @@ La proposition cree une nouvelle **version** de l'item. Cette version passe ensu Plusieurs versions peuvent etre proposees simultanement pour un meme item. Lorsqu'une version est acceptee, toutes les autres versions en attente sont automatiquement rejetees. :: +## Vote permanent sur les items + +Les documents actifs sont sous **vote permanent** : il n'y a pas de periode speciale pour proposer des changements. A tout moment, un membre authentifie peut proposer une modification a n'importe quel item. + +Chaque item peut avoir un **protocole de vote specifique** qui definit les parametres de seuil (duree, majorite, gradient, criteres Smith/TechComm). Si aucun protocole specifique n'est defini, le protocole par defaut du document s'applique. + +Le vote permanent garantit que les textes fondateurs peuvent evoluer de maniere continue et organique, en refletant en permanence la volonte de la communaute. + ## Examiner et accepter/rejeter une version Les membres habilites (selon le protocole de vote associe) peuvent examiner les versions proposees : @@ -106,7 +148,7 @@ Le document est en vigueur et sous **vote permanent**. Tout membre authentifie p ### Archive (archived) -Le document a ete archive dans le **Sanctuaire**. Son contenu est fige et preservee de maniere immuable via : +Le document a ete archive dans le **Sanctuaire**. Son contenu est fige et preserve de maniere immuable via : - Un hash SHA-256 pour garantir l'integrite. - Un stockage sur IPFS pour la distribution decentralisee. @@ -114,6 +156,14 @@ Le document a ete archive dans le **Sanctuaire**. Son contenu est fige et preser Un document archive ne peut plus etre modifie. Pour le consulter, rendez-vous dans la section Sanctuaire. +## Statuts des documents + +| Statut | Description | +| --------- | ------------------------------------------------ | +| Brouillon | En cours de redaction, non soumis au vote | +| Actif | Document en vigueur, sous vote permanent | +| Archive | Document archive dans le Sanctuaire, plus modifiable | + ## Archiver un document dans le Sanctuaire Pour archiver un document actif (necessite une authentification) : @@ -132,14 +182,52 @@ Pour archiver un document actif (necessite une authentification) : L'archivage est une operation irreversible. Une fois archive, le document ne peut plus etre modifie. :: -## Statuts des documents - -| Statut | Description | -| --------- | ------------------------------------------------ | -| Brouillon | En cours de redaction, non soumis au vote | -| Actif | Document en vigueur, sous vote permanent | -| Archive | Document archive dans le Sanctuaire, plus modifiable | - ## Versionnage Chaque document possede un numero de version semantique (ex: `2.0.0`). Chaque modification adoptee peut entrainer une mise a jour de version selon l'importance du changement. + +## Exemple concret : modifier un article de la Licence G1 + +Prenons un exemple complet de bout en bout. Vous souhaitez modifier la regle de distance (item 3.1) de la Licence G1 pour preciser une condition supplementaire. + +### 1. Trouver l'item + +- Rendez-vous dans **Documents** et ouvrez la **Licence G1**. +- Faites defiler jusqu'a l'item **3.1 -- Distance rule** et cliquez dessus. + +### 2. Consulter le texte courant + +- Lisez le texte en vigueur et l'historique des versions precedentes. +- Verifiez que votre modification n'a pas deja ete proposee par quelqu'un d'autre. + +### 3. Proposer votre modification + +- Cliquez sur **Proposer une modification**. +- Redigez votre nouveau texte. Par exemple, ajoutez une precision sur le nombre de pas de distance autorise. +- Dans le champ **Justification**, expliquez pourquoi : "La regle actuelle ne precise pas le cas ou un membre est a la limite exacte de la distance maximale. Cette modification clarifie que la distance est inclusive." +- Soumettez votre proposition. + +### 4. Le diff est genere + +Le systeme genere automatiquement un diff montrant les differences : + +```diff +- Le membre doit respecter la regle de distance WoT. ++ Le membre doit respecter la regle de distance WoT. La distance ++ maximale est inclusive : un membre a exactement 5 pas est conforme. +``` + +### 5. Examen et vote + +- La communaute peut consulter votre proposition, lire votre justification et examiner le diff. +- Si un processus de decision est lance, une session de vote est creee. +- Les membres votent selon le protocole de vote associe a cet item. + +### 6. Resultat + +- **Si acceptee** : votre texte remplace le texte courant de l'item 3.1. Toutes les autres propositions en attente pour cet item sont automatiquement rejetees. Le numero de version du document peut etre mis a jour. +- **Si rejetee** : le texte courant reste inchange. Votre proposition est archivee avec le statut "rejetee" pour transparence. + +### 7. Archivage + +Si la modification est suffisamment importante, le document mis a jour peut etre archive dans le Sanctuaire, creant une trace immuable de cette evolution. diff --git a/docs/content/user/5.voting.md b/docs/content/user/5.voting.md index 545ccf6..f2f816c 100644 --- a/docs/content/user/5.voting.md +++ b/docs/content/user/5.voting.md @@ -19,20 +19,142 @@ Chaque votant choisit **Pour** ou **Contre**. Le seuil est calcule par la formul Chaque votant exprime son opinion sur une echelle a 6 niveaux : -| Niveau | Label | Comptage | -| ------ | ------------- | --------------- | -| 0 | CONTRE | Negatif | -| 1 | PAS DU TOUT | Negatif | -| 2 | PAS D'ACCORD | Negatif | -| 3 | NEUTRE | Positif | -| 4 | D'ACCORD | Positif | -| 5 | TOUT A FAIT | Positif | +| Niveau | Label | Signification | Comptage | +| ------ | ------------- | --------------------------------------------------- | -------- | +| 0 | CONTRE | Opposition totale et ferme | Negatif | +| 1 | PAS DU TOUT | Desaccord fort, points essentiels non remplis | Negatif | +| 2 | PAS D'ACCORD | Desaccord modere, des reserves importantes | Negatif | +| 3 | NEUTRE | Ni pour ni contre, ou acceptation sans enthousiasme | Positif | +| 4 | D'ACCORD | Approbation avec eventuellement des reserves mineures | Positif | +| 5 | TOUT A FAIT | Approbation totale et enthousiaste | Positif | -Le vote est adopte si les niveaux positifs (3, 4, 5) representent au moins 80% des votes et qu'un nombre minimum de participants est atteint. +Le vote nuance est adopte si les niveaux positifs (3, 4, 5) representent au moins **80%** des votes et qu'un nombre minimum de participants est atteint (par defaut 59). -## Comment voter -- Vote binaire +### Quand utiliser le vote nuance ? -### Etape par etape +Le vote nuance est recommande pour : + +- Les textes longs comportant de nombreux articles (les votants peuvent exprimer un accord partiel). +- Les decisions ou la nuance est importante (budget, parametre technique). +- Les cas ou il est utile de mesurer le degre d'adhesion, pas seulement le resultat binaire. + +## La formule de seuil expliquee simplement + +### L'analogie de l'inertie + +Imaginez un gros rocher pose au sommet d'une colline. Pour le deplacer, il faut une force considerable : c'est l'**inertie**. Dans Glibredecision, le rocher represente le statu quo et la force necessaire represente le nombre de votes favorables. + +- **Quand peu de personnes poussent** (faible participation) : il faut que presque tout le monde pousse dans la meme direction. Si seulement 10 personnes sur 7000 votent, il faut que 9 sur 10 soient pour. +- **Quand beaucoup de personnes poussent** (forte participation) : la majorite simple suffit. Si 7000 personnes votent, il suffit que 3500 soient pour (50%). + +Ce mecanisme empeche qu'un petit groupe prenne des decisions engageant toute la communaute, tout en permettant une gouvernance efficace quand la participation est large. + +### La formule + +La formule exacte est : + +``` +Seuil = C + B^W + (M + (1-M) * (1 - (T/W)^G)) * max(0, T-C) +``` + +Ou : + +| Symbole | Signification | Exemple | +| ------- | ------------- | ------- | +| C | Base constante (plancher minimum) | 0 | +| B | Exposant de base (negligeable pour grandes communautes) | 0.1 | +| W | Taille de la Toile de Confiance (membres eligibles) | 7224 | +| T | Nombre total de votes exprimes | 120 | +| M | Ratio de majorite cible a pleine participation | 0.50 (50%) | +| G | Gradient : vitesse de convergence vers la majorite | 0.2 | + +### Exemple concret + +Pour le vote de l'**Engagement Forgeron v2.0.0** : + +- WoT = 7224 membres, 120 votes (1.7% de participation) +- Parametres : M=50%, B=0.1, G=0.2, C=0 +- **Seuil calcule : 94 votes favorables requis** (soit 78% des votants) +- Resultat : 97 pour, 23 contre -- **Adopte** (97 >= 94) + +La faible participation a rendu le seuil exigeant : 78% au lieu de 50%. C'est l'inertie en action. + +### Tableau d'inertie + +Le tableau suivant montre comment l'exigence evolue avec la participation, pour les parametres de reference `M50 G.2` avec une WoT de 7224 : + +| Participation | % de la WoT | % favorables requis | +| ------------: | ----------: | ------------------: | +| 10 | 0.1% | 87.4% | +| 50 | 0.7% | 82.3% | +| 100 | 1.4% | 80.1% | +| 120 | 1.7% | 79.5% | +| 200 | 2.8% | 77.7% | +| 500 | 6.9% | 74.4% | +| 1000 | 13.8% | 71.2% | +| 2000 | 27.7% | 67.7% | +| 5000 | 69.2% | 62.6% | +| 7224 | 100% | 50.0% | + +On constate que meme a 500 votants (6.9%), il faut encore 74.4% de votes favorables. L'inertie ne descend en dessous de 66% qu'au-dela de 2000 votants. + +## Criteres additionnels + +### Critere Smith (Forgerons) + +Certaines decisions exigent un nombre minimum de votes favorables de la part des **forgerons** (membres Smith de la WoT). Ce critere garantit que les decisions qui affectent le reseau sont soutenues par ceux qui le maintiennent. + +Le seuil Smith est calcule par : `ceil(SmithWotSize^S)` ou `S` est l'exposant Smith. + +**Exemple** : Avec 20 forgerons et S=0.1, le seuil est `ceil(20^0.1) = ceil(1.35) = 2`. Il faut au minimum 2 votes favorables de forgerons. + +### Critere TechComm (Comite Technique) + +De maniere similaire, certaines decisions exigent un nombre minimum de votes favorables du **Comite Technique**. Cela concerne les decisions techniques (runtime upgrades, modifications d'infrastructure). + +Le seuil TechComm est calcule par : `ceil(CoTecSize^T)` ou `T` est l'exposant TechComm. + +**Exemple** : Avec 5 membres TechComm et T=0.1, le seuil est `ceil(5^0.1) = ceil(1.17) = 2`. Il faut au minimum 2 votes favorables du Comite Technique. + +### Adoption finale + +Un vote n'est adopte que si **les trois conditions** sont remplies simultanement : + +1. Les votes favorables atteignent le seuil WoT (formule principale) +2. Les votes favorables des forgerons atteignent le seuil Smith (si actif) +3. Les votes favorables du Comite Technique atteignent le seuil TechComm (si actif) + +## Mode params : decoder les parametres + +Les parametres de formule sont encodes dans une chaine compacte pour faciliter la lecture. Voici comment decoder `"D30M50B.1G.2T.1"` : + +| Code | Valeur | Signification | +| ---- | ------ | ------------- | +| D30 | 30 | Duree du vote : **30 jours** | +| M50 | 50% | Majorite cible : **50%** (majorite simple a pleine participation) | +| B.1 | 0.1 | Exposant de base : **0.1** (negligeable pour grandes WoT) | +| G.2 | 0.2 | Gradient : **0.2** (convergence rapide vers la majorite) | +| T.1 | 0.1 | Exposant TechComm : **0.1** (critere TechComm actif) | + +Autres codes possibles : + +| Code | Parametre | Exemple | Signification | +| ---- | --------- | ------- | ------------- | +| C | Base constante | C3 | Minimum 3 votes favorables quelle que soit la formule | +| S | Exposant Smith | S.1 | Critere Smith actif avec exposant 0.1 | +| N | Multiplicateur ratio | N1.5 | Multiplicateur 1.5 en mode ratio | +| R | Mode ratio | R1 | Mode ratio active | + +### Exemples de configurations + +- `"D30M50B.1G.2"` : 30 jours, majorite 50%, configuration standard +- `"D30M50B.1G.2S.1T.1"` : idem avec criteres Smith et TechComm +- `"D60M66B.05G.3"` : 60 jours, super-majorite 66%, gradient plus strict +- `"D14M50B.1G.1"` : 14 jours, gradient rapide (decisions urgentes) + +## Comment voter : etapes concretes + +### Vote binaire 1. **Acceder a la session de vote** : Rendez-vous dans la section **Votes** depuis le menu principal, ou accedez directement a la page d'une decision en cours. Les sessions ouvertes sont signalees par un badge vert "En cours". @@ -48,6 +170,18 @@ Le vote est adopte si les niveaux positifs (3, 4, 5) representent au moins 80% d 6. **Confirmer la soumission** : Une fois la signature effectuee, cliquez sur **Soumettre**. Un message de confirmation apparait avec le resume de votre vote. +### Vote nuance + +1. **Acceder a la session** : Meme procedure que pour le vote binaire. Les sessions de vote nuance sont identifiees par un badge "Nuance" en plus du badge de statut. + +2. **Consulter le sujet** : Lisez le document ou la decision soumise au vote. + +3. **Choisir votre niveau** : Le panneau de vote nuance affiche une echelle a 6 niveaux sous forme de curseur ou de boutons. Les niveaux 3, 4 et 5 sont comptes comme **positifs**. Les niveaux 0, 1 et 2 sont comptes comme **negatifs**. + +4. **Ajouter un commentaire** (optionnel) : Particulierement utile pour les niveaux intermediaires (1, 2, 3, 4), afin d'expliquer vos reserves ou vos attentes. + +5. **Signer et soumettre** : Meme procedure que pour le vote binaire. + ### Modifier son vote Tant que la session est ouverte, vous pouvez changer votre vote : @@ -58,39 +192,7 @@ Tant que la session est ouverte, vous pouvez changer votre vote : 4. Selectionnez votre nouveau choix et signez a nouveau. 5. L'ancien vote est desactive (conserve dans l'historique pour audit) et remplace par le nouveau. -## Comment voter -- Vote nuance - -### Etape par etape - -1. **Acceder a la session** : Meme procedure que pour le vote binaire. Les sessions de vote nuance sont identifiees par un badge "Nuance" en plus du badge de statut. - -2. **Consulter le sujet** : Lisez le document ou la decision soumise au vote. - -3. **Choisir votre niveau** : Le panneau de vote nuance affiche une echelle a 6 niveaux sous forme de curseur ou de boutons : - - | Niveau | Label | Signification | - | -----: | ------------- | ---------------------------------------------------------- | - | 0 | CONTRE | Opposition totale et ferme | - | 1 | PAS DU TOUT | Desaccord fort, points essentiels non remplis | - | 2 | PAS D'ACCORD | Desaccord modere, des reserves importantes | - | 3 | NEUTRE | Ni pour ni contre, ou acceptation sans enthousiasme | - | 4 | D'ACCORD | Approbation avec eventuellement des reserves mineures | - | 5 | TOUT A FAIT | Approbation totale et enthousiaste | - - Les niveaux 3, 4 et 5 sont comptes comme **positifs**. Les niveaux 0, 1 et 2 sont comptes comme **negatifs**. - -4. **Ajouter un commentaire** (optionnel) : Particulierement utile pour les niveaux intermediaires (1, 2, 3, 4), afin d'expliquer vos reserves ou vos attentes. - -5. **Signer et soumettre** : Meme procedure que pour le vote binaire. - -### Quand utiliser le vote nuance ? - -Le vote nuance est recommande pour : -- Les textes longs comportant de nombreux articles (les votants peuvent exprimer un accord partiel). -- Les decisions ou la nuance est importante (budget, parametre technique). -- Les cas ou il est utile de mesurer le degre d'adhesion, pas seulement le resultat binaire. - -## Comprendre la jauge de seuil +## Comprendre les resultats : la jauge de seuil La page de chaque session de vote affiche une **jauge de seuil** qui represente visuellement l'etat du vote en temps reel. @@ -123,6 +225,58 @@ En cliquant sur le bouton **Voir le detail** sous la jauge, une modale s'ouvre a - Les criteres Smith et TechComm si applicables. - Un lien vers le simulateur de formules pour experimenter d'autres scenarios. +### Page de resultat + +La page de resultat affiche : + +| Information | Description | +| ------------------- | ---------------------------------------------------- | +| Votes pour | Nombre de votes favorables | +| Votes contre | Nombre de votes defavorables | +| Total | Nombre total de votes exprimes | +| Taille WoT | Nombre de membres WoT eligibles (snapshot au debut) | +| Seuil requis | Seuil calcule par la formule d'inertie | +| Critere Smith | Seuil et validation des votes Smith (si applicable) | +| Critere TechComm | Seuil et validation des votes TechComm (si applicable) | +| Resultat | **Adopte** ou **Rejete** | + +## Simulateur de formules + +Le simulateur de formules vous permet de tester le comportement du seuil d'adoption avec differentes configurations, sans creer de session de vote. + +### Acceder au simulateur + +1. Depuis le menu principal, selectionnez **Outils** puis **Simulateur de formules**. +2. Vous pouvez aussi y acceder depuis le detail du calcul de seuil d'une session de vote (bouton **Ouvrir le simulateur**). + +### Utiliser le simulateur + +Le simulateur presente un formulaire avec les parametres ajustables : + +| Parametre | Description | Curseur/Champ | +| ----------------------- | ---------------------------------------------- | ------------- | +| Taille WoT ($W$) | Nombre de membres eligibles | Curseur | +| Total votes ($T$) | Nombre de votes exprimes | Curseur | +| Majorite ($M$) | Pourcentage de majorite cible | Curseur | +| Base ($B$) | Exposant de base | Champ | +| Gradient ($G$) | Vitesse de convergence | Curseur | +| Base constante ($C$) | Plancher minimum de votes | Champ | +| Votes Pour | Nombre de votes favorables (pour tester l'adoption) | Curseur | + +En ajustant les curseurs, le resultat se met a jour en temps reel : +- Le **seuil calcule** est affiche. +- Un graphique montre la courbe du seuil en fonction de la participation. +- Le resultat **Adopte** / **Rejete** s'affiche en fonction des votes pour saisis. + +### Exemple de scenario + +Vous souhaitez comparer deux configurations pour un vote attendu avec environ 200 participants sur une WoT de 7224 : + +1. Configuration actuelle `M50 G.2` : Seuil = 156 (77.7% requis). +2. Configuration proposee `M50 G.4` : Seuil = 171 (85.5% requis). + +Le simulateur montre visuellement l'impact : avec un gradient plus eleve, l'exigence est sensiblement plus forte a faible participation. + ## Mises a jour en temps reel Glibredecision utilise une connexion **WebSocket** pour diffuser les mises a jour de vote en temps reel a tous les utilisateurs qui consultent une session de vote. @@ -166,43 +320,6 @@ Sur la page d'une session de vote, l'onglet **Votes** affiche la liste de tous l Les votes sont publics et verifiables par tous. Il n'y a pas de vote secret dans Glibredecision : la transparence est un principe fondamental. -## Simulateur de formules - -Le simulateur de formules vous permet de tester le comportement du seuil d'adoption avec differentes configurations, sans creer de session de vote. - -### Acceder au simulateur - -1. Depuis le menu principal, selectionnez **Outils** puis **Simulateur de formules**. -2. Vous pouvez aussi y acceder depuis le detail du calcul de seuil d'une session de vote (bouton **Ouvrir le simulateur**). - -### Utiliser le simulateur - -Le simulateur presente un formulaire avec les parametres ajustables : - -| Parametre | Description | Curseur/Champ | -| ----------------------- | ---------------------------------------------- | ------------- | -| Taille WoT ($W$) | Nombre de membres eligibles | Curseur | -| Total votes ($T$) | Nombre de votes exprimes | Curseur | -| Majorite ($M$) | Pourcentage de majorite cible | Curseur | -| Base ($B$) | Exposant de base | Champ | -| Gradient ($G$) | Vitesse de convergence | Curseur | -| Base constante ($C$) | Plancher minimum de votes | Champ | -| Votes Pour | Nombre de votes favorables (pour tester l'adoption) | Curseur | - -En ajustant les curseurs, le resultat se met a jour en temps reel : -- Le **seuil calcule** est affiche. -- Un graphique montre la courbe du seuil en fonction de la participation. -- Le resultat **Adopte** / **Rejete** s'affiche en fonction des votes pour saisis. - -### Exemple de scenario - -Vous souhaitez comparer deux configurations pour un vote attendu avec environ 200 participants sur une WoT de 7224 : - -1. Configuration actuelle `M50 G.2` : Seuil = 156 (77.7% requis). -2. Configuration proposee `M50 G.4` : Seuil = 171 (85.5% requis). - -Le simulateur montre visuellement l'impact : avec un gradient plus eleve, l'exigence est sensiblement plus forte a faible participation. - ## Meta-gouvernance : voter sur les regles du vote La meta-gouvernance est la capacite de **modifier les regles du systeme de vote en utilisant le systeme de vote lui-meme**. C'est le mecanisme par lequel la communaute garde le controle sur les parametres fondamentaux de la prise de decision. @@ -250,32 +367,6 @@ Un membre estime que le gradient $G = 0.2$ est trop permissif et propose de pass - **Transparence** : toute modification de regle est tracable et soumise au vote. - **Coherence** : les regles de modification sont les memes que les regles de decision (le systeme s'applique a lui-meme). -## Comprendre les resultats - -La page de resultat affiche : - -| Information | Description | -| ------------------- | ---------------------------------------------------- | -| Votes pour | Nombre de votes favorables | -| Votes contre | Nombre de votes defavorables | -| Total | Nombre total de votes exprimes | -| Taille WoT | Nombre de membres WoT eligibles (snapshot au debut) | -| Seuil requis | Seuil calcule par la formule d'inertie | -| Critere Smith | Seuil et validation des votes Smith (si applicable) | -| Critere TechComm | Seuil et validation des votes TechComm (si applicable) | -| Resultat | **Adopte** ou **Rejete** | - -### Exemple concret - -Pour le vote de l'Engagement Forgeron v2.0.0 : - -- Taille WoT : 7224 membres -- 97 votes pour, 23 votes contre (120 total) -- Seuil calcule : 94 (avec les parametres M50 B.1 G.2) -- Resultat : **Adopte** (97 >= 94) - -La faible participation (120 sur 7224 = 1.7%) a rendu le seuil exigeant (94 pour sur 120 = 78%), bien au-dessus de la majorite simple de 50%. - ## Preuve cryptographique Chaque vote est accompagne d'une signature Ed25519 qui garantit : @@ -296,3 +387,25 @@ Chaque session de vote est liee a un **protocole de vote** qui definit : - Les criteres Smith et TechComm eventuels Les protocoles sont reutilisables et peuvent eux-memes etre soumis a meta-gouvernance. + +## FAQ Vote + +### Le seuil peut-il changer pendant le vote ? + +Oui. Le seuil depend du nombre total de votes exprimes ($T$). A chaque nouveau vote, le seuil est recalcule. Il augmente legerement a mesure que la participation croit, mais converge vers la majorite simple. C'est pourquoi le resultat affiche est toujours provisoire tant que la session est ouverte. + +### Que se passe-t-il si le seuil n'est pas atteint a la cloture ? + +Le vote est **rejete**. La proposition n'est pas adoptee et le statu quo est maintenu. Le resultat detaille est archive dans le Sanctuaire pour transparence. + +### Un vote en cours peut-il etre annule ? + +Seul le createur de la session ou un membre du Comite Technique peut cloturer une session de maniere anticipee. La cloture anticipee calcule le resultat avec les votes exprimes a ce moment. + +### Comment sont geres les ex aequo ? + +Il n'y a pas d'ex aequo possible avec la formule d'inertie. Le seuil est un nombre entier (arrondi inferieur) et le nombre de votes favorables est un entier. Si `votes_pour >= seuil`, le vote est adopte. Sinon, il est rejete. + +### Puis-je voter si mon statut WoT change pendant le vote ? + +Votre statut WoT est enregistre au moment de votre vote. Si vous perdez votre statut de membre apres avoir vote, votre vote reste comptabilise car le snapshot de la taille WoT est pris au debut de la session. diff --git a/docs/content/user/7.sanctuary.md b/docs/content/user/7.sanctuary.md index 3061075..4eb545a 100644 --- a/docs/content/user/7.sanctuary.md +++ b/docs/content/user/7.sanctuary.md @@ -5,21 +5,76 @@ description: Guide de l'archivage immuable sur Glibredecision # Sanctuaire -## Principe +## Qu'est-ce que le Sanctuaire ? -Le Sanctuaire est la couche d'archivage immuable de Glibredecision. Chaque document adopte, resultat de vote ou decision finalisee est archive de maniere permanente grace a trois mecanismes : +Le Sanctuaire est la couche d'**archivage immuable** de Glibredecision. C'est l'endroit ou les decisions adoptees, les documents archives et les resultats de vote sont preserves de maniere permanente et verifiable. -1. **Hash SHA-256** du contenu pour garantir l'integrite -2. **Stockage IPFS** pour la distribution decentralisee -3. **Ancrage on-chain** via `system.remark` sur la blockchain Duniter V2 +Le principe est simple : une fois qu'un contenu entre dans le Sanctuaire, il ne peut plus etre modifie ni supprime. Meme si la plateforme Glibredecision disparaissait, les preuves resteraient accessibles et verifiables de maniere independante. + +## Triple preuve : SHA-256 + IPFS + Blockchain + +Le Sanctuaire repose sur trois mecanismes complementaires qui forment une **triple preuve** d'integrite : + +### 1. Hash SHA-256 -- Preuve d'integrite + +Un hash SHA-256 est un empreinte numerique unique du contenu. Si ne serait-ce qu'un seul caractere du document change, le hash est completement different. En recalculant le hash et en le comparant a celui enregistre, on peut verifier que le contenu n'a pas ete modifie. + +**Analogie** : C'est comme une empreinte digitale. Deux documents differents n'auront jamais la meme empreinte. + +### 2. IPFS -- Stockage distribue et contenu adressable + +IPFS (InterPlanetary File System) est un reseau de stockage distribue. Le contenu n'est pas stocke sur un seul serveur mais replique sur plusieurs noeuds du reseau. Chaque contenu est identifie par un **CID** (Content Identifier) qui est derive de son contenu meme. + +**Analogie** : Au lieu de dire "le document est a l'adresse www.example.com/doc1" (localisation), on dit "le document dont l'empreinte est Qm1234..." (contenu). Peu importe ou il est stocke, tant que l'empreinte correspond, c'est le bon document. + +**Avantages** : + +- **Resilience** : le contenu reste accessible meme si un serveur tombe. +- **Integritegarantie** : le CID change si le contenu change, toute falsification est detectable. +- **Perennite** : tant qu'au moins un noeud heberge le contenu, il est accessible. + +### Acceder au contenu via IPFS + +Chaque entree du Sanctuaire possede un **CID IPFS** qui permet d'acceder au contenu : + +- **Passerelle publique** : `https://ipfs.io/ipfs/{CID}` +- **Passerelle locale** (si vous executez un noeud kubo) : `http://localhost:8080/ipfs/{CID}` + +En cliquant sur le lien CID dans l'interface, le contenu s'ouvre directement dans votre navigateur. + +Si vous avez un noeud IPFS local (kubo), vous pouvez aussi utiliser la ligne de commande : + +```bash +# Afficher le contenu +ipfs cat {CID} + +# Telecharger le contenu +ipfs get {CID} -o document_archive.txt +``` + +### 3. Ancrage on-chain -- Preuve horodatee et infalsifiable + +L'ancrage on-chain consiste a enregistrer le hash SHA-256 du contenu sur la blockchain Duniter V2, via un appel `system.remark`. Cette transaction est incluse dans un bloc de la blockchain, ce qui fournit : + +- **Horodatage** : la date du bloc prouve que le contenu existait a cette date. +- **Immutabilite** : une fois inscrit dans la blockchain, le remark ne peut pas etre modifie. +- **Independance** : la preuve est verifiable sur la blockchain, independamment de Glibredecision. + +Le format du remark est : + +``` +glibredecision:sanctuary:{hash_sha256_du_contenu} +``` + +**Analogie** : C'est comme publier un hash dans un journal date et immuable. N'importe qui peut verifier que le hash etait bien la a cette date. ## Pourquoi le Sanctuaire ? La gouvernance exige la transparence et la tracabilite. Le Sanctuaire garantit que : -- Aucune decision adoptee ne peut etre modifiee retroactivement -- Tout membre peut verifier l'authenticite d'un document ou d'un resultat de vote -- L'historique des decisions est preservee independamment de la plateforme +- **Aucune decision adoptee ne peut etre modifiee retroactivement** : le hash et l'ancrage on-chain rendent toute falsification detectable. +- **Tout membre peut verifier l'authenticite** d'un document ou d'un resultat de vote de maniere independante. +- **L'historique des decisions est preserve** independamment de la plateforme : meme sans Glibredecision, les preuves restent sur IPFS et la blockchain. ## Types d'entrees @@ -51,7 +106,7 @@ Pour retrouver toutes les entrees du Sanctuaire liees a un document, une decisio Cette fonctionnalite permet de retracer l'historique complet d'archivage d'un document au fil de ses modifications adoptees. -## Verification d'integrite +## Comment verifier une archive ### Verification automatique @@ -69,44 +124,46 @@ Glibredecision propose une verification automatique d'integrite pour chaque entr Si les trois controles sont valides, le contenu est authentique et n'a pas ete modifie depuis son archivage. :: -### Verification manuelle +### Verification manuelle (independante de la plateforme) -Pour une verification independante de la plateforme : +Pour une verification totalement independante de Glibredecision, suivez ces etapes : -1. Recuperez le contenu via IPFS en utilisant le CID affiche. -2. Calculez le hash SHA-256 du contenu telecharge. -3. Comparez avec le hash enregistre dans le Sanctuaire. -4. Verifiez que le meme hash est present dans le remark on-chain (via un explorateur blockchain). +#### Etape 1 : Recuperer le contenu via IPFS -Si les trois hash correspondent, le contenu est authentique et n'a pas ete modifie. - -## Acces au contenu via IPFS - -Chaque entree du Sanctuaire possede un **CID IPFS** (Content Identifier) qui permet d'acceder au contenu archive de maniere decentralisee. - -### Utiliser le lien IPFS gateway - -Le CID est affiche sous forme de lien cliquable pointant vers une passerelle IPFS publique : - -- **Passerelle publique** : `https://ipfs.io/ipfs/{CID}` -- **Passerelle locale** (si vous executez un noeud kubo) : `http://localhost:8080/ipfs/{CID}` - -En cliquant sur le lien CID dans l'interface, le contenu archive s'ouvre directement dans votre navigateur. - -### Recuperer le contenu via la CLI IPFS - -Si vous avez un noeud IPFS local (kubo), vous pouvez recuperer le contenu directement : +Utilisez le CID affiche sur l'entree du Sanctuaire pour telecharger le contenu : ```bash -ipfs cat {CID} +# Via un noeud IPFS local +ipfs cat QmXyz... > document.txt + +# Via une passerelle publique +curl https://ipfs.io/ipfs/QmXyz... > document.txt ``` -Ou le telecharger : +#### Etape 2 : Calculer le hash SHA-256 ```bash -ipfs get {CID} -o document_archive.txt +sha256sum document.txt ``` +Comparez le hash obtenu avec le hash affiche dans le Sanctuaire. Ils doivent etre identiques. + +#### Etape 3 : Verifier l'ancrage on-chain + +1. Copiez le **hash de transaction** affiche sur l'entree du Sanctuaire. +2. Ouvrez un explorateur de la blockchain Duniter V2 (par exemple Polkadot.js Apps connecte au reseau Duniter). +3. Recherchez la transaction par son hash ou parcourez le **bloc** indique. +4. Dans les extrinsics du bloc, reperez l'appel `system.remark` contenant le hash SHA-256. +5. Verifiez que le hash dans le remark correspond a celui que vous avez calcule. + +#### Resultat + +Si les trois hash correspondent (calcul local, Sanctuaire, on-chain), le contenu est authentique, integre et horodate. La triple preuve est confirmee. + +::callout{type="tip"} +L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain. +:: + ## Comprendre les informations d'ancrage on-chain Chaque entree du Sanctuaire affiche des informations relatives a son ancrage sur la blockchain Duniter V2 : @@ -117,20 +174,6 @@ Chaque entree du Sanctuaire affiche des informations relatives a son ancrage sur | Numero de bloc | Le bloc de la blockchain dans lequel la transaction a ete incluse | | Date d'archivage | Horodatage de la creation de l'entree dans le Sanctuaire | -### Verifier sur un explorateur blockchain - -Pour verifier l'ancrage on-chain de maniere independante : - -1. Copiez le **hash de transaction** affiche sur l'entree du Sanctuaire. -2. Ouvrez un explorateur de la blockchain Duniter V2 (par exemple Polkadot.js Apps connecte au reseau Duniter). -3. Recherchez la transaction par son hash ou parcourez le **bloc** indique. -4. Dans les extrinsics du bloc, reperer l'appel `system.remark` contenant le hash SHA-256 du contenu. -5. Si le hash dans le remark correspond au hash SHA-256 affiche dans le Sanctuaire, l'ancrage est confirme. - -::callout{type="tip"} -L'ancrage on-chain fournit une preuve horodatee et infalsifiable de l'existence du contenu a la date du bloc. Meme si la plateforme Glibredecision disparait, la preuve reste verifiable sur la blockchain. -:: - ## Automatisation L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus decisionnel est finalise : @@ -139,3 +182,23 @@ L'archivage dans le Sanctuaire est declenche automatiquement lorsqu'un processus - Quand une session de vote est **cloturee**, le resultat detaille est archive. - Quand une decision est **executee**, l'ensemble de la decision est archive. - Quand un document est **archive** via le bouton d'archivage, l'integralite du document est archivee dans le Sanctuaire. + +## Resume : la chaine de confiance + +``` +Contenu adopte + | + v +[Calcul SHA-256] --> hash = empreinte unique du contenu + | + +---> [Upload IPFS] --> CID = identifiant distribue du contenu + | + +---> [system.remark on-chain] --> preuve horodatee et immuable + | + v +[Entree Sanctuaire] = hash + CID + tx_hash + block_number + | + v +Verification : recalculer le hash, comparer avec IPFS et on-chain + = Triple preuve d'integrite +``` diff --git a/docs/content/user/8.faq.md b/docs/content/user/8.faq.md index 70123ad..05a081f 100644 --- a/docs/content/user/8.faq.md +++ b/docs/content/user/8.faq.md @@ -13,33 +13,99 @@ Pour **consulter** les documents, decisions et resultats de vote, aucune authent ### Comment fonctionne la connexion sans mot de passe ? -Glibredecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Vous signez un texte aleatoire avec votre cle privee, et la plateforme verifie la signature avec votre cle publique. Votre cle privee n'est jamais transmise. +Glibredecision utilise un systeme challenge-response base sur la cryptographie Ed25519. Voici le processus : + +1. Vous fournissez votre adresse Duniter SS58. +2. Le serveur genere un texte aleatoire (le "challenge") de 64 caracteres hexadecimaux. +3. Votre extension Polkadot.js ou votre portefeuille Cesium signe ce challenge avec votre cle privee Ed25519. +4. Le serveur verifie la signature avec votre cle publique. +5. Si la verification reussit, un jeton de session vous est attribue. + +Votre cle privee n'est **jamais** transmise au serveur. Seule la signature du challenge est envoyee. + +### Quels portefeuilles sont compatibles ? + +Tout portefeuille supportant la signature Ed25519 sur Substrate est compatible : + +- **Extension Polkadot.js** (recommandee) : disponible sur Chrome et Firefox +- **Cesium2** : l'application officielle de la communaute Duniter +- **Tout portefeuille Substrate** supportant Ed25519 ### Ma session a expire, que faire ? Les sessions durent 24 heures. Reconnectez-vous en suivant le meme processus (challenge + signature). Vos votes et propositions precedents ne sont pas affectes. +### Que se passe-t-il si je perds l'acces a ma cle privee ? + +Glibredecision ne stocke jamais votre cle privee. Si vous perdez l'acces a votre cle, vous ne pourrez plus vous authentifier avec cette adresse. Vos votes passes restent enregistres et comptabilises. Contactez la communaute Duniter pour les procedures de recuperation d'identite si necessaire. + +### Puis-je me connecter depuis plusieurs appareils ? + +Oui. Chaque connexion genere un jeton de session independant. Vous pouvez etre connecte simultanement depuis plusieurs appareils. Chaque session est independante et expire apres 24 heures. + ## Vote +### Comment voter sur un item de document ? + +1. Rendez-vous dans la section **Votes** ou accedez a une decision en cours. +2. Lisez le sujet soumis au vote. +3. Choisissez **Pour** ou **Contre** (vote binaire) ou un niveau de 0 a 5 (vote nuance). +4. Optionnellement, ajoutez un commentaire. +5. Signez le payload avec votre cle privee (votre extension de portefeuille vous le demandera). +6. Cliquez sur **Soumettre**. + +### Puis-je changer mon vote ? + +Oui, tant que la session de vote est ouverte, vous pouvez modifier votre vote autant de fois que necessaire. L'ancien vote est conserve en base de donnees pour l'audit mais marque comme inactif. Seul le dernier vote est pris en compte dans le decompte. + ### Pourquoi le seuil est-il si eleve quand peu de personnes votent ? C'est le mecanisme d'**inertie**. Quand la participation est faible, le seuil est eleve pour empecher qu'un petit groupe prenne des decisions engageant toute la communaute. A mesure que la participation augmente, le seuil converge vers la majorite simple. Cela incite a la participation large. -### Puis-je changer mon vote ? +Par exemple, avec 120 votes sur une WoT de 7224 membres (1.7% de participation), il faut 78% de votes favorables. Si toute la WoT votait, 50% suffiraient. -Oui, tant que la session de vote est ouverte, vous pouvez modifier votre vote. L'ancien vote est conserve en base de donnees pour l'audit mais marque comme inactif. Seul le dernier vote est pris en compte dans le decompte. +### Comment comprendre le seuil affiche sur la jauge ? + +La jauge de seuil montre : + +- **La barre verte** : nombre de votes favorables actuels +- **La barre rouge** : nombre de votes defavorables +- **Le trait vertical** : position du seuil requis (calcule par la formule d'inertie) +- **Le compteur** : `votes_pour / seuil` (par exemple "97 / 94") +- **Le badge** : "Adopte" si votes_pour >= seuil, sinon "Non atteint" + +Le seuil est recalcule en temps reel a chaque nouveau vote. ### Qu'est-ce que le critere Smith ? -Certaines decisions exigent un nombre minimum de votes favorables de la part des **forgerons** (membres Smith de la WoT). Cela garantit que les decisions techniques sont soutenues par ceux qui maintiennent le reseau. +Certaines decisions exigent un nombre minimum de votes favorables de la part des **forgerons** (membres Smith de la WoT). Le seuil est calcule par `ceil(SmithWotSize^S)` ou S est l'exposant configure. Cela garantit que les decisions techniques sont soutenues par ceux qui maintiennent le reseau. ### Qu'est-ce que le critere TechComm ? -De maniere similaire, certaines decisions exigent un nombre minimum de votes favorables du **Comite Technique**. Cela concerne les decisions qui affectent le runtime ou l'infrastructure technique. +De maniere similaire, certaines decisions exigent un nombre minimum de votes favorables du **Comite Technique**. Le seuil est calcule par `ceil(CoTecSize^T)`. Cela concerne les decisions qui affectent le runtime ou l'infrastructure technique. ### Comment fonctionnent les votes nuances ? -Au lieu de "pour" ou "contre", vous choisissez un niveau de 0 (CONTRE) a 5 (TOUT A FAIT). Les niveaux 3, 4 et 5 comptent comme positifs. Pour que le vote soit adopte, il faut que les votes positifs representent au moins 80% du total et qu'un nombre minimum de participants soit atteint. +Au lieu de "pour" ou "contre", vous choisissez un niveau de 0 (CONTRE) a 5 (TOUT A FAIT). Les niveaux 3, 4 et 5 comptent comme positifs. Pour que le vote soit adopte, il faut que les votes positifs representent au moins 80% du total et qu'un nombre minimum de participants soit atteint (par defaut 59). + +### Que signifie "D30M50B.1G.2S.1T.1" ? + +C'est le codage compact des parametres de formule : + +- **D30** : duree de 30 jours +- **M50** : majorite cible de 50% +- **B.1** : exposant de base 0.1 +- **G.2** : gradient 0.2 +- **S.1** : critere Smith avec exposant 0.1 +- **T.1** : critere TechComm avec exposant 0.1 + +### Les votes sont-ils secrets ? + +Non. Les votes et leurs signatures cryptographiques sont **publics**, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment par quiconque possede la cle publique du votant. Il n'y a pas de vote secret dans Glibredecision. + +### Le seuil peut-il changer pendant le vote ? + +Oui. Le seuil depend du nombre total de votes exprimes. A chaque nouveau vote, le seuil est recalcule. Il augmente legerement a mesure que la participation croit, mais converge vers la majorite simple. C'est pourquoi le resultat affiche est toujours provisoire tant que la session est ouverte. ## Documents @@ -47,34 +113,146 @@ Au lieu de "pour" ou "contre", vous choisissez un niveau de 0 (CONTRE) a 5 (TOUT Un document de reference est un texte fondateur de la communaute Duniter (Licence G1, Engagement Forgeron, Reglement du Comite Technique, etc.). Il est compose d'items modulaires sous vote permanent. +### Qu'est-ce qu'un item ? + +Un item est une unite modulaire d'un document : une clause, une regle, une verification, un preambule ou une section. Chaque item a une position hierarchique (ex: "1", "1.1", "3.2"), un texte courant et un historique de versions. + ### Comment proposer une modification ? -Ouvrez le document, selectionnez l'item a modifier, cliquez sur "Proposer une modification", redigez le nouveau texte avec une justification, puis soumettez. La proposition sera soumise a un processus de decision et de vote. +1. Ouvrez le document dans la section **Documents**. +2. Selectionnez l'item a modifier. +3. Cliquez sur **Proposer une modification**. +4. Redigez le nouveau texte avec une justification. +5. Soumettez. + +Un diff automatique est genere. La proposition sera soumise a examen et vote par la communaute. ### Que signifie "vote permanent" ? Les documents actifs sont toujours ouverts aux propositions de modification. Il n'y a pas de periode speciale pour proposer des changements. Cela permet une evolution continue et organique des textes. -## Sanctuaire +### Puis-je proposer plusieurs modifications a la fois ? + +Oui. Vous pouvez proposer des modifications a differents items d'un meme document, ou a des items de documents differents. Chaque proposition est independante et suit son propre cycle de vie (proposition, examen, vote). + +### Que se passe-t-il si ma proposition est rejetee ? + +Le texte courant de l'item reste inchange. Votre proposition est archivee avec le statut "rejetee" pour transparence. Vous pouvez proposer une nouvelle modification a tout moment, eventuellement amelioree suite aux retours de la communaute. + +### Comment consulter le diff d'une proposition ? + +Sur la page de detail d'une version proposee, le diff est affiche automatiquement. Les ajouts sont surlignees en vert, les suppressions en rouge. Vous pouvez basculer entre la vue diff et la vue texte complet. + +## Decisions et mandats + +### Quelle est la difference entre un document et une decision ? + +Un **document** est un texte fondateur sous vote permanent (Licence, Engagement, Reglement). Une **decision** est un processus ponctuel multi-etapes (qualification, examen, vote, execution, rapport) pour prendre un choix collectif specifique (runtime upgrade, attribution de mandat, changement de parametre). + +### Comment creer une decision ? + +1. Rendez-vous dans la section **Decisions**. +2. Cliquez sur **Nouvelle decision**. +3. Renseignez le titre, la description, le contexte et le type. +4. Choisissez un protocole de vote. +5. Ajoutez les etapes necessaires (qualification, examen, vote, execution, reporting). +6. Soumettez, puis utilisez **Avancer** pour demarrer le processus. + +### Qu'est-ce qu'un mandat ? + +Un mandat est une responsabilite formelle attribuee a un membre de la communaute pour une duree determinee, apres validation par vote collectif. Les types de mandats sont : `techcomm` (Comite Technique), `smith` (forgeron) et `custom` (personnalise). + +### Comment fonctionne l'election d'un mandataire ? + +Le processus suit un cycle : formulation du mandat, periode de candidature, vote collectif, assignation du mandataire, exercice du mandat, reporting et completion. Le vote utilise le meme systeme de seuil WoT avec inertie. + +### Un mandat peut-il etre revoque ? + +Oui. Un mandat actif peut etre revoque de maniere anticipee via l'action "Revoquer". La revocation est une action de gouvernance qui peut necessiter un vote prealable. + +## Sanctuaire et archivage ### Pourquoi archiver sur IPFS et la blockchain ? -IPFS fournit un stockage distribue : le contenu est accessible meme si la plateforme Glibredecision est hors ligne. L'ancrage on-chain via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter. Ensemble, ils garantissent que les decisions de la communaute sont preservees de maniere permanente et verifiable. +**IPFS** fournit un stockage distribue : le contenu est accessible meme si la plateforme Glibredecision est hors ligne. L'**ancrage on-chain** via `system.remark` cree une preuve horodatee immuable sur la blockchain Duniter V2. Le **hash SHA-256** garantit l'integrite du contenu. Ensemble, ils forment une **triple preuve** que le contenu n'a pas ete modifie depuis son archivage. ### Comment verifier qu'un document n'a pas ete modifie ? -Telechargez le document depuis IPFS via son CID, calculez le hash SHA-256, puis comparez-le au hash enregistre dans le Sanctuaire et au remark on-chain. Si les trois correspondent, le document est intact. +Trois methodes : -## Technique +1. **Verification automatique** : cliquez sur **Verifier l'integrite** sur l'entree du Sanctuaire. Le systeme recalcule le hash et verifie IPFS et on-chain automatiquement. +2. **Verification manuelle** : telechargez le contenu depuis IPFS via son CID, calculez le hash SHA-256 avec `sha256sum`, puis comparez avec le hash enregistre dans le Sanctuaire et le remark on-chain. +3. **Verification blockchain** : consultez le bloc reference dans un explorateur Duniter V2 et verifiez le `system.remark`. + +### L'archivage est-il automatique ? + +Oui. L'archivage est declenche automatiquement : + +- Quand une version d'item de document est acceptee +- Quand une session de vote est cloturee +- Quand une decision est executee +- Quand un document est archive manuellement + +### Puis-je acceder aux archives sans Glibredecision ? + +Oui. Les contenus archives sont accessibles via : + +- **IPFS** : utilisez le CID pour recuperer le contenu depuis n'importe quelle passerelle IPFS publique ou un noeud local +- **Blockchain** : le hash SHA-256 est enregistre dans un `system.remark` sur la blockchain Duniter V2, verifiable via tout explorateur compatible + +## Questions techniques ### Sur quelle blockchain Glibredecision fonctionne-t-il ? Glibredecision se connecte a la blockchain **Duniter V2** (basee sur Substrate). En environnement de developpement, il se connecte au reseau de test GDev (`wss://gdev.p2p.legal/ws`). +### Que se passe-t-il si la blockchain Duniter est indisponible ? + +L'essentiel des fonctionnalites (consultation des documents, votes, decisions) reste accessible car les donnees sont stockees dans la base PostgreSQL locale. Seules les fonctions dependant de la blockchain sont affectees : + +- L'authentification peut temporairement echouer si la verification WoT n'est pas en cache +- L'ancrage on-chain des entrees du Sanctuaire est differe jusqu'au retablissement +- Les snapshots de taille WoT/Smith/TechComm utilisent le cache + +### Que se passe-t-il si le noeud IPFS est indisponible ? + +L'upload sur IPFS est differe si le noeud est temporairement indisponible. Le hash SHA-256 et les metadonnees sont enregistres dans la base de donnees. L'upload peut etre relance ulterieurement. Le contenu deja uploade reste accessible via les passerelles IPFS publiques. + ### Les donnees de vote sont-elles publiques ? Oui. Les votes et leurs signatures cryptographiques sont publics, conformement au principe de transparence de la gouvernance. Chaque vote peut etre verifie independamment. +### Les mises a jour sont-elles en temps reel ? + +Oui. Glibredecision utilise une connexion WebSocket pour diffuser les mises a jour en temps reel : + +- Nouveaux votes soumis : la jauge de seuil est recalculee instantanement +- Votes modifies : la jauge reflette le changement immediatement +- Sessions cloturees : le resultat final s'affiche +- Un indicateur de connexion (point vert/orange/rouge) en bas a droite indique l'etat de la connexion temps reel + ### Ou est heberge Glibredecision ? La plateforme est hebergee sur une infrastructure geree par la communaute, avec deploiement automatise via Docker et Woodpecker CI. Le code source est ouvert et disponible sur le depot Git Duniter. + +## Securite + +### Ma cle privee est-elle transmise au serveur ? + +Non. Jamais. L'authentification utilise un mecanisme challenge-response : vous signez un texte aleatoire avec votre cle privee, et seule la signature est envoyee au serveur. La cle privee reste exclusivement dans votre portefeuille (extension Polkadot.js, Cesium, etc.). + +### Comment la signature Ed25519 protege-t-elle mon vote ? + +Chaque vote est accompagne d'une signature Ed25519 qui garantit : + +- **Authenticite** : seul le proprietaire de l'adresse Duniter peut voter en son nom +- **Integrite** : le payload signe contient le vote, l'identifiant de la session et un horodatage. Toute modification du vote apres signature est detectable. +- **Non-repudiation** : le votant ne peut pas nier avoir vote + +### Mes votes peuvent-ils etre falsifies ? + +Non. Chaque vote est signe cryptographiquement avec votre cle privee Ed25519. Le payload signe contient le vote, l'identifiant de la session et un horodatage. Toute modification du vote apres signature est detectable car la verification de la signature echouerait. De plus, les votes sont publics et verifiables par n'importe qui. + +### Comment verifier la signature d'un vote ? + +Sur la page d'une session de vote, chaque vote affiche un lien **Verifier la signature**. En cliquant, le systeme utilise la cle publique du votant (derivee de son adresse SS58) pour verifier que la signature correspond bien au payload affiche. Vous pouvez aussi effectuer cette verification de maniere independante avec n'importe quelle bibliotheque Ed25519. diff --git a/frontend/app/app.vue b/frontend/app/app.vue index f4215a0..a7a6e20 100644 --- a/frontend/app/app.vue +++ b/frontend/app/app.vue @@ -30,7 +30,20 @@ const navigationItems = [ }, ] +/** Mobile drawer state. */ +const mobileMenuOpen = ref(false) + +/** Close mobile menu on route change. */ +watch(() => route.path, () => { + mobileMenuOpen.value = false +}) + +/** WebSocket connection and notifications. */ +const ws = useWebSocket() +const { setupWsNotifications } = useNotifications() + onMounted(async () => { + // Hydrate auth from localStorage auth.hydrateFromStorage() if (auth.token) { try { @@ -39,30 +52,62 @@ onMounted(async () => { auth.logout() } } + + // Connect WebSocket and setup notifications + ws.connect() + setupWsNotifications(ws) +}) + +onUnmounted(() => { + ws.disconnect() }) + + diff --git a/frontend/app/components/common/ErrorBoundary.vue b/frontend/app/components/common/ErrorBoundary.vue new file mode 100644 index 0000000..94c5ed0 --- /dev/null +++ b/frontend/app/components/common/ErrorBoundary.vue @@ -0,0 +1,70 @@ + + + diff --git a/frontend/app/components/common/LoadingSkeleton.vue b/frontend/app/components/common/LoadingSkeleton.vue new file mode 100644 index 0000000..07ccaa5 --- /dev/null +++ b/frontend/app/components/common/LoadingSkeleton.vue @@ -0,0 +1,81 @@ + + + diff --git a/frontend/app/components/common/OfflineBanner.vue b/frontend/app/components/common/OfflineBanner.vue new file mode 100644 index 0000000..507d227 --- /dev/null +++ b/frontend/app/components/common/OfflineBanner.vue @@ -0,0 +1,95 @@ + + + + + diff --git a/frontend/app/composables/useApi.ts b/frontend/app/composables/useApi.ts index 1a2ddf3..4e3a05c 100644 --- a/frontend/app/composables/useApi.ts +++ b/frontend/app/composables/useApi.ts @@ -3,7 +3,73 @@ * * Uses the runtime config `apiBase` and automatically injects the Bearer token * from the auth store when available. + * + * Production-grade features: + * - Error interceptor with user-friendly French messages + * - Retry logic with exponential backoff for 5xx errors + * - Request timeout (30s default) + * - AbortController support for cancellation */ + +/** Map of HTTP status codes to user-friendly French error messages. */ +const HTTP_ERROR_MESSAGES: Record = { + 401: 'Session expirée, veuillez vous reconnecter', + 403: 'Accès non autorisé', + 404: 'Ressource introuvable', + 409: 'Conflit avec une ressource existante', + 422: 'Données invalides', + 429: 'Trop de requêtes, veuillez patienter', + 500: 'Erreur serveur, veuillez réessayer', + 502: 'Service temporairement indisponible', + 503: 'Service en maintenance, veuillez réessayer', + 504: 'Délai de réponse dépassé', +} + +/** Default request timeout in milliseconds. */ +const DEFAULT_TIMEOUT_MS = 30_000 + +/** Maximum number of retry attempts for 5xx errors. */ +const MAX_RETRIES = 3 + +/** Base delay for exponential backoff in milliseconds. */ +const BASE_BACKOFF_MS = 1_000 + +export interface ApiOptions extends Record { + /** Custom timeout in milliseconds (default: 30000). */ + timeout?: number + /** External AbortController for request cancellation. */ + signal?: AbortSignal + /** Disable automatic retry for this request. */ + noRetry?: boolean +} + +export interface ApiError { + status: number + message: string + detail?: string +} + +/** + * Resolve a user-friendly error message from an HTTP status code. + */ +function resolveErrorMessage(status: number, fallback?: string): string { + return HTTP_ERROR_MESSAGES[status] || fallback || 'Une erreur inattendue est survenue' +} + +/** + * Wait for the specified duration in milliseconds. + */ +function delay(ms: number): Promise { + return new Promise(resolve => setTimeout(resolve, ms)) +} + +/** + * Determine whether an HTTP status code is retryable (5xx server errors). + */ +function isRetryable(status: number): boolean { + return status >= 500 && status < 600 +} + export function useApi() { const config = useRuntimeConfig() const auth = useAuthStore() @@ -12,20 +78,119 @@ export function useApi() { * Perform a typed fetch against the backend API. * * @param path - API path relative to apiBase, e.g. "/documents" - * @param options - $fetch options (method, body, query, headers, etc.) + * @param options - $fetch options (method, body, query, headers, timeout, signal, noRetry) * @returns Typed response + * @throws ApiError with status, message, and optional detail */ - async function $api(path: string, options: Record = {}): Promise { + async function $api(path: string, options: ApiOptions = {}): Promise { + const { + timeout = DEFAULT_TIMEOUT_MS, + signal: externalSignal, + noRetry = false, + ...fetchOptions + } = options + const headers: Record = {} if (auth.token) { headers.Authorization = `Bearer ${auth.token}` } - return await $fetch(`${config.public.apiBase}${path}`, { - ...options, - headers: { ...headers, ...options.headers }, - }) + const maxAttempts = noRetry ? 1 : MAX_RETRIES + let lastError: any = null + + for (let attempt = 1; attempt <= maxAttempts; attempt++) { + // Create an AbortController for timeout management + const controller = new AbortController() + const timeoutId = setTimeout(() => controller.abort(), timeout) + + // If an external signal is provided, forward its abort + let externalAbortHandler: (() => void) | null = null + if (externalSignal) { + if (externalSignal.aborted) { + clearTimeout(timeoutId) + throw _createApiError(0, 'Requête annulée') + } + externalAbortHandler = () => controller.abort() + externalSignal.addEventListener('abort', externalAbortHandler, { once: true }) + } + + try { + const result = await $fetch(`${config.public.apiBase}${path}`, { + ...fetchOptions, + headers: { ...headers, ...fetchOptions.headers }, + signal: controller.signal, + }) + + return result + } catch (err: any) { + lastError = err + + const status = err?.response?.status || err?.status || 0 + + // Handle 401: auto-logout for expired sessions + if (status === 401 && auth.token) { + auth.token = null + auth.identity = null + auth._clearToken() + navigateTo('/login') + } + + // Handle abort (timeout or external cancellation) + if (err?.name === 'AbortError' || controller.signal.aborted) { + if (externalSignal?.aborted) { + throw _createApiError(0, 'Requête annulée') + } + throw _createApiError(0, 'Délai de réponse dépassé') + } + + // Retry only for 5xx errors and if we have remaining attempts + if (isRetryable(status) && attempt < maxAttempts) { + const backoffMs = BASE_BACKOFF_MS * Math.pow(2, attempt - 1) + await delay(backoffMs) + continue + } + + // Build and throw a structured error + const detail = err?.data?.detail || err?.message || undefined + const message = resolveErrorMessage(status, detail) + throw _createApiError(status, message, detail) + } finally { + clearTimeout(timeoutId) + if (externalAbortHandler && externalSignal) { + externalSignal.removeEventListener('abort', externalAbortHandler) + } + } + } + + // Fallback: should not reach here, but handle gracefully + const fallbackStatus = lastError?.response?.status || lastError?.status || 0 + const fallbackDetail = lastError?.data?.detail || lastError?.message || undefined + throw _createApiError(fallbackStatus, resolveErrorMessage(fallbackStatus, fallbackDetail), fallbackDetail) } - return { $api } + /** + * Create a structured ApiError object. + */ + function _createApiError(status: number, message: string, detail?: string): ApiError { + const error: ApiError = { status, message } + if (detail) error.detail = detail + return error + } + + /** + * Create an AbortController for manual request cancellation. + * Useful for component unmount cleanup. + * + * @example + * const { controller, signal } = createAbortController() + * await $api('/documents', { signal }) + * // On unmount: + * controller.abort() + */ + function createAbortController() { + const controller = new AbortController() + return { controller, signal: controller.signal } + } + + return { $api, createAbortController } } diff --git a/frontend/app/composables/useNotifications.ts b/frontend/app/composables/useNotifications.ts new file mode 100644 index 0000000..a36492e --- /dev/null +++ b/frontend/app/composables/useNotifications.ts @@ -0,0 +1,180 @@ +/** + * Composable for toast notifications using Nuxt UI. + * + * Provides typed notification helpers with French messages and + * integration with WebSocket events for real-time notifications. + */ + +export type NotificationType = 'success' | 'error' | 'warning' | 'info' + +interface NotifyOptions { + /** Toast title. */ + title: string + /** Toast description (optional). */ + description?: string + /** Notification type. */ + type?: NotificationType + /** Auto-close duration in milliseconds (default: 5000). */ + duration?: number +} + +/** Map notification types to Nuxt UI toast color props. */ +const TYPE_COLORS: Record = { + success: 'success', + error: 'error', + warning: 'warning', + info: 'info', +} + +/** Map notification types to Lucide icon names. */ +const TYPE_ICONS: Record = { + success: 'i-lucide-check-circle', + error: 'i-lucide-alert-circle', + warning: 'i-lucide-alert-triangle', + info: 'i-lucide-info', +} + +/** Default duration for toasts (ms). */ +const DEFAULT_DURATION = 5_000 + +export function useNotifications() { + const toast = useToast() + + /** + * Show a toast notification. + * + * @param options - Notification options (title, description, type, duration) + */ + function notify(options: NotifyOptions): void + function notify(title: string, description?: string, type?: NotificationType): void + function notify( + titleOrOptions: string | NotifyOptions, + description?: string, + type?: NotificationType, + ): void { + let opts: NotifyOptions + + if (typeof titleOrOptions === 'string') { + opts = { + title: titleOrOptions, + description, + type: type || 'info', + } + } else { + opts = titleOrOptions + } + + const notifType = opts.type || 'info' + + toast.add({ + title: opts.title, + description: opts.description, + icon: TYPE_ICONS[notifType], + color: TYPE_COLORS[notifType] as any, + duration: opts.duration ?? DEFAULT_DURATION, + }) + } + + /** + * Show a success toast. + */ + function notifySuccess(message: string, description?: string): void { + notify({ + title: message, + description, + type: 'success', + }) + } + + /** + * Show an error toast. + */ + function notifyError(message: string, description?: string): void { + notify({ + title: message, + description, + type: 'error', + duration: 8_000, + }) + } + + /** + * Show a warning toast. + */ + function notifyWarning(message: string, description?: string): void { + notify({ + title: message, + description, + type: 'warning', + }) + } + + /** + * Show an info toast. + */ + function notifyInfo(message: string, description?: string): void { + notify({ + title: message, + description, + type: 'info', + }) + } + + /** + * Setup WebSocket event listeners that auto-show notifications. + * Call this once in app.vue or a layout component. + */ + function setupWsNotifications(wsComposable: ReturnType): void { + wsComposable.onVoteSubmitted((data) => { + notifyInfo( + 'Nouveau vote enregistre', + data?.session_title || 'Un vote a ete soumis dans une session active.', + ) + }) + + wsComposable.onDecisionAdvanced((data) => { + notifySuccess( + 'Decision avancee', + data?.title + ? `La decision "${data.title}" est passee a l'etape suivante.` + : 'Une decision a progresse dans son processus.', + ) + }) + + wsComposable.onMandateUpdated((data) => { + notifyInfo( + 'Mandat mis a jour', + data?.title + ? `Le mandat "${data.title}" a ete modifie.` + : 'Un mandat a ete mis a jour.', + ) + }) + + wsComposable.onDocumentChanged((data) => { + notifyInfo( + 'Document modifie', + data?.title + ? `Le document "${data.title}" a ete modifie.` + : 'Un document de reference a ete modifie.', + ) + }) + + wsComposable.onSanctuaryArchived((data) => { + notifySuccess( + 'Document archive au sanctuaire', + data?.title + ? `"${data.title}" a ete ancre sur IPFS.` + : 'Un document a ete archive de maniere immuable.', + ) + }) + } + + return { + notify, + notifySuccess, + notifyError, + notifyWarning, + notifyInfo, + setupWsNotifications, + } +} diff --git a/frontend/app/composables/useWebSocket.ts b/frontend/app/composables/useWebSocket.ts index a0e9a16..febd25c 100644 --- a/frontend/app/composables/useWebSocket.ts +++ b/frontend/app/composables/useWebSocket.ts @@ -1,15 +1,73 @@ /** - * Composable for WebSocket connectivity to receive live vote updates. + * Composable for WebSocket connectivity to receive live updates. * * Connects to the backend WS endpoint and allows subscribing to * individual vote session channels for real-time tally updates. + * + * Production-grade features: + * - Bearer token authentication via query param + * - Heartbeat (ping every 25s, pong expected within 10s) + * - Exponential backoff on reconnect (1s, 2s, 4s, 8s, max 30s) + * - Max reconnect attempts: 10 + * - Typed event handlers for domain events + * - Message queue during disconnection with replay on reconnect */ + +/** WebSocket event types from the backend. */ +export type WsEventType = + | 'vote_submitted' + | 'decision_advanced' + | 'mandate_updated' + | 'document_changed' + | 'sanctuary_archived' + | 'pong' + | 'error' + +/** Typed WebSocket message from the backend. */ +export interface WsMessage { + event: WsEventType + data: any + timestamp?: string +} + +/** Event handler callback type. */ +type WsEventHandler = (data: any) => void + +/** Maximum reconnect attempts before giving up. */ +const MAX_RECONNECT_ATTEMPTS = 10 + +/** Base delay for reconnection backoff (ms). */ +const RECONNECT_BASE_MS = 1_000 + +/** Maximum delay between reconnection attempts (ms). */ +const RECONNECT_MAX_MS = 30_000 + +/** Interval between heartbeat pings (ms). */ +const HEARTBEAT_INTERVAL_MS = 25_000 + +/** Maximum time to wait for a pong response (ms). */ +const PONG_TIMEOUT_MS = 10_000 + export function useWebSocket() { const config = useRuntimeConfig() + const auth = useAuthStore() + let ws: WebSocket | null = null let reconnectTimer: ReturnType | null = null + let heartbeatTimer: ReturnType | null = null + let pongTimeoutTimer: ReturnType | null = null + let reconnectAttempts = 0 + let intentionalClose = false + const connected = ref(false) - const lastMessage = ref(null) + const lastMessage = ref(null) + const error = ref(null) + + /** Message queue: messages sent while disconnected are replayed on reconnect. */ + const messageQueue: string[] = [] + + /** Typed event handlers registry. */ + const eventHandlers: Map> = new Map() /** * Open a WebSocket connection to the backend live endpoint. @@ -19,23 +77,40 @@ export function useWebSocket() { return } - const wsUrl = config.public.apiBase + // Reset state + error.value = null + intentionalClose = false + + // Build WS URL with authentication token + let wsUrl = config.public.apiBase .replace(/^http/, 'ws') .replace(/\/api\/v1$/, '/api/v1/ws/live') + if (auth.token) { + wsUrl += `?token=${encodeURIComponent(auth.token)}` + } + ws = new WebSocket(wsUrl) ws.onopen = () => { connected.value = true - if (reconnectTimer) { - clearTimeout(reconnectTimer) - reconnectTimer = null - } + reconnectAttempts = 0 + error.value = null + + // Start heartbeat + _startHeartbeat() + + // Replay queued messages + _flushMessageQueue() } - ws.onclose = () => { + ws.onclose = (event: CloseEvent) => { connected.value = false - reconnect() + _stopHeartbeat() + + if (!intentionalClose) { + _scheduleReconnect() + } } ws.onerror = () => { @@ -44,9 +119,20 @@ export function useWebSocket() { ws.onmessage = (event: MessageEvent) => { try { - lastMessage.value = JSON.parse(event.data) + const message: WsMessage = JSON.parse(event.data) + lastMessage.value = message + + // Handle pong for heartbeat + if (message.event === 'pong') { + _onPongReceived() + return + } + + // Dispatch to typed event handlers + _dispatchEvent(message) } catch { - lastMessage.value = event.data + // Non-JSON message, store as-is + lastMessage.value = { event: 'error', data: event.data } } } } @@ -55,46 +141,223 @@ export function useWebSocket() { * Subscribe to real-time updates for a vote session. */ function subscribe(sessionId: string) { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ action: 'subscribe', session_id: sessionId })) - } + _send(JSON.stringify({ action: 'subscribe', session_id: sessionId })) } /** * Unsubscribe from a vote session's updates. */ function unsubscribe(sessionId: string) { - if (ws?.readyState === WebSocket.OPEN) { - ws.send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId })) - } + _send(JSON.stringify({ action: 'unsubscribe', session_id: sessionId })) } /** * Gracefully close the WebSocket connection. */ function disconnect() { + intentionalClose = true + if (reconnectTimer) { clearTimeout(reconnectTimer) reconnectTimer = null } + + _stopHeartbeat() + if (ws) { ws.onclose = null + ws.onerror = null + ws.onmessage = null ws.close() ws = null } + connected.value = false + reconnectAttempts = 0 + error.value = null + } + + // ---- Typed event handler registration ---- + + /** + * Register a handler for when a vote is submitted. + */ + function onVoteSubmitted(handler: WsEventHandler): () => void { + return _addEventHandler('vote_submitted', handler) } /** - * Schedule a reconnection attempt after a delay. + * Register a handler for when a decision advances to the next step. */ - function reconnect() { + function onDecisionAdvanced(handler: WsEventHandler): () => void { + return _addEventHandler('decision_advanced', handler) + } + + /** + * Register a handler for when a mandate is updated. + */ + function onMandateUpdated(handler: WsEventHandler): () => void { + return _addEventHandler('mandate_updated', handler) + } + + /** + * Register a handler for when a document is changed. + */ + function onDocumentChanged(handler: WsEventHandler): () => void { + return _addEventHandler('document_changed', handler) + } + + /** + * Register a handler for when a document is archived to the sanctuary. + */ + function onSanctuaryArchived(handler: WsEventHandler): () => void { + return _addEventHandler('sanctuary_archived', handler) + } + + // ---- Internal helpers ---- + + /** + * Send a message via WebSocket, or queue it if disconnected. + */ + function _send(message: string) { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(message) + } else { + messageQueue.push(message) + } + } + + /** + * Replay all queued messages after reconnection. + */ + function _flushMessageQueue() { + while (messageQueue.length > 0) { + const message = messageQueue.shift()! + if (ws?.readyState === WebSocket.OPEN) { + ws.send(message) + } else { + // Put it back if connection dropped again + messageQueue.unshift(message) + break + } + } + } + + /** + * Register an event handler and return an unsubscribe function. + */ + function _addEventHandler(event: WsEventType, handler: WsEventHandler): () => void { + if (!eventHandlers.has(event)) { + eventHandlers.set(event, new Set()) + } + eventHandlers.get(event)!.add(handler) + + // Return unsubscribe function + return () => { + eventHandlers.get(event)?.delete(handler) + } + } + + /** + * Dispatch a received message to all registered handlers for its event type. + */ + function _dispatchEvent(message: WsMessage) { + const handlers = eventHandlers.get(message.event) + if (handlers) { + for (const handler of handlers) { + try { + handler(message.data) + } catch (err) { + console.error(`[WS] Erreur dans le handler pour "${message.event}":`, err) + } + } + } + } + + /** + * Start the heartbeat: send ping every 25s and expect pong within 10s. + */ + function _startHeartbeat() { + _stopHeartbeat() + + heartbeatTimer = setInterval(() => { + if (ws?.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify({ action: 'ping' })) + + // Start pong timeout + pongTimeoutTimer = setTimeout(() => { + console.warn('[WS] Pong non recu dans le delai imparti, reconnexion...') + // Force close and reconnect + if (ws) { + ws.close() + } + }, PONG_TIMEOUT_MS) + } + }, HEARTBEAT_INTERVAL_MS) + } + + /** + * Stop heartbeat timers. + */ + function _stopHeartbeat() { + if (heartbeatTimer) { + clearInterval(heartbeatTimer) + heartbeatTimer = null + } + if (pongTimeoutTimer) { + clearTimeout(pongTimeoutTimer) + pongTimeoutTimer = null + } + } + + /** + * Handle pong response: cancel the pong timeout. + */ + function _onPongReceived() { + if (pongTimeoutTimer) { + clearTimeout(pongTimeoutTimer) + pongTimeoutTimer = null + } + } + + /** + * Schedule a reconnection attempt with exponential backoff. + */ + function _scheduleReconnect() { if (reconnectTimer) return + + if (reconnectAttempts >= MAX_RECONNECT_ATTEMPTS) { + error.value = 'Connexion au serveur perdue. Veuillez rafraichir la page.' + console.error(`[WS] Nombre maximum de tentatives de reconnexion atteint (${MAX_RECONNECT_ATTEMPTS})`) + return + } + + const backoffMs = Math.min( + RECONNECT_BASE_MS * Math.pow(2, reconnectAttempts), + RECONNECT_MAX_MS, + ) + + reconnectAttempts++ + console.info(`[WS] Tentative de reconnexion ${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} dans ${backoffMs}ms`) + reconnectTimer = setTimeout(() => { reconnectTimer = null connect() - }, 3000) + }, backoffMs) } - return { connected, lastMessage, connect, subscribe, unsubscribe, disconnect } + return { + connected, + lastMessage, + error, + connect, + subscribe, + unsubscribe, + disconnect, + onVoteSubmitted, + onDecisionAdvanced, + onMandateUpdated, + onDocumentChanged, + onSanctuaryArchived, + } } diff --git a/frontend/app/pages/index.vue b/frontend/app/pages/index.vue index fedf992..e08bda0 100644 --- a/frontend/app/pages/index.vue +++ b/frontend/app/pages/index.vue @@ -1,6 +1,9 @@