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 <noreply@anthropic.com>
This commit is contained in:
Yvv
2026-02-28 15:12:50 +01:00
parent 3cb1754592
commit 403b94fa2c
31 changed files with 4472 additions and 356 deletions

View File

@@ -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<number, string> = {
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<string, any> {
/** 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<void> {
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<T>(path: string, options: Record<string, any> = {}): Promise<T> {
async function $api<T>(path: string, options: ApiOptions = {}): Promise<T> {
const {
timeout = DEFAULT_TIMEOUT_MS,
signal: externalSignal,
noRetry = false,
...fetchOptions
} = options
const headers: Record<string, string> = {}
if (auth.token) {
headers.Authorization = `Bearer ${auth.token}`
}
return await $fetch<T>(`${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<T>(`${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 }
}

View File

@@ -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<NotificationType, string> = {
success: 'success',
error: 'error',
warning: 'warning',
info: 'info',
}
/** Map notification types to Lucide icon names. */
const TYPE_ICONS: Record<NotificationType, string> = {
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<typeof useWebSocket>): 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,
}
}

View File

@@ -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<typeof setTimeout> | null = null
let heartbeatTimer: ReturnType<typeof setInterval> | null = null
let pongTimeoutTimer: ReturnType<typeof setTimeout> | null = null
let reconnectAttempts = 0
let intentionalClose = false
const connected = ref(false)
const lastMessage = ref<any>(null)
const lastMessage = ref<WsMessage | null>(null)
const error = ref<string | null>(null)
/** Message queue: messages sent while disconnected are replayed on reconnect. */
const messageQueue: string[] = []
/** Typed event handlers registry. */
const eventHandlers: Map<WsEventType, Set<WsEventHandler>> = 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,
}
}