{
  "openapi": "3.1.0",
  "info": {
    "title": "France Monitor : API publique",
    "version": "0.1.0",
    "summary": "Signaux de conscience situationnelle temps réel pour la France (énergie, feux, cyber, actualités, santé opérationnelle).",
    "description": "API de lecture exposant les données agrégées par France Monitor, tableau de bord OSINT de conscience situationnelle pour la France.\n\n**Sources ouvertes.** Toutes les données proviennent de sources publiques et ouvertes (RTE/ODRE, NASA FIRMS, ENTSOG, ANSSI/CERT-FR, HaveIBeenPwned, RansomwareLive, presse RSS, etc.). Un signal remonté ici est un **indice**, pas un fait confirmé : les événements peuvent être approximatifs, retardés, dédoublonnés de façon imparfaite ou géolocalisés grossièrement. Ne pas utiliser ces données comme source unique pour une décision critique.\n\n**Stabilité.** Version 0.1.0, stade expérimental. Aucune garantie de stabilité formelle du contrat à ce stade : les schémas de réponse peuvent évoluer sans préavis. Les champs marqués nullables peuvent l'être en pratique.\n\n**Accès.** Endpoints en lecture seule, méthode GET, sans authentification. CORS ouvert en lecture (`Access-Control-Allow-Origin: *`). Réponses JSON en UTF-8.\n\n**Fraîcheur / cache.** Les fonctions positionnent des directives de cache CDN (`s-maxage`) indiquant la cadence de rafraîchissement en amont. En sortie de CDN, l'en-tête `Cache-Control` renvoyé au client peut être normalisé par l'hébergeur (par ex. `public` ou `public, max-age=0, must-revalidate`) : se fier à la cadence documentée par endpoint plutôt qu'à la valeur brute de l'en-tête.\n\n**Licence.** Code et spécification sous licence MIT. Les données restent soumises aux licences de leurs sources amont respectives.",
    "contact": {
      "name": "France Monitor",
      "url": "https://www.francemonitor.com/contact"
    },
    "license": {
      "name": "MIT",
      "identifier": "MIT"
    }
  },
  "security": [],
  "servers": [
    {
      "url": "https://francemonitor.com",
      "description": "Domaine racine. Redirige en 308 vers https://www.francemonitor.com : les clients qui ne suivent pas les redirections doivent viser directement le domaine www ci-dessous."
    },
    {
      "url": "https://www.francemonitor.com",
      "description": "Domaine canonique servant réellement les fonctions /api/*."
    }
  ],
  "tags": [
    { "name": "Actualités", "description": "Fil d'actualités ingéré (presse nationale + régionale) et son agrégation temporelle." },
    { "name": "Santé", "description": "Santé opérationnelle de la plateforme et historique de la situation." },
    { "name": "Énergie", "description": "Mix électrique régional/national et flux gaziers aux frontières." },
    { "name": "Finance", "description": "Stabilité financière : indicateurs de la BCE et écart de taux OAT-Bund." },
    { "name": "Environnement", "description": "Vigilance météo, crues, feux de forêt (détections satellite, foyers, météo des forêts), sécheresse, qualité de l'air, séismes et marégraphes." },
    { "name": "Société", "description": "Mouvements sociaux : manifestations annoncées (agenda militant) et signalées par la presse." },
    { "name": "Souveraineté", "description": "Défense (aéronefs militaires ou d'État visibles en ADS-B, page Vigipirate du SGDSN, précision de position GNSS et météo spatiale, registre des gels), connectivité (câbles télécom sous-marins, grands réseaux, points d'échange), vigilance cyber (CERT-FR, vulnérabilités exploitées, revendications agrégées) : mesures datées de sources ouvertes, sans lieu inventé." }
  ],
  "paths": {
    "/api/news": {
      "get": {
        "tags": ["Actualités"],
        "operationId": "getNews",
        "summary": "Fil d'actualités ingéré, le plus récent d'abord",
        "description": "Retourne les articles ingérés depuis les flux RSS surveillés (presse nationale et régionale), joints aux métadonnées de leur flux, triés par date de publication décroissante. Chaque article porte une catégorie, une sévérité et une confiance issues du classifieur interne, et éventuellement des coordonnées géographiques.\n\nCache CDN visé : `s-maxage=60, stale-while-revalidate=300` (rafraîchissement ~1 min).",
        "parameters": [
          {
            "name": "since",
            "in": "query",
            "required": false,
            "description": "Borne basse (incluse) sur la date de publication. Format ISO 8601 (`2026-07-05T00:00:00Z`) ou millisecondes epoch. Défaut : il y a 24 h.",
            "schema": { "type": "string" },
            "example": "2026-07-05T00:00:00Z"
          },
          {
            "name": "until",
            "in": "query",
            "required": false,
            "description": "Borne haute (stricte) sur la date de publication : `published_at < until`. ISO 8601 ou epoch ms. Optionnel.",
            "schema": { "type": "string" }
          },
          {
            "name": "before",
            "in": "query",
            "required": false,
            "description": "Alias de `until` (même effet `published_at < before`), pratique pour la pagination par curseur temporel. Si `until` et `before` sont fournis, les deux bornes s'appliquent.",
            "schema": { "type": "string" }
          },
          {
            "name": "category",
            "in": "query",
            "required": false,
            "description": "Filtre exact sur la catégorie. Valeurs courantes : `social`, `security`, `energy`, `weather`, `transport`, `finance`, `health`, `infrastructure`, `fires`, `general`.",
            "schema": { "type": "string" },
            "example": "energy"
          },
          {
            "name": "severity",
            "in": "query",
            "required": false,
            "description": "Filtre sur la sévérité, liste séparée par des virgules (union). Valeurs : `critical`, `high`, `medium`, `low`, `info`.",
            "schema": { "type": "string" },
            "example": "high,critical"
          },
          {
            "name": "region",
            "in": "query",
            "required": false,
            "description": "Filtre exact sur la région rattachée au flux source (peut être vide pour les flux nationaux).",
            "schema": { "type": "string" }
          },
          {
            "name": "limit",
            "in": "query",
            "required": false,
            "description": "Nombre maximum d'articles renvoyés. Borné entre 1 et 1000. Défaut : 500.",
            "schema": { "type": "integer", "minimum": 1, "maximum": 1000, "default": 500 }
          }
        ],
        "responses": {
          "200": {
            "description": "Liste d'articles.",
            "headers": {
              "Cache-Control": {
                "description": "Directive de cache (voir note de fraîcheur globale).",
                "schema": { "type": "string" }
              }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/NewsListResponse" }
              }
            }
          },
          "400": {
            "description": "Paramètre `since` invalide (ni ISO 8601 ni epoch ms).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "invalid since parameter (expect ISO 8601 or epoch ms)" } } }
          },
          "405": {
            "description": "Méthode non autorisée (seul GET est accepté).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "method not allowed" } } }
          },
          "503": {
            "description": "Base d'ingestion non configurée ou indisponible.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "news database unavailable" } } }
          }
        }
      }
    },
    "/api/news/history": {
      "get": {
        "tags": ["Actualités"],
        "operationId": "getNewsHistory",
        "summary": "Agrégation temporelle des actualités (compteurs par tranche)",
        "description": "Retourne le nombre d'articles groupés par tranche temporelle, catégorie et sévérité, pour alimenter une frise chronologique. La granularité est configurable.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=300` (rafraîchissement ~5 min).",
        "parameters": [
          {
            "name": "from",
            "in": "query",
            "required": false,
            "description": "Début de la fenêtre (inclus). ISO 8601 ou epoch ms. Défaut : il y a 7 jours.",
            "schema": { "type": "string" }
          },
          {
            "name": "to",
            "in": "query",
            "required": false,
            "description": "Fin de la fenêtre (inclus). ISO 8601 ou epoch ms. Défaut : maintenant. Doit être strictement postérieur à `from`.",
            "schema": { "type": "string" }
          },
          {
            "name": "bucket",
            "in": "query",
            "required": false,
            "description": "Granularité des tranches.",
            "schema": { "type": "string", "enum": ["hour", "day", "week"], "default": "hour" }
          }
        ],
        "responses": {
          "200": {
            "description": "Compteurs agrégés.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/NewsHistoryResponse" }
              }
            }
          },
          "400": {
            "description": "Paramètre invalide : `from`/`to` non parsable, `from >= to`, ou `bucket` hors { hour, day, week }.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "bucket must be \"hour\", \"day\", or \"week\"" } } }
          },
          "405": {
            "description": "Méthode non autorisée (seul GET est accepté).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          },
          "503": {
            "description": "Base d'ingestion non configurée ou indisponible.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/health-check": {
      "get": {
        "tags": ["Santé"],
        "operationId": "getHealthCheck",
        "summary": "Santé opérationnelle de la plateforme",
        "description": "Cible pour sondes de monitoring. Agrège deux signaux : le dernier tick du cron d'ingestion et l'âge du dernier article inséré. Statut `ok` si tick < 15 min ET article < 60 min ; `degraded` si le tick est frais mais les articles sont vieux ; `down` sinon. Jamais mis en cache (`Cache-Control: no-store`).",
        "responses": {
          "200": {
            "description": "Statut calculé (le corps est toujours renvoyé même en statut `degraded`/`down`).",
            "headers": {
              "Cache-Control": { "description": "Toujours `no-store`.", "schema": { "type": "string" } }
            },
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HealthCheckResponse" },
                "example": {
                  "status": "ok",
                  "checkedAt": "2026-07-05T13:06:54.232Z",
                  "ingest": { "lastTick": { "timestamp": "2026-07-05T13:05:08.599Z", "feedsProcessed": 10, "inserted": 5, "errors": [], "durationMs": 1336 }, "lastTickAgeMs": 105633 },
                  "articles": { "lastArticleAt": "2026-07-05T13:05:07.557Z", "lastArticleAgeMs": 106675, "error": null }
                }
              }
            }
          },
          "405": {
            "description": "Méthode non autorisée (seul GET est accepté).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Method not allowed" } } }
          },
          "503": {
            "description": "`DATABASE_URL` non configuré côté serveur. Le corps reprend la forme de la réponse avec `status: \"down\"` et des sous-objets nuls.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/HealthCheckResponse" },
                "example": { "status": "down", "error": "DATABASE_URL not configured", "ingest": null, "articles": null }
              }
            }
          }
        }
      }
    },
    "/api/situation-history": {
      "get": {
        "tags": ["Santé"],
        "operationId": "getSituationHistory",
        "summary": "Historique de l'indice de situation (grille de créneaux 6 h)",
        "description": "Retourne les instantanés (\"snapshots\") de l'indice de situation nationale sur une grille canonique UTC de créneaux de 6 heures (00, 06, 12, 18). Les créneaux non capturés sont renvoyés explicitement comme `{ slotKey, status: \"missing\" }`. Jamais mis en cache (`Cache-Control: no-store`).\n\nNote : cet endpoint accepte aussi POST, réservé à l'écriture interne des snapshots par le client ; il n'est pas destiné à un usage tiers et n'est pas documenté ici.",
        "parameters": [
          {
            "name": "days",
            "in": "query",
            "required": true,
            "description": "Profondeur de l'historique en jours. Doit valoir exactement 7 ou 30 (soit 28 ou 120 créneaux de 6 h).",
            "schema": { "type": "integer", "enum": [7, 30] }
          }
        ],
        "responses": {
          "200": {
            "description": "Grille de créneaux, capturés ou manquants.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/SituationHistoryResponse" }
              }
            }
          },
          "400": {
            "description": "`days` absent ou différent de 7/30.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "days must be 7 or 30" } } }
          },
          "405": {
            "description": "Méthode non autorisée.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" } } }
          }
        }
      }
    },
    "/api/energy/ecowatt": {
      "get": {
        "tags": ["Énergie"],
        "operationId": "getEnergyMix",
        "summary": "Mix électrique temps réel (régional + échanges frontières)",
        "description": "Proxy de l'API ODRE (Open Data Réseaux Énergies, dataset eco2mix). Renvoie les derniers relevés régionaux du mix de production/consommation électrique (une ligne par région) et le dernier relevé national des échanges commerciaux aux frontières. Toutes les valeurs de puissance sont en mégawatts (MW). Le nom historique \"ecowatt\" est conservé ; la charge utile porte le mix eco2mix, pas les signaux EcoWatt.\n\nCache CDN visé : `s-maxage=900, stale-while-revalidate` (rafraîchissement ~15 min).",
        "responses": {
          "200": {
            "description": "Enveloppe { regional, national } reprenant telles quelles les réponses ODRE.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/EcowattResponse" }
              }
            }
          },
          "500": {
            "description": "Échec du fetch amont (exception réseau/timeout).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Fetch failed" } } }
          },
          "502": {
            "description": "Réponse non-OK d'ODRE (statuts amont repris dans le message).",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/Error" }, "example": { "error": "Upstream error: 500 / 200" } } }
          }
        }
      }
    },
    "/api/energy/gas-pir": {
      "get": {
        "tags": ["Énergie"],
        "operationId": "getGasPir",
        "summary": "Flux gaziers nets aux points d'interconnexion frontaliers (PIR)",
        "description": "Proxy ENTSOG (Physical Flow journalier) pour les points d'interconnexion réseau (PIR) aux frontières françaises, opérateurs GRTgaz/NaTran (FR-TSO-0003) et TERÉGA (FR-TSO-0002). Renvoie le flux net par point en GWh/jour (positif = import net vers la France). En cas d'échec amont, l'endpoint renvoie tout de même 200 avec `status: \"error\"`, `points: []` et un champ `error`.\n\nCache CDN visé : `s-maxage=1800, stale-while-revalidate` (rafraîchissement ~30 min).",
        "responses": {
          "200": {
            "description": "Flux nets par PIR. Voir `status` pour la complétude.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/GasPirResponse" },
                "example": {
                  "points": [
                    { "pointKey": "ITP-00304", "pointLabel": "VIP PIRINEOS", "flowGWhDay": -43.185297, "periodFrom": "2026-07-04T06:00:00+02:00" }
                  ],
                  "fetchedAt": "2026-07-05T13:08:22.898Z",
                  "status": "ok"
                }
              }
            }
          }
        }
      }
    },
    "/api/environment/fires": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentFires",
        "summary": "Feux de forêt : détections satellite en France, foyers, météo des forêts (NASA FIRMS, Météo-France)",
        "description": "Dernière collecte FIRMS du serveur (VIIRS Suomi NPP, NOAA-20, NOAA-21 et MODIS, 24 h glissantes) : chaque détection est rattachée à un département par point dans polygone, celles de la boîte hors de France sont comptées à part. Détections regroupées en foyers (confirmés à partir de deux passages ; récurrents à part, probablement industriels), dix derniers jours, passages attendus et météo des forêts, chacun avec sa propre date. Une détection est une anomalie thermique, pas nécessairement un incendie confirmé.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=900` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Collecte FIRMS ou météo des forêts lue ; une partie en panne est nommée dans `errors`.",
            "content": {
              "application/json": {
                "schema": { "$ref": "#/components/schemas/FiresResponse" }
              }
            }
          },
          "502": {
            "description": "Ni collecte FIRMS ni météo des forêts (clé absente comprise) : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FiresResponse" } } }
          }
        }
      }
    },
    "/api/environment/vigilance": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentVigilance",
        "summary": "Vigilance météo : carte Météo-France par département et par phénomène, bulletins, historique",
        "description": "Carte de vigilance Météo-France (aujourd'hui et demain) par département et par phénomène, avec ses créneaux, les domaines littoraux, le commentaire, les bulletins national, zonaux et départementaux, et les départements en vigilance sur 30 jours (archive open data). Couleurs officielles reprises telles quelles, carte datée par son `update_time`.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Carte ou textes lus ; une partie en panne est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VigilanceResponse" } } }
          },
          "502": {
            "description": "Ni carte ni textes (clé absente comprise) : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/VigilanceResponse" } } }
          }
        }
      }
    },
    "/api/environment/flood-zones": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentFloodZones",
        "summary": "Zones inondables officielles d'une boîte (Directive inondation, TRI, Géorisques)",
        "description": "Zones inondables cartographiées (débordement de cours d'eau) des scénarios fréquent, moyen (~centennal) et extrême, lues dans le WFS Géorisques pour la boîte `bbox=minLon,minLat,maxLon,maxLat` (ordre lon,lat, 1° au plus par côté, 400 sinon). Anneaux extérieurs simplifiés (~30 m) et arrondis à 5 décimales ; au plus 300 entités par scénario (note dans `errors` si tronqué). Seuls les territoires à risque important (TRI) sont cartographiés : une réponse vide (`covered` faux) est normale ailleurs et ne dit pas qu'il n'y a pas de risque.\n\nCache CDN visé : `s-maxage=86400, stale-while-revalidate=604800` (plus court si la réponse est partielle).",
        "parameters": [
          { "name": "bbox", "in": "query", "required": true, "description": "`minLon,minLat,maxLon,maxLat` (CRS84).", "schema": { "type": "string", "example": "4.05,43.65,4.15,43.75" } }
        ],
        "responses": {
          "200": {
            "description": "Au moins un scénario lu (zones éventuellement vides hors TRI) ; un scénario en panne est nommé dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FloodZonesResponse" } } }
          },
          "502": {
            "description": "Aucun scénario lu : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FloodZonesResponse" } } }
          }
        }
      }
    },
    "/api/environment/floods": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentFloods",
        "summary": "Crues : tronçons Vigicrues en vigilance et hauteurs des stations (Hub'Eau)",
        "description": "Vigilance crues par tronçon (InfoVigiCru, relevé du serveur) : comptes par couleur sur les tronçons surveillés, tronçons en vigilance jaune ou plus avec leur tracé et les stations Hub'Eau de chacun (hauteur, débit, variation, courbe). Couleurs officielles reprises telles quelles.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Relevé Vigicrues lu (ou servi du cache avec sa date) ; territoires, stations ou Hub'Eau en panne : nommés dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FloodsResponse" } } }
          },
          "502": {
            "description": "Vigicrues jamais lu : même corps, panne nommée dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FloodsResponse" } } }
          }
        }
      }
    },
    "/api/environment/drought": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentDrought",
        "summary": "Sécheresse : restrictions d'eau en vigueur par département (VigiEau)",
        "description": "Niveau de restriction le plus haut par département et par usage (eaux superficielles, souterraines, potable), comptes par niveau, date des arrêtés et série quotidienne depuis la mise en service. Un stock : jamais dans le score.\n\nCache CDN visé : `s-maxage=3600, stale-while-revalidate=7200` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "VigiEau lu (ou servi du cache avec sa date) ; une panne de la relecture est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DroughtResponse" } } }
          },
          "502": {
            "description": "VigiEau jamais lu : même corps, panne nommée dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DroughtResponse" } } }
          }
        }
      }
    },
    "/api/environment/pollen": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentPollen",
        "summary": "Pollens : niveau par département de J à J+2 (Atmo France)",
        "description": "Niveau de pollens (1 très faible à 6 extrêmement élevé) de J, J+1 et J+2, agrégé par département à partir des communes : plus haut code, communes à « élevé » ou plus, alerte, et plus haut code de chaque taxon (aulne, bouleau, olivier, graminées, armoise, ambroisie). Requêtes WFS filtrées par date, un jour par requête ; un jour non publié est absent de `days`.\n\nCache CDN visé : `s-maxage=1800, stale-while-revalidate=3600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Au moins un jour lu ; un jour en panne est nommé dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PollenResponse" } } }
          },
          "502": {
            "description": "Aucun jour lu : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/PollenResponse" } } }
          }
        }
      }
    },
    "/api/environment/air": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentAir",
        "summary": "Qualité de l'air : épisodes de pollution et indice ATMO (Atmo France)",
        "description": "Épisodes de pollution de J à J+2 (information-recommandation, alerte) par zone et par polluant, et indice ATMO de J agrégé par département. Requêtes WFS toujours filtrées par date ; un jour non publié est dit, jamais « aucun épisode ».\n\nCache CDN visé : `s-maxage=1800, stale-while-revalidate=3600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Épisodes ou indice lus ; une couche en panne est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AirQualityResponse" } } }
          },
          "502": {
            "description": "Aucune des deux couches lue : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/AirQualityResponse" } } }
          }
        }
      }
    },
    "/api/environment/earthquakes": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentEarthquakes",
        "summary": "Séismes : 7 derniers jours en France et à 20 km autour (BCSF-RéNaSS, EMSC en repli)",
        "description": "Séismes des 7 derniers jours, plus récent d'abord : magnitude, profondeur, position, département, « en France » (territoire ou eaux françaises) ou à 20 km au plus, statut (revu ou automatique). Tirs de carrière, explosions et glissements écartés et comptés.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "BCSF-RéNaSS ou EMSC lu (ou un relevé précédent servi avec sa date) ; une panne est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EarthquakesResponse" } } }
          },
          "502": {
            "description": "Ni BCSF-RéNaSS ni EMSC : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EarthquakesResponse" } } }
          }
        }
      }
    },
    "/api/environment/disasters": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentDisasters",
        "summary": "Catastrophes : alertes GDACS (ONU / Commission européenne), territoire français et alertes orange ou rouges du monde",
        "description": "Alertes GDACS des 7 derniers jours : sur le territoire français (métropole, outre-mer), tous niveaux ; ailleurs, orange et rouge seulement. Niveaux calculés par modèle, pas une vigilance officielle française.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Au moins une requête GDACS lue (ou un relevé précédent servi avec sa date) ; une panne est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DisastersResponse" } } }
          },
          "502": {
            "description": "Aucune requête GDACS lue : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/DisastersResponse" } } }
          }
        }
      }
    },
    "/api/social/protests": {
      "get": {
        "tags": ["Société"],
        "operationId": "getSocialProtests",
        "summary": "Manifestations : rendez-vous annoncés (Démosphère), presse française ingérée et signal de la presse mondiale (GDELT)",
        "description": "Rendez-vous de manifestation des éditions françaises de Démosphère (agenda militant participatif), commencés depuis moins de 6 h ou à venir dans les 7 jours, placés sur la commune exacte du référentiel geo.api.gouv.fr (sinon sans lieu) ; manifestations rapportées sur 24 h par les titres de la presse française déjà ingérée, une entrée par ville ou département nommé dans le titre ; manifestations en France relevées par GDELT dans la presse mondiale sur 24 h, un relevé par lieu (rarement plus précis que la ville), omis quand la presse française couvre déjà la ville. Une annonce n'est pas une manifestation constatée. Aucun effet sur l'indice de stabilité.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Au moins une des trois sources lue ; une panne (source ou édition) est nommée dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProtestsResponse" } } }
          },
          "502": {
            "description": "Aucune source lue : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/ProtestsResponse" } } }
          }
        }
      }
    },
    "/api/environment/ems": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentEms",
        "summary": "Catastrophes : activations Copernicus EMS (cartographie satellite d'urgence) sur le territoire français",
        "description": "Activations en cours (avec zones cartographiées, déclencheur, population dans l'emprise), activations des 12 derniers mois, et nombre d'activations en cours hors de France.\n\nCache CDN visé : `s-maxage=900, stale-while-revalidate=900` (plus court si la réponse est partielle).",
        "responses": {
          "200": { "description": "Liste lue (ou relevé précédent servi avec sa date) ; une panne est nommée dans `errors`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmsResponse" } } } },
          "502": { "description": "Liste non lue : même corps, panne nommée dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/EmsResponse" } } } }
        }
      }
    },
    "/api/finance/stability": {
      "get": {
        "tags": ["Finance"],
        "operationId": "getFinanceStability",
        "summary": "Stabilité financière : CISS, taux directeurs, €STR, EUR/USD (BCE) et écart OAT-Bund (TradingView)",
        "description": "Séries BCE quotidiennes (90 observations), taux directeurs avec date et sens du dernier changement, écart de taux à 10 ans France-Allemagne relevé en direct et enregistré un point par jour. Une série est en retard au-delà de 4 jours calendaires après sa date de valeur.\n\nCache CDN visé : `s-maxage=900, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Au moins une partie lue ; une panne ou un retard est nommé dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FinancialStabilityResponse" } } }
          },
          "502": {
            "description": "Aucune partie lue : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/FinancialStabilityResponse" } } }
          }
        }
      }
    },
    "/api/environment/sea-levels": {
      "get": {
        "tags": ["Environnement"],
        "operationId": "getEnvironmentSeaLevels",
        "summary": "Marégraphes : hauteurs d'eau observées du SHOM (19 ports)",
        "description": "Hauteur d'eau observée de chaque marégraphe (zéro hydrographique), variation sur 1 h, domaine littoral de vigilance et courbe de 24 h. La marée prédite n'est pas publiée sans clé : aucun écart à la prédiction.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (plus court si la réponse est partielle).",
        "responses": {
          "200": {
            "description": "Au moins un marégraphe lu ; chaque marégraphe en panne est nommé dans `errors`.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SeaLevelsResponse" } } }
          },
          "502": {
            "description": "Aucun marégraphe lu : même corps, pannes nommées dans `errors`, jamais mis en cache.",
            "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SeaLevelsResponse" } } }
          }
        }
      }
    },
    "/api/sovereignty/military": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyMilitary",
        "summary": "Aéronefs militaires au-dessus de la France, urgences confirmées, comptes horaires (Données adsb.lol, ODbL 1.0)",
        "description": "Dernière collecte du serveur sur l'API publique d'adsb.lol (/v2/mil), au plus toutes les 2 min : aéronefs au-dessus de la France (département métropolitain, ou moins de 22 km de la côte en mer), pays par bloc d'adresse OACI, appareils français en compte par département seulement (ni indicatif, ni type, ni position), appareils PIA ou LADD jamais montrés, positions hors de France comptées à part, urgences (7500, 7600, 7700) confirmées sur deux lectures, aéronefs distincts par heure sur 7 jours. `readAt` date la collecte ; une panne est nommée dans `errors`. Un appareil absent du flux n'est pas absent du ciel.\n\nCache CDN visé : `s-maxage=60, stale-while-revalidate=120`.",
        "responses": {
          "200": { "description": "Collecte de moins de 2 h servie avec sa date.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Aucune collecte de moins de 2 h : même forme, `readAt` nul, panne nommée dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/cables-watch": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyCablesWatch",
        "summary": "Navires lents près d'un câble télécom sous-marin (AIS du relais, câbles du Shom et d'OpenStreetMap)",
        "description": "Relevé du relais AIS toutes les 5 min : navires des eaux françaises à moins de 2 nœuds et à moins de 500 m d'un câble du Shom ou d'OpenStreetMap, confirmés sur deux relevés espacés d'au moins 5 min, hors zone de mouillage du Shom qui ne recoupe pas une zone de câbles ; à moins de 2 km d'un atterrage (approches d'atterrage et ports), seuls les navires déclarés au mouillage (statut AIS 1) dans une zone de câbles du Shom ; bâtiments militaires français écartés (sous pavillon français, outre-mer compris : type AIS 35, ou nom AIS « FRENCH WARSHIP ») ; « à vérifier », jamais une menace : seule la préfecture maritime qualifie une infraction. Flux AIS muet, relais injoignable ou fichier des câbles illisible : `evaluated` faux, alertes gardées, non évaluées. Aucun appel d'un service tiers depuis cette route.\n\nCache CDN visé : `s-maxage=120, stale-while-revalidate=300`.",
        "responses": {
          "200": { "description": "Le relais a répondu au moins une fois (même muet).", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Relais jamais lu : même forme, `readAt` nul, panne nommée dans `errors`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/cyber": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyCyber",
        "summary": "Vigilance cyber : alertes et avis du CERT-FR, vulnérabilités exploitées (CISA KEV), revendications agrégées, fuites en .fr, Cybermalveillance.gouv.fr",
        "description": "Collecte du serveur, chaque partie datée par sa propre lecture : CERT-FR (Licence ouverte 2.0) avec le statut officiel de chaque alerte (en cours, clôturée) et la date de la dernière version lue sur la page, catalogue des vulnérabilités exploitées de la CISA (domaine public) croisé avec les références du CERT-FR, revendications de rançongiciels en France rapportées à leur moyenne (Source : Ransomware.live, comptes seulement, aucun nom de victime), fuites en .fr ajoutées à Have I Been Pwned (CC BY 4.0), alertes et actualités de Cybermalveillance.gouv.fr. Une partie en panne est nommée dans `errors`.\n\nCache CDN visé : `s-maxage=600, stale-while-revalidate=1800`.",
        "responses": {
          "200": { "description": "Au moins une partie lue une fois.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Aucune partie lue : même forme, pannes nommées dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/vigipirate": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyVigipirate",
        "summary": "Relecture quotidienne de la page Vigipirate du SGDSN : date de lecture, empreinte, date du dernier changement",
        "description": "Le serveur relit chaque jour la page https://www.sgdsn.gouv.fr/vigipirate (GET, empreinte SHA-256 du contenu utile, sans le reprendre) et sert `readAt` (dernière lecture réussie), `fingerprint` et `pageChangedAt` (lecture où l'empreinte a changé pour la dernière fois). Jamais le texte de la page : le niveau affiché vient d'une saisie datée (Source : site internet du SGDSN), à revérifier sur sgdsn.gouv.fr quand la page a changé. Type `VigipiratePageCheck` de `src/types/index.ts`.\n\nCache CDN visé : `s-maxage=3600, stale-while-revalidate=7200`.",
        "responses": {
          "200": { "description": "Page lue au moins une fois ; une panne ultérieure est nommée dans `errors`, la dernière lecture servie avec sa date.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Page jamais lue : même forme, `readAt` nul, panne nommée dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/gnss": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyGnss",
        "summary": "Précision de position GNSS dégradée (comptes sur 24 h, mailles du jour UTC précédent) et météo spatiale (Données adsb.lol, ODbL 1.0 ; NOAA SWPC)",
        "description": "Dernière grille complète de la collecte du serveur : cinq lectures adsb.lol (/v2/point) toutes les 10 min, mailles de 0,5° ; sur 24 h glissantes, un aéronef distinct par maille avec sa pire précision de position déclarée (`nac_p` 8 ou plus : bonne ; 1 à 7 : dégradée ; 0 : comptée à part, sauf si elle suit un `nac_p` de 8 ou plus du même appareil dans la même fenêtre, le jour UTC pour les mailles localisées, 24 h pour les comptes en direct : elle compte alors dégradée). Part dégradée = 100 × (dégradés − 1) / (bons + dégradés), au moins 5 aéronefs ; jaune de 2 à 10 %, orange au-delà. En direct, un compte sans lieu (`degraded` : mailles françaises au-delà de 10 % sur 24 h glissantes et sur les deux derniers jours UTC complets, veille d'abord, `null` pour un jour non couvert) ; les mailles localisées (`cells`) ne sont publiées que pour le jour UTC précédent (`cellsDay`), et seulement s'il est couvert en entier. Dégradation générale (plus de 30 % des mailles françaises dégradées et Kp de 5− ou plus sur la fenêtre) : aucune maille comptée. Une précision de position dégradée est à vérifier : seules la DGAC et l'ANFR qualifient un brouillage. Météo spatiale de la NOAA (domaine public) : échelles R, S et G du jour, prévisions datées, indice Kp sur 7 jours, dernière alerte. Type `GnssResponse` de `src/types/index.ts`.\n\nCache CDN visé : `s-maxage=300, stale-while-revalidate=600` (30 s pendant une collecte en cours).",
        "responses": {
          "200": { "description": "Grille ou météo spatiale servie ; une partie en panne est nommée dans `errors`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Ni grille ni météo spatiale : même forme, pannes nommées dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/connectivity": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntyConnectivity",
        "summary": "Visibilité des six grands réseaux français (RIPEstat) et points d'échange en France (PeeringDB)",
        "description": "Instantané RIPEstat `routing-status` (instantanés RIPE RIS de 00 h, 08 h et 16 h UTC, relevés chaque heure) pour Orange, SFR, Bouygues Telecom, Free, RENATER et OVHcloud : routeurs témoins RIS qui voient chaque réseau en IPv4 et en IPv6, préfixes annoncés ; un réseau est dit vu par au moins 99 % des routeurs témoins RIPE ou non. Un réseau non lu est nommé dans `unread` avec sa panne, jamais compté à 0. Séries de la visibilité minimale et des préfixes annoncés sur 30 jours. Points d'échange publiés en France par PeeringDB (annuaire, sans état en direct). Hors score. Type `ConnectivityResponse` de `src/types/index.ts`.\n\nCache CDN visé : `s-maxage=1800, stale-while-revalidate=3600` (60 s pendant une lecture en cours).",
        "responses": {
          "200": { "description": "RIPEstat ou PeeringDB servi ; une partie en panne est nommée dans `errors`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Ni RIPEstat ni PeeringDB : même forme, pannes nommées dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    },
    "/api/sovereignty/sanctions": {
      "get": {
        "tags": ["Souveraineté"],
        "operationId": "getSovereigntySanctions",
        "summary": "Registre national des gels (DG Trésor) : date de publication, comptes par nature, nouveaux gels et radiations",
        "description": "Date de la dernière publication du registre national des gels, relue chaque heure ; comptes par nature (personnes physiques, personnes morales, navires) ; nouveaux gels et radiations (différences d'identifiants avec la publication précédente, `null` au premier relevé, jamais « tout est nouveau ») ; historique des publications sur 400 jours. Aucun nom n'est servi : consulter la dernière version du registre (https://gels-avoirs.dgtresor.gouv.fr/). Hors score. Type `SanctionsResponse` de `src/types/index.ts`.\n\nCache CDN visé : `s-maxage=1800, stale-while-revalidate=3600` (60 s pendant une lecture en cours).",
        "responses": {
          "200": { "description": "Publication connue ou date lue ; une panne est nommée dans `errors`.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } },
          "502": { "description": "Date jamais lue et aucune publication connue : même forme, panne nommée dans `errors`, jamais mis en cache.", "content": { "application/json": { "schema": { "$ref": "#/components/schemas/SovereigntyResponse" } } } }
        }
      }
    }
  },
  "components": {
    "schemas": {
      "Error": {
        "type": "object",
        "description": "Corps d'erreur standard.",
        "properties": {
          "error": { "type": "string", "description": "Message d'erreur lisible (en anglais côté serveur)." },
          "message": { "type": "string", "description": "Détail complémentaire, présent sur certains proxies." }
        },
        "required": ["error"]
      },
      "NewsItem": {
        "type": "object",
        "description": "Un article ingéré, enrichi des métadonnées de son flux et de la classification interne.",
        "properties": {
          "id": { "type": ["integer", "null"], "description": "Identifiant interne de l'article." },
          "feedId": { "type": ["string", "null"], "description": "Identifiant du flux source (slug), ex. `bfm-tv`." },
          "feedName": { "type": ["string", "null"], "description": "Nom lisible du flux source, ex. `BFM TV`." },
          "feedRegion": { "type": ["string", "null"], "description": "Région rattachée au flux (null pour un flux national)." },
          "tier": { "type": ["integer", "null"], "description": "Niveau de priorité éditoriale du flux (1 = national de référence, valeurs croissantes = plus local/secondaire)." },
          "title": { "type": ["string", "null"], "description": "Titre de l'article." },
          "link": { "type": ["string", "null"], "description": "URL de l'article original." },
          "description": { "type": ["string", "null"], "description": "Extrait / chapô de l'article (texte, éventuellement tronqué)." },
          "publishedAt": { "type": ["string", "null"], "format": "date-time", "description": "Date de publication déclarée par le flux (ISO 8601 UTC)." },
          "collectedAt": { "type": ["string", "null"], "format": "date-time", "description": "Date d'ingestion côté serveur (ISO 8601 UTC)." },
          "category": { "type": ["string", "null"], "description": "Catégorie attribuée par le classifieur : `social`, `security`, `energy`, `weather`, `transport`, `finance`, `health`, `infrastructure`, `fires`, `general`." },
          "severity": { "type": ["string", "null"], "description": "Sévérité estimée : `critical`, `high`, `medium`, `low`, `info`." },
          "confidence": { "type": ["number", "null"], "description": "Confiance de la classification, de 0 à 1." },
          "lat": { "type": ["number", "null"], "description": "Latitude géolocalisée (WGS84), null si non localisé." },
          "lon": { "type": ["number", "null"], "description": "Longitude géolocalisée (WGS84), null si non localisé." }
        }
      },
      "NewsListResponse": {
        "type": "object",
        "properties": {
          "items": { "type": "array", "description": "Articles, du plus récent au plus ancien.", "items": { "$ref": "#/components/schemas/NewsItem" } },
          "count": { "type": "integer", "description": "Nombre d'articles dans `items`." },
          "generatedAt": { "type": "string", "format": "date-time", "description": "Horodatage de génération de la réponse (ISO 8601 UTC)." }
        },
        "required": ["items", "count", "generatedAt"]
      },
      "NewsHistoryBucket": {
        "type": "object",
        "description": "Compteur d'articles pour une combinaison (tranche, catégorie, sévérité).",
        "properties": {
          "t": { "type": "string", "format": "date-time", "description": "Début de la tranche temporelle (ISO 8601 UTC, tronqué selon `bucket`)." },
          "category": { "type": ["string", "null"], "description": "Catégorie (voir NewsItem.category), ou null." },
          "severity": { "type": ["string", "null"], "description": "Sévérité (voir NewsItem.severity), ou null." },
          "count": { "type": "integer", "description": "Nombre d'articles dans cette combinaison." }
        },
        "required": ["t", "count"]
      },
      "NewsHistoryResponse": {
        "type": "object",
        "properties": {
          "buckets": { "type": "array", "description": "Compteurs triés par tranche croissante.", "items": { "$ref": "#/components/schemas/NewsHistoryBucket" } },
          "from": { "type": "string", "format": "date-time", "description": "Début effectif de la fenêtre (ISO 8601 UTC)." },
          "to": { "type": "string", "format": "date-time", "description": "Fin effective de la fenêtre (ISO 8601 UTC)." },
          "bucket": { "type": "string", "enum": ["hour", "day", "week"], "description": "Granularité appliquée." }
        },
        "required": ["buckets", "from", "to", "bucket"],
        "example": {
          "buckets": [
            { "t": "2026-06-28T13:00:00.000Z", "category": "transport", "severity": "low", "count": 2 }
          ],
          "from": "2026-06-28T13:00:00.000Z",
          "to": "2026-07-05T13:00:00.000Z",
          "bucket": "hour"
        }
      },
      "IngestTick": {
        "type": "object",
        "description": "Résumé du dernier passage du cron d'ingestion.",
        "properties": {
          "timestamp": { "type": "string", "format": "date-time", "description": "Horodatage du tick (ISO 8601 UTC)." },
          "feedsProcessed": { "type": "integer", "description": "Nombre de flux traités durant ce tick." },
          "inserted": { "type": "integer", "description": "Nombre de nouveaux articles insérés." },
          "errors": { "type": "array", "description": "Erreurs non bloquantes rencontrées (peut être vide).", "items": { "type": "string" } },
          "durationMs": { "type": "integer", "description": "Durée du tick en millisecondes." }
        }
      },
      "HealthCheckResponse": {
        "type": "object",
        "properties": {
          "status": { "type": "string", "enum": ["ok", "degraded", "down"], "description": "Statut agrégé : `ok` (tick < 15 min et article < 60 min), `degraded` (tick frais mais articles anciens), `down` (sinon)." },
          "checkedAt": { "type": ["string", "null"], "format": "date-time", "description": "Horodatage du contrôle (ISO 8601 UTC). Absent en cas de 503 de configuration." },
          "error": { "type": ["string", "null"], "description": "Message d'erreur global (par ex. base non configurée), null en fonctionnement normal." },
          "ingest": {
            "type": ["object", "null"],
            "description": "Signal d'ingestion, ou null si la base n'est pas configurée.",
            "properties": {
              "lastTick": { "anyOf": [ { "$ref": "#/components/schemas/IngestTick" }, { "type": "null" } ], "description": "Dernier tick connu, ou null." },
              "lastTickAgeMs": { "type": ["integer", "null"], "description": "Âge du dernier tick en millisecondes, ou null." }
            }
          },
          "articles": {
            "type": ["object", "null"],
            "description": "Signal de fraîcheur des articles, ou null si la base n'est pas configurée.",
            "properties": {
              "lastArticleAt": { "type": ["string", "null"], "format": "date-time", "description": "Date d'insertion du dernier article (ISO 8601 UTC), ou null." },
              "lastArticleAgeMs": { "type": ["integer", "null"], "description": "Âge du dernier article en millisecondes, ou null." },
              "error": { "type": ["string", "null"], "description": "Erreur d'accès base pour ce signal, ou null." }
            }
          }
        },
        "required": ["status"]
      },
      "SituationSnapshot": {
        "type": "object",
        "description": "Instantané capturé de l'indice de situation pour un créneau de 6 h.",
        "properties": {
          "version": { "type": "integer", "description": "Version du schéma de snapshot (actuellement 1)." },
          "slotKey": { "type": "string", "description": "Clé de créneau UTC au format `YYYY-MM-DDTHH:00` avec HH ∈ {00,06,12,18}.", "example": "2026-06-28T18:00" },
          "capturedAt": { "type": "string", "format": "date-time", "description": "Horodatage réel de capture (ISO 8601 UTC)." },
          "score": { "type": "number", "description": "Score de situation global (0–100, 100 = situation nominale)." },
          "axes": {
            "type": "object",
            "description": "Sous-scores par axe de risque.",
            "properties": {
              "continuity": { "type": "number", "description": "Continuité des infrastructures." },
              "defense": { "type": "number", "description": "Défense / activité militaire." },
              "security": { "type": "number", "description": "Sécurité intérieure." },
              "signal": { "type": "number", "description": "Signaux faibles agrégés." },
              "cyber": { "type": "number", "description": "Menace cyber." },
              "social": { "type": "number", "description": "Tensions sociales." }
            }
          },
          "situations": { "type": "array", "description": "Situations notables retenues pour ce créneau (souvent vide).", "items": { "type": "object", "additionalProperties": true } },
          "meta": {
            "type": "object",
            "description": "Métadonnées d'agrégation.",
            "properties": {
              "totalSituations": { "type": "integer", "description": "Nombre total de situations considérées." },
              "maxSeverity": { "type": ["string", "null"], "description": "Sévérité maximale observée, ou null." },
              "avgConfidence": { "type": "number", "description": "Confiance moyenne des situations." }
            }
          },
          "dataStatus": {
            "type": "object",
            "description": "État des sources ayant alimenté le snapshot.",
            "properties": {
              "overall": { "type": "string", "enum": ["ok", "degraded"], "description": "État global des données pour ce créneau." },
              "sources": { "type": "object", "additionalProperties": { "type": "string" }, "description": "État par source (ex. `countryAxes`, `cyber`, `social`)." }
            }
          }
        },
        "required": ["version", "slotKey", "score"]
      },
      "SituationMissingSlot": {
        "type": "object",
        "description": "Créneau attendu mais non capturé.",
        "properties": {
          "slotKey": { "type": "string", "description": "Clé de créneau manquant, format `YYYY-MM-DDTHH:00`." },
          "status": { "type": "string", "const": "missing", "description": "Toujours `missing`." }
        },
        "required": ["slotKey", "status"]
      },
      "SituationHistoryResponse": {
        "type": "object",
        "properties": {
          "requestedRange": {
            "type": "object",
            "description": "Bornes de la grille demandée.",
            "properties": {
              "from": { "type": "string", "format": "date-time", "description": "Premier créneau (ISO 8601 UTC)." },
              "to": { "type": "string", "format": "date-time", "description": "Dernier créneau (ISO 8601 UTC)." }
            }
          },
          "slotCount": {
            "type": "object",
            "description": "Décompte de complétude de la grille.",
            "properties": {
              "expected": { "type": "integer", "description": "Nombre de créneaux attendus (days × 4)." },
              "captured": { "type": "integer", "description": "Créneaux effectivement capturés." },
              "missing": { "type": "integer", "description": "Créneaux manquants." },
              "degraded": { "type": "integer", "description": "Créneaux capturés dont `dataStatus.overall` vaut `degraded`." }
            }
          },
          "slots": {
            "type": "array",
            "description": "Grille complète, du plus ancien au plus récent. Chaque élément est soit un snapshot capturé, soit un marqueur de créneau manquant.",
            "items": {
              "oneOf": [
                { "$ref": "#/components/schemas/SituationSnapshot" },
                { "$ref": "#/components/schemas/SituationMissingSlot" }
              ]
            }
          }
        },
        "required": ["requestedRange", "slotCount", "slots"]
      },
      "OdreRegionalRecord": {
        "type": "object",
        "description": "Relevé régional eco2mix (puissances en MW).",
        "properties": {
          "code_insee_region": { "type": ["string", "null"], "description": "Code INSEE de la région." },
          "libelle_region": { "type": ["string", "null"], "description": "Nom de la région." },
          "date_heure": { "type": ["string", "null"], "format": "date-time", "description": "Horodatage du relevé (ISO 8601 avec fuseau)." },
          "consommation": { "type": ["number", "null"], "description": "Consommation électrique (MW)." },
          "nucleaire": { "type": ["number", "null"], "description": "Production nucléaire (MW)." },
          "eolien": { "type": ["number", "null"], "description": "Production éolienne (MW)." },
          "solaire": { "type": ["number", "null"], "description": "Production solaire (MW)." },
          "hydraulique": { "type": ["number", "null"], "description": "Production hydraulique (MW)." },
          "thermique": { "type": ["number", "null"], "description": "Production thermique fossile (MW)." },
          "bioenergies": { "type": ["number", "null"], "description": "Production bioénergies (MW)." },
          "ech_physiques": { "type": ["number", "null"], "description": "Échanges physiques nets de la région (MW)." }
        }
      },
      "OdreNationalRecord": {
        "type": "object",
        "description": "Relevé national des échanges commerciaux aux frontières (MW ; négatif = export net depuis la France).",
        "properties": {
          "ech_comm_angleterre": { "type": ["number", "null"], "description": "Échange commercial avec l'Angleterre (MW)." },
          "ech_comm_espagne": { "type": ["number", "null"], "description": "Échange commercial avec l'Espagne (MW)." },
          "ech_comm_italie": { "type": ["number", "null"], "description": "Échange commercial avec l'Italie (MW)." },
          "ech_comm_suisse": { "type": ["number", "null"], "description": "Échange commercial avec la Suisse (MW)." },
          "ech_comm_allemagne_belgique": { "type": ["number", "null"], "description": "Échange commercial avec l'Allemagne + Belgique (MW)." }
        }
      },
      "EcowattResponse": {
        "type": "object",
        "properties": {
          "regional": {
            "type": "object",
            "description": "Derniers relevés régionaux (jusqu'à 20 lignes, une par région), enveloppe ODRE brute (Opendatasoft Explore v2.1).",
            "properties": {
              "total_count": { "type": "integer", "description": "Nombre total d'enregistrements correspondant à la requête amont." },
              "results": { "type": "array", "description": "Relevés régionaux.", "items": { "$ref": "#/components/schemas/OdreRegionalRecord" } }
            }
          },
          "national": {
            "type": "object",
            "description": "Dernier relevé national des échanges aux frontières (1 ligne), enveloppe ODRE brute.",
            "properties": {
              "total_count": { "type": "integer", "description": "Nombre total d'enregistrements correspondant à la requête amont." },
              "results": { "type": "array", "description": "Relevé national des échanges commerciaux.", "items": { "$ref": "#/components/schemas/OdreNationalRecord" } }
            }
          }
        },
        "required": ["regional", "national"],
        "example": {
          "regional": { "total_count": 12, "results": [ { "code_insee_region": "53", "libelle_region": "Bretagne", "date_heure": "2026-07-05T09:45:00+00:00", "consommation": 1647, "nucleaire": 0, "eolien": 18, "solaire": 514, "hydraulique": 0, "thermique": 0, "bioenergies": 45, "ech_physiques": 1097 } ] },
          "national": { "total_count": 1, "results": [ { "ech_comm_angleterre": -3553, "ech_comm_espagne": -2350, "ech_comm_italie": -3437, "ech_comm_suisse": -2203, "ech_comm_allemagne_belgique": -3736 } ] }
        }
      },
      "GasPirPoint": {
        "type": "object",
        "description": "Flux net journalier à un point d'interconnexion réseau (PIR).",
        "properties": {
          "pointKey": { "type": "string", "description": "Identifiant ENTSOG du point (ex. `ITP-00304`)." },
          "pointLabel": { "type": "string", "description": "Libellé lisible du point (ex. `VIP PIRINEOS`)." },
          "flowGWhDay": { "type": "number", "description": "Flux net en GWh/jour. Positif = import net vers la France, négatif = export net." },
          "periodFrom": { "type": ["string", "null"], "format": "date-time", "description": "Début de la période gazière du relevé retenu (ISO 8601 avec fuseau), ou null." }
        },
        "required": ["pointKey", "pointLabel", "flowGWhDay"]
      },
      "GasPirResponse": {
        "type": "object",
        "properties": {
          "points": { "type": "array", "description": "Flux nets par PIR (vide si `status: error`).", "items": { "$ref": "#/components/schemas/GasPirPoint" } },
          "fetchedAt": { "type": "string", "format": "date-time", "description": "Horodatage de récupération (ISO 8601 UTC)." },
          "status": { "type": "string", "enum": ["ok", "partial", "error"], "description": "`ok` = tous les PIR résolus, `partial` = certains, `error` = aucun (voir `error`)." },
          "error": { "type": "string", "description": "Message d'erreur amont, présent uniquement si `status: error`." }
        },
        "required": ["points", "fetchedAt", "status"]
      },
      "FireDetection": {
        "type": "object",
        "description": "Détection satellite en France (anomalie thermique), rattachée à un département.",
        "properties": {
          "id": { "type": "string", "description": "Identifiant `lat_lon_date_HHMM_satellite` (dédoublonnage)." },
          "lat": { "type": "number", "description": "Latitude du pixel (WGS84)." },
          "lon": { "type": "number", "description": "Longitude du pixel (WGS84)." },
          "acquiredAt": { "type": "string", "format": "date-time", "description": "Acquisition (ISO 8601 UTC)." },
          "satellite": { "type": "string", "enum": ["Suomi NPP", "NOAA-20", "NOAA-21", "Terra", "Aqua"] },
          "sensor": { "type": "string", "enum": ["VIIRS", "MODIS"] },
          "confidence": { "type": "string", "enum": ["faible", "nominale", "haute"], "description": "Classes FIRMS : VIIRS `l`, `n`, `h` ; MODIS 0 à 29, 30 à 79, 80 à 100." },
          "confidenceRaw": { "type": "string", "description": "Confiance telle que publiée (`l`, `n`, `h` ou `0` à `100`)." },
          "frpMw": { "type": "number", "description": "Puissance radiative du feu (MW)." },
          "daynight": { "type": "string", "enum": ["D", "N"], "description": "Passage de jour (`D`) ou de nuit (`N`)." },
          "dept": { "type": "string", "description": "Département (point dans polygone)." },
          "recurrent": { "type": "boolean", "description": "Détection à moins de 1 km au moins 5 des 10 derniers jours (probablement industrielle)." },
          "foyerId": { "type": "string", "description": "Foyer de la détection." }
        },
        "required": ["id", "lat", "lon", "acquiredAt", "satellite", "sensor", "confidence", "confidenceRaw", "frpMw", "daynight", "dept", "recurrent", "foyerId"]
      },
      "FiresResponse": {
        "type": "object",
        "description": "Feux de forêt, type `FiresResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Dernière collecte FIRMS réussie ; `null` : jamais." },
          "lastAcquisitionAt": { "type": ["string", "null"], "format": "date-time", "description": "Acquisition la plus récente dans la boîte (France et marges)." },
          "sources": { "type": "array", "description": "Produits FIRMS lus (`VIIRS_SNPP_NRT`, `VIIRS_NOAA20_NRT`, `VIIRS_NOAA21_NRT`, `MODIS_NRT`, ou `VIIRS_SNPP_PUBLIC_24H` sans clé), chacun avec `ok` et sa dernière acquisition.", "items": { "type": "object" } },
          "detections": { "type": "array", "description": "Détections en France, 24 h glissantes.", "items": { "$ref": "#/components/schemas/FireDetection" } },
          "abroadCount": { "type": "integer", "description": "Détections de la boîte hors de France, 24 h." },
          "abroad": { "type": "array", "description": "Les mêmes, pour la carte (position, acquisition, FRP, satellite).", "items": { "type": "object" } },
          "foyers": { "type": "array", "description": "Foyers en France, 24 h : départements, centroïde, détections, passages, confirmé, récurrent, FRP cumulée et maximale, première et dernière acquisition.", "items": { "type": "object" } },
          "daily": { "type": "object", "description": "Détections par jour UTC d'acquisition sur 10 jours (France, dont récurrentes)." },
          "nextPasses": { "type": "array", "description": "Passages estimés (passages de la veille + 24 h encore à venir) ; vide si inconnus.", "items": { "type": "object" } },
          "forestDanger": { "type": ["object", "null"], "description": "Météo des forêts (Météo-France) : date de publication, jours couverts, saison, niveau J1 et J2 par département, historique de la saison ; `null` : jamais lue." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["readAt", "lastAcquisitionAt", "sources", "detections", "abroadCount", "abroad", "foyers", "daily", "nextPasses", "forestDanger", "errors"]
      },
      "VigilanceResponse": {
        "type": "object",
        "description": "Vigilance météo, type `VigilanceResponse` de `src/types/index.ts`.",
        "properties": {
          "updateTime": { "type": ["string", "null"], "format": "date-time", "description": "`update_time` de la carte Météo-France ; `null` : carte jamais lue." },
          "textsUpdateTime": { "type": ["string", "null"], "format": "date-time", "description": "`update_time` des textes (bulletins)." },
          "periods": { "type": "array", "description": "Échéances J puis J1 : couleur maximale, départements et phénomènes avec leurs créneaux, domaines littoraux, commentaire ; vide sans carte.", "items": { "type": "object" } },
          "bulletins": { "type": "array", "description": "Bulletins national, zonaux et départementaux non vides.", "items": { "type": "object" } },
          "history": { "type": "object", "description": "Départements en vigilance par jour sur 30 jours au plus (`days`, `since`)." },
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Dernière lecture réussie de la carte par le serveur." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["updateTime", "textsUpdateTime", "periods", "bulletins", "history", "readAt", "errors"]
      },
      "FloodsResponse": {
        "type": "object",
        "description": "Crues, type `FloodsResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Relevé Vigicrues du serveur ; `null` : jamais lu." },
          "total": { "type": "integer", "description": "Tronçons surveillés." },
          "counts": { "type": "object", "description": "Tronçons par couleur (`vert`, `jaune`, `orange`, `rouge`)." },
          "sections": { "type": "array", "description": "Tronçons en vigilance jaune ou plus : niveau, territoire, tracé, stations Hub'Eau.", "items": { "type": "object" } },
          "stationsReadAt": { "type": ["string", "null"], "format": "date-time", "description": "Dernière lecture Hub'Eau réussie." },
          "stationsOmitted": { "type": "integer", "description": "Stations au-delà du plafond de lecture." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["readAt", "total", "counts", "sections", "stationsReadAt", "stationsOmitted", "errors"]
      },
      "DroughtResponse": {
        "type": "object",
        "description": "Sécheresse, type `DroughtResponse` de `src/types/index.ts`.",
        "properties": {
          "asOf": { "type": ["string", "null"], "format": "date-time", "description": "Date des arrêtés la plus récente publiée par VigiEau." },
          "departments": { "type": "array", "description": "Départements : disponibilité, niveau maximal et niveau par usage.", "items": { "type": "object" } },
          "counts": { "type": "object", "description": "Départements disponibles par niveau (`vigilance`, `alerte`, `alerte_renforcee`, `crise`, `aucun`)." },
          "history": { "type": "object", "description": "Série quotidienne depuis la mise en service (`days`, `since`)." },
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Lecture VigiEau servie ; `null` : jamais lue." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["asOf", "departments", "counts", "history", "readAt", "errors"]
      },
      "AirQualityResponse": {
        "type": "object",
        "description": "Qualité de l'air, type `AirQualityResponse` de `src/types/index.ts`.",
        "properties": {
          "days": { "type": "array", "description": "Jours J, J+1 et J+2 (heure de Paris).", "items": { "type": "string", "format": "date" } },
          "episodesUpdatedAt": { "type": ["string", "null"], "format": "date-time", "description": "`date_maj` la plus récente des épisodes ; `null` : couche non lue." },
          "zonesCovered": { "type": "integer", "description": "Zones distinctes publiées." },
          "episodes": { "type": "array", "description": "Épisodes de pollution publiés (hors « pas de dépassement »).", "items": { "type": "object" } },
          "perPollutant": { "type": "array", "description": "Épisodes par polluant et par jour.", "items": { "type": "object" } },
          "index": { "type": "object", "description": "Indice ATMO de J par département : date, mise à jour, communes couvertes, communes par classe." },
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Lecture la plus récente des deux couches ; `null` : aucune." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["days", "episodesUpdatedAt", "zonesCovered", "episodes", "perPollutant", "index", "readAt", "errors"]
      },
      "FloodZonesResponse": {
        "type": "object",
        "description": "Zones inondables, type `FloodZonesResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Lecture du serveur ; `null` : aucun scénario lu." },
          "bbox": { "type": "array", "description": "Boîte réellement lue (arrondie à 0,05°) : minLon, minLat, maxLon, maxLat.", "items": { "type": "number" } },
          "zones": { "type": "array", "description": "Zones : identifiant, scénario (frequent, moyen, extreme) et anneau extérieur fermé `[lon, lat]`.", "items": { "type": "object" } },
          "covered": { "type": "boolean", "description": "Au moins une zone trouvée ; faux hors TRI (normal)." },
          "errors": { "type": "array", "description": "Pannes et troncatures nommées.", "items": { "type": "string" } }
        },
        "required": ["readAt", "bbox", "zones", "covered", "errors"]
      },
      "PollenResponse": {
        "type": "object",
        "description": "Pollens, type `PollenResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Lecture du serveur ; `null` : aucun jour lu." },
          "days": { "type": "array", "description": "Jours publiés parmi J, J+1 et J+2 (heure de Paris).", "items": { "type": "string", "format": "date" } },
          "updatedAt": { "type": ["string", "null"], "format": "date-time", "description": "`date_maj` la plus récente." },
          "departments": { "type": "array", "description": "Départements couverts : code, nom, et par jour plus haut code, communes comptées, communes à 4 ou plus, alerte, taxons triés par code décroissant.", "items": { "type": "object" } },
          "sources": { "type": "array", "description": "Associations de surveillance (AASQA) vues dans la réponse.", "items": { "type": "string" } },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["readAt", "days", "updatedAt", "departments", "sources", "errors"]
      },
      "EarthquakesResponse": {
        "type": "object",
        "description": "Séismes, type `EarthquakesResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Relevé du serveur ; `null` : aucune source lue." },
          "source": { "type": ["string", "null"], "enum": ["BCSF-RéNaSS", "EMSC", null], "description": "Source servie (EMSC : repli)." },
          "quakes": { "type": "array", "description": "Séismes des 7 derniers jours, plus récent d'abord.", "items": { "type": "object" } },
          "nonSeismic": { "type": "integer", "description": "Tirs de carrière, explosions et glissements écartés sur 7 jours." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["readAt", "source", "quakes", "nonSeismic", "errors"]
      },
      "DisastersResponse": {
        "type": "object",
        "description": "Alertes catastrophes GDACS, type `DisastersResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Relevé du serveur ; `null` : aucune requête GDACS lue." },
          "alerts": {
            "type": "array",
            "description": "Territoire français d'abord, puis rouge, orange, vert, puis plus récent d'abord.",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string", "description": "`${eventtype}-${eventid}` (ex. « TC-1001335 »)." },
                "type": { "type": "string", "enum": ["cyclone", "seisme", "inondation", "volcan", "secheresse", "feu"] },
                "level": { "type": "string", "enum": ["vert", "orange", "rouge"] },
                "name": { "type": "string" },
                "countries": { "type": "array", "description": "ISO3 des pays touchés.", "items": { "type": "string" } },
                "frenchTerritory": { "type": ["string", "null"], "description": "Territoire français touché (« La Réunion »), sinon null." },
                "lat": { "type": "number" },
                "lon": { "type": "number" },
                "from": { "type": "string", "format": "date-time" },
                "to": { "type": "string", "format": "date-time" },
                "current": { "type": "boolean" },
                "severity": { "type": ["string", "null"] },
                "url": { "type": ["string", "null"], "description": "Fiche GDACS." }
              },
              "required": ["id", "type", "level", "name", "countries", "frenchTerritory", "lat", "lon", "from", "to", "current", "severity", "url"]
            }
          },
          "errors": { "type": "array", "description": "Pannes nommées (« GDACS Guadeloupe : HTTP 500 »).", "items": { "type": "string" } }
        },
        "required": ["readAt", "alerts", "errors"]
      },
      "ProtestsResponse": {
        "type": "object",
        "description": "Manifestations annoncées et signalées, type `ProtestsResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Relevé du serveur ; `null` : aucune source lue." },
          "items": {
            "type": "array",
            "description": "Signalés d'abord (plus récent d'abord), puis annonces par heure de début.",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string" },
                "source": { "type": "string", "enum": ["demosphere", "presse", "gdelt"] },
                "status": { "type": "string", "enum": ["annonce", "signale"], "description": "`annonce` : rendez-vous publié à l'avance, jamais confirmé ; `signale` : relevé dans la presse." },
                "title": { "type": "string" },
                "startsAt": { "type": ["string", "null"], "format": "date-time" },
                "allDay": { "type": "boolean" },
                "city": { "type": ["string", "null"] },
                "dept": { "type": ["string", "null"] },
                "lat": { "type": ["number", "null"] },
                "lon": { "type": ["number", "null"] },
                "url": { "type": ["string", "null"] },
                "edition": { "type": ["string", "null"], "description": "Édition Démosphère ; sinon `null`." },
                "violent": { "type": "boolean", "description": "Presse : au moins un titre de heurts ou tensions ; GDELT : code CAMEO 145." },
                "participants": { "type": ["integer", "null"], "description": "Presse française : plus grand nombre de participants cité pour ce seul lieu." },
                "precision": { "type": ["string", "null"], "enum": ["commune", "departement", null] },
                "articleCount": { "type": "integer", "description": "Articles du lieu sur 24 h (dépêche reprise comptée une fois)." },
                "articles": {
                  "type": "array",
                  "items": {
                    "type": "object",
                    "properties": {
                      "url": { "type": "string" }, "domain": { "type": "string" },
                      "seenAt": { "type": "string", "format": "date-time" }, "violent": { "type": "boolean" },
                      "title": { "type": ["string", "null"] }, "outlet": { "type": ["string", "null"] }
                    }
                  }
                }
              }
            }
          },
          "demosphere": { "type": "object", "description": "`readAt`, `editions` lues, `failedEditions`, `scanned` (rendez-vous parcourus)." },
          "gdelt": { "type": "object", "description": "`readAt`, `files` (exports de 15 min lus), `missingFiles`." },
          "presse": { "type": "object", "description": "`readAt`, `scanned` (titres candidats lus en base), `matched` (titres de manifestation), `unplaced` (dont sans lieu)." },
          "errors": { "type": "array", "items": { "type": "string" } }
        }
      },
      "EmsResponse": {
        "type": "object",
        "description": "Activations Copernicus EMS, type `EmsResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time" },
          "active": { "type": "array", "items": { "type": "object" } },
          "recent": { "type": "array", "items": { "type": "object" } },
          "elsewhere": { "type": "object", "properties": { "count": { "type": "integer" }, "countries": { "type": "array", "items": { "type": "string" } } }, "required": ["count", "countries"] },
          "errors": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["readAt", "active", "recent", "elsewhere", "errors"]
      },
      "FinancialPoint": {
        "type": "object",
        "properties": { "date": { "type": "string", "format": "date" }, "value": { "type": "number" } },
        "required": ["date", "value"]
      },
      "FinancialStabilityResponse": {
        "type": "object",
        "description": "Bloc « Stabilité financière », type `FinancialStabilityResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": "string", "format": "date-time" },
          "series": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string", "enum": ["cissFr", "cissEa", "estr", "eurusd"] },
                "value": { "type": ["number", "null"] },
                "date": { "type": ["string", "null"], "format": "date" },
                "history": { "type": "array", "items": { "$ref": "#/components/schemas/FinancialPoint" } },
                "status": { "type": "string", "enum": ["ok", "stale", "unavailable"] },
                "error": { "type": ["string", "null"] }
              },
              "required": ["id", "value", "date", "history", "status", "error"]
            }
          },
          "rates": {
            "type": "array",
            "items": {
              "type": "object",
              "properties": {
                "id": { "type": "string", "enum": ["depot", "refi"] },
                "value": { "type": ["number", "null"] },
                "since": { "type": ["string", "null"], "format": "date" },
                "previous": { "type": ["number", "null"] },
                "status": { "type": "string", "enum": ["ok", "stale", "unavailable"] },
                "error": { "type": ["string", "null"] }
              },
              "required": ["id", "value", "since", "previous", "status", "error"]
            }
          },
          "spread": {
            "type": "object",
            "properties": {
              "value": { "type": ["number", "null"], "description": "Écart en points de pourcentage." },
              "fr10y": { "type": ["number", "null"] },
              "de10y": { "type": ["number", "null"] },
              "readAt": { "type": ["string", "null"], "format": "date-time" },
              "history": { "type": "array", "items": { "$ref": "#/components/schemas/FinancialPoint" } },
              "status": { "type": "string", "enum": ["ok", "stale", "unavailable"] },
              "error": { "type": ["string", "null"] }
            },
            "required": ["value", "fr10y", "de10y", "readAt", "history", "status", "error"]
          },
          "errors": { "type": "array", "items": { "type": "string" } }
        },
        "required": ["readAt", "series", "rates", "spread", "errors"]
      },
      "SeaLevelsResponse": {
        "type": "object",
        "description": "Marégraphes, type `SeaLevelsResponse` de `src/types/index.ts`.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Relevé du serveur ; `null` : aucun marégraphe lu." },
          "gauges": { "type": "array", "description": "Marégraphes : nom, domaine littoral, position, dernière mesure, hauteur, variation sur 1 h, courbe de 24 h.", "items": { "type": "object" } },
          "predictionAvailable": { "type": "boolean", "const": false, "description": "Marée prédite non publiée sans clé." },
          "errors": { "type": "array", "description": "Pannes nommées (source et cause).", "items": { "type": "string" } }
        },
        "required": ["readAt", "gauges", "predictionAvailable", "errors"]
      },
      "SovereigntyResponse": {
        "type": "object",
        "description": "Réponse d'une route Souveraineté : champs propres à la route (types MilitaryResponse, CablesWatchResponse, CyberResponse, VigipiratePageCheck, GnssResponse, ConnectivityResponse, SanctionsResponse de src/types/index.ts), toujours une date de lecture et la liste des pannes nommées.",
        "properties": {
          "readAt": { "type": ["string", "null"], "format": "date-time", "description": "Dernière lecture réussie par le serveur ; nul si jamais lue." },
          "errors": { "type": "array", "items": { "type": "string" }, "description": "Pannes nommées par leur source (« CERT-FR, avis : HTTP 503 »)." }
        },
        "required": ["readAt", "errors"]
      }
    }
  }
}
