diff --git a/kanban.html b/kanban.html index ceb287f..cbc1085 100644 --- a/kanban.html +++ b/kanban.html @@ -458,13 +458,13 @@
-
1Urgent
+
2Urgent
1Migration v3
2Bugs ouverts
-
10À tester
+
11À tester
36Planifié
-
8Bascule prod
-
60Fait récemment
+
9Bascule prod
+
62Fait récemment
@@ -490,7 +490,7 @@
-

Urgent

1
+

Urgent

2
@@ -500,6 +500,19 @@ todo-features.md — Sécurité réseau
+
+
manager-servicesécuritéLe contenu de n'importe quelle instance est lisible par qui connaît son identifiant
+

L'API publique répond sans clé API

+

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.

+ conversation 08/09 — vérification de la préprod depuis internet +
+
@@ -547,7 +560,7 @@
-

À tester

10
+

À tester

11
@@ -634,6 +647,19 @@ v1-mediatheque-plan.md — les 8 étapes sont codées
+
+
mymuseum-visitappUIhors ligneJamais vu tourner sur device
+

Home v3 — téléchargement hors ligne porté, et mort de l'ancienne home

+

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.

+ conversation du 2026-09-08 — captures du Fort de Saint-Héribert +
+
@@ -693,20 +719,25 @@

Carte hors ligne — tile packs Mapbox

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 TileRegionvé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.

analyse du code le 2026-09-04 — MapProvider, flutter_map, mapbox_maps_flutter
-
cartoofflinemanager-apphors ligne par construction, et c'est ce qu'un musée dessine déjà
-

Plan illustré géoréférencé — un 3e fournisseur de carte

-

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.

- analyse du code le 2026-09-04 — MapProvider n'a que Google et MapBox +
cartoofflinemanager-apple seul chemin vers une carte hors ligne sans coût tiers
+

Plan illustré — un 3e fournisseur de carte, sans tuiles

+

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-04
@@ -1017,7 +1048,7 @@
-

Bascule prod

8
+

Bascule prod

9
@@ -1047,12 +1078,23 @@
-
étape 18Filet du jour J
+
étape 18scripts + destination prêts, attend la prod Postgres

pg_dump avant / après + cron quotidien

-

Noté 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.

STATUS.md §4 · §1quinquies — étape 18
+
+
étape 18une alerte que personne ne lit n'est pas une alerte
+

Brancher notify.sh sur un canal réel

+

Trois 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.

+ conversation 07/09 — chantier sauvegardes +
+
étape 19

Rejouer pour de vrai, instance par instance

@@ -1124,6 +1166,12 @@ L9 était un malentendu de doc, pas du travail. La « cible Assistant / Persona » du Mode preview est exactement ce que le §5 du Guide IA a livré le 11/08. Les trois autres cibles (iframes mobile/web/kiosk, viewports, preview-token) sont hors V1. ⚠️ Mais l'aperçu polluait les stats du client : chaque essai de personnalité par le gestionnaire était journalisé comme une question de visiteur. Corrigé — et le drapeau d'hier, IsAutoTriggered, est renommé IsVisitorQuestion (défaut true) parce que son vrai sens couvrait déjà les deux cas. dotnet test 218.
+
+ Trancher la destination des sauvegardes hors site +

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 + objectViewerincapable 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.

+
+
Une conversation, plusieurs surfaces — miroir vocal et 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.
+
+ L'app visiteur lit le détail d'instance, en vue réduite + Corrigé le 08/09. 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.

Le piège, qui vaut pour tout le projet : le test « est-ce un utilisateur du manager » ne peut PAS se baser sur un claim de permission. 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.

Rectification : la carte annonçait une fuite de 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.

Vérifié sur la préprod, cinq cas : 401 sans clé, 200 sur sa propre instance, 403 en croisé dans les deux sens, 200 pour le manager avec le DTO complet. Déployé en version-3.1.3.
+
+
Le client généré nettoyé — et la règle « pas de régénération » enfin verrouillée Le compte exact : 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.

diff --git a/kanban/done/300-l-app-visiteur-lit-le-detail-d-instance-en-vue.md b/kanban/done/300-l-app-visiteur-lit-le-detail-d-instance-en-vue.md new file mode 100644 index 0000000..39d50d4 --- /dev/null +++ b/kanban/done/300-l-app-visiteur-lit-le-detail-d-instance-en-vue.md @@ -0,0 +1,4 @@ +--- +title: L'app visiteur lit le détail d'instance, en vue réduite +--- +Corrigé le 08/09. 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.

Le piège, qui vaut pour tout le projet : le test « est-ce un utilisateur du manager » ne peut PAS se baser sur un claim de permission. 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.

Rectification : la carte annonçait une fuite de 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.

Vérifié sur la préprod, cinq cas : 401 sans clé, 200 sur sa propre instance, 403 en croisé dans les deux sens, 200 pour le manager avec le DTO complet. Déployé en version-3.1.3.