Informations clés pour comprendre le cadre de la simulation.
Saisissez ou modifiez les informations clés du scénario.
Rédigez le message qui sera publié.
Ceci est un aperçu. Le rendu final peut varier selon le type d'appareil.
Ces valeurs pourront être ajustées après publication.
Aucun paramètre supplémentaire pour ce canal.
En publiant cet inject, tous les participants le recevront dans leur environnement de simulation.
Vue d'ensemble de la connectivité et de l'activité du système.
Testez la connectivité et les réponses des APIs externes utilisées par PULSE.
// La réponse brute apparaîtra ici après un test.
Suivi de l'usage Anthropic, des tokens consommés et des économies liées au prompt caching.
Gérez les accès à l'application et renforcez la sécurité de votre environnement.
ORBIT est l'autorité d'identité unique de la suite SECALYS. La gestion des comptes nominatifs, des passkeys, des sessions et des accès par outil se fait désormais dans l'administration ORBIT. PULSE consomme ces accès via le SSO (connexion « Se connecter avec ORBIT »).
Les liens d'accès d'exercice (participation par lien) restent gérés ici, ci-dessous.
Gérez les clés d'accès aux services externes utilisés par PULSE.
Les clés API sont stockées de manière sécurisée et chiffrée. Elles ne sont jamais partagées avec des tiers.
Gérez la liste des comptes X (Twister) et des flux RSS de médias web utilisés dans les simulations.
Activité utilisateur et erreurs enregistrées par le serveur.
PULSE est un outil pédagogique de simulation de crise médiatique édité par SECALYS. Il permet aux formateurs de reconstituer une crise réaliste en temps réel - publications Twister, articles de presse fictifs, réactions spontanées - pour entraîner les équipes à la communication de crise.
PULSE fait partie d'une suite d'outils conçus par SECALYS pour couvrir l'ensemble du cycle de gestion des situations critiques :
PULSE met en scène une crise médiatique fictive en temps réel. Vous y entrez comme observateur d'un mur de publications - tweets, articles de presse, réactions du public - qui se déroulent sous vos yeux comme si la crise se passait maintenant. Votre mission : analyser la situation, identifier les messages clés et préparer des décisions de communication.
L'interface du joueur est structurée en deux colonnes synchronisées et un bandeau de pilotage en haut.
Tweets de l'exercice (comptes fictifs créés par l'animateur, tweets de réactions générés par IA) et - si le scénario l'active - tweets réels de comptes officiels surveillés.
Articles simulés rédigés par l'animateur, et selon le scénario, articles réels issus de flux RSS de médias nationaux ou régionaux.
Les chiffres flashent quand ils sautent significativement, et un petit +N s'affiche brièvement à côté du compteur. Sur un tweet haineux qui décolle, un pulse rouge accompagne la montée.
Pendant l'exercice, vous croiserez plusieurs profils - chacun avec son ton et son intention. Apprendre à les distinguer rapidement est l'un des bénéfices pédagogiques de PULSE.
Le mur joueur est en lecture seule. Vous ne pouvez ni publier, ni effacer, ni modifier les contenus, ni interagir directement avec les comptes affichés. Les outils de réponse sont propres aux animateurs et observateurs. Vos décisions se prennent autour de l'écran (cellule de crise, oral, écrit hors plateforme), pas sur l'écran.
Deux moyens d'arriver dans la Salle de rédaction :
Le contexte est la matière première de toute l'IA. Plus il est précis, plus les injects et les réactions sont crédibles.
| Champ | Description | Limite |
|---|---|---|
| Nom | Nom affiché du compte (ex. BFMTV, Marie Lefèvre). | texte libre |
| @handle | Identifiant sans le @. | texte libre |
| Certifié | Affiche le badge ✓ bleu si oui. | Non / Oui |
| Contenu | Texte du tweet - un compteur affiche les caractères restants. | 280 caractères |
| Image URL | URL d'une image illustrative (optionnelle). | http(s) |
| Likes / Reposts / Vues | Statistiques initiales. Croissent ensuite seules pendant l'exercice. | nombres |
| Viral | Marque le tweet comme à fort impact : multiplicateur ×2.8 sur la croissance des stats. | Non / Oui 🔥 |
| Champ | Description |
|---|---|
| Rubrique | Catégorie éditoriale (Société, Économie, Faits divers, International…). |
| Titre | Le titre principal de l'article. |
| Chapô | Amorce d'1 à 2 phrases qui apparaît sous le titre. |
| Signature | Auteur et nom du média (ex. Par A. Dupont, Le Quotidien). |
| Ville | Ville de référence, en capitales (ex. NANTES). |
| Image URL | URL d'une image (optionnel) - l'article est cliquable côté joueur pour zoomer. |
Le format Prompt n'apparaît pas sur le mur joueur - c'est une instruction privée pour l'animateur, pour mettre en scène un appel téléphonique, un mail interne, une remontée terrain, etc.
| Champ | Description |
|---|---|
| Sujet | Catégorie courte (Activité, Technique, RH, Communication…). |
| Résumé | Titre court d'identification. |
| Émetteur | Qui appelle / écrit (ex. Cellule de crise SECALYS). |
| Récepteur | Qui reçoit (ex. Teddy Batel - RSI). |
| Stimuli | Le message exact à délivrer aux participants. |
| Réaction attendue | Comportement ou décision qu'on souhaite voir émerger. |
| Commentaire | Note privée pour l'animateur. |
Cliquez ✨ Générer des injects IA. PULSE envoie le contexte de l'exercice au modèle claude-sonnet-4-6 (via l'API Anthropic) et reçoit en retour 5 publications variées : tweets officiels, journalistes, citoyens, articles de presse - équilibrées 50/50 entre X et presse.
Chaque inject est livré avec une justification pédagogique qui explique son intention. Cliquez Utiliser sur un inject pour pré-remplir le formulaire correspondant - vous pouvez ensuite l'éditer avant de le programmer.
Le bouton 🔍 Trouver une image, présent sous les champs Image URL des composeurs X et Presse, demande à l'IA de chercher en ligne (via la recherche web Anthropic) une image pertinente pour le contenu rédigé. La requête envoie le contenu du tweet ou le titre/chapô de l'article comme topic. PULSE filtre les résultats qui ressemblent à des logos.
Si aucune image n'est retournée, c'est généralement que le sujet est trop vague, trop sensible ou que la recherche web n'a pas trouvé de visuel pertinent. Saisissez alors une URL manuellement, ou laissez le champ vide (la publication s'affichera sans illustration).
Saisissez un décalage T+ (minutes) dans le champ offset-min et cliquez + Programmer. Une fois l'exercice démarré, les publications se déclenchent automatiquement à startTime + offsetMin, côté serveur – indépendamment de l'onglet animateur.
Dans la liste « Publications programmées » :
Boutons disponibles sur chaque entrée : ▶ Publier maintenant (force l'envoi immédiat), Modifier, Supprimer, et - sur les injects envoyés - les trois boutons de réactions (👍 / 💙 / 😡).
Le scénario complet (publications + offsets) peut être exporté/importé au format JSON depuis l'interface. Un import Excel (.xlsx chronogramme) est également supporté avec génération IA d'une couverture médiatique cohérente.
Les sources live sont activées automatiquement au démarrage de l'exercice et coupées au stop. Elles sont limitées à 20 entrées par type.
Sur chaque publication envoyée, trois boutons déclenchent une vague de 6 réactions générées par l'IA. Les tweets n'arrivent pas tous d'un coup : ils sont distribués aléatoirement sur ~1 minute pour un effet de montée réaliste — pendant la distribution, le bouton pulse et son compteur grimpe au fil des arrivées.
Le panneau Réputation de la salle de rédaction agrège tonalité (positif / négatif / neutre), volume et tendance, avec un sparkline temporel. La classification est faite par l'IA via /api/sentiment (5 catégories : very_negative, negative, neutral, positive, very_positive). Utilisez-le comme aide à la décision pour ajuster votre prochain inject.
Depuis le Configurateur, page Accès, choisis l'exercice dans la liste déroulante (peuplée des exercices réels), puis génère un lien d'accès signé pour chaque rôle :
Durée de validité par défaut : 48 heures (configurable via ACCESS_TOKEN_TTL_HOURS). Un lien peut être révoqué à tout moment depuis la liste des liens actifs (la session du porteur est coupée en temps réel).
| Symptôme | Cause probable | Action |
|---|---|---|
| « Génération IA » échoue avec un 429 | Quota Anthropic atteint | Attendre 60 s ou augmenter RATE_LIMIT_MAX_ANTHROPIC |
| L'IA répond mais avec du contenu hors-sujet | Contexte vide ou trop vague | Renseigner client / scénario / acteurs précisément, puis régénérer |
| « Trouver une image » ne renvoie rien | Recherche web sans match | Saisir une URL manuelle ou laisser vide |
| Une source Twister ne renvoie rien | Quota Twister ou compte vide récemment | Vérifier dans Configurateur → Tests → Twister |
| Un flux RSS ne renvoie rien | URL invalide ou flux indisponible | Tester l'URL dans un navigateur ; corriger ou retirer |
| Un inject ne se déclenche pas | Onglet animateur masqué très longtemps | Revenir à l'onglet ; cliquer Publier maintenant si nécessaire |
| « 401 Unauthorized » sur une mutation | Token expiré ou rôle insuffisant | Se reconnecter avec un lien facilitator ou admin |
PULSE n'expose pas de raccourcis clavier globaux dans cette version. La navigation se fait au Tab sur les contrôles, et la touche Entrée dans les champs Source Twister et Source RSS ajoute la source.
L'observateur suit l'exercice sans pouvoir intervenir sur le mur de crise. Il dispose en revanche d'outils dédiés que les joueurs ne voient pas : timeline détaillée du scénario, indicateurs de fréquentation, bloc-notes personnel et chat avec l'animateur. Sa mission : évaluer, conseiller, capitaliser.
| Élément | Joueur | Observateur |
|---|---|---|
| Mur de crise (X + Presse) | Plein écran, deux colonnes | Non - vue centrée sur le scénario |
| Timeline du scénario | - | Liste des injects passés et à venir |
| Chat avec l'animateur | - | Oui (panneau collapsible) |
| Bloc-notes | - | Oui (sauvegardé dans le navigateur) |
| KPIs (joueurs connectés, prochain inject…) | - | Oui |
| Statut de l'exercice | Implicite (chronomètre) | Badge explicite (REPOS / LIVE / EN PAUSE / TERMINÉ) |
L'observateur arrive via un lien d'accès signé de rôle observer, généré par l'animateur ou l'administrateur depuis le Configurateur. Pas de mot de passe à retenir.
Si vous n'avez reçu qu'un jeton brut, collez-le dans l'onglet Lien d'accès de la page de connexion : PULSE le redirigera automatiquement vers la salle d'observation.
La salle d'observation affiche les injects programmés en deux sections :
Cliquez un inject passé (X ou presse) pour ouvrir sa fiche détaillée. Les prompts animateur ouvrent une modale dédiée avec les champs Émetteur / Récepteur / Stimuli / Réaction attendue.
Panneau collapsible relié à la route /api/observer-messages. Permet d'échanger avec l'animateur sans passer par les joueurs : poser une question, signaler un dysfonctionnement, proposer un inject.
Notes locales, sauvegardées dans le localStorage du navigateur (clé pulse_observer_notes_v2). Maximum 100 entrées horodatées.
| Indicateur | Sens |
|---|---|
| Salles connectées | Nombre total de connexions actives (toutes pages confondues). |
| Joueurs | Nombre de connexions de rôle player. |
| Restants | Injects programmés non encore envoyés. |
| Prochain inject | Nom + horodatage T+ du prochain inject programmé. ✓ Tous envoyés si le scénario est terminé. |
PULSE n'inclut pas d'export automatique des observations. Voici la méthode recommandée :
PULSE est une application Node.js / Express servie en SPA. Le backend expose une API REST (et un canal SSE) consommée par un frontend HTML/CSS/JS vanilla. Le stockage est dual : fichier data/store.json pour le développement local, Azure Cosmos DB en production. Les intégrations externes sont l'API Anthropic (Claude Sonnet 4) et l'API Twister v2, plus la lecture de flux RSS.
Cette doc couvre la version 2.1.0-rc.69. Vérifiez CHANGELOG.md pour les évolutions.
┌──────────────┐ HTTPS ┌─────────────────────────────────┐
│ Navigateurs │────────────▶│ Express (Node 18+) │
│ (joueurs, │◀────────────│ ┌───────────────────────────┐ │
│ anim, obs) │ SSE / JSON │ │ Routes /api/* + /auth │ │
└──────────────┘ │ │ helmet · CORS · rate-lim │ │
│ └───────────────────────────┘ │
│ ┌───────────────────────────┐ │
│ │ memoryStore (in-process) │ │
│ └─────┬───────────────┬─────┘ │
└────────┼───────────────┼────────┘
│ │
┌───────▼─────┐ ┌──────▼──────┐
│ data/store │ │ Cosmos DB │
│ .json │ │ (optional) │
└─────────────┘ └─────────────┘
Externes :
┌────────────┐ ┌────────────┐ ┌────────────┐
│ Anthropic │ │ Twister │ │ RSS feeds │
│ Claude API │ │ v2 API │ │ (HTTP) │
└────────────┘ └────────────┘ └────────────┘
Toutes lues dans src/config/env.js. Mettez-les dans un fichier .env à la racine ou dans la configuration de votre PaaS.
| Variable | Défaut | Description |
|---|---|---|
TWISTER_BEARER_TOKEN | - | Bearer Token Twister (recherche de tweets). |
ANTHROPIC_API_KEY | - | Clé API Anthropic (génération injects, réactions, sentiment). |
| Variable | Défaut | Description |
|---|---|---|
PORT | 3000 | Port d'écoute Express. |
NODE_ENV | development | Obligatoire en prod (active cookies secure, log info, désactive les stack traces). À vérifier post-déploiement via GET /api/status → champ system.env. |
ALLOWED_ORIGINS | http://localhost:3000 | Liste séparée par virgules pour CORS. * est refusé au boot en prod. Chaque entrée doit être une URL parseable. |
TRUST_PROXY | 1 auto sur Azure | Auto-détecté sur Azure App Service via WEBSITE_INSTANCE_ID (défaut 1). Sur tout autre reverse proxy (Nginx, Caddy…), poser TRUST_PROXY=1 ou un range CIDR explicitement. JAMAIS true en prod (refusé au boot – rendrait X-Forwarded-For spoofable). |
| Variable | Défaut | Description |
|---|---|---|
ACCESS_TOKEN_SECRET | - | Secret HMAC pour signer les JWT d'exercice (liens /access?token=…). Obligatoire en prod (boot refusé sinon depuis v1.8.6). |
ACCESS_TOKEN_TTL_HOURS | 4 | Durée de vie des liens JWT d'exercice (les sessions plus longues passent ttlHours explicite à POST /admin/access-link). |
IAM_WEBAUTHN_RP_ID | secalys.fr | Relying Party ID WebAuthn (D13). Domaine de cookie au sens passkey : une passkey enrôlée sous secalys.fr fonctionne pour tout sous-domaine (pulse.secalys.fr, pulse-staging.secalys.fr). À ne jamais changer une fois des comptes enrôlés (invaliderait toutes les passkeys). |
IAM_WEBAUTHN_ORIGINS | auto (RP-ID + sous-domaines + localhost hors prod) | Liste séparée par virgules d'origines attendues pour la validation WebAuthn (ex. https://pulse.secalys.fr,https://pulse-staging.secalys.fr). Surchargeable explicitement. |
IAM_MIN_CREDENTIALS | 2 | Nombre minimum de passkeys par compte (anti-lockout R1 / D12a). Refuse la suppression de la dernière passkey en deçà du seuil. Sysadmin = idem (filet en plus des codes de récupération). |
IAM_RECOVERY_CODE_COUNT | 10 | Nombre de codes de récupération générés à chaque appel de POST /iam/recovery/codes. Codes à usage unique, hashés SHA-256 au repos, affichés une seule fois. |
IAM_ASSISTED_GRANT_TTL_MIN | 30 | Durée (minutes) du jeton d'enrôlement (enrolToken) émis par le sysadmin pour un reset assisté (POST /iam/admin/users/:id/reset-credentials) ou par POST /iam/bootstrap / création de compte. Borné [1 ; 1440]. |
IAM_BREAK_GLASS_SECRET | - | Active l'endpoint de déblocage d'urgence POST /iam/break-glass (lockout total sysadmin). Absent → endpoint 404 (désactivé, posture par défaut). À ne poser que le temps d'une intervention, puis retirer. Secret fort, comparé en timing-safe via le header X-Break-Glass-Secret. |
BOOTSTRAP_SYSADMIN_EMAIL | - | Email du 1ᵉ sysadmin créé au démarrage (poule/œuf). Voie recommandée en production : crée un compte sysadmin vide (sans passkey, sans mot de passe – la Phase 1 n'existe plus) ; le 1er enrôlement passe par POST /iam/bootstrap gardé par l'invariant « 0 sysadmin » qui retourne un enrolToken. |
PULSE_BASE_URL | - | URL absolue utilisée pour construire le lien d'accès (ex. https://pulse.exemple.fr). |
TRAINER_TOKEN | - | Token optionnel à passer dans X-Trainer-Token pour autoriser les mutations /api sans rôle facilitator/admin (cas usage : tests automatisés). |
BOOTSTRAP_SYSADMIN_PASSWORD et IAM_MFA_KEY ont été retirées en v2.1.0-rc.15 (WU11) avec l'auth Phase 1 (mot de passe + TOTP). Si elles traînent dans les App Settings, elles sont ignorées – à supprimer pour la propreté.| Variable | Défaut | Description |
|---|---|---|
RATE_LIMIT_WINDOW_MS | 60000 | Fenêtre de comptage en ms. |
RATE_LIMIT_MAX_TWISTER | 10 | Requêtes Twister / fenêtre. |
RATE_LIMIT_MAX_ANTHROPIC | 20 | Requêtes Anthropic / fenêtre. |
| Variable | Défaut | Description |
|---|---|---|
COSMOS_ENDPOINT | - | URL du compte Cosmos. Active le stockage Cosmos. |
COSMOS_KEY | - | Clé primaire Cosmos. |
COSMOS_DATABASE | pulse | Nom de la base. |
COSMOS_CONTAINER | store | Nom du container (partition key /type). |
| Variable | Défaut | Description |
|---|---|---|
APPLICATIONINSIGHTS_CONNECTION_STRING | - | Si présent, initialise Application Insights (auto-collect requêtes, exceptions, dépendances, console). |
AI_SAMPLING_PERCENT | 100 | Pourcentage de télémétrie envoyé à Application Insights (1-100). Baisser sans redéploiement si la facture grimpe pendant un incident (boucle d'erreur, retry storm). |
git clone <repo> cd simulateur-crise cp .env.example .env # à créer si absent # éditer .env avec vos clés API npm install npm run dev # nodemon, hot reload # ou npm start # mode production simple npm test # jest
Au premier démarrage, data/store.json est créé s'il n'existe pas. L'application est disponible sur http://localhost:3000.
Le dépôt déploie via un seul workflow GitHub Actions, sur le slot staging de l'App Service pulse-simulator-prod. Le slot production (live, https://pulse.secalys.fr) ne se met à jour qu'après un slot swap manuel.
| Étape | Déclencheur | Cible |
|---|---|---|
| Déploiement PROD/staging | Push sur main (auto) | pulse-simulator-prod slot staging |
| Mise en live | Slot swap manuel | pulse-simulator-prod slot production |
Flux nominal :
main → le workflow PROD/staging part automatiquement, version disponible sur le slot staging.staging, target: production). Ou en CLI : az webapp deployment slot swap --resource-group <rg> --name pulse-simulator-prod --slot staging --target-slot production.pulse-simulator-dev a été supprimée le 2026-05-09 au profit de cette architecture single-app à deux slots. Le slot staging tourne sur la même Cosmos que prod, ce qui donne une validation plus fidèle qu'un environnement DEV séparé.Aucun Dockerfile n'est fourni dans le repo. Pour packager :
FROM node:20-alpine WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY . . ENV NODE_ENV=production EXPOSE 3000 CMD ["node", "src/server.js"]
Les deux slots (production = slot par défaut, staging = --slot staging) sont configurés avec les paramètres runtime ci-dessous. À reporter à l'identique sur tout nouveau slot ou fork.
| Paramètre | Valeur | Pourquoi |
|---|---|---|
httpsOnly | true (les 2 slots) | Trafic en clair refusé (cookie de session, JWT du lien /access?token=, body POST). Une requête HTTP est redirigée 301 vers HTTPS. |
alwaysOn | true | L'app ne s'endort plus après ~20 min d'inactivité – la connexion SSE temps réel n'est plus rompue, plus de cold-start au réveil. |
use32BitWorkerProcess | false (64-bit) | Plafond mémoire et perf JS alignés sur le runtime NODE|22-lts Linux. |
http20Enabled | true | HTTP/2 : multiplexage des flux SSE sur un seul TCP (sinon ~6 connexions max par origine côté navigateur). |
healthCheckPath | /api/status | Route publique, 200 inconditionnel, montée avant le gate d'auth (src/app.js). Permet le redémarrage auto d'une instance malade et sert de contrat au smoke test de déploiement (#140). |
Logs plateforme – filet hors Application Insights (#137), activés sur les deux slots : applicationLogs.fileSystem = Information (le log stream montre la sortie pino / requestId), detailedErrorMessages = true (stack trace sur 5xx), failedRequestsTracing = true, httpLogs.fileSystem = true (cap 35 Mo déjà posé). Filet si Application Insights cesse d'ingérer (incident région, quota, mauvaise config workspace #138).
Vérifier l'état après tout (re)provisionnement ou passage du wizard OIDC (qui peut réécrire la config du site) :
RG=rg-pulse-prod ; APP=pulse-simulator-prod
for SLOT in "" "--slot staging"; do
az webapp show -g $RG -n $APP $SLOT --query "{httpsOnly:httpsOnly}" -o jsonc
az webapp config show -g $RG -n $APP $SLOT --query "{alwaysOn:alwaysOn,use32:use32BitWorkerProcess,http20:http20Enabled,healthCheckPath:healthCheckPath}" -o jsonc
az webapp log show -g $RG -n $APP $SLOT --query "{appLogFs:applicationLogs.fileSystem.level,detailedErrors:detailedErrorMessages.enabled,failedReqTracing:failedRequestsTracing.enabled,httpLogsFs:httpLogs.fileSystem.enabled}" -o jsonc
done
httpsOnly sur staging : vérifier d'abord qu'aucun outil interne ne tape en http://.L'endpoint d'administration SCM/Kudu (déploiement ZIP, console, log stream, dump d'environnement) n'accepte plus que l'authentification Microsoft Entra (AAD) : le Basic Auth (publishing profile / mot de passe SCM, réutilisable depuis n'importe où) est désactivé sur les deux slots.
| Slot | scm basic auth | ftp basic auth |
|---|---|---|
production | false | false |
staging | false | false |
Le pipeline GitHub Actions déploie en OIDC (azure/login + token AAD, aucun publishing-profile) – il n'est pas impacté (validé par un déploiement de test après le changement). L'accès admin manuel passe par le Portail Azure ou az (AAD), sans secret statique.
Vérifier l'état (à rejouer après tout (re)provisionnement ou wizard OIDC) :
RG=rg-pulse-prod ; APP=pulse-simulator-prod
for SCOPE in "sites/$APP" "sites/$APP/slots/staging"; do
for POL in scm ftp; do
az resource show -g $RG --namespace Microsoft.Web \
--resource-type basicPublishingCredentialsPolicies \
--name $POL --parent "$SCOPE" --query properties.allow -o tsv
done
done
scmIpSecurityRestrictions = Allow all) volontairement non posée : risque résiduel faible une fois le Basic Auth coupé (accès Kudu = token AAD court, scoped, MFA-able ; plus de secret statique réutilisable). Une allowlist IP serait fragile (IP des runners GitHub Actions mouvantes) pour un gain marginal – risque résiduel accepté.Réservé au rôle admin. L'icône engrenage de l'en-tête a été retirée : le Configurateur s'ouvre désormais par navigation directe (deep-link #ws=configurator). Pages :
IAM_MIN_CREDENTIALS passkeys (défense en profondeur anti-lockout, pas un seuil bloquant) ; « codes de récupération » compte les comptes ayant au moins un code non consommé (2ᵉ filet, D12b).{ email, displayName?, role } → réponse { user, enrolToken, expiresAt } à transmettre hors-bande au titulaire pour son 1ᵉ enrôlement), liste avec compteur passkeys + état codes de récupération, reset assisté sysadmin (révoque passkeys + sessions, ré-émet un enrolToken usage-unique).iam_sid est lié à la session – révocation = coupe immédiate de tout /api/*, pas seulement de l'écran admin).rpId, minCredentialsPerAccount, webauthnUvRequired, TTL sessions admin/sysadmin, fenêtre + seuil de lockout.IAM_MIN_CREDENTIALS), « Régénérer mes codes de récupération » (révélés une seule fois, à conserver hors-bande), liste + suppression de ses propres passkeys (garde min. impose ≥2 – refuse la suppression de l'avant-dernière).navigator.credentials.get, allowCredentials vide), entrée « Utiliser un code de récupération » (redeem du code → jeton d'enrôlement immédiat sans session → ré-enrôlement d'une passkey directement depuis la mire), et entrée « Activer mon accès avec un jeton d'enrôlement » (panel tab-activate) — un seul champ pour coller l'enrolToken reçu hors-bande après création de compte ou reset assisté, sans étape recovery/redeem préalable (un compte fraîchement créé n'a pas encore de codes de récupération).BOOTSTRAP_SYSADMIN_EMAIL non posé ou compte sysadmin pas encore enrôlé), POST /iam/bootstrap { email } est ouvert tant qu'aucun sysadmin actif n'existe – il crée le compte vide et retourne un enrolToken pour son 1ᵉ enrôlement de passkey via /iam/webauthn/register/*. Auto-fermé ensuite (409 dès qu'un sysadmin existe).iam_sid, plus aucune clé partagée). Contrat JWT inchangé (R6) : les liens existants émis avant la bascule passwordless restent valides jusqu'à expiration.Sources : src/security/iamUsers.js, iamSession.js, iamWebauthn.js, iamRecovery.js, src/routes/iamAuth.js, iamAdmin.js. Depuis v2.1.0-rc.15 (WU11), l'auth humaine est exclusivement WebAuthn / passkey : tout mot de passe et TOTP a été retiré, l'écran de connexion ne propose plus que la passkey ou un code de récupération.
users.pwd retiré du modèle, jeté au boot par _normalizeUser). D7 : l'UI ne montre les actions mutantes (créer/désactiver/supprimer/reset assisté/tout révoquer) qu'au sysadmin ; un admin est en lecture seule (le serveur refuse 403 de toute façon).allowCredentials: [], l'OS/navigateur choisit la clé), User Verification = 2ᵉ facteur (biométrie/PIN), défi usage-unique lié IP (anti-rejeu R4), RP ID = IAM_WEBAUTHN_RP_ID (défaut secalys.fr). Session pleine émise à la vérification (plus de mfaPending).enrolToken usage-unique (cf. bootstrap / reset / recovery). Garde minimum : ≥IAM_MIN_CREDENTIALS passkeys par compte (défaut 2, anti-lockout R1 / D12a) – refuse la suppression de l'avant-dernière.IAM_RECOVERY_CODE_COUNT codes à usage unique, hashés SHA-256 au repos, affichés une seule fois à conserver hors-bande (gestionnaire de mots de passe, coffre-fort). POST /iam/recovery/redeem (public, authLimiter, anti-énumération R4 – réponse générique en cas d'échec) → jeton d'enrôlement usage-unique pour ré-enrôler une passkey sans session après perte de tous les authentificateurs.enrolToken à usage unique, TTL IAM_ASSISTED_GRANT_TTL_MIN (défaut 30 min), à transmettre hors-bande. Le titulaire ré-enrôle ensuite une passkey via /iam/webauthn/register/* sans session./api/*, pas seulement l'écran admin.{ user, enrolToken, expiresAt } : le jeton émis sert à enrôler la 1ʳᵉ passkey via /iam/webauthn/register/*. Variante App Settings : BOOTSTRAP_SYSADMIN_EMAIL au démarrage crée un compte sysadmin vide au boot (idempotent) ; l'enrôlement initial passe alors par le même flow /iam/bootstrap./iam/bootstrap est fermé. Désactivé par défaut : l'endpoint répond 404 tant que la variable d'environnement IAM_BREAK_GLASS_SECRET n'est pas posée. Une fois le secret configuré, un appel avec le header X-Break-Glass-Secret (comparé en timing-safe) émet un enrolToken pour le compte ciblé, injecté directement dans le store du worker. Le titulaire enrôle ensuite une passkey via l'écran « Activer mon accès ». À n'activer (poser le secret) que le temps de l'intervention, puis retirer le secret – l'endpoint redevient 404. Audité (IAM_BREAK_GLASS / IAM_BREAK_GLASS_DENIED).POST /iam/bootstrap { email } pour récupérer un enrolToken et enrôler la 1ʳᵉ passkey. Enrôlez immédiatement ≥2 passkeys (filet anti-lockout) et générez des codes de récupération conservés hors-bande.Source : src/security/accessTokens.js, src/routes/admin.js, src/routes/access.js.
Initialisé dans src/app.js si APPLICATIONINSIGHTS_CONNECTION_STRING est défini. Auto-collecte requêtes HTTP, exceptions, dépendances et console. Le sampling est piloté par AI_SAMPLING_PERCENT (défaut 100%).
Buffer en mémoire, effacé au redémarrage (volatile). Lisible via GET /api/logs.
Source : src/services/anthropicMetrics.js, route GET /api/anthropic-metrics. Onglet Configurateur → Consommation IA.
Liste blanche depuis ALLOWED_ORIGINS. En production, lister explicitement les domaines (pas de wildcard).
| Limiteur | Cible | Limite par défaut |
|---|---|---|
globalLimiter | tout /api/* | 200 req / 60 s |
authLimiter | /iam/bootstrap, /iam/webauthn/login/*, /iam/recovery/redeem | 8 req / 60 s |
twisterLimiter | /api/twister/* | 10 req / 60 s |
anthropicLimiter | /api/anthropic/*, /api/inject*, /api/sentiment | 20 req / 60 s |
/access?token=…) et iam_sid (session nominative opaque, ouverte par WebAuthn) : httpOnly, Secure en prod, SameSite=Lax.iam_sid → lookup store ; révocation = coupure immédiate)./api/preview résout les DNS de l'URL fournie et refuse toute IP privée, loopback ou link-local. Limite de fetch à 150 KB.
POST /api/reset (rôle facilitator ou admin) - vide feed, scénario, sources, contexte, injects, messages observateur. Préserve l'auth et le journal d'activité.
| Symptôme | Diagnostic | Action |
|---|---|---|
401 sur /iam/webauthn/login/verify | Assertion WebAuthn invalide (passkey absente/désynchronisée/RP-ID changé), compte verrouillé (5 échecs/15 min) ou rate-limit (8/min) | Tester avec une autre passkey enrôlée ; ou redeem d'un code de récupération ; ou reset assisté par un sysadmin ; si tous les sysadmin sont locked → recourir aux codes de récupération hors-bande (cf. runbook bascule WU11) |
403 sur une mutation /api | Token de rôle insuffisant | Re-générer un lien facilitator ou admin |
429 sur /api/inject* | Rate-limit Anthropic (20/min) | Augmenter RATE_LIMIT_MAX_ANTHROPIC ou attendre |
| Erreurs Twister récurrentes | Quota Twister dev v2 atteint ou bearer invalide | Tester dans Configurateur, rotater le bearer si besoin |
| RSS « EN DIRECT » silencieux | Flux down ou bloqué | Tester l'URL ; remplacer ; cache 60 s côté serveur |
| Mémoire qui gonfle | Activity log saturé (peu probable, capé à 500), ou fuite SSE | Inspecter /api/status, redémarrer si besoin |
| Inject programmé non parti | Scheduler serveur arrêté, ou exercice en paused / sans startTime, ou SSE coupée (l'inject est parti mais le mur ne l'affiche pas) | Vérifier l'état via /api/status + le statut de l'exercice ; rétablir la connexion SSE (recharger l'onglet) ; Publier maintenant pour forcer un envoi manuel |
| Doublons d'injects | Réimport du même chronogramme | La dédup serveur (_dedupKey) gère ; sinon vider via DELETE /api/feed |
| Store corrompu | store.json illisible (JSON invalide) | Restaurer la dernière sauvegarde ; ou en mode Cosmos, supprimer le fichier local et redémarrer |
| Cosmos indisponible | Endpoint/key invalide ou réseau coupé | L'app continue avec le store local en lecture/écriture ; logs warn |
| SSE qui se déconnecte sans cesse | Reverse proxy avec timeout court | Augmenter timeout (> 120 s), désactiver buffering (X-Accel-Buffering: no) |
| Redémarrage / slot-swap | SIGTERM envoyé par la plateforme (Azure restart, swap, déploiement) | Le process draine les connexions SSE puis flush async Cosmos (réécrit le doc shared + tous les exercices) ; en cas d'échec Cosmos, fallback backup synchrone data/store.json. Hard exit à 8 s si tout traîne – un redémarrage propre ne perd pas l'état (cf. issue #465 — fix régression WU3) |
| Process mort sans cause visible | Exception non capturée / rejet de promesse non géré | Chercher la ligne de log level: fatal (« FATAL – flushing store backup and exiting ») : elle porte le kind et la stack ; un backup store.json a été flushé avant la sortie |
Refus de démarrage [config] | Variable d'env numérique invalide (PORT, RATE_LIMIT_*, ACCESS_TOKEN_TTL_HOURS non entier positif) | Le boot échoue volontairement (fail-fast) avec [config] <VAR> doit être un entier positif au lieu d'un NaN silencieux – corriger la valeur dans les App Settings puis redémarrer |
credentialId, clé publique (jamais privée), signCount, aaguid, transports, label utilisateur. Pour la récupération : codes hashés SHA-256 au repos (jamais en clair après affichage initial). Session opaque iam_sid (cookie).IAM_MIN_CREDENTIALS=2 impose ≥2) : il se connecte avec, supprime l'ancienne et ré-enrôle une nouvelle clé.POST /iam/recovery/redeem → jeton d'enrôlement immédiat → ré-enrôle une passkey sans session.POST /iam/admin/users/:id/reset-credentials) → révoque passkeys + sessions et émet un enrolToken usage-unique à transmettre hors-bande.BOOTSTRAP_SYSADMIN_EMAIL ; POST /iam/bootstrap reste ouvert tant qu'aucun sysadmin actif n'existe./api/me).token.exerciseId === :id (403 sinon, tous rôles
inclus). WU6 :
eventBus pub/sub par canal –
un onglet abonné à /api/exercises/:id/events ne reçoit
que son exercice ; le canal GLOBAL ne porte
que la révocation de session.
POST /login, /login/mfa,
/auth/logout (légacy SPA) ;
POST /iam/login, /iam/mfa/enroll,
/iam/mfa/verify, /iam/me/password ;
/iam/admin/users/:id/reset-{password,mfa}. Modules
retirés : iamMfa.js, appPasswords.js,
routes/login.js. Modèle users : champs
pwd et mfa retirés (jetés au boot par
_normalizeUser). Sessions : mfaPending
retiré. Deps : otplib + qrcode retirés
(+19 transitifs). BOOTSTRAP_SYSADMIN_PASSWORD et
IAM_MFA_KEY ignorées. Traité en MINOR
par dérogation tracée (mono-tenant, front mis à jour synchrone) –
cf. CHANGELOG v2.1.0 § WU11 et règle CLAUDE.md
(non refondue, scope strict). Détails plan :
PLAN-IAM-ACCES-SECURITE.md §6.
simulateur-crise/ ├── data/ │ └── store.json # État applicatif (mode local) ├── public/ # Assets servis statiquement │ ├── index.html # SPA (toutes les vues) – embarque la mire passkey │ ├── css/style.css # Design system unique │ ├── assets/ # Logos, illustrations │ └── js/ # Modules frontend (vanilla) │ ├── bootstrap.js # Patch fetch (X-Trainer-Token) │ ├── auth.js # Mire passkey + recovery (login/logout) │ ├── iam.js # Écran IAM admin (Comptes / Sessions / …) │ ├── app.js # Orchestration, modes, SSE/polling │ ├── wall.js # Mur joueur │ ├── observer.js # Salle d'observation │ ├── newsroom.js # Salle de rédaction (animateur) │ ├── configurator.js # Configurateur (admin) + modale Doc │ ├── context.js # Édition contexte d'exercice │ ├── sources.js # Gestion sources live │ └── reputation.js # Indicateurs de tonalité ├── src/ │ ├── server.js # Bootstrap (charge config, init store) │ ├── app.js # Express : middlewares, montage routes │ ├── config/env.js # Lecture & validation des variables d'env │ ├── middleware/ # rateLimiter, trainerAuth, validate, … │ ├── security/ # accessTokens (JWT exercices), iamUsers, │ │ # iamSession, iamWebauthn, iamRecovery │ ├── routes/ # voir §4 (iamAuth, iamAdmin, …) │ ├── services/ # anthropicService, twisterService, activityLog │ ├── repositories/ # storeRepository (Cosmos OU disque) │ ├── store/ # memoryStore + eventBus (pub/sub SSE) │ ├── db/cosmos.js # Client Cosmos + migration store.json │ └── utils/ # logger (pino), diskStore ├── src/__tests__/ # Suites Jest + supertest ├── package.json # Scripts: start, dev, test ├── README.md └── CHANGELOG.md
Application en couches strictes :
┌────────────────────────────────────────────┐
│ Routes (src/routes/*.js) │ HTTP boundary
│ - parse body (zod) │
│ - apply middlewares (auth, rate-limit) │
│ - 1 fichier = 1 ressource │
└────────────────┬───────────────────────────┘
│
┌────────────────▼───────────────────────────┐
│ Services (src/services/*.js) │ Business logic
│ - composent plusieurs stores/APIs ext. │
│ - sans dépendance Express │
└────────────────┬───────────────────────────┘
│
┌────────────────▼───────────────────────────┐
│ Store + Repository │ Persistance
│ memoryStore: source de vérité en mémoire │
│ storeRepository: write-through Cosmos + │
│ disque │
└────────────────────────────────────────────┘
Source de vérité du shape : les schémas zod dans src/routes/ (validation à l'entrée). Aperçus :
{
"id": "x_1715180000_xy7k",
"type": "x" | "of" | "prompt",
"name": "BFMTV", "handle": "BFMTV", "verified": true,
"content": "...", "img": "https://...",
"likes": 1240, "reposts": 320, "views": 18400,
"viral": false,
"live": false,
"_serverTime": 1715180000123,
"tone": "neutral" | "very_negative" | "negative" |
"positive" | "very_positive",
"quoted": { "author": { "name", "username", "verified" }, "content" },
"replyTo": { "name", "handle", "content" },
// type 'of' :
"rubrique": "Société", "headline": "...", "chapo": "...",
"byline": "Par A. Dupont", "city": "NANTES",
// type 'prompt' :
"sujet": "Technique", "resume": "...", "emetteur": "...",
"recepteur": "...", "stimuli": "...", "reaction": "...",
"comment": "..."
}
{ "item": { ...feedItem }, "offsetMin": 5 }
// store.sources
{
"twister": ["GouvernementFR", "AFP", ...], // 20 max
"media": ["https://www.lemonde.fr/rss/...", ...] // 20 max
}
{
"ts": 1715180000123,
"type": "LOGIN_SUCCESS",
"severity": "info" | "warn" | "error",
"role": "admin" | "player" | "facilitator" | "observer",
"ip": "203.0.113.0/24",
"message": "Admin connecté",
"meta": { /* libre */ }
}
{
"id": "usr_…", "email": "alice@…", "displayName": "Alice",
"role": "admin" | "sysadmin",
"active": true,
"createdAt": "…", "createdBy": "usr_…",
"lockoutUntil": null,
"webauthn": {
"userHandle": "uh_…", // ID stable de l'utilisateur côté WebAuthn
"credentials": [{
"credId": "…", // base64url
"publicKey": "…", // base64url COSE
"signCount": 42,
"aaguid": "…",
"transports": ["internal", "hybrid"],
"label": "iPhone d'Alice",
"createdAt": "…", "lastUsedAt": "…"
}]
},
"recovery": {
"codes": [{ "hash": "sha256:…", "usedAt": null }],
"generatedAt": "…"
}
}
// pwd / mfa : retirés depuis v2.1.0-rc.15 (WU11). sanitize() les masque toujours.
{
"id": "ch_…",
"kind": "login" | "register" | "enrol" | "bootstrap",
"challenge": "…", // base64url (32 octets)
"userId": "usr_…" | null,
"ip": "203.0.113.0/24",
"expiresAt": 1715181000000,
"consumedAt": null // usage unique R4
}
{ "exerciseId": "ex_3f2a…", "role": "player", "jti": "…", "iat": ..., "exp": ... }
// exerciseId = vrai id store ; requireExercise exige token.exerciseId === :id (403 sinon)
{ "client": "...", "objectif": "...", "scenario": "...", "acteurs": "..." }
Préfixes : /iam (auth passwordless / bootstrap / récupération + /iam/admin/* gestion comptes ; session iam_sid), /access (redemption JWT exercice), /admin (liens exercice, session IAM), /api (reste, globalLimiter). Auth applicative par cookie access_token (JWT exercice) ou Authorization: Bearer. Phase 1 (mdp + TOTP) retirée en v2.1.0-rc.15 (WU11) : les routes /login, /login/mfa, /auth/logout, /iam/login, /iam/mfa/*, /iam/me/password, /iam/admin/users/:id/reset-{password,mfa} répondent 404.
| Méthode | Chemin | Auth | Body / Notes |
|---|---|---|---|
| POST | /iam/bootstrap | authLimiter + invariant 0-sysadmin | { email, displayName? } → { user, enrolToken, expiresAt } ; auto-fermé (409) dès qu'un sysadmin existe |
| POST | /iam/webauthn/login/options · /verify | authLimiter | Login passwordless – usernameless (allowCredentials: [], passkey découvrable), UV requis (D3′), défi usage-unique lié IP (R4), session pleine émise à verify |
| POST | /iam/logout | session | Révoque la session courante côté serveur + efface iam_sid |
| GET | /iam/me | - | Identité nominative (soft, jamais 401) + bootstrap (true tant qu'aucun sysadmin actif) |
| POST · GET · DELETE | /iam/webauthn/register/options · /register/verify · /credentials[/:credId] | session pleine ou enrolToken (self) | Enrôlement & gestion des passkeys – residentKey: 'required', attestation none (D14), refuse doublon credId, garde min. IAM_MIN_CREDENTIALS (défaut 2 – refuse la suppression de l'avant-dernière) |
| POST | /iam/recovery/codes | session pleine | Génère IAM_RECOVERY_CODE_COUNT codes (défaut 10), hashés SHA-256 au repos, affichés une seule fois |
| POST | /iam/recovery/redeem | authLimiter (public) | { email, code } → { enrolToken, expiresAt } ; réponse générique en cas d'échec (R4 anti-énumération) |
| GET · POST · PATCH · DELETE | /iam/admin/users* · /iam/admin/sessions* · /iam/admin/security/* | session (lecture admin+sysadmin ; mutations sysadmin / D7) | CRUD comptes (POST = { email, displayName?, role } → { user, enrolToken, expiresAt }), sessions, summary (KPI webauthn « ≥N passkeys »), policies (rpId / minCredentialsPerAccount / webauthnUvRequired en lecture seule, D10) |
| POST | /iam/admin/users/:id/reset-credentials | sysadmin (D7) | Reset assisté (D12c) – révoque passkeys + sessions ; émet un enrolToken usage-unique (TTL IAM_ASSISTED_GRANT_TTL_MIN min, défaut 30) à transmettre hors-bande |
| GET | /access?token=… | - | Valide JWT exercice, pose cookie access_token, redirige selon le rôle |
| POST | /admin/access-link | session IAM (admin|sysadmin) | { exerciseId, role, ttlHours? } → { url, token } |
| GET | /api/status | public | État APIs + version + uptime |
| GET | /api/me | any | { authenticated, exerciseId, role } |
| GET | /api/events | any | SSE - { feed, state, connections, messages } |
| GET | /api/feed | any | { items: [...] } |
| POST | /api/feed | trainerAuth | Item validé (zod) |
| DELETE | /api/feed/:id | trainerAuth | Supprime un item |
| DELETE | /api/feed | trainerAuth | Vide le mur |
| GET / POST | /api/scenario | trainerAuth | Liste / remplacement (max 500) |
| GET / POST | /api/state | trainerAuth | { status: idle|running|paused|stopped, startTime? } |
| POST | /api/twister/search | twisterLimiter | { query, max_results, since_id? } |
| GET | /api/twister/syndication/:id | twisterLimiter | Photos/videos d'un tweet |
| POST | /api/anthropic/search | anthropicLimiter | { userPrompt } → { text } |
| GET | /api/exercises/:id/rss | any | Flux presse agrégés des sources media de l'exercice (cache 60 s) |
| GET / POST | /api/sources | trainerAuth | { twister[20], media[20] } |
| GET / POST | /api/context | trainerAuth | Contexte d'exercice |
| GET / POST / DELETE | /api/inject | trainerAuth + anthropicLimiter | Génère / lit / vide les injects IA |
| POST | /api/inject/reactions | trainerAuth + anthropicLimiter | Vague de réactions (positif / compatissant / haineux) |
| POST | /api/inject/replenish | trainerAuth + anthropicLimiter | Remplace un inject utilisé |
| POST | /api/inject/find-image | trainerAuth + anthropicLimiter | { topic } → URL d'image |
| POST | /api/sentiment | trainerAuth + anthropicLimiter | Classifie 5 catégories |
| POST | /api/preview | any | SSRF-safe - og:image + og:title |
| GET / POST | /api/settings/keys | trainerAuth | Lit/met à jour Twister + Anthropic |
| GET / POST | /api/settings/suggested-sources | trainerAuth | Suggestions UI |
| POST | /api/reset | trainerAuth | Réinit complet (préserve audit) |
| GET | /api/logs | trainer (pas player) | { errors, activity } (200 max) |
| DELETE | /api/logs/activity | admin | Vide le journal d'activité |
| GET / POST / DELETE | /api/observer-messages | any | Chat observateur (200 max) |
| POST | /api/import | trainerAuth (8 MB) | Import chronogramme XLSX → IA |
Erreurs standard : 400 (validation zod), 401 (non authentifié), 403 (rôle insuffisant), 429 (rate limit), 500 (erreur serveur). Format : { error: "...", details? }.
Tous les scripts sont chargés en bas de public/index.html, dans cet ordre :
navigator.credentials.get/create, exports window.WA). Wire wireLogout() sur /iam/logout.window.fetch pour injecter X-Trainer-Token depuis localStorage (cas dev / tests)._viewWall, _viewNewsroom…).PulseRouter.go(), gardes de rôle, historique pushState + popstate/hashchange). Les show*() historiques y délèguent./api/events est l'option par défaut. En cas d'échec, app.js bascule en polling toutes les 2 s sur /api/feed + /api/state. L'observateur a un fallback à 3 s (_startObsPoll).
Scheduler côté serveur (WU8) – src/services/injectScheduler.js. Le déclenchement n'est plus calculé dans le navigateur animateur (throttling / onglet fermé). Mécanisme :
requireSession() (session pleine, cookie iam_sid) – cf. src/security/iamSession.js ; vérification WebAuthn déléguée à @simplewebauthn/server (verifyAuthenticationResponse / verifyRegistrationResponse) dans src/security/iamWebauthn.js – D11 / D13 (RP-ID = secalys.fr ou env) / D14 (attestation none) / R8 (signCount toléré à 0 et exigé monotone après).requireAccess([roles]) et trainerAuth – voir src/security/accessTokens.js et src/middleware/trainerAuth.js.crypto.timingSafeEqual sur les codes de récupération hashés (anti-timing-leak) ; signCount monotone strict (anti-clone R8) ; défis & jetons d'enrôlement usage-unique + TTL + liés IP (R4)./iam/webauthn/login/verify et /iam/recovery/redeem (aucune fuite « compte inexistant » vs « code invalide »).src/routes/preview.js filtre les IPs privées et limite la taille de fetch.httpOnly, secure en prod, SameSite=Lax.src/app.js (CSP avec unsafe-inline à terme à durcir).npm test # jest, testTimeout 10s, testEnvironment node – 32 suites / 449 tests verts
Suites présentes dans src/__tests__/ (sélection) :
@simplewebauthn/server.exerciseAuthz, tenancyE2e, etc.).Suites retirées en WU11 (testaient des routes/modules Phase 1 retirés) : iamAuth, iamMfa, iamHardening, iamE2e, iamUsersStore, appPasswords, logout, plus les redondances couvertes par WU12 E2E (iamWebauthnLogin/Register/Recovery, iamAdmin, iamAdminResetCredentials, iamAccessLinksPasswordless, iamSessionsPasswordless, iamSecurityWebauthnKpi).
Conventions : supertest pour les tests d'intégration HTTP, mocks Anthropic/Twister par stubs locaux. Pas de couverture mesurée à ce jour.
Pas d'i18n actuellement (UI 100 % française). Pour l'introduire : extraire les chaînes en dictionnaires, choisir une lib (i18next) ou un mécanisme léger maison.
Pour les changements visuels (UI, branding), capturer un avant/après dans la PR.
/access?token=… (redemption d'un lien JWT d'exercice).secalys.fr (D13).secalys.fr fonctionne pour tout sous-domaine, mais pas si on change de RP-ID (cf. IAM_WEBAUTHN_RP_ID).IAM_ASSISTED_GRANT_TTL_MIN min) émis par POST /iam/bootstrap, création de compte, reset-credentials ou recovery/redeem. Permet d'enrôler une passkey via /iam/webauthn/register/* sans session.IAM_RECOVERY_CODE_COUNT) générés sur demande, hashés SHA-256 au repos, affichés une seule fois. À conserver hors-bande. Redeem public anti-énumération (réponse générique en cas d'échec).