diff --git a/kanban.html b/kanban.html
index ceb287f..cbc1085 100644
--- a/kanban.html
+++ b/kanban.html
@@ -458,13 +458,13 @@
Constaté le 08/09 sur la préprod, depuis internet, sans authentification :
+curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047" + → 200, avec le contenu complet et ses traductions+
La même requête avec X-Api-Key renvoie le même 200. Cause exacte, vérifiée le 08/09 : Get (ConfigurationController.cs:52) et GetDetailAsync (:117) portent un [AllowAnonymous] explicite. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (client.dart:54), et le mécanisme existe et fonctionne : ApiKeyAuthenticationHandler + la policy AppReadAccess, déjà utilisée par byPin (:83) et export (:383) du même contrôleur.
Ce que ça expose : le contenu éditorial de n'importe quelle instance, à qui connaît un instanceId — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.
⚠️ À vérifier avant de conclure : le périmètre exact. J'ai testé /api/Configuration ; il faut passer en revue les autres routes que consomment les apps visiteur (/api/Section/configuration/{id}, /api/Resource/{id}, /api/SectionMap, /api/SectionQuiz) avant de savoir si le trou est ponctuel ou général.
C'est le problème symétrique du 403 sur GET /api/Instance/{id}, corrigé le 08/09 (voir la carte close) : la même app est trop bloquée sur une route et pas bloquée du tout sur les autres. Les deux se traitent ensemble, en décidant ce que X-Api-Key est censé garder.
Trois trous de home_3.0.dart comblés le 2026-09-08, aucun vu tourner sur device.
(1) Les configurations désactivées s'affichaient. isActive ne vit pas sur ConfigurationDTO mais sur le lien config↔canal (AppConfigurationLinkDTO.isActive, ce que bascule app_configuration_link_screen:407). Ni le fetch par liens ni le set mobileConfigIds ne le lisaient. ⚠️ L'ancienne home avait le même bug — ce n'était pas une régression de la v3. La référence était à côté : visitapp-web/src/lib/api/client.ts:170 filtre déjà link.isActive && link.configuration.
(2) Une visite hors ligne était un cul-de-sac. Pas de bouton télécharger, pas de garde : la tuile ouvrait ConfigurationPage, et body.dart:250 lisant ses sections uniquement en base locale quand isOffline est vrai, le visiteur voyait un détail vide, sans message ni erreur. Porté depuis configurations_list.dart : badge en pastille de verre sur la tuile bento (↓ / ✓), tuile ternie tant qu'elle n'est pas prête, dialogue sombre choix de langue + progression (DownloadConfigurationWidget réutilisé tel quel). Zéro nouvelle clé i18n, tout existait dans les 10 langues.
⚠️ Piège trouvé en câblant : alreadyDownloaded se déduisait des lignes de la table configurations — table dans laquelle le fetch écrit une ligne par visite pour cacher order/gridSpan. Au deuxième lancement, toutes les visites se seraient déclarées téléchargées, et le clic aurait rouvert un détail vide. L'état se déduit maintenant des sections en base locale, ce que lit le détail.
(3) Passe visuelle. Nouveau Components/GlassPill.dart (verre dépoli, 3 usages) : le cog devient Icons.tune + drapeau de la langue courante en pastille, GlassesStatusWidget passe sur la même pastille, et la feuille de réglages passe en sombre — une feuille blanche dans une app noire était le contraste le plus violent de l'écran.
✅ À supprimer une fois validé sur device : Screens/Home/home.dart et Screens/Home/configurations_list.dart. Ils ne sont plus référencés par personne — HomePage est orphelin, CustomAppBar, main.dart:220, body.dart:122 et menu_page.dart:136 pointent tous sur HomePage3. Ils n'étaient gardés que pour le téléchargement, qui vient d'être porté. Ne les supprimer qu'après avoir joué §21 du test-plan sur device : ce sont les seules références du comportement d'origine si le portage se révèle incomplet.
⚠️ Deux verrues mises en lumière, pas corrigées. downloadPrompt annonce « 39,8MB » en dur dans les 10 langues et aucune donnée de poids n'existe côté DTO (la colonne weightMasonryGrid a été abandonnée en v3) : soit la taille entre dans l'export, soit le chiffre sort du texte. Et home_3.0.dart:904 porte isOnline = true; // Todo remove if not local test — la branche hors ligne ne s'exécute jamais (l'analyzer le confirme, dead_code juste après) : une visite téléchargée s'ouvre, mais l'accueil en mode avion passe quand même par le réseau.
Aujourd'hui aucune carte ne fonctionne hors ligne, et ce n'est pas une question de données : MapDTO porte déjà points, centerLatitude, zoom et iconResourceId, et les icônes sont téléchargées avec la visite. C'est le fond qui manque — les tuiles sont chargées en réseau, sans cache.
Le GPS, lui, n'est pas le problème : geolocator ^13.0.0 lit le GNSS, qui est autonome. Seule réserve à annoncer au client : sans A-GPS, le premier point peut demander 30 à 60 s sous couvert forestier.
Le chemin court existe déjà dans le pubspec : mapbox_maps_flutter ^2.0.0 expose OfflineManager (style packs) et TileStore (tile regions) — on télécharge une emprise bornée au moment du téléchargement de la visite. Pour un domaine comme le Fourneau Saint-Michel, quelques dizaines de Mo. MapProvider.MapBox existe déjà côté modèle.
Le chemin court existe déjà dans le pubspec : la version résolue est 2.8.0 (pas 2.0.0 comme le laisse croire la contrainte), et le paquet installé contient bien OfflineManager, TileStore, StylePackLoadOptions, TileRegionLoadOptions et TileRegion — vérifié dans le cache pub, pas supposé. On télécharge une emprise bornée au moment du téléchargement de la visite ; pour un domaine comme le Fourneau, quelques dizaines de Mo.
✅ Mapbox est désormais le fournisseur par défaut (04/09) : le null retombait sur Google dans map_page.dart (deux endroits) et map_config.dart. ⚠️ Effet de bord à surveiller — les sections carte existantes de MDLF et Fort Saint-Héribert dont le mapProvider est null basculent silencieusement de Google à Mapbox. Le token est déjà en place, en dur dans main.dart:60.
⚠️ Ce n'est pas gratuit, et le compteur est le mauvais : ce qui coûte n'est pas la surface de l'emprise mais le nombre de téléchargements — un tile pack par visiteur. La facture monte donc avec la fréquentation, c'est-à-dire avec le succès du client. Grille à vérifier chez Mapbox, ligne « tile packs » et pas seulement « map loads ». C'est exactement ce calcul que supprime v2/plan-illustre-plan.md.
⛔ Google est une impasse, et pas seulement techniquement. Le SDK Maps pour Android n'expose aucune API de tuiles hors ligne — les « zones hors connexion » sont une fonction de l'app Google Maps, pas du SDK intégrable. Et le code n'utilise même pas ce SDK pour les sections carte : il passe par flutter_map sur https://mt1.google.com/vt/lyrs=m&x={x}&y={y}&z={z}, un endpoint non documenté dont la mise en cache est explicitement interdite. Conclusion : Mapbox devient le fournisseur du mode hors ligne, Google reste en ligne seulement.
⚠️ Tant que ce chantier n'est pas fait, SectionType.Map reste volontairement hors de offlineCapableSectionTypes (downloadConfiguration.dart) : l'ajouter donnerait une carte grise, ce qui est pire que ne pas la proposer. Une fois les tuiles packagées, c'est une ligne à ajouter dans cette constante.
Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné. Aujourd'hui MapProvider ne connaît que Google et MapBox : il n'existe aucun moyen de téléverser une image de plan et d'y placer les points.
Ce qu'il faut : une troisième valeur de MapProvider, une ressource image portée par la section, et un calage — deux points d'ancrage suffisent (coin haut-gauche / bas-droit en coordonnées réelles) pour projeter un GeoPoint sur l'image et y afficher la position du visiteur.
Pourquoi c'est le meilleur des trois : aucune tuile, donc hors ligne par construction — l'image part avec la visite comme n'importe quelle ressource, et le mécanisme de téléchargement n'a rien de nouveau à apprendre. Le rendu est aussi plus lisible qu'un fond routier sur un site de plusieurs dizaines de bâtiments.
-Périmètre réel : c'est le plus gros des trois chemins carto — backend (modèle + migration), manager-app (téléversement du plan et pose des ancres), mymuseum-visitapp et visitapp-web (rendu). À faire après Carte hors ligne — tile packs Mapbox, qui débloque le besoin immédiat avec un fond classique.
Un musée de plein air ne distribue pas un fond OpenStreetMap, il distribue son plan dessiné. Conception complète, modèle de données et périmètre par repo : DOCS/v2/plan-illustre-plan.md. Rien n'est implémenté.
La décision qui structure tout : les repères se posent directement sur l'image, à la main, et c'est cette position qui fait foi — un plan dessiné n'étant ni à l'échelle ni orienté au nord, projeter des GeoPoint depuis leurs coordonnées les ferait tomber à côté des bâtiments. Le calage GPS ne sert qu'à afficher le visiteur, et devient donc facultatif.
⚠️ Deux affirmations de la première version de cette carte étaient fausses, corrigées le 04/09 : on ne projette pas les repères, et deux points d'ancrage ne suffisent pas — il en faut trois pour une transformation affine qui absorbe rotation et étirement, et pour pouvoir vérifier le calage au lieu de diluer l'erreur.
+✅ Le calage se fait assis : cliquer le même angle de bâtiment sur le plan puis sur une carte réelle en regard. GeolocInputContainer ouvre déjà un FlutterLocationPicker (vraie carte, recherche d'adresse) — le composant est écrit, il faut le mettre à côté du plan. Plus besoin de prestation d'installation.
✅ Hors ligne gratuit : le plan est une Resource, une ligne dans GetReferencedResourceIds() et le pipeline existant l'embarque. Aucun tiers, aucun quota — contrairement aux tile packs Mapbox, facturés par visiteur.
⚠️ Conséquence web à trancher : visitapp-web reste sur Leaflet (lot E, W1), et mapProviderMobileOnlyNote le dit déjà au client. Pour un plan illustré c'est bloquant — le plan est le produit. Soit on porte le rendu en CSS dans la foulée, soit le plan Essentiel, web-only, n'y a pas droit.
Livrable qui se vend seul : le plan sans calage — téléversement, pose des repères, rendu, hors ligne. C'est le produit que les audioguides vendent depuis trente ans.
+ v2/plan-illustre-plan.md — conçu le 2026-09-04Noté comme « à faire » depuis juillet et jamais fait. Ici ça cesse d'être une bonne pratique : c'est la seule chose qui permette de recommencer si la bascule tourne mal.
+Scripts écrits et testés le 07/09 dans manager-service/ManagerService/Deployment/backup/ : dump -Fc avec vérification de relecture et rotation, test de restauration comparant les comptages table par table (25 tables identiques), extraction par instance, contrôle de fraîcheur, units systemd, et une procédure de restauration écrite (RESTORE.md).
La destination existe depuis le 07/09 : BACKUP_DEST=gcs:unov-myinfomate-backups/pg, service account incapable de supprimer (vérifié par un 403). Voir la carte close « Trancher la destination des sauvegardes hors site ».
Ce qui reste : rclone n'est ni installé ni configuré sur le VPS, et il n'y a de toute façon pas encore de base Postgres en prod à sauvegarder. Cette carte est donc une étape à l'intérieur de « Créer l'environnement prod Postgres », pas un chantier parallèle — une vingtaine de minutes le jour venu, plus l'acheminement de la clé du service account en chmod 600.
Deux constats du test : les embeddings exclus font passer le dump de 12 Mo à 1,7 Mo, et le « collation version mismatch » du README s'est reproduit — il bloque createdb, donc toute restauration.
notify.sh sur un canal réelTrois surveillances sont en place — échec du script (OnFailure=), sauvegarde plus vieille que 48 h, test de restauration mensuel — et toutes appellent backup/notify.sh, qui ne fait aujourd'hui qu'un logger.
Le mode de panne réel n'est pas « le script plante » : c'est « le script ne tourne plus depuis trois semaines et personne ne l'a vu ». C'est précisément celui qu'un OnFailure n'attrape pas et que le contrôle de fraîcheur attrape — à condition que l'alerte sorte de la machine. Renseigner BACKUP_ALERT_WEBHOOK, ou brancher le canal mail du service.
IsAutoTriggered, est renommé IsVisitorQuestion (défaut true) parce que son vrai sens couvrait déjà les deux cas. dotnet test 218.
Arrêté et créé le 07/09 : projet GCP myinfomate-backups, bucket gs://unov-myinfomate-backups en EU multi-région, versioning, accès public interdit, lifecycle 400 jours sous pg/. Service account backup-writer en objectCreator + objectViewer — incapable de supprimer, vérifié par un 403.
Les médias pèsent 1,43 Go (mesuré, pas estimé) : ils tiennent dans le même bucket sous media/, donc pas d'OVH Object Storage. La disposition croisée envisagée le matin même est abandonnée — elle se justifiait sur un volume supposé de 45 Go, qui venait en réalité du stockage Google One personnel.
conversationId
Le chat écrit, le vocal et le proactif partagent désormais une seule conversation, portée par VisitAppContext.assistant. Les trois AssistantService séparés ont disparu ; le maxHistory: 6 du vocal s'aligne sur 10, deux surfaces qui partagent une conversation ne pouvant pas la tronquer différemment selon le point d'entrée. La concision vocale vient d'isVoice, qui change le prompt côté serveur.
@@ -1277,6 +1325,11 @@
Trouvé en cherchant le « backdoor #if DEBUG » du rapport d'audit : dans AuthenticationController.Authenticate, un bloc écrasait l'email et le mot de passe reçus par un compte de test — toute compilation en Debug authentifiait n'importe quelle saisie en tant que test@email.be. Retiré. EnableSensitiveDataLogging (qui écrit les valeurs des paramètres dans les logs : hashes, clés API, données de visiteurs) passe sous #if DEBUG, l'idiome déjà employé dans ce même fichier pour le CORS et Hangfire — et le Dockerfile publiant en -c Release, c'est un vrai verrou, ce qu'un build Release a confirmé. Les autres #if DEBUG du repo sont légitimes.
GET /api/Instance/{id} renvoyait 403 à mymuseum-visitapp, qui l'appelle au démarrage pour la voix du guide : tout InstanceController porte [Authorize(SuperAdmin)] et GetDetail n'avait pas d'exception, contrairement à slug, byPin et app-key. Une clé API donne désormais accès à sa seule instance — clé croisée = 403 — et à une vue réduite : StripCommercialFields retire plan, quotas, usage IA, essai, TVA, facturation et le pinCode, qui ouvre l'appairage des tablettes. Le manager continue de tout voir.AuthorizationMiddleware authentifie avec les schémas de la policy du contrôleur — JwtBearer et ApiKey — et peuple HttpContext.User avant de court-circuiter sur [AllowAnonymous]. Une clé API produit donc un User authentifié auquel le handler pose le claim Viewer : la première version laissait passer tout le monde. Elle ne se voyait pas, parce que les champs sensibles de l'instance testée étaient naturellement nuls — c'est le test croisé sur une instance avec un pinCode qui l'a révélée. Le test porte maintenant sur le schéma d'authentification.StripeCustomerId et StripeSubscriptionId. C'est faux — ils sont sur l'entité mais pas exposés par ToDTO. Ce qui fuyait vraiment : plan, quotas, TVA, facturation, pinCode.version-3.1.3.
+ api.dart déclarait 139 part pour 153 fichiers. Les 14 restants portaient part of openapi.api; sans être listés par leur bibliothèque — en Dart, hors du graphe de compilation : l'analyzer les lisait, le compilateur ne les voyait jamais. Toute l'asymétrie qui a coûté du temps était là, et avec elle la garantie que les supprimer ne pouvait pas casser un build. Mesuré : flutter analyze sur le package passe de 67 à 3 issues, et l'analyse globale de manager-app de 68 erreurs de bruit à 3 erreurs, toutes dans test/widget_test.dart (cassé et connu) — flutter analyze redevient un feu vert utilisable, ce qui débloque les lots design. flutter build web ✅ avant comme après.
diff --git a/kanban/cards/1-urgent/015-l-api-publique-repond-sans-cle-api.md b/kanban/cards/1-urgent/015-l-api-publique-repond-sans-cle-api.md
new file mode 100644
index 0000000..612d2a7
--- /dev/null
+++ b/kanban/cards/1-urgent/015-l-api-publique-repond-sans-cle-api.md
@@ -0,0 +1,14 @@
+---
+title: L'API publique répond sans clé API
+area: backend
+tags: manager-service, sécurité
+flag: critical | Le contenu de n'importe quelle instance est lisible par qui connaît son identifiant
+src: conversation 08/09 — vérification de la préprod depuis internet
+---
+Constaté le 08/09 sur la préprod, depuis internet, sans authentification :
+curl "https://api.mymuseum.be/api/Configuration?instanceId=633ee379d9405f32f166f047" +→ 200, avec le contenu complet et ses traductions+
La même requête avec X-Api-Key renvoie le même 200. Cause exacte, vérifiée le 08/09 : Get (ConfigurationController.cs:52) et GetDetailAsync (:117) portent un [AllowAnonymous] explicite. Ce n'est donc pas une clé mal vérifiée, c'est une ouverture assumée — probablement héritée de la v2, où les apps visiteur n'avaient pas de clé. Les apps l'envoient pourtant (client.dart:54), et le mécanisme existe et fonctionne : ApiKeyAuthenticationHandler + la policy AppReadAccess, déjà utilisée par byPin (:83) et export (:383) du même contrôleur.
Ce que ça expose : le contenu éditorial de n'importe quelle instance, à qui connaît un instanceId — lequel se lit en clair dans un APK de flavor, ou se devine à partir d'un export. Pas de données personnelles, mais tout le travail éditorial d'un client.
⚠️ À vérifier avant de conclure : le périmètre exact. J'ai testé /api/Configuration ; il faut passer en revue les autres routes que consomment les apps visiteur (/api/Section/configuration/{id}, /api/Resource/{id}, /api/SectionMap, /api/SectionQuiz) avant de savoir si le trou est ponctuel ou général.
C'est le problème symétrique du 403 sur GET /api/Instance/{id}, corrigé le 08/09 (voir la carte close) : la même app est trop bloquée sur une route et pas bloquée du tout sur les autres. Les deux se traitent ensemble, en décidant ce que X-Api-Key est censé garder.
GET /api/Instance/{id} renvoyait 403 à mymuseum-visitapp, qui l'appelle au démarrage pour la voix du guide : tout InstanceController porte [Authorize(SuperAdmin)] et GetDetail n'avait pas d'exception, contrairement à slug, byPin et app-key. Une clé API donne désormais accès à sa seule instance — clé croisée = 403 — et à une vue réduite : StripCommercialFields retire plan, quotas, usage IA, essai, TVA, facturation et le pinCode, qui ouvre l'appairage des tablettes. Le manager continue de tout voir.AuthorizationMiddleware authentifie avec les schémas de la policy du contrôleur — JwtBearer et ApiKey — et peuple HttpContext.User avant de court-circuiter sur [AllowAnonymous]. Une clé API produit donc un User authentifié auquel le handler pose le claim Viewer : la première version laissait passer tout le monde. Elle ne se voyait pas, parce que les champs sensibles de l'instance testée étaient naturellement nuls — c'est le test croisé sur une instance avec un pinCode qui l'a révélée. Le test porte maintenant sur le schéma d'authentification.StripeCustomerId et StripeSubscriptionId. C'est faux — ils sont sur l'entité mais pas exposés par ToDTO. Ce qui fuyait vraiment : plan, quotas, TVA, facturation, pinCode.version-3.1.3.