Files
bruno 7468e497c6
FlowDeck CI / lint (push) Successful in 1m34s
FlowDeck CI / test (push) Failing after 15m40s
FlowDeck CI / docker (push) Skipped
fix: le bouton close de Settings ferme en un clic (v7.69.4)
Le bouton close de Settings devait être cliqué deux fois. Symptomatique depuis
le menu utilisateur ; depuis /workspaces ça marchait.

Cause : closeSettings() faisait history.back() PUIS window.location.reload()
après un délai FIXE de 100 ms. Le retour est une navigation asynchrone : quand
elle n'est pas terminée en 100 ms, le reload() retombe sur /settings et le
panneau « se ferme puis revient ». La profondeur d'historique diffère selon le
parcours — d'où le caractère intermittent et le fait que /workspaces
échappait au symptôme.

Correctif : location.replace() vers la page d'origine. Une seule navigation
déterministe — ni history.back(), ni popstate, ni rechargement différé. Aucun
chemin ne peut plus se terminer sans navigation. Effet secondaire assumé et
souhaitable : « Retour » ne rouvre plus Settings.

La branche window.opener ne fait plus de return inconditionnel : window.close()
échoue en silence quand l'onglet n'a pas été ouvert par du script, et l'ancien
code abandonnait alors sans navigation de repli — le bouton semblait mort.

CONTRÔLE NÉGATIF FAIT — le même spec a été exécuté contre deux builds :
  ancien closeSettings() -> le parcours menu-utilisateur ÉCHOUE
                            (Received: "…/settings"), /workspaces passe ;
  correctif              -> les deux passent.
Le test verrouille aussi que le panneau position:fixed n'est plus peint
(.settings-overlay absent), qui est le symptôme exact rapporté.

Correction de diagnostic : le diagnostic posé en v7.69.3 (« Alpine ne lie pas
le composant x-data="settingsInit()" ») était FAUX — un artefact de mon banc
d'essai target="_blank", pas le parcours réel. La page étant réactive après le
premier clic, le handler s'exécutait bien. ROADMAP.md et CHANGELOG.md § v7.69.3
corrigés pour ne pas laisser une piste fausse en héritage.

pytest 1421 passed / 0 failed · ruff OK · OpenAPI 526 chemins / 7.69.4
e2e 18/18 verts sur l'instance déployée
2026-10-08 22:02:08 -04:00

201 KiB
Raw Permalink Blame History

Roadmap FlowDeck — Notion Clone

Vision: Copie conforme de l'UI/UX Notion, intégrée à Gitea/GitHub. MVP en production : v4.0.0 — authentification locale/OAuth, pages blocs, sidebar, library, share/publish, intégrations. Branche principale : main (production) · develop (intégration) · feat/* (features).

🏗️ Stratégie de branches

main       ──●────────────●────── production (tags vX.Y.Z)
              \          /
develop       ●──●──●──●────── intégration continue
                  \  \  \
feat/xxx          ●  ●  ●──── feature branches
  • main : code en production, déploiement Docker automatique
  • develop : branche d'intégration, merges des feat/*
  • feat/<nom> : une branche par feature/fix, PR → code review → merge dans develop
  • Tags vX.Y.Z sur main pour chaque release

Workflow :

git checkout -b feat/mon-truc develop
# ... coder, commit, push ...
# Créer une PR feat/mon-truc → develop sur Gitea
# Après review + CI verte → merge
# Pour release: PR develop → main + tag vX.Y.Z

Règles :

  • Ne jamais commiter directement sur main
  • Une branche = une feature / un fix
  • Tests passent avant merge (CI Gitea Actions)
  • Commit + push après chaque modification significative

Completed

v0.2.0 ✅ — Base

Dashboard + Board Kanban + Gitea sync + Notes + Docker

v0.3.0 ✅ — Auth

OAuth2 Gitea, token par utilisateur, CSRF, rate limiting, webhooks

v0.4.0 ✅ — UI Notion

Layout 3 zones (sidebar 240px + topbar 44px), dark mode, CSS 31KB, Alpine.js

v0.5.0 ✅ — Kanban

Colonnes, groupes, cartes enrichies, drag&drop, modale détail, checklists, commentaires

v0.6.0 ✅ — Table View

6 colonnes triables, groupes rétractables

v0.7.0 ✅ — Filtres & Tri

Filtres cumulables AND, status dropdown, sort multi-critères

v0.8.0 ✅ — Vues Spéciales

Status Overview (donut SVG), Team Load (barres), Detailed Board

v0.9.0 ✅ — Backend

Propriétés custom, AI keywords, sync API, 12 tables DB

v1.0.0 ✅ — Production

  • FastAPI lifespan (deprecation fix)
  • 28 tests — 5 vues, filtres, tris, APIs, CSRF, CORS, DB
  • CI/CD Gitea Actions (.gitea/workflows/ci.yml)
  • CHANGELOG.md, ARCHITECTURE.md à jour
  • Version 1.0.0 partout (main.py, api.py, health)
  • Tag git v1.0.0

v1.1.0 ✅ — Pages & Sidebar

  • Pages Markdown avec arbre imbriqué (parent_id, sort_order)
  • Block editor mode (content_format='blocks', JSON storage)
  • Soft delete → Trash → Restore → Permanent delete
  • Drag & drop réorganisation pages
  • Sidebar Notion-style avec sections Recents/Private/Shared
  • "+ New page" footer button
  • Workspace header avec avatar + chevron toggle

v1.2.0 ✅ — Éditeur Notion

  • Slash menu (/) 15 commandes fonctionnelles
  • Navigation clavier (flèches, Enter, Escape)
  • Placeholders Notion-style (Heading 1/2/3/4, Toggle, Quote, Code)
  • Suppression outline blanc sur les blocs
  • Heading 4 support
  • Breadcrumbs contextuels (workspace > projet > page)

v1.3.0 ✅ — Database Concept

  • Tables collections, collection_pages, collection_views
  • Rétrocompatibilité Gitea via GiteaBoardCompat
  • Router /db — API CRUD collections + pages (12 endpoints)
  • 34/34 tests passent

v1.4.0 ✅ — Propriétés Avancées

  • Table collection_properties — 21 types Notion
  • Service property_types.py — validation, formatage, auto-values
  • 38/38 tests passent

v1.5.0 ✅ — Relations & Rollups

  • Type relation — lien bidirectionnel entre collections
  • Type rollup — aggregation via relation (COUNT, SUM, AVG, etc.)
  • Type formula — moteur d'expressions (19 fonctions)
  • Formula engine + Rollup engine
  • 43/43 tests passent

v1.6.0 ✅ — Vues Manquantes

  • Calendar, Gallery, List, Timeline, Table views
  • View tabs navigation
  • 49/49 tests passent

v1.7.0 ✅ — Vues Améliorées

v1.8.0 ✅ — Sub-items & Dépendances

  • View config API, Save view as
  • Sub-items (parent_id auto-référence)
  • Status aggregate, Dependencies (blocking constraint)
  • 56/56 tests passent

v1.9.0 ✅ — My Tasks & Dashboard Unifié

  • Vue /my-tasks — agrège pages assignées à l'utilisateur
  • Groupement par collection, filtres globaux
  • 60/60 tests passent

v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs

  • Workspaces, Comments, Page History, Favorites
  • Database Templates, Page Templates, CSV Import/Export
  • Public Sharing
  • 67/67 tests passent

v2.1.0 ✅ — Workspace Local & Arborescence

v2.2.0 ✅ — Tags & Recherche Avancée

v2.3.0 ✅ — Multi-Vues Workspace & Preview Panel

v2.4.0 ✅ — Tags Personnalisés par Utilisateur

v2.5.0 ✅ — Administration Avancée

v2.6.0 ✅ — My Account & Auth Locale

v2.7.0 ✅ — Intégration Gitea Phase 1

v2.7.1 ✅ — Private Pages Gitea

v2.8.0 ✅ — Gitea Phase 2

  • Créer fichiers dans le repo (New file UI + API PUT)
  • Upload fichiers binaires (POST /upload, drag-drop)
  • Syntax highlighting via Prism.js CDN
  • Labels Gitea → tags FlowDeck (POST /sync-labels, 16 labels importés)
  • Historique commits par fichier (GET /commits?path=)

v2.9.0 ✅ — GitHub OAuth Provider

  • GitHubProvider dans app/auth/providers.py
  • GitHubAdapter dans app/services/github_adapter.py
  • Config GitHub dans .env.example

v3.0.0 ✅ — UX Professionnelle

  • Multi-sélection (checkboxes + Shift/Ctrl-click dans le sidebar)
  • Raccourcis clavier (Ctrl+N, F2, Delete, Escape, Ctrl+S)
  • Inline rename (double-clic → champ inline)
  • Loading skeletons (animations pulse dans workspaces + file viewer)
  • Grid/card view (mode grille/vignettes)
  • Table sorting (tri par colonnes)
  • Sélecteur de vue: Tree, List, Details, Title, Content (5 modes)
  • Preview Panel latéral (contenu, métadonnées, tags, actions)
  • Tri par date (Oldest/Newest)

v4.0.0 ✅ — Accounts, Integrations & Sharing (MVP)

  • auth_method sur users (local/gitea/github)
  • Badge OAuth dans sidebar
  • Settings page avec Connect/Disconnect Gitea + GitHub
  • Sidebar rules (Private = distant seulement, Trash local-only)
  • Library page avec tabs + filtres source (All/Local/Gitea/GitHub)
  • Modale Share (2 tabs: Share + Publish)
  • Page publique /p/<slug> (no auth, rendu HTML)
  • Table page_shares, colonnes is_published, is_shared, share_mode
  • Table recents — tracking automatique
  • Édition unifiée local/distant (même UI, bouton Commit pour Gitea)
  • 133 tests passent

v7.46.0 — Sidebar : onglets compressibles Home / Chat / Meeting / Inbox ✅ (2026-10-04)

Objectif : remplacer la rangée d'icônes par une navigation par onglets (Home, Chat, Meeting, Inbox dans cet ordre) où l'onglet actif est une pilule étiquetée, les autres sont réduits à leur icône, et chaque sélection remplace le contenu de la barre latérale. COMPLETED.

  • Onglets compressibles — sidebar-nav-row : 4 onglets dans l'ordre Home → Chat → Meeting → Inbox, libellé visible uniquement sur l'onglet actif (pilule), role="tab" + aria-selected, onglet mémorisé (fd_sidebar_tab, valeur validée)
  • Panneaux contextuels — 4 panneaux Alpine (sidebar-tab-panel) : Home (arbre + sections + libraries), Chat, Meeting, Inbox ; un seul visible à la fois, footer commun conservé
  • Onglet Chat branché sur l'historique réel — raccourcis circulaires d'agents + conversations groupées Past 30 days / Older ; clic → window.fdAgent.openConversation(id) ouvre le panneau agent sur la conversation
  • Onglet Meeting branché sur le calendrier réel — nouveau GET /api/v2/meetings/upcoming (collections calendaires liées, propriété date, horizon paramétrable et clampé), filtres Upcoming / Today, actions Transcribe a podcast et New AI meeting note
  • Onglet Inbox branché sur les notifications réelles — All / Unread, marquage lu au clic, Mark all read, état Inbox Zero, badge non lus- [x] Verrouillé — tests/test_sidebar_tabs.py (15 tests) + e2e/sidebar_tabs.spec.js (3 scénarios, mesure de largeur de la pilule, clic Home n'affiche que le libellé Home, CSS périmée en cache) ; suite complète 1182 tests verts

v7.46.0 — Audit de fonctionnement : 10 anomalies corrigées ✅ (2026-10-04)

Objectif : passer l'instance au crible (routes, sécurité, navigation, schémas) et supprimer les anomalies constatées. COMPLETED.

  • Audited : 677 routes, 280 endpoints GET sondés anonymement, 64 templates/JS croisés avec les routes enregistrées, crawl Playwright authentifié (12 pages), intégrité SQLite. 10 anomalies, toutes corrigées.
  • Écritures anonymes closes — POST/DELETE /api/workspaces, PUT /api/workspaces/{id}, select et local-workspace/items répondaient 200 sans session (repli uid = … else 1) : un anonyme pouvait renommer ou supprimer un espace. → _require_user_id() + _require_ws_owner()
  • Router /workspace authentifié — export/csv, historique, commentaires, sprints, dashboards et templates servaient des données réelles en anonyme (500 inclus). → Depends(_require_session) sur le router, la page de partage public bascule dans un public_router sans dépendance
  • password_hash retiré de /auth/user et du cookie de session (signé mais non chiffré) → app.auth.session.public_user()
  • OAuth Gitea — placeholders test-id/test-secret traités comme « provider non configuré » (avant : redirection Gitea avec un client_id invalide) ; GITEA_OAUTH_CLIENT_ID/SECRET documentés dans .env.example
  • Bouton Home — pointait sur local_workspaces[0] (1er espace trié par nom) au lieu de l'espace actif : le clic pouvait changer d'espace sans annonce. → local_ws_id exposé par les 2 sidebars (gitea_mirror_ws_id séparé) et prioritaire dans base.html
  • /api/v2/agents/conversations — 422 car masqué par /agents/{agent_id} → {agent_id:int}
  • Sélecteur de blocs synchronisés — /api/synced-blocks (404) → /board/api/synced-blocks, message d'aide corrigé
  • /workspace/favorites — 500 permanent (no such column: f.collection_id, dérive de la migration v2.2.0) → schéma actuel + propriété du favori
  • Synced blocks — 500 anonyme (NoneType.get) + absence de contrôle de propriété sur PUT/DELETE → 401 / 403 (auteur ou admin)
  • Jinja non silencieux — LoggingUndefined : rendu identique, mais un WARNING journalise toute variable absente du contexte (c'est ce silence qui avait masqué le bug du bouton Home)
  • Tests — tests/test_anomalies_v746.py : 34 tests (fuites, écriture anonyme, propriété, ordre de routes, schéma, navigation, logs). Suite complète : 1167 tests verts, ruff check app/ tests/ propre

v7.46.0 — Web tools de l'agent & 11 nouveaux skills ✅ (2026-10-04)

Objectif : doter l'agent d'un accès au web (recherche en ligne, lecture de page, recherche GitHub) et élargir la galerie de skills. COMPLETED.

  • 3 nouveaux tools agent — app/services/tool_registry.py (25 tools, +3) :
    • web_search — recherche web sourcée (titre + URL + extrait), provider piloté par WEB_SEARCH_PROVIDER
    • fetch_url — télécharge une page et la rend en Markdown (titres, listes, code, tableaux) pour que l'IA puisse la citer
    • search_code — GitHub repositories | code | issues, sans PAT (10 req/min) ou avec GITHUB_TOKEN (30 req/min)
  • Service réseau — app/services/web_search.py (nouveau) : Exa (recherche sémantique + extraits) avec repli DuckDuckGo sans compte, client partagé A42, aucune dépendance ajoutée, couche transport injectable pour les tests
  • Garde-fou SSRF réutilisé — fetch_url s'appuie sur importers.url_fetch._validate_url / _is_public_host : schémas http(s) uniquement, résolution DNS puis refus des IP loopback / privées / lien-local / réservées, re-validation à chaque saut de redirection (une URL publique ne peut pas rediriger vers 169.254.169.254)
  • Config — WEB_SEARCH_PROVIDER, EXA_API_KEY, GITHUB_TOKEN, WEB_FETCH_MAX_CHARS (app/config.py + .env.example)
  • Galerie : 6 → 17 presets — app/services/skill_gallery.py :
    • vague 1 (locaux) : veille-documentaire, chasse-decisions, onboarding-nouveau, audit-base, sprint-review
    • vague 2 (web) : recherche-marche, veille-techno, debug-web — chaque affirmation doit porter sa source [titre — URL], sinon « non vérifié »
    • vague 3 (pouvoir) : triage-incident, nettoyage-base (aperçu avant toute écriture), briefing-quotidien
  • UI — libellés d'action du journal agent pour les 3 tools (Recherche web effectuée, Page web lue, Recherche GitHub effectuée)
  • Tests — tests/test_agent_web_tools.py : 29 tests, réseau et DNS jamais touchés (httpx.MockTransport + résolution neutralisée) : registre + schémas + périmètre, cohérence galerie/registre, export→import des presets web, provider + repli + filtrage des URLs anti-bot, markdown + troncature + 6 vecteurs SSRF refusés (localhost, 127.0.0.1, metadata cloud, IPv6 loopback, file:, ftp:) + redirection vers metadata cloud + redirection publique suivie, GitHub repos/issues/quota. Suite complète : 1128 tests verts.

v7.51.0 → v7.58.0 — Menu + de l'assistant : hub de contexte (phases 1-8 ✅ 2026-10-06 — menu + complet)

Objectif : faire du bouton + du panneau agent (à gauche de la zone d'édition) un menu à sections, tel que demandé :

+ ├─ Ajouter des fichiers ou répertoires … → picker actuel + parcours de l'arborescence
   ├─ Compétences-skills … … … … … … … … → Deep research, Skill-creator, … + Gérer + Parcourir
   ├─ Connecteurs … … … … … … … … … … … → Parcourir + Ajouter un connecteur personnalisé
   ├─ Design System – Canevas … … … … … → galerie de canevas (templates de page)
   ├─ Add plugins … … … … … … … … … … … → catalogue de modules on/off
   └─ Mémoire (on/off) … … … … … … … … → mémoire persistante de l'agent

Doc de design : docs/V74_Agent_Plus_Menu.md — maquette, découpage front/back, modèle de données, endpoints, décisions, pièges.

État des lieux lu dans le code le 2026-10-06 (constat avant la phase 1) :

  • Le + = toggleAddPicker() (static/js/agent_panel_2.js:1074) → menu plat de mentions (GET /api/agent/mentions, app/routers/agent.py:596) : documents, collections, pages. Pas de dossiers, pas de sous-menu, pas de section.
  • Compétences : 11 skills builtin (FD_SKILLS, static/js/agent_panel_2.js:11) + table agent_skills (CRUD /api/agent/skills) + galerie de 17 presets — mais aucune UI « Gérer » ni « Parcourir » : tout passe par la palette /.
  • Canevas : GET /api/page-templates (built-ins app/services/block_templates.py + personnels) et POST /api/page-templates/{id}/use existent déjà, jamais appelés depuis le panneau agent.
  • Connecteurs / Plugins / Mémoire : inexistants — 0 hit connector|plugin|memory dans app/ hors « in-memory ».

Ordre : phases 1 → 8, chacune indépendante et livrable (version + tests verts + CHANGELOG). Chiffrage : S < ½ j · M = 1–2 j · L = 3–5 j.

Phase 1 — v7.51.0 — Menu hub + fichiers/répertoires · effort S–M ✅ (livrée 2026-10-06)

  • Structure du menu — sections + sous-menus, navigation clavier (↑/↓/Entrée/Échap), styles réutilisés depuis .fd-ap-mention-menu ; les sections pas encore livrées s'affichent « bientôt » et désactivées (le menu ne promet rien que le code ne fait) → FD_PLUS_MENU / FD_PLUS_FILES (static/js/agent_panel_2.js), markup .fd-ap-plus-menu (agent_panel.html), Échap remonte d'un niveau, la frappe referme le menu
  • Fichiers & répertoires — picker actuel (recherche) + mode « Parcourir… » : arborescence un niveau par appel via GET /api/nav/menu?parent_id= (contrat : dossier = icon === 'folder' — plus simple que le tree récursif, déjà testé), fil d'Ariane cliquable ; un répertoire s'épingle comme un fichier (ligne « 📌 Épingler le dossier »)
  • Jeton folder:<id> résolu par ContextBuilder._mentions_context → _single_folder() : titre du dossier + documents directs, budget ~12k caractères (marqueur « tronqué ») ; enfants directs seulement (récursion si besoin réel)
  • Tests — tests/test_v751_plus_menu.py : 7 tests (résolution, budget, id inconnu, contrat /api/nav/menu, rendu du menu, 6 sections / 5 désactivées à la livraison de la phase 1, jeton folder: front+back) ; suite complète 1273 verts à la livraison, ruff 0, eslint 0 erreur
  • Livraison — VERSION + app/main.py = 7.51.0, CHANGELOG, ce ROADMAP

Phase 2 — v7.52.0 — Compétences : « Gérer » + « Parcourir » · effort M ✅ (livrée 2026-10-06)

  • Sous-menu Compétences — 11 builtin + skills enregistrés listés, le clic épingle le skill au contexte (même chemin que la palette /, comportement conservé)
  • Libellés affichés — alias « Deep research » (/research) et « Skill-creator » (/create-new-skill) via FD_SKILL_ALIAS, slugs intacts
  • « Gérer les compétences » — section du menu + formulaire intégré (nom, description, prompt) plutôt que modale : CRUD POST/PATCH/DELETE sur /api/agent/skills — PATCH /api/agent/skills/{id} créé (champs présents seuls, whitelist de colonnes, 400/401/404/409) + export JSON (/skills/{id}/export → téléchargement) et import fichier (/skills/import, overwrite:true → pas de doublon) ; suppression = nettoyage du chip épinglé
  • « Parcourir les compétences » — section galerie sur GET /api/agent/skills/gallery, filtre (nom/description/slug), installation en 1 clic (réutilise installGallerySkill)
  • En-tête ← + titre dans le menu (navigation à 3 niveaux)
  • Fix — épingler un skill enregistré n'efface plus le message tapé
  • Tests — tests/test_v752_skills_menu.py : 7 tests (PATCH complet + partiel, 400/404, 401 sans session, import sans doublon, export→import, câblage sections/alias, markup) ; test_v751 adapté (4 sections « bientôt ») ; suite complète 1280 verts, ruff 0, eslint 0 problème
  • Livraison — VERSION + app/main.py = 7.52.0, OpenAPI régénéré (515 chemins), CHANGELOG, ce ROADMAP

Phase 3 — v7.53.0 — Design System – Canevas · effort M ✅ (livrée 2026-10-06)

  • Sous-menu Canevas — GET /board/api/page-templates (built-ins + personnels) ; piège confirmé : le router est sous prefix="/board" → l'URL réelle est /board/api/page-templates, pas /api/page-templates (404 silencieux corrigé dans le front)
  • Deux actions (3 en réalité) — page de détail par canevas : « Créer une page à partir du canevas » (POST …/0/use avec {key} dans le body — la clé en query est ignorée par la route), « Insérer dans le document ouvert » (GET …/blocks nouvelle route → ensureBlockIds() global → concat dans E.blocks + autoSave/render, désactivé sans document ouvert) et « Enregistrer le document ouvert comme canevas » (POST + page_id)
  • Canevas « design system » — preset design_system dans block_templates.py (callout tokens, TOC, toggles composants, grille, checklist revue UI) ; design-tokens.css référencé dans le callout
  • Sauver le doc ouvert comme canevas — POST /board/api/page-templates (existante) branchée depuis le menu, nom via prompt() natif
  • Tests — tests/test_v753_canvases.py : 5 tests (preset listé, route blocks intégré/personnel + 404, création de page vérifiée en base avec ids, câblage 3 « bientôt » + garde-fou key-dans-le-body) ; test_v751 adapté ; suite complète 1285 verts, ruff 0, eslint 0 problème
  • Livraison — VERSION + app/main.py = 7.53.0, OpenAPI régénéré (516 chemins), CHANGELOG, ce ROADMAP

Phase 4 — v7.54.0 — Mémoire de l'agent · effort M ✅ (livrée 2026-10-06)

  • Table agent_memory — une ligne résumé par conversation (conversation_id PK, upsert) + colonne agent_conversations.memory_enabled (migration 31, défaut 1) ; la variante « mémoire d'espace » prévue au design est remise (YAGNI : rien ne l'écrit — ponytail: noté dans le service)
  • Service app/services/agent_memory.py — extraction déterministe (pas d'LLM) : 1 note par échange (objectif 180 car. → réponse 700 car.), 20 notes max, écrite en fin de run via agent_memory.remember() ; toute erreur est journalisée, un run ne peut pas échouer à cause de la mémoire
  • Injection — AgentEngine.run() appelle agent_memory.context_for() après ContextBuilder.build() : bloc « ## Mémoire de la conversation », budget 4 000 caractères (troncature par la gauche + marqueur) ; OFF = ni lecture ni écriture → contexte strict du run courant
  • Toggle « Mémoire » dans le menu + — entrée racine à l'état réel (🧠/💭 + libellé), PATCH /api/agent/conversations/{id} (memory_enabled ajouté au whitelist), état lu à l'ouverture et à la création, défaut AGENT_MEMORY_DEFAULT (config + réponse de création)
  • Tests — tests/test_v754_memory.py : 8 tests dont 2 runs réels du moteur (FakeLLM capture le prompt : mémoire présente ON / absente OFF), budget et nombre de notes bornés, toggle relu en base ; test_v751 + test_v753 adaptés (2 sections « bientôt ») ; suite complète 1293 verts, ruff 0, eslint 0 problème
  • Livraison — VERSION + app/main.py = 7.54.0, OpenAPI régénéré (516 chemins), CHANGELOG, ce ROADMAP

Phase 5 — v7.55.0 — Connecteurs : socle · effort L ✅ (livrée 2026-10-06)

  • Table agent_connectors (migration 32) — colonnes plates (name, url, secret_encrypted, enabled, status, detail) ; le config_json prévu est remis (YAGNI : rien ne le remplissait — les scopes OAuth des phases 6-7 ajouteront le leur)
  • app/services/connectors.py — 1 fichier au lieu du package connectors/ promis (3 probes + 1 fetch ne justifient pas une arborescence) ; 3 natifs gitea/github/web servis à la volée, statut sans réseau (native_state()) ; probe() et connector_fetch() passent par _get() (gardé SSRF + re-vérification après redirection, timeout 10 s, borne 20 000 car.)
  • Menu « Connecteurs » — « Parcourir » = catalogue avec badge (✓ ok / ✗ error / ⚠ missing / ? inconnu) + fiche (Tester, Activer/Désactiver, Supprimer — masqués pour les natifs) ; « Ajouter un connecteur personnalisé » = formulaire nom + URL + clé (type="password")
  • Tool agent — un seul connector_fetch (id/nom/kind + path + query) au lieu d'un outil par connecteur : même capacité, moins de bruit de schéma — l'outil dispatche vers Gitea/GitHub/web/personnalisé. Connecteur désactivé ou inconnu = erreur outil, jamais une exception de run
  • Sécurité — _validate_url (SSRF) à la création et à chaque appel, clé chiffrée Fernet et jamais renvoyée (has_secret seulement), 401 sans session sur les 5 routes, CSRF global
  • Tests — tests/test_v755_connectors.py : 13 tests (3 natifs, clé chiffrée roundtrip, 5 URLs refusées : localhost / 127.0.0.1 / metadata cloud / ftp: / file:, toggle persisté, 401, probe OK + probe erreur persistée, outil complet, câblage menu) ; 3 autres fichiers de phase adaptés (1 section « bientôt » restante) ; suite complète 1306 verts, ruff 0, eslint 0
  • Livraison — VERSION + app/main.py = 7.55.0, OpenAPI régénéré (519 chemins), CHANGELOG, ce ROADMAP

Phase 6 — v7.56.0 — Connecteurs : Google + Microsoft 365 · effort L ✅ (livrée 2026-10-06)

  • OAuth2 PKCE Google — Drive / Gmail / Calendar en lecture seule ; connexion (begin() → URL + state + code_verifier S256), échange du code, refresh automatique (60 s de marge, refresh_token conservé si absent de la réponse), déconnexion — table connector_tokens (migration 33, PK (kind, user_id)), tokens chiffrés Fernet réutilisant calendar_sync._encrypt_tokens (zéro dépendance ajoutée)
  • OAuth2 Microsoft (Graph) — Files.Read / Mail.Read / Calendars.Read
    • offline_access, même code (2 providers décrits par 1 dict)
  • Écran connecteur — pas de page dédiée : l'état vit dans la fiche du menu + (badge « connecté · N scope(s) » / « non connecté »), boutons Se connecter / Déconnecter, toast au retour du flux (?oauth=connected / ?oauth_error= nettoyés par history.replaceState)
  • Tests — tests/test_v756_oauth_connectors.py : 13 tests, 0 appel réseau réel (_post_form / _api_get monkeypatchés) — URL d'autorisation (PKCE, scopes, redirect URI, cookies), 400 non configuré, callback → tokens chiffrés + redirection, state forgé refusé sans échange, callback sans session, refresh, api_get (Bearer, URL absolue refusée), déconnexion, catalogue, outil LLM, 401/404, câblage menu ; test_v755 adapté (5 natifs) ; suite complète 1319 verts, ruff 0, eslint 0
  • Livraison — VERSION + app/main.py = 7.56.0, .env.example (GOOGLE_/MS_ CLIENT_ID/SECRET + redirect URI), OpenAPI régénéré (523 chemins), CHANGELOG, ce ROADMAP

Phase 7 — v7.57.0 — Connecteurs : Discord, Telegram, Teams, MCP · effort L ✅

  • Presets Discord / Telegram — champ Type dans le formulaire « Ajouter un connecteur » : kind + auth (bearer/bot/none) ajoutés à agent_connectors (migration 34) ; Discord = en-tête d'autorisation avec le jeton préfixé, Telegram = jeton dans l'URL via le substitut {secret} (remplacé à l'appel, jamais stocké en clair — colonne url = …/bot{secret})
  • Teams — scope ChannelMessage.Read.All ajouté aux Graph scopes M365 (consentement admin)
  • MCP complet — app/services/mcp_client.py : initialize → notifications/initialized → tools/list → tools/call (JSON-RPC 2.0, réponse JSON ou 1er data: d'un text/event-stream), garde SSRF, outils cachés en base (tools_json, maj par le bouton « Tester »)
  • Outils dynamiques au registre — ToolRegistry._all() merge le cache MCP à chaque run : nom mcp_<serveur>_<outil> + inputSchema exposés au LLM, execute() dispatche sur tools/call ; serveur désactivé → outil absent du schéma ; erreur RPC → ToolResult(error) (jamais de run perdu)
  • Tests — tests/test_v757_mcp_discord.py : 12 tests, 0 appel réseau (_rpc / _get monkeypatchés) — presets, {secret} absent de la base, kind/auth invalides → 400, handshake + séquence d'appels, cache tools_json, dispatch LLM, serveur désactivé, erreur handshake → probe error, parse SSE/erreurs JSON-RPC, slug, scopes Teams, câblage menu
  • Livraison — VERSION + app/main.py = 7.57.0, OpenAPI régénéré, CHANGELOG, ce ROADMAP

Phase 8 — v7.58.0 — Add plugins · effort M ✅

  • Registre plugins (slug, name, description, enabled) — migration 35, table pré-remplie avec les 3 modules ; ligne absente = activé (défaut sûr)
  • Catalogue dans le menu + — l'entrée « Add plugins » devient vivante (fini le disabled:true) : liste des 3 plugins + bascule via GET/PATCH /api/agent/plugins[/slug]
  • Bascule on/off à effet réel :
    • automations → toutes les routes /workspace/automations* refusées (dépendance FastAPI posée à l'include_router, aucun router touché) + scheduler de fond en veille (garde par tick) ;
    • web-clipper → GET /extensions + /api/v2/web-clipper/* refusés ;
    • web-tools → web_search / fetch_url retirés du schéma et de execute() (ToolRegistry._all()) ;
    • UI → nav « Extensions » / « Automations » rendues sous {% if plugin_enabled(...) %} (global Jinja, élément absent du DOM), sections conditionnées en x-show
  • Tests — tests/test_v758_plugins.py : 10 tests — plugin OFF = route refusée (302 hors /api, 404 JSON pour /api*) + UI absente + outil retiré, persistance, 401 sans session
  • Livraison — VERSION + app/main.py = 7.58.0, OpenAPI régénéré (525 chemins), CHANGELOG, ce ROADMAP

Plan produit le 2026-10-06 à partir du code réel — phases 1 à 8 livrées le 2026-10-06 (v7.51.0 → v7.58.0) : le menu + est complet (aucune section « bientôt »). Toute extension ultérieure = nouvelle phase à valider. Chaque phase se clôt par tests verts, bump de version, CHANGELOG et ROADMAP à jour.


🔜 Prochaines versions

v4.0.1 — Onboarding & Polish ✅ (2026-07-18)

Objectif : rendre l'app utilisable dès la première visite, sans friction. COMPLETED.

  • Landing page / pour visiteurs non-auth — template landing.html, design Notion dark
  • Page /auth/register GET — formulaire d'inscription dédié (tab register actif)
  • Redirection / intelligente — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
  • Empty states — déjà présents dans local workspace (« Welcome to your Workspace »)
  • Page 404 stylisée — thème Notion sombre, JSON pour /api/*, HTML pour le reste
  • Unifier les routes API — /api/pages GET/POST → 307 redirect vers /board/api/pages
  • Page Settings — lien déjà présent dans le menu utilisateur (Settings → /accounts/settings)

v4.0.2 — Qualité & Robustesse ✅ (2026-07-18)

Objectif : zéro crash, zéro regression, DX impeccable.

  • Gestion d'erreurs — try/catch dans create_page, message user-friendly (500 JSON)
  • Validation inputs — login/register déjà validés, titre vide → "Untitled"
  • Rate limiting — 100 req/min/IP déjà en place, testé OK
  • Session expiry UX — redirect avec ?expired=1, bannière sur la page login
  • CSRF token refresh — auto-refresh sur 403, endpoint /api/csrf-token
  • Mobile responsive — sidebar slide-in, modales centrées, landing adaptative
  • Tests de non-régression — +10 tests (landing, register, 404, validation, duplicate, expiry)
  • 143 tests passent

v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)

Objectif : Collections multi-sources, databases liées (linked). COMPLETED. Doc : NOTION_DATABASE_TASKS_GUIDE.md Phase 1

  • Table collection_data_sources — multi-sources par collection (FK collection_id + source_collection_id, flag is_linked)
  • API sources — GET /db/{id}/sources/api (liste), POST /db/{id}/sources/api (ajouter), DELETE /db/{id}/sources/{sid}/api (retirer)
  • API linked DB — POST /db/{id}/linked/api (crée une collection liée : copie vues + propriétés + data source is_linked=1)
  • Full-page vs Inline — POST /db/{id}/toggle-inline/api (toggle), POST /db/inline/api (créer inline avec parent_page_id)
  • Règle permissions — linked DB hérite du workspace_id de la source → mêmes ACLs
  • Migration — ajout colonnes workspace_id, created_by sur collections
  • 153 tests passent (+10 nouveaux)

v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)

Objectif : Templates réutilisables + dashboards combinant vues/widgets. COMPLETED.

  • Table page_templates enrichie — colonnes description, is_recurring, recurrence_rule (daily/weekly/monthly/yearly)
  • Table collection_dashboards — layout widgets (grille configurable : columns, widgets avec view_id, x, y, width, height)
  • API templates CRUD complet — PUT /workspace/collections/{id}/templates/page/{tid} (update), DELETE (delete)
  • API dashboards CRUD — GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards
  • 159 tests passent (+6 nouveaux)

v4.3.0 — Database Views complètes (10 types) ✅ (2026-07-21)

Objectif : Tous les types de vues Notion avec layouts. COMPLETED.

  • Chart — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
  • Form — formulaire HTML auto-généré depuis les propriétés → POST création page
  • Map — Leaflet/OSM pour propriété Place (lat,lng parsing)
  • Feed — vue chronologique type fil d'actualité (newest first)
  • Timeline/Gantt — barres horizontales avec group_by et plages de dates
  • View tabs — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
  • Layout options — card_size, chart_type, group_by, place_property via config_json
  • 166 tests passent (+7 nouveaux)

v4.4.0 — Tasks, Sub-items & Dependencies ✅ (2026-07-21)

Objectif : Système complet de tâches Notion-style. COMPLETED.

  • Flag is_task sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
  • Table page_dependencies — bloque/bloqué par/related avec auto_shift
  • API dependencies — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
  • Auto-shift dates — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
  • 171 tests passent (+5 nouveaux)

v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)

Objectif : Sprints agiles + vue My Tasks cross-databases. COMPLETED.

  • Tables sprints, sprint_pages — sprints agiles avec velocity_points
  • API sprints — CRUD + assignation/retrait pages
  • Burndown chart — GET /workspace/collections/{id}/sprints/burndown/{sid} (total/completed/remaining/ideal)
  • My Tasks — agrégation cross-databases avec filtre (all/today/overdue/upcoming)
  • 175 tests passent (+4 nouveaux)

v4.6.0 — Content Blocks enrichis ✅ (2026-09-02)

Objectif : parité d'édition avec Notion.

  • Callout blocks — boîtes colorées avec sélecteur d'emoji/icône
  • Table of contents — auto-généré depuis les headings avec ancres
  • Math equations — LaTeX / KaTeX (self-hosted) block
  • Toggle lists — contenu expandable/collapsible avec enfants
  • Multi-colonnes — layout flexible (2, 3 colonnes) + bouton ajouter/retirer colonne

Détails livrés :

  • 5 types de blocs enrichis dans le slash menu (callout, table_of_contents, math, columns, toggle)
  • Rendu des nouveaux blocs dans les pages publiques (/p/<slug>) — TOC ancré, KaTeX, colonnes, toggle <details>
  • Intégration KaTeX 0.16.11 self-hosté (/static/js/katex.min.js, /static/css/katex.min.css, /static/fonts/)
  • Persistance children (colonnes/toggle) via le format de blocs JSON
  • Export Markdown étendu aux nouveaux blocs
  • 183 tests au total (dont 5 nouveaux tests v4.6.0)

v4.7.0 — Export ✅ (2026-09-03)

Objectif : exporter une page (ou un site) depuis l'éditeur.

  • Export Markdown — avec images et sous-pages (récursif)
  • Export PDF — via xhtml2pdf (fidèle, sans CDN/lib système)
  • Export HTML — site statique standalone (document autonome + site .zip)

Détails livrés :

  • Service serveur app/services/export.py : conversion blocs → Markdown / HTML / PDF
  • 4 formats exposés : /api/export/markdown|html|pdf|site/{page_id}
  • Sous-pages incluses récursivement (Markdown et site)
  • UI : menu « More › Export » dans l'éditeur (Markdown, HTML, PDF, Site .zip)
  • PDF généré via xhtml2pdf (pip) — aucun lib système requise
  • 6 nouveaux tests (Markdown, sous-pages, HTML, PDF, Site, 404)

v4.7.1 — Fix UI Export / Share / More ✅ (2026-09-03)

  • Share / More / Activity / Move-to popovers — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
  • Sous-menu Export — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
  • Cache CSS — query string app.css bumpé v4.7.1
  • Vérifié Playwright headless + 190/190 tests

v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)

  • Export MD/HTML/PDF des documents — le service ne lisait que les pages blocks ; les pages file (upload .md/code, contenu sur disque) et markdown sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
  • 5 nouveaux tests → 195/195 ; vérifié sur les vraies données

v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)

  • Tableaux — parseur GFM (bloc table) + rendu <table> HTML (thead/tbody, alignements, bordures .ftable) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
  • Émojis PDF — passage du moteur à WeasyPrint (émojis couleur via Pango + fonts-noto-color-emoji dans l'image) ; repli automatique xhtml2pdf si libs natives absentes (dev Windows).
  • Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
  • 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés

v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)

  • Bloc table éditable dans l'éditeur — insertion via /table (commandes slash, section BASIC), tableau 3×3 avec en-tête, cellules éditables au clic, auto-sauvegarde.
  • Lignes — bouton + Row sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
  • Colonnes — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
  • Import Markdown (GFM) — md2b transforme les tableaux pipe en bloc table au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc table.

v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)

  • « + Row » (bas) corrigé — l'insertion de ligne utilisait splice(index, ligne) (mauvaise signature) → aucune ligne ajoutée ; désormais splice(index, 0, ligne).
  • Bouton « + » à droite — ajoute une colonne à droite (équivalent « Add column »).
  • Redimensionnement des colonnes — curseur col-resize au survol des bordures d'en-tête, drag = largeur ; persisté via colsW.

v4.9.0 — Collaboration ✅ (2026-09-04)

Objectif : commentaires inline, mentions @, notifications in-app + email. COMPLETED. Doc : docs/Guide_Complet_Notion_sharing_collaborartion.md

  • Inline comments — commentaires sur sélection de texte dans l'éditeur (bouton flottant 💬) ; table comments étendue (target_type/target_id/anchor_block_id/anchor_start/anchor_end) et migrée (FK générique, idempotente) ; panneau de commentaires + résolution/suppression + compteur topbar
  • @mentions — autocomplétion @ (recherche utilisateurs temps réel), insertion @login dans les blocs et commentaires, table notifications ; endpoint POST /api/pages/{id}/mentions (notif des mentions de contenu)
  • Email notifications — service SMTP (app/services/mailer.py + templates HTML) ; préférences email par utilisateur (comments/mentions) ; config .env (SMTP_*, APP_BASE_URL) ; repli no-op sans SMTP
  • Centre de notifications in-app — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
  • Settings — toggles réels dans Settings → Notifications (Commentaires / Mentions)
  • 208 tests passent (+9 v4.9.0)

v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅

Livré (2026-09-05) : credentials par utilisateur (table user_llm_keys, plusieurs providers), chargement dynamique des modèles depuis le fournisseur (POST /keys/{p}/models), commandes slash dans le chat (/provider, /model, /keys, … via PATCH conversation), /run utilise la clé de l'utilisateur. 239 tests verts.

v4.10.1 — Agent IA : config LLM dans l'UI ✅

Livré (2026-09-05) : sélecteur provider/modèle dans le panneau agent (persisté par conversation), config runtime DB-backed (llm_config), écran admin Agent & IA avec test de connexion (LLMClient.ping(), sans fallback mock). 232 tests verts.

v4.10.0 — FlowDeck Agent (Agent IA natif) ✅

Livré (2026-09-05) : agent conversationnel complet — boucle ReAct, streaming SSE, 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents, déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés. Voir docs/Flowdeck_Agent_integration.md.

Objectif : Un agent IA intégré à FlowDeck, capable de planifier, rechercher et agir directement sur les workspaces — collections, pages, propriétés, vues, issues Gitea. Inspiré de Notion Agent (2026).

Documentation :

docs/Flowdeck_Agent_integration.md (702 lignes) · docs/Guide_Complet_Notion_Agent_2026.md (464 lignes)

Concept

Là où un assistant IA classique répond (prompt → réponse), FlowDeck Agent réalise :
objectif → planification → collecte contexte → actions API → résultat

Notion AI FlowDeck Agent
Répond, résume, réécrit Planifie, recherche, agit
Bloc de texte Crée collections, pages, propriétés, vues
Sans état Historique conversation + snapshots rollback
Aucune action DB Modifie le workspace via API FastAPI existantes

Fonctionnalités clés

  • Interface conversationnelle — panneau agent dans le sidebar (🤖 Agents) + popup chat
  • Boucle ReAct — Raisonnement → Action → Observation → itération (max 12 tours)
  • Streaming SSE — affichage temps réel du raisonnement et des actions
  • 10+ outils actionnables — search, read/write collections/pages, views, properties, Gitea sync
  • Permissions — même ACL que l'utilisateur (PermissionManager), audit log + rollback
  • Contexte automatique — workspace actif, pages mentionnées, fichiers uploadés
  • Custom agents — instructions, modèle, outils, scope par agent
  • Déclencheurs — planifiés, webhooks, événements workspace
  • Skills réutilisables — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
  • Multi-LLM — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter

Architecture (nouveaux composants)

app/
├── routers/agent.py        — /api/agent/* (run, conversations, tools)
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
├── services/llm_client.py   — Abstraction multi-fournisseur LLM
├── services/tool_registry.py — 10+ outils wrappant les API existantes
├── services/context_builder.py — Collecte contexte workspace
└── templates/
    ├── agent_panel.html     — Panneau conversation Alpine.js
    └── agent_message.html   — Fragment message (streaming)

Nouvelles tables

Table Rôle
agents Config agent (nom, instructions, modèle, tools scope)
agent_conversations Sessions (titre, agent, workspace, statut)
agent_messages Messages (role, content, tool_calls, artifacts)
agent_actions Journal d'audit (tool, args, result, undo_snapshot)
agent_skills Skills réutilisables (nom, prompt template, outils)
agent_triggers Déclencheurs (cron, webhook, event, agent cible)

Cas d'usage FlowDeck

  • Créer un CRM complet — agent crée collection, propriétés, vues, exemples
  • Résumer les notes de réunion — lit les pages, synthétise, crée une page résumé
  • Générer des OKR — crée la database OKR avec relations et vues
  • Analyser un repo Gitea — lit les issues, crée un dashboard de suivi
  • Rédiger un rapport hebdomadaire — agrège les pages modifiées, génère le rapport

Plan de migration (5 phases)

  1. Phase 1 — Core Agent ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
  2. Phase 2 — UI : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
  3. Phase 3 — Outils avancés ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
  4. Phase 4 — Autonomie ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
  5. Phase 5 — Plateforme ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills

Limitations (v1)

  • Pas de modification concurrente (lock optimiste DB)
  • Pas d'appels API externes non-FlowDeck (sandbox strict)
  • Budget tokens max par conversation (500k tokens)
  • Timeout 5 minutes par run

v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)

Objectif : Ctrl+K / Ctrl+P palette de commandes universelle + recherche full-text. COMPLETED.

  • Command palette — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
  • Quick actions — navigation, création, commandes
  • Recherche full-text — SQLite FTS5 sur pages + propriétés (prérequis technique de la palette)

v5.1.0 — Automations ✅ (2026-09-07)

Objectif : moteur de règles if-this-then-that + boutons cliquables. COMPLETED.

  • Database automations — moteur de règles if-this-then-that (trigger + condition + action)
    • Déclencheurs : événement (page.created/updated/deleted/moved, collection.*) / cron (*/N, minute fixe, @hourly, @daily) / bouton
    • Conditions combinables : eq, neq, contains, not_contains, is_empty, is_not_empty, changed
    • Actions : webhook (X-FlowDeck-Secret), set_property (validé), create_page (interpolation [[prop]]/{{title}}), notify
    • Tables automations + automation_runs (migration v5), scheduler de fond (60 s), historique des exécutions
  • Buttons — boutons cliquables déclenchant des actions
    • Bloc button dans l'éditeur (menu slash) + picker d'automation inline
    • Endpoint /api/automations/{id}/run (exempt CSRF) + UI Settings → Automations
  • Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
  • 11 tests tests/test_automations.py ; suite complète 289 verte

v5.2.0 — Infrastructure & Polish ✅ (2026-09-11)

Objectif : fondations de production — design system, sécurité, infra, forge. COMPLETED.

Design system

  • Design tokens — static/css/design-tokens.css (couleurs, espacements, typo, ombres, z-index) chargé après app.css
  • Composants réutilisables — static/css/components.css (btn/input/modal/dropdown/toast/card/badge/empty/table)

Sécurité & Utilisateur

  • API Tokens — générer/lister/révoquer dans Settings → API tokens (app/routers/security.py, table api_tokens, bearer /api/v1)
  • Sessions actives — liste + révocation (user_sessions, SessionManager.list_sessions/revoke_session)
  • Onboarding wizard — /welcome (workspace → forge → premier projet) + welcome.html

Infrastructure

  • 🥇 Migrations versionnées — table schema_version + runner app/migrations.py (baseline v1, indexes v2, FTS5 v3)
  • Backup automatique — snapshot SQLite quotidien (app/services/backup.py, scheduler + API admin, rétention configurable)
  • Index manquants — users.email, user_oauth_tokens(user_id, provider), collections/pages(workspace_id), pages(deleted_at)
  • Linting — ruff (Python, pyproject.toml) + eslint flat (eslint.config.mjs) ; ruff check et eslint sans erreur
  • Tests parallèles — pytest-xdist (pytest -n auto en local et en CI ; DB + dossier backup isolés par test)
  • Build Docker multi-stage — builder + runtime (libs WeasyPrint), image allégée

Forge integration

  • DB: table projects — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
  • Cron: sync périodique des projets — project_sync_scheduler (défaut : chaque heure)
  • GitHubAdapter complet — API GitHub v3 → interface ForgeAdapter

Tests (cibles)

  • 397 tests — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
  • Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode link, rejet d'état invalide
  • Tests des adapters forge (mock HTTP) — GitHubAdapter via httpx.MockTransport
  • Tests multi-user (permissions croisées) — PermissionManager owner/editor/viewer

✅ Validation (2026-09-11) :

  • pytest tests/test_v52_infra.py -v → 18/18 passed (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
  • pytest -q (suite complète) → 397 passed
  • ruff check app tests → All checks passed! · eslint static/js → 0 problème
  • CI Gitea (commit ba363ea ; run push #1412 + PR #15 run #1413) → success sur push et pull_request : jobs lint (≈43 s), test (-n auto, ≈4 min), docker multi-stage (≈1 min)
  • Docker : stages builder + runtime vérifiés par le job CI docker (from app.main import app OK)

Complétion du 2026-09-11 :

  • Isolation des tests corrigée : tests/conftest.py mute le singleton settings (au lieu de le remplacer), sinon les modules ayant importé settings au chargement (ex. backup.py) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
  • Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
  • init_db() crée désormais aussi webhook_subscriptions (schéma complet sans dépendre du lifespan FastAPI).
  • Tests OAuth mock (flux complet) ajoutés dans tests/test_v52_infra.py.
  • CI : job lint (ruff + eslint) + tests parallèles (-n auto), déclenché sur toutes les branches.

v5.3.0 — Database Avancée ✅ (2026-09-06)

Objectif : databases inline + templates prédéfinis + validation propriétés. COMPLETED.

  • Inline databases dans n'importe quelle page — slash command /database (groupe DATA) → sélecteur de templates → bloc embed_type:'collection' rendu par FlowDeckDB
  • Templates de database prédéfinis — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et /database ; POST /db/api & /db/inline/api acceptent template et matérialisent les propriétés
  • Validation des propriétés (required, unique, min/max) — côté serveur (validate_property_rule, 400 + messages) + UI (modale propriété + erreurs de cellule)

v5.10.0 — Éditeur : interactions de bloc ✅ (2026-09-08)

Objectif : parité de manipulation des blocs avec Notion — les blocs étaient éditables mais statiques (impossible de les déplacer, dupliquer ou annuler une action). COMPLETED.

  • Drag & drop des blocs — poignée ⋮⋮ au survol de chaque bloc, drag vertical pour réordonner, indicateur de drop (ligne bleue) ; multi-sélection (Shift+clic sur poignée → sélection groupée déplacée d'un seul geste, ordre reconstruit depuis le DOM via data-id)
  • Menu contextuel de bloc — clic (ou clic droit) sur ⋮⋮ : Turn into (sous-menu conservant le contenu), Duplicate, Copy link to block (#fdblk-<id> avec re-focus), Move to (recherche de pages + API), Delete, couleurs texte/fond appliquées au bloc ou à la sélection
  • Undo / Redo — Ctrl+Z / Ctrl+Shift+Z/Ctrl+Y, pile de 100 opérations en mémoire du tab, re-focus du bloc restauré, déclencheur sur toutes les mutations (entrée, retour arrière, slash, tableaux, colonnes, toggles…)
  • Duplicate block — Ctrl+D, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
  • Slash command étendue — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
  • En-têtes de tableau — toggles « Header row » / « First col » dans la barre du tableau (has_header défaut actif + nouveau first_col_header), persistés + rendu <th> en preview et exports Markdown/HTML
  • Intégration realtime — syncNow() poussé après chaque mutation programmatique ; 9 tests tests/test_block_interactions.py ; suite complète 307 verte (+3 PDF pré-existants)

v5.13.0 — Collaboration temps réel ✅ (2026-09-08)

Objectif : édition collaborative en direct (parité Notion en équipe). COMPLETED. Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).

  • WebSocket gateway — endpoint WS /ws/pages/{id}, auth via cookie session (refus 4401), page introuvable/supprimée → 4404 ; rooms par page en mémoire, chargées depuis la base au premier connect, droppées quand vides
  • Présence — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; welcome = self + peers
  • Curseurs live — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
  • Merge de modifications — diffusion des ops de blocs insert/update/delete/move avec last-write-wins par bloc + version de page (stale → sync complet) ; titre synchronisé (debounce) ; persistance debounce ~1 s + flush à la déconnexion du dernier client
  • Fallback polling — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
  • CSP connect-src étendu à ws: ; 12 tests tests/test_realtime.py ; suite complète 298 verte (+3 PDF pré-existants)

v5.4.0 — Expérience éditeur ✅ (2026-09-11)

Objectif : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. COMPLETED.

  • Backlinks — GET /board/api/pages/{id}/backlinks (scan des liens internes /pages/<id>) + popover « Lié depuis… »
  • Duplicates — POST /board/api/pages/{id}/duplicate (copie profonde des blocs) + POST /db/{id}/duplicate (vues + propriétés + pages) ; entrées « Duplicate » dans les menus ...
  • Corbeille globale améliorée — vue cross-workspace GET /board/api/trash + app/services/trash.py (purge automatique > 30 jours, scheduler quotidien)
  • Historique de version UI — snapshots page_versions à chaque sauvegarde modifiée, GET /api/pages/{id}/versions, POST .../versions/{vid}/restore, popover « Version history »
  • Import — POST /api/pages/import (markdown) + POST /api/pages/import/file (.md/.txt/.zip d'export Notion) ; import CSV collections POST /workspace/collections/{id}/import/csv
  • 17 tests tests/test_v54.py ; suite complète 397 verte

v5.5.0 — Embeds & Média Riche ✅ (2026-09-12)

Objectif : parité avec les 60+ embeds Notion — contenu tiers rendu dans la page. Source : analyse Notion clone (delta v4 → v5.5, 2026-09-04). COMPLETED.

  • Bloc Embed universel — /embed : YouTube (watch/shorts/youtu.be), Vimeo, Figma, Google Maps, Google Docs/Sheets/Slides, Loom, X/Twitter, CodePen, Miro, Spotify, SoundCloud, Twitch, Pinterest, Office…
  • Bookmark cards — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
  • Image lightbox — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur et les pages publiques
  • Previews inline — PDF, vidéo, audio rendus directement dans la page
  • Cover & icône de page — upload image de couverture (fichier ou URL) + emoji/icône custom

Détails livrés :

  • Service app/services/embeds.py : détection multi-provider + réécriture d'URL, resolve_embed(), inline_kind(), embed_html() ; endpoint POST /board/api/embed/resolve
  • Service app/services/og_fetcher.py : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint POST /board/api/og/metadata
  • Endpoints POST/DELETE /board/api/pages/{id}/cover (JSON URL ou upload image) et POST /board/api/pages/{id}/icon
  • Éditeur : résolution d'embed à la saisie (URL d'origine conservée + embed_src mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
  • Pages publiques /p/<slug> : cover + icône, embed résolu, lightbox clavier
  • Export Markdown/HTML/PDF : nouveau bloc embed/bookmark avec embed_src résolu
  • Fix chemins API : l'éditeur appelait /api/pages/... alors que les routes sont /board/api/pages/... (cover, icon, versions, backlinks, import, move, OG) — corrigé
  • 47 tests tests/test_v55.py ; suite complète 444 verte ; ruff check OK

v5.6.0 — Import de données (étendu) ✅ (2026-09-13)

Objectif : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base (Markdown/CSV/Notion, déjà dans v5.4.0). COMPLETED. Sources retenues : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep, OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket, Readwise, Shaarli, .ics, OPML, Standard Notes. Sources exclues : Confluence, Evernote, Asana, Trello (non utilisées).

Phase 0 — Socle d'import unifié (prérequis) ✅

Toutes les sources partagent le même pipeline ; à livrer avant les imports.

  • Service app/services/importers/ — interface commune (detect(), parse(), to_pages()) + résultat normalisé (ImportResult : pages, pièces jointes, warnings, stats)
  • Pipeline commun — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie parent_id) → rapport d'import
  • Pièces jointes — extraction (images/fichiers), upload, réécriture des liens dans les blocs
  • Frontmatter YAML → propriétés (title, tags, dates, champs custom)
  • Assistant UI d'import — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
  • Import asynchrone (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
  • Idempotence / déduplication + reprise sur erreur partielle
  • Tests — harnais d'import + fixtures par source
  • Réutilise : _md_to_blocks (app/services/export.py), PROPERTY_TYPES + validate_property_rule (property_types.py), db_templates.materialize_properties, endpoints upload/cover existants

Phase 1 — Notes & Markdown (réutilise _md_to_blocks) — faible effort ✅

  • Obsidian (vault .zip/dossier) — frontmatter YAML, [[wikilinks]], ![[embeds]], attachments, hiérarchie de dossiers (prépare v5.11.0)
  • Notion (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
  • Logseq / Roam Research — markdown outliné (puces imbriquées), ((block refs)), pages journal
  • Apple Notes / Bear / Ulysses — import export HTML/Markdown
  • Google Keep — Takeout JSON + HTML
  • OneNote — export HTML/PDF, best-effort (fidélité limitée)

Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅

  • CSV/TSV typé — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
  • Excel .xlsx (openpyxl) — multi-feuilles → collections
  • Google Sheets — export CSV/XLSX
  • JSON générique — mapping configurable (JSONPath → propriétés)

Phase 3 — Documents & bureautique — effort moyen ✅

  • Word .docx (mammoth/pandoc) — titres, listes, tableaux, images
  • Google Docs — Takeout .docx/HTML
  • HTML (fichiers/dossier, web clipper) — conversion HTML → blocs
  • PDF — extraction texte + images (fidélité limitée)

Phase 4 — Signets, dev & divers ✅

  • Gitea / GitHub issues + labels + milestones → collection (via API — quasi natif)
  • Raindrop.io (CSV/HTML) → bookmark cards (réutilise v5.5.0)
  • Pocket (CSV/HTML)
  • Readwise (highlights CSV/Markdown)
  • Shaarli (API/export JSON) → bookmark cards
  • Calendrier .ics (Google/Outlook/Apple)
  • OPML (flux/outlines)
  • Standard Notes / autres

Phase 5 — Durcissement & finition ✅

Objectif : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).

  • Import incrémental / re-sync — modes skip / update (upsert des pages + lignes par titre) / duplicate, via import_items
  • Import de dépôt forge — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
  • Import par URL / web clipper — POST /api/import/url : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
  • Lot multi-fichiers + file d'attente — POST /api/import/run-batch + file d'attente UI (statut par fichier)
  • Relations Notion — POST /api/import/relations/resolve : colonnes texte référençant une autre collection → propriétés relation (ids de pages) ; auto-exécuté après un import Notion
  • Reprise / erreurs partielles + rapport exportable — import qui continue par page en cas d'erreur (status: partial, liste errors) ; rapport JSON téléchargeable (GET /api/import/jobs/{id}/report + bouton UI)
  • Valeurs de propriétés par id — correction : les lignes importées stockent les valeurs par id de propriété (rendu correct dans les vues DB)

Priorisation

Ordre Phase Effort Impact
1 Phase 0 — Socle unifié M 🔴 prérequis
2 Phase 1 — Notes/Markdown S–M 🔴 fort (Obsidian, Notion)
3 Phase 2 — Données/tableaux S–M 🔴 fort (databases)
4 Phase 4 — Signets/dev/divers S 🟠 moyen (Gitea natif, Raindrop)
5 Phase 3 — Documents M 🟠 moyen (docx, HTML)

v5.7.0 — Database Avancée (Pt. 2) ✅ (2026-09-13)

Objectif : compléter la parité sur les propriétés, les vues sauvegardées et le Kanban pro. COMPLETED.

  • Types propriété — person, created_time, created_by, last_edited_time, last_edited_by câblés : auto-valeurs calculées serveur (apply_auto_properties) à la création/mise à jour, sélecteur person (membres du workspace via GET /db/{id}/members/api), rendu chips/avatars et édition dédiée dans l'UI
  • Groupes de propriétés — colonne collection_properties.group_name, header de table avec sections pliables (db-group-row + toggle) et POST /db/{id}/property-groups/api
  • Vues sauvegardées par utilisateur — collection_views.created_by ; GET /db/{id}/views/api filtre par propriétaire (les vues partagées NULL restent visibles), save-as/duplicate/DELETE + renommage
  • Swimlanes Kanban — sub_group_by (2e dimension de groupement) : une rangée de colonnes par lane
  • WIP limits — wip_limits par colonne, alerte visuelle au dépassement (wip-exceeded)
  • Cartes configurables — card_properties, card_size (compact/détaillé), couverture cover_mode (none/icon/color/property) via cover_property
  • Calendar avec drag & drop — grille mensuelle, date_property, navigation mois/Today, drop d'une carte sur un jour = reschedule
  • Gallery avec couvertures — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large

Détails livrés :

  • Migration 10 : collection_properties.group_name, collection_views.created_by/updated_at, collection_pages.cover_url
  • app/services/property_types.py : user_ref(), apply_auto_properties(), validation person
  • collections.py : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (sub_group_by, wip_limits, card_size, cover_mode, card_properties, date_property, property_groups)
  • dashboard.py : auto-props à la création de ligne, views exposées dans table-data et le contexte de page
  • _database_table_scripts.html réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
  • 12 tests tests/test_v57_db_advanced.py ; suite complète 503 verte ; ruff check OK

v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)

Objectif : calendrier complet + notifications proactives. COMPLETED.

  • Vues Jour / Semaine / Mois — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par GET /db/{id}/calendar/api avec expansion serveur des occurrences
  • Récurrence d'événements — moteur app/services/recurrence.py (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans property_values_json.__recurrence__ ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
  • Support timezone — colonne users.timezone + picker dans Settings → Notifications (GET/POST /api/notifications/timezone, zones via GET /db/timezones/api) ; timezone par événement (__timezone__) > règle de récurrence > préférence utilisateur
  • Rappels — app/services/reminders.py : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (reminder_scheduler), dédup reminder_log, notifie les personnes assignées (repli admin), in-app + email (pref reminders) ; stockés dans property_values_json.__reminder__, édités dans le popover événement
  • Centre de notifications — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences reminders + assignments ; notification d'assignation émise par notify_assignment() sur PUT /db/pages/{id}/api (comparaison avant/après des propriétés person)
  • Template Meeting Notes — propriétés Agenda et Notes ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là

Détails livrés :

  • Migrations 11 (reminder_log, users.timezone) et 12 (template Meeting notes)
  • app/config.py : reminders_enabled, reminder_scan_interval_seconds
  • _database_table_scripts.html : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
  • 20 tests tests/test_v58_calendar_reminders.py ; suite complète 523 verte ; ruff check OK
  • Version — 5.11.7

v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)

Objectif : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). COMPLETED.

  • Slash AI commands — groupe « AI » dans le menu / : Write with AI, Summarize, Translate, Continue writing (E.aiSlash)
  • Autocomplétion — module AIAC : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au Tab, rejet à Escape
  • AI properties — bouton ✨ par ligne de la table database : POST /api/agent/writing/properties propose Status/Priority/Résumé et applique les valeurs
  • Service app/services/ai_writing.py — 6 actions sans outils, replis déterministes hors-ligne
  • Endpoints — POST /api/agent/writing + POST /api/agent/writing/properties
  • 29 tests tests/test_ai_writing.py ; version 5.9.0

v5.10.0 — Éditeur : interactions de bloc ✅ (livré — voir section Completed)

Objectif : le graphe de connaissances Notion. Complète les backlinks de v5.4.0 (section « Lié depuis ») par la création des liens depuis l'éditeur. COMPLETED.

  • Wiki-links [[ — taper [[ ouvre le picker de pages (GET /board/api/wiki/pages, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module WM dans _page_editor_scripts.html ; tokens [[fdpage:ID]] stockés dans le texte des blocs et relus intacts par gtTok() (autosave, drag, undo/redo préservés)
  • Mention de page @ — le menu @ (utilisateurs v4.9.0) gagne la section « Pages » : @nom cherche les pages et insère le chip inline
  • Mention de date @ — section « Date » : @today, @tomorrow, @hier ou @YYYY-MM-DD insèrent [[fddate:…]], rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
  • Renommage propagé — le token ne stocke que page_id ; GET /board/api/wiki/titles résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
  • Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; _render_blocks_public rend les chips en page publiée (HTML échappé) ; service app/services/wiki_links.py ; 15 tests dédiés (voir v5.12.0)

v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)

Objectif : démarrage rapide productif et protection des pages stabilisées. Les page_templates existent (v2.0.0/v4.2.0) mais uniquement côté collections. COMPLETED.

  • Template picker global — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (app/services/block_templates.py — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (page_global_templates, migration 13, cloisonnés par created_by)
  • Bouton « Use template » — POST /board/api/page-templates/{id}/use (id 0 = built-in par clé) duplique le contenu des blocs dans une nouvelle page ; « Empty » retombe sur la création classique ; menu « … » → « 📑 Save as template » capture la page courante
  • Page lock — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur POST /board/api/pages/{id}/lock + garde _ensure_page_editable → 423 sur PUT /api/pages/{id} et POST /api/pages/{id}/blocks ; déverrouillage réservé au poseur du lock (locked_by) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
  • Full-width mode — toggle dans le menu « … », persisté par page (pages.full_width), POST /board/api/pages/{id}/options, classe CSS .full-width
  • Small text / typo options — toggle « Aa Small text » (pages.font_small), classe .small-text (taille réduite ; serif/mono : non demandé au-delà du compact — volontairement hors scope)

v5.13.0 — Collaboration temps réel ✅ (livré — voir section Completed)

v5.14.0 — Synced blocks ✅ (2026-09-15)

Objectif : avancer depuis v6.0.0 un bloc Notion très utilisé (même contenu dans plusieurs pages, édité une fois). COMPLETED.

  • Bloc synced_block — table synced_blocks (source de vérité) + références par page ; slash command /synced
  • Rendu — ring rouge + badge « Synced » sur le bloc ; édition à un endroit => mise à jour partout (via rooms WS v5.13.0 si actives, sinon au reload)
  • Unsync — action « Unsync » qui convertit l'instance en copie indépendante
  • Copy & sync across pages — copier un bloc dans une autre page avec option « Paste and sync »

v5.15.0 — Webhooks v2 ✅ (2026-09-21)

Objectif : webhooks sécurisés, fiables et complets pour les intégrations tierces. COMPLETED.

  • Signature HMAC SHA-256 — header X-FlowDeck-Signature sur chaque payload, vérification côté receveur
  • Retries avec backoff — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
  • 20+ nouveaux événements — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
  • API v2 endpoints — /api/v2/webhooks/test-signature (test HMAC), /api/v2/webhooks/retry (relancer les échecs)
  • Tests — 21 dédiés (tests/test_webhooks_v2.py) ; 7 tests d'intégration câblés (retry échec→succès, échec après MAX_ATTEMPTS, event inconnu ignoré, aucun abonné, fan-out wildcard, flow complet signé bout en bout, retry_due_deliveries superseded→delivered) — fixture db = SQLite isolée par test + httpx.MockTransport + RETRY_DELAYS neutralisé ; suite complète 749 verts, 0 skip

v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)

Objectif : support hors ligne complet (manifest, service worker, IndexedDB, queue de mutations, sync serveur + résolution de conflits). COMPLETED.

  • Manifest & icônes PWA — static/manifest.json, static/icons/*, scripts/generate_pwa_icons.py
  • Service Worker — static/sw.js (precache shell, network-first HTML/API, page offline, Background Sync)
  • IndexedDB client — static/js/offline.js (window.FlowOffline) : queue, delta, flush, marqueurs dirty
  • Endpoints sync — app/routers/sync.py + app/services/sync_engine.py (/api/v2/sync/delta|batch|status)
  • Migrations — table offline_sync_queue + colonnes sync_version (triggers AFTER UPDATE)
  • Conflits — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
  • UI — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
  • Durcissement — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
  • Tests — test_sync.py, test_sync_migrations.py, test_service_worker.py, test_pwa_offline.py, E2E e2e/pwa_offline.spec.js
  • Doc — section /help « Offline mode (PWA) »

v6.1.0 — Granular Permissions ✅ (2026-09-19)

Objectif : page-level, collection-level & property-level ACL + groupes + audit. COMPLETED.

  • Page permissions — modes inherit|restricted|private (pages.permission_type, collections.permission_type, collection_pages.permission_type), grants explicites user/group (page_permissions role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
  • Collection permissions — collection_permissions + permission_type, /db/{id} et /db/{id}/api masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
  • Property-level visibility — property_permissions (viewer|editor), GET /db/{id}/properties/api filtré par get_visible_properties(), GET /api/v2/collections/{id}/properties/visible (visible/hidden), grant = owner collection uniquement
  • Groupes réutilisables — tables user_groups + group_members (workspace-scoped, UNIQUE(name)), CRUD /api/v2/groups + /groups/{id}/members, grant par group_id (page/collection/property), retrait membre révoque l'accès
  • API — app/routers/permissions.py : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit GET /api/v2/audit/permissions
  • Guards — board.py (GET/PUT /board/api/pages/{id}) + collections.py (delete collection, GET/PUT/DELETE page, properties), 403/404 conformes, is_admin/owner bypass + _session_user() (no admin fallback)
  • PermissionManager — app/services/permission_manager.py étendu : _explicit_grant_role() (best rank user+groups), get_page/collection_permission(), can_view/edit_page|collection, can_view/edit_property(), get_visible_properties(), groups, log_permission_change(), cache 60s + invalidate()
  • Audit — table permission_audit_log (resource_type, action grant/revoke/type_change/group_*), index, log sur tous les mutateurs, GET /api/v2/audit/permissions (owner/admin only, limit 500)
  • Migration 18 — migrations.py : création 6 tables + 3 colonnes permission_type + indexes (idempotent)
  • Tests — tests/test_v60_granular_permissions.py 21 tests (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)

v6.2.0 — Web Clipper : extension navigateur ✅ (2026-09-19)

Objectif : capturer n'importe quelle page web en page FlowDeck (article, sélection, bookmark, screenshot) depuis une extension Manifest V3 + API directe. COMPLETED.

  • Extension Manifest V3 — extension/ + static/extension/ (manifest, background.js, content.js, popup.html/js, clipper.css, icônes 16/32/48/128, flowdeck-clipper.zip servi à /static/extension/)
  • 4 types de capture — article (HTML complet → sanitize_html + html_to_blocks), sélection, bookmark (carte OG v5.5.0), screenshot (base64) ; cap 10 MB / 200 blocs
  • Serveur — POST /api/v2/web-clipper/clip, GET /status, POST /auth/verify (register device → token fd_… montré une fois), GET /devices, DELETE /devices/{id} + page HTML GET /extensions ; auth triple (session OU Bearer api_tokens OU Bearer extension_devices OU legacy user_tokens), rate-limit 50/h/device
  • Tables migration 19 — extension_devices, extension_clips + index idx_ext_*
  • Settings UI — onglet Extensions (devices, clips count, revoke)
  • 16 tests tests/test_web_clipper.py ; VERSION 6.2.0 ; wiring app/main.py:50,158

v6.2.1 — Web Clipper polish ✅ (2026-09-20)

  • Bouton flottant rond transparent draggable — toggle d'affichage persistant, clipper:clipped refresh auto sidebar
  • Fix bloc bookmark — create_page_from_clip() émet un bloc bookmark fidèle (OG + embed_src résolu), rendu/correct en éditeur + /p/<slug> + exports
  • Fix duplicate inner SyntaxError dans _page_editor_scripts.html
  • flowdeck-clipper.zip régénéré

v6.3.0 — API publique complète v2 ✅ (2026-09-21)

Objectif : REST API documentée OpenAPI, CRUD complet, un seul chemin de code (wrappers sur les services internes). Parité docs/API_GUIDE_V6.md §4 (~80 endpoints) + scopes hiérarchiques read < write < admin. COMPLETED. Route : feat/v6-api-v2 → develop → main — livraison en une fois (tous domaines). Doc : docs/API_GUIDE_V6.md · OpenAPI : /docs + docs/openapi-v2.json (402 chemins)

Phase 0 — Roadmap & doc catch-up ✅

  • Tagguer v6.2.0/v6.2.1 dans CHANGELOG.md + ROADMAP.md + docs/V6_Web_Clipper.md → COMPLETED
  • Détailler v6.3.0 phases 1-8 dans ROADMAP.md (plan gelé)

Phase 1 — Migrations socles (v20) ✅

  • api_tokens : colonnes scopes TEXT DEFAULT 'read,write', expires_at TIMESTAMP (idempotent, rétro-compat)
  • webhook_deliveries : id, webhook_id FK, status, http_code, error, duration_ms, attempt, created_at
  • api_audit_log : id, user_id, token_id, action, resource_type, resource_id, ip, created_at
  • idempotency_keys : key TEXT PRIMARY KEY, user_id, response_json, created_at

Phase 2 — Helpers & auth v2 unifiée (scopes hiérarchiques) ✅

  • app/config.py : public_api_insecure_ok: bool = False — fd-public-key accepté seulement si True (dev local)
  • app/services/api_v2_helpers.py : parse_pagination() (+X-Total-Count), to_iso8601(), handler RFC 7807 application/problem+json, require_scope() (hiérarchie admin ⊇ write ⊇ read), resolve_bearer_token() / get_bearer_user() (hash sha256, revoked + expires_at + scopes, last_used_at, extension_devices)
  • Auth Bearer unifié partagé (clipper + legacy user_tokens supportés) ; handler d'erreurs unifié sur StarletteHTTPException

Phase 3 — Tokens CRUD v2 ✅

  • POST /api/v2/tokens (name, scopes, expires_at) → fd_{urlsafe(32)} hashé, montré une fois
  • GET /api/v2/tokens (prefix only), DELETE /api/v2/tokens/{id} (revoke), POST /api/v2/tokens/{id}/rotate

Phase 4 — Wrappers read (pagination/filtres/tri/fields) ✅

  • app/routers/api_v2.py (prefix="/api/v2", tag api-v2) — GET /collections, GET /collections/{id}, GET /pages/{id}, GET /collections/{id}/pages?filter[]=&sort=&fields=&query=, GET /search?query=&workspace_id=&type= (FTS5), header X-Total-Count, filter[] AND, sort=prop/-prop

Phase 5 — Wrappers write critiques (Idempotency-Key) ✅

  • Collections : POST/GET/PATCH/DELETE /collections/{id} + /linked, /task, /sources
  • Pages : POST/GET/PATCH/DELETE /pages/{id} + /restore, /move, /sub-items, /dependencies
  • Properties : GET/POST /collections/{id}/properties, PATCH/DELETE /properties/{id}, POST .../relation, evaluate-formula, compute-rollup
  • Views/Dashboards/Comments/Notifications/Favorites/Tags/Recents/Sharing/History/Sprints/Templates/Export/Workspaces/Users/Admin — regroupés par ressource

Phase 6 — Webhooks v2 — CRUD simple ✅

  • GET/POST/PATCH/DELETE /api/v2/webhooks, POST /api/v2/webhooks/{id}/test (ping)
  • GET /api/v2/webhooks/{id}/deliveries (journal basique)
  • (reporté v6.4, livré plus tôt) v5.15.0 : signature HMAC X-FlowDeck-Signature, retry 2s/10s/60s, +20 events (Webhooks v2)

Phase 7 — Forges & ressources restantes ✅

  • Sprints, dashboards, templates, export/import, workspaces/members, users, admin
  • Forges : GET /projects, /projects/{owner}/{repo}/tree (best-effort via gitea_client)
  • (reporté, livré v6.4.0) : migration de sync.py vers Bearer — auth Authorization: Bearer (scopes read/write) via get_bearer_user() + repli cookie session conservé pour compat PWA offline (app/routers/sync.py)

Phase 8 — OpenAPI, tests & docs ✅

  • docs_url="/docs" + redoc_url="/redoc" activés ; docs/openapi-v2.json généré (402 chemins)
  • tests/test_public_api_v2.py — 24 tests (auth scopes, pagination, filtres, RFC 7807, idempotency, webhooks deliveries, CRUD multi-domaines)
  • Vérif ruff check app tests + pytest -n auto → 668 verte

v6.4.0 — Realtime editing (production) ✅ (2026-09-22)

Objectif : passer le realtime v5.13.0 en « production » — résolution de conflits au-delà du last-write-wins + édition à grande échelle (broadcast non bloquant, plusieurs rooms/pages, observabilité). COMPLETED.

Conflits au-delà du LWW

  • Merge à 3 voix (diff3-lite) — nouveau service app/services/realtime_merge.py : merge_text_3way() (merge de caractères) + merge_block_3way() (merge champ-par-champ), fonctions pures et testées sans WebSocket ni base
  • Régions disjointes conservées — deux utilisateurs tapant à des endroits différents du même bloc voient leurs deux saisies survivre (au lieu d'écraser l'une par l'autre) ; ordre d'arrivée indifférent
  • Chevauchement réel → LWW par champ + drapeau — un conflit n'est plus « tout le bloc perdu » mais limité au champ concerné ; conflict: true renvoyé dans l'ack et le broadcast
  • Protocole base — le client embarque dans chaque update la version du bloc dont dérive sa saisie ; le serveur fait merge_block_3way(base, current, incoming) ; sans base → LWW historique (rétro-compat ancien client)
  • Adoption côté client — l'ack renvoie le bloc fusionné ; le client met à jour sa base, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit
  • Broadcast du résultat fusionné — le serveur diffuse toujours le bloc final fusionné (jamais la proposition brute) pour convergence garantie de tous les clients

Échelle & robustesse

  • Broadcast non bloquant — chaque connexion a une file sortante (asyncio.Queue) + une tâche _writer dédiée ; _broadcast() fait put_nowait et n'attend plus le socket → un client lent ne fige plus la room
  • Coalescence des curseurs — le writer réduit les messages sel empilés à la position la plus récente (seule la dernière compte), tout en préservant l'ordre des messages importants
  • Déconnexion des clients trop lents — file pleine (MAX_OUT_QUEUE=512) → fermeture 4413 + compteur slow_disconnects (évite qu'une room entière stagne sur un pair mortel)
  • Anti-flood — budget d'opérations par connexion (OP_WINDOW_MAX=400 / 10 s) ; au-delà, réponse ack stale sans application
  • Fix fuite de rooms — une connexion 4404 n'enregistre plus de Room orpheline en mémoire ; room_state() sur page inexistante retourne un dict vide au lieu de planter sur None
  • Observabilité — GET /api/realtime/stats (authentifié) : rooms, connexions, ops, merges, conflits, déconnexions lentes + détail par page

Détails livrés :

  • app/services/realtime_merge.py — merge 3-voix (nouveau, ~170 lignes)
  • app/services/realtime_server.py — RTConn (file + writer + budget), _apply() avec merge, _evict_slow(), stats(), fix fuites
  • app/routers/realtime.py — endpoint GET /api/realtime/stats
  • app/templates/_page_editor_realtime.html — envoi de base, adoption du bloc fusionné, toast de conflit
  • 26 tests tests/test_realtime_v64.py (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; 14 tests tests/test_realtime.py préservés
  • ruff check app tests OK · eslint static/js 0 problème
  • Version — 6.4.0

v6.7.0 — SSO / SAML + OIDC entreprise (Enterprise Auth) ✅ (2026-09-24)

Objectif : dernière feature de la roadmap v6.0.0 — authentification fédérée via un IdP d'entreprise. COMPLETED. Design : docs/V6_SSO_SAML_Enterprise_Auth.md.

  • SAML 2.0 SP — app/auth/providers/saml_provider.py (python3-saml 1.16.0) : login SP-initié (/auth/saml/login), ACS signé (signature/audience/destination/InResponseTo validés), SP metadata public (/auth/saml/metadata), SLO (/auth/saml/logout) ; allowSingleLabelDomains pour les hôts homelab/LAN
  • OIDC + PKCE — app/auth/providers/oidc_provider.py (authlib 1.8.0) : state + code_verifier en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h
  • Provisioning — app/services/sso_provisioning.py : auto-provision au 1er login (auth_method=saml|oidc, lien par email), rôle par défaut + default_workspace_id, mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode SSO only (login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay sso_requests, historique sso_login_history
  • Routeur — app/routers/sso.py : parcours navigateur + API admin GET/POST/DELETE /api/v2/sso/config (admin + CSRF), GET /api/v2/sso/workspaces, POST /api/v2/sso/sync ; CSRF exclut /auth/saml/* + /auth/oidc/*
  • Migration 23 — tables sso_config, sso_login_history, sso_requests
  • UI admin — onglet « SSO / Enterprise » dans Settings (config SAML/OIDC, provisioning, groupes → rôles, URL métadonnées SP copiable, désactivation) ; bouton SSO sur la page de login
  • PermissionManager (design §7.2) — is_sso_only_workspace(), get_sso_roles(), sync_sso_permissions()
  • Help — section « SSO (Enterprise) » + badge .help-badge.sso
  • Docs — CHANGELOG, WORKLOAD, README, ARCHITECTURE, docs/API_GUIDE_V6.md §2.5, OpenAPI régénéré : docs/openapi-v2.json → 439 chemins, info.version = 6.7.0
  • Tests — tests/test_v67_sso.py : 38 tests (config admin + CSRF + secret préservé, métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper, group mapping, sso_only, SLO, historique, rate limit, extension PermissionManager)
  • Version — 6.7.0 · ruff check app tests OK · suite 802 verts, 0 skip

v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24)

Objectif : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » : exposer l'agent en API publique pour les intégrations tierces et rendre les skills partageables (marketplace). COMPLETED. Voir docs/API_GUIDE_V6.md §2.4.

  • API publique agent — app/routers/api_v2_agent.py (15 routes /api/v2/agents/*, Bearer + scopes read/write, rate limit par token, idempotence Idempotency-Key, api_audit_log) :
    • agents CRUD, conversations + messages, run synchrone JSON (le flux SSE reste réservé à l'UI), journal agent_actions + POST /agents/actions/{id}/undo, POST /agents/{id}/trigger
    • propriété des conversations vérifiée par user_id : un token tiers reçoit 404 (pas de fuite)
    • réponse de run : {status, final, error, reasoning[], actions[], events[], duration_ms}
  • Marketplace skills — app/services/skill_gallery.py (source unique interne + v2) :
    • export portable {format: "flowdeck-skill", version: 1, skill{name, description, prompt_template, allowed_tools}} (aucun id/workspace/auteur local ne fuit) + import avec contrôle de collision (409, overwrite: true pour écraser)
    • galerie de 6 presets installables : rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document (GET /api/v2/skills/gallery, POST /api/v2/skills/gallery/{slug}/install)
    • routes jumelles côté session : GET /api/agent/skills/gallery, POST .../gallery/{slug}/install, POST /api/agent/skills/import, GET /api/agent/skills/{id}/export, DELETE /api/agent/skills/{id} (comble le trou CRUD : pas de suppression de skill avant)
    • UI branchée : section « Galerie » dans la palette / du panneau agent → installation + épinglage automatique du chip
  • Webhooks de cycle de vie agent — agent.run.started et agent.run.failed émis pour la 1re fois (seul agent.run.finished l'était) ; les 3 sont dans le catalogue webhook_outbound → abonnement agent.*
  • Docs — docs/API_GUIDE_V6.md (§2.4 + correction du nom d'événement agent.run.completed → agent.run.finished), OpenAPI régénéré : docs/openapi-v2.json → 427 chemins, info.version = 6.6.0
  • Tests — tests/test_v66_agent_api.py : 15 tests (auth/scopes, CRUD agents, run synchrone, ownership 404, trigger, export/import/validation, galerie + outils valides, routes internes, cycle de vie webhooks)
  • Version — 6.6.0 · ruff check app tests OK · eslint static/js 0 problème · suite 764 verts, 0 skip

v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)

Objectif : passer les synced blocks en « production » (résolution + propagation fiables partout) et les faire vivre dans les databases/vues — le « reste en v6 » du point v5.14.0. COMPLETED.

Contenu des lignes de database (le point « databases »)

  • Migration 22 — pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE + index partiel ; chaque ligne de database gagne une page contenu (page blocks standard) portant son éditeur complet
  • Service app/services/row_pages.py — ensure_row_page() : création lazy/réparation (page trashée restaurée, parent_section='DbRow'), workspace hérité de la page hôte (DB full-page → page parente inline → collections.gitea_owner/repo), collection_id hérité pour que les ACL de collection s'appliquent au contenu
  • Endpoint GET /db/pages/{row_id}/open/api — renvoie {page_id} (garde _require_view de la collection) ; utilisé par le peek de toute vue (table/board/gallery/list/calendar)
  • Fix bug d'ouverture de ligne — le peek appelait /pages/{rowId} qui lit la table pages → avec des ids croisés il ouvrait une page au hasard (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe /pages/{pageId}
  • Titres synchronisés bidirectionnellement — renommer la ligne (PUT /db/pages/{id}/api) → page contenu ; renommer dans l'éditeur (PUT /board/api/pages/{id}, POST .../blocks, PUT /api/pages/{id}/rename) → ligne
  • Cascade — supprimer une ligne supprime sa page contenu (FK ON DELETE CASCADE, refs page_synced_blocks en cascade)
  • Exclusions des listings — AND collection_row_id IS NULL sur : arbre sidebar (_build_page_tree), racines workspace (_load_workspace_pages), liste des pages du board, projets builtins, arbre Library (_build_tree_children + API tree), page_count des workspaces ; parent_section='DbRow' les tient hors des sections Private/Trash

Résolution serveur + propagation (le point « production »)

  • Résolution à chaque lecture — resolve_content_json() (app/services/synced_blocks.py) appliqué sur : les 2 routes de rendu /pages/{id} (dashboard + board, dual-route), GET /api/pages/{id}/content, GET /api/local-workspace/page-content/{id}, page publique /p/{slug}, GET /api/v2/pages/{id}, et les rooms realtime (load_room) → le cache _synced_content périmé n'est plus jamais servi
  • Propagation écrite réelle — PUT /api/synced-blocks/{id} réécrit le contenu stocké de chaque page référente (sync_synced_blocks_in_page, jusqu'ici jamais appelé = code mort depuis v5.14.0) avant le broadcast WS ; récursif dans les children (colonnes, toggles)
  • Suppression de source — ids des pages référentes collectés avant la cascade FK, mark_synced_block_deleted() marque _synced_deleted dans le stocké, broadcast dédié manager._broadcast_synced_to()
  • État « deleted » — resolve_synced_block() marque _synced_deleted (source absente) ; rendu dédié dans l'éditeur (« Deleted synced block » au lieu d'un cache fantôme / « Loading… » éternel)
  • Rendu public des synced blocks — branche synced ajoutée à _render_blocks_public (avant : JSON brut en <p>) + état deleted
  • API v2 — content_page_id sur GET /api/v2/pages/{id} pour une ligne (sans création lazy en lecture)

Tests

  • tests/test_v65_synced_db.py — 17 tests (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, content_page_id v2)
  • Suite complète 742 verts (pytest -n auto) · ruff check app tests OK · eslint static/js 0 problème
  • Version — 6.5.0 (VERSION + app/main.py)

v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)

  • PWA — Progressive Web App, offline support ✅ (livré) — 📄 Conception détaillée
  • Granular permissions — page-level, property-level access control ✅ (livré v6.1.0) — 📄 Conception détaillée
  • Web Clipper — extension navigateur ✅ (livré v6.2.0/6.2.1) — 📄 Conception détaillée
  • API publique complète — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — 📄 API Guide v2 · 📄 OpenAPI
  • SSO/SAML — enterprise authentication ✅ (livré v6.7.0) — 📄 Conception détaillée
  • Realtime editing (production) ✅ livré v6.4.0 (merge 3-voix au-delà du LWW, broadcast non bloquant) ; voir v5.13.0 pour le socle (curseurs + présence)
  • Synced blocks (production) ✅ livré v6.5.0 (page contenu par ligne de database, résolution serveur à chaque lecture, propagation écrite réelle, état deleted, rendu public) ; socle : v5.14.0 (bloc de base)

v7.0.0 — Cycle « Publier, Retrouver, Automatiser » (planifié)

Contexte (2026-09-28) : roadmap v6.0.0 COMPLETED (802 tests). Le core Notion est à parité (blocs, 11 vues, 21 props, realtime, synced, PWA, API v2, agent, SSO). Le cycle v7 comble les couches où Notion a poussé en 2025-2026 : Sites, Forms, Search sémantique, Automations/Workers, Calendar sync, SCIM, MCP. Référence : notion.com/product/features + notion.com/releases (Workers, Meeting Notes → agents, Developer bar, MCP).

v6.8.0 — Sites & Forms publics ✅ (2026-09-28)

Objectif : publier (site multi-pages) et collecter (formulaires anonymes). Parité Notion Sites + Forms. Doc : docs/V68_Sites_Forms.md

  • Sites multi-pages — table sites (slug UNIQUE, root_page_id, title, theme light/dark, custom_domain, password_hash, expires_at, noindex, analytics_id), table site_pages (arbre public ordonné) ; nav latérale auto ; réutilise _render_blocks_public (synced + wiki résolus)
  • SEO & partage — OG/Twitter cards par site/page, sitemap /s/<slug>/sitemap.xml, meta robots, preview sociale
  • Gating public — mot de passe (hash salé password_utils, cookie signé 24h), expiry (410), noindex, analytics vues (site_views : jour + compteur, pas d'IP brute)
  • Routes — GET /s/<slug> (home), GET /s/<slug>/<page-slug> (résolution slug + id), GET|POST /s/<slug>/auth, GET/PATCH/DELETE /api/v2/sites* (+ pages + stats), domaine custom via Host header
  • Forms publics — form_config_json par collection (public_token f_*, fields, required, success_message, notify_user_ids) ; GET /f/<token> (no-auth) + POST /f/<token> (anonyme, rate-limit 20/h/IP, honeypot, validation validate_property_rule) ; chaque soumission = collection_pages + log form_responses (ip_hash jour)
  • Embed & notifs — ?embed=1 sans chrome, notif in-app aux notify_user_ids, trigger form.submitted (branché v7.0)
  • Migrations 24 — sites, site_pages, site_views, form_responses, colonne collections.form_config_json
  • Tests — tests/test_v68_sites_forms.py (20 tests : migration, CRUD, slug/conflit, auth, isolation, pages add/remove + root protégée, rendu home/subpage/blocs, 404, vues, password gate, expiry 410, sitemap, noindex, form config/submit/required/404/honeypot/rate-limit/embed)
  • Version — 6.8.0 (VERSION + app/main.py) · ruff check OK · CSRF exempt /s/ + /f/

v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)

Objectif : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations). Parité Notion Enterprise Search + AI Q&A. Doc : docs/V69_Search_Ask_AI.md

  • Embeddings — tables semantic_embeddings (resource_type, resource_id, chunk_id, chunk_text, embedding BLOB, model hash-256) + search_index_state ; encodeur hashed-TF déterminstique (md5 % 256, L2, zéro dépendance, embed_texts() pluggable) ; job incrémental (updated_at > indexed_at, batch 50, scheduler 5 min dans le lifespan) + purge_orphans()
  • Recherche hybride — GET /api/v2/search/hybrid (lexical FTS5/LIKE via search.py + cosine, fusion RRF k=60, X-Total-Count, pagination) ; filtres workspace_id, scope membership + PermissionManager (pages restreintes masquées), search_excluded respecté partout
  • Ask AI — POST /api/v2/search/ask ({question, workspace_id} → {answer_markdown, citations}) : top-8 chunks autorisés (ACL avant prompt), LLMClient.complete() si provider configuré sinon extractif offline avec [[fdpage:ID]], cache 10 min, rate-limit 30/min, audit
  • Observabilité — GET /api/v2/search/index-status (ressources indexées, vecteurs, modèle)
  • Migrations 25 — semantic_embeddings, semantic_index_state, colonne pages.search_excluded
  • Tests — tests/test_v69_search_ask.py (24 tests : migration, chunk/overlap, déterminisme/norme, cosinus, index idempotent, exclusion, purge, rappel vectoriel partiel, hybride keyword/auth/400/pagination/isolation ACL/exclusion/collections, ask citations/auth/400/cache/ACL/rate-limit, index-status)
  • Version — 6.9.0 (VERSION + app/main.py) · ruff check OK
  • UI — palette Ctrl+K : onglets Pages / ✨ Réponses IA livrés (v7.44.0) — onglet IA = POST /api/v2/search/ask (CSRF via getCsrf(), debounce 150 ms, échappement AVANT injection des liens [[fdpage:ID]] → citations cliquables + bloc « Sources ») ; gate E2E étendu (onglet cliqué → réponse non-échappée). Reporté : onglet Fichiers — aucun backend (/api/search ne renvoie que pages/collections) → à créer d'abord.

v7.0.0 — Automations v2 + Workers ✅ (2026-09-28)

Objectif : automatiser au-delà du if-this-then-that + custom code sandboxé. Parité Notion Automations + Workers (07/2026 : credits dashboard, partage équipe). Doc : docs/V70_Automations_Workers.md

  • Automations multi-étapes — table automation_steps (automation_id, kind, position, config_json) ; triggers any (défaut) / all (fenêtre 5 min, PUT .../mode) ; form.submitted branché de bout en bout ; conditions AND (réutilise match_condition_props) ; actions slack (incoming webhook, secret chiffré Fernet), email (via mailer, user: résolu), forge_issue (Gitea GiteaClient / GitHub API, token user_oauth_tokens), agent_trigger (conversation + run AgentEngine), delay (sleep plafonné 300s)
  • Bouton DB natif — type button dans PROPERTY_TYPES, colonne button_automation_id, POST /api/automations/press-button (CSRF-exempt, contexte ligne, erreurs 400 explicites)
  • Workers lite — app/services/workers.py : lint AST (imports os/sys/subprocess/socket, open/exec/eval, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier daily_budget_s, fork des partagés ; app/routers/workers.py : CRUD /api/v2/workers* (session ou Bearer write, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
  • Compat legacy — automations v5.1.0 sans steps inchangées ; matcher legacy ignore les automatisations à steps (pas de double run) ; triggers inconnus du catalogue webhook acceptés côté steps
  • Migrations 26 — automation_steps, workers, worker_runs, automations.trigger_mode, collection_properties.button_automation_id
  • Tests — tests/test_v70_automations_workers.py (31 tests : migration, steps CRUD/validation/auth, mode any/all, form.submitted, chaînes + interpolation, condition, delay, slack + secret chiffré, email no-SMTP, forge mock + sans-token, agent mock + 404, button press/validation, legacy single-run, workers CRUD/auth/rejet code/run ok/error/timeout/budget/fork/privacy/usage/cron/masquage code)
  • Version — 7.0.0 (VERSION + app/main.py) · ruff check OK
  • Éditeur visuel — pipeline steps dans Settings → Automations (v7.45.0) : cartes ordonnées (trigger/condition/delay/action) avec config typée par type (8 actions : webhook/set_property/create_page/notify/slack/email/forge_issue/agent_trigger + ops condition + datalist événements), ajout/édition/suppression/↑↓ via l'API /steps + bouton ✨ Convertir le JSON en pipeline (legacy → steps ordonnés). Bonus trouvé par le gate : 51 expressions multi-instructions (a=1; b() — ; = SEULE expression par directive interdit sous le parseur CSP, non couvert par le scan tokens) → converties en méthodes dans 11 fichiers (nav settings ×8, menu section base ×7, partages/éditeur ×13, breadcrumb ×5, lib/local ×6, board ×3, ctx/agent/workspaces/card/gitea ×6). Nouveau scanner scan_semi ajouté au lot.
  • Fix SW + CSRF uploads (v7.45.1) — logout ne sert plus la page « hors ligne » (le fetch event de navigation est en redirect:'manual' → 302 lue opaqueredirect → rejet ; rejoué en redirect:'follow' + 302 synthétique vers l'URL finale, Chromium refusant les responses redirected servies à une navigation) et drag & drop de fichiers/dossiers réparé (_doUpload + toggleFavorite sans X-CSRF-Token = oublis A19, derniers appels mutants du front). timeoutFetch annule enfin ses requêtes. Gate e2e/regression_logout_dnd.spec.js (2 tests verts).
  • Navigation partielle Alpine (v7.45.2) — les swaps fdLoad initialisaient .main-wrapper avant l'exécution des scripts de page (courses : « Undefined variable » sur l'éditeur, reading 'has' sur $store.fdCtx, SyntaxError: 'LW' already declared à la 2ᵉ visite). Fix maison dans app.js : x-ignore sur la zone swapée (nœud remplacé uniquement) → démontage unique à tous <script src> chargés via Alpine.initTree idempotent ; garde de fichier sur 5 scripts à const de haut niveau ; if (window.Alpine) sur 8 scripts + 7 templates inline ; polices Inter orphelines purgées d'app.css. Gates : regression_editor_mount + regression_partial_nav (7 pages × complet/partiel/2ᵉ = 0 erreur).
  • Flashs de navigation (v7.45.3) — pendant la fenêtre x-ignore (swap fdLoad → Alpine.initTree), la zone .main-wrapper était peinte non montée : tous ses [x-show] visibles à leur valeur brute (menus/panneaux/états loading+empty+table superposés) puis masqués d'un coup au montage = la rafale de fenêtres qui flashent à chaque changement de page/section. Fix 6 lignes dans app.js : opacity:0 + pointer-events:none posés dans htmx:afterSwap (même task que le swap → aucun paint intermédiaire), retirés après initTree sur les 3 chemins de démontage. Mesure avant : 19 éléments bruts/87 ms sur /library (CLS 0.149), 80 éléments/732 ms sur /settings — après : 0 frame de contenu brut. Gate maison e2e/probe_nav_perf.spec.js (marques htmx + fenêtre x-ignore + échantillon par frame + layout-shift).
    • Suivis restants (mesurés, non traités) : CLS résiduel 0.058 sur /library (états lib-loading/lib-empty → lib-table : le remplacement du spinner par les lignes décale le contenu — nécessiterait un squelette de lignes, pas un réglage CSS) · fenêtre x-ignore de ~150–300 ms sur /settings (coût des scripts de page avant initTree, pas de gain sans changer l'ordre de montage) · la topbar est dans .main-wrapper (base.html) → re-swappée et re-montée à chaque navigation (avec le masquage elle disparaît/revient au lieu de flasher — la sortir demanderait de la swapper séparément).
    • Suite v7.45.4 : x-cloak posé sur .app-layout (racine Alpine) → les chargements complets (F5, SW, hors ligne) ne peignent plus la sidebar brute ni la bande rouge hors ligne — le masquage fdLoad ne couvrait que les swaps partiels. fd-navigating (classe sans règle CSS) supprimée. Probe étendu à 4 scénarios (nav / clic Home réel / Home hors ligne / plein chargement) avec assertions. Symptôme Home non rejouable en navigateur frais (0 frame brut, 0 erreur console) → à confirmer sur le navigateur signalé.
  • Poubelle (v7.45.5) — page /trash alignée sur ce que fait ce type de section : sélection multiple (select all porté au filtre courant + barre Restore/Delete/Clear), Empty Trash (POST /board/api/trash/empty, purge en masse en 1 requête), tri (récents/anciens/nom) et filtre emplacement réels (les deux boutons décoratifs « Last edited by ▾ » / « In ▾ » qui ne faisaient rien sont remplacés), date de suppression + jours restants par élément (rétention 30 j), compteur de résultats, états vides distincts, toasts sur chaque action. 4 bugs trouvés en route : (1) Restore/Delete ne marchaient jamais (getCsrf() = chaîne mais code lu csrf?.[1] → 403 CSRF silencieux), (2) « Move to Trash » de l'éditeur = 404 (route /board/api/pages/{id}/trash inexistante, .then() naviguait quand même → la page n'allait jamais à la poubelle), (3) page restaurée invisible en Library/Recents/Private (parent_section='Trash' réécrit au delete, jamais remis → deleted_at = source de vérité unique + réparation au boot), (4) routes trash sans auth (401 ajouté, vérifié anonyme). Gates : tests/test_trash_api.py (5) + e2e/trash_ui.spec.js (1) + suite 1099 passed.
  • Fenêtre de confirmation thématisée (v7.45.6) — les 3 confirmations destructives de /trash (suppression unitaire, groupée, Empty Trash) ne passent plus par le confirm() du navigateur : fenêtre FlowDeck réutilisant les classes flowdeck-modal* déjà présentes dans base.html (variables du thème, overlay cliquable + Échap pour annuler, Cancel + bouton de confirmation coloré porteur du libellé exact, titre/message avec le nom de la page ou le nombre de pages, role="alertdialog"). Composant dans trashData() : askConfirm / cancelConfirm / acceptConfirm (mutations de propriétés individuelles, pitfall réactivité Alpine). Gate e2e : ouverture + titre + message + annulation (page toujours là) + confirmation (contenu réellement effacé) + purge, et le test échoue si un dialog natif revient (NATIVE_DIALOG).

v7.1.0 — Calendar sync + Meeting Notes ✅ (2026-09-28)

Objectif : calendrier bidirectionnel + transcription → agents (cf. Notion 07/2026 : Meeting Notes trigger Custom Agents). Doc : docs/V71_Calendar_Meetings.md

  • Sync externe — table calendar_links (user_id, provider google/caldav, tokens_enc Fernet, calendar_id, collection_id, date_property, sync_token, last_sync) ; pull (event → ligne, date + external_event_id) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif calendar.conflict, résolution par édition manuelle) ; passe pull marquée touched (jamais repoussée)
  • Providers sans dépendance — Google REST (Bearer, 401 → « relink » explicite), CalDAV brut REPORT/PUT + parseur multistatus minimal (UID/SUMMARY/DTSTART/DESCRIPTION) ; I/O module-level = monkeypatchables
  • Meeting Notes v2 — upload audio (.mp3/.wav/.m4a/.ogg/.flac/.aac, 100 MB, meetings_dir()) → transcription STT_COMMAND ou transcript manuel (client-side STT) → résumé ai_writing.summarize (offline-capable) → trigger meeting.summarized (agents v7.0 : update tracker, post Slack, file tickets)
  • Free/busy — GET /db/{id}/calendar/freebusy?from=&to= (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
  • Migrations 27 — calendar_links, meeting_transcripts, colonne collection_pages.external_event_id + index
  • Tests — tests/test_v71_calendar_meetings.py (15 tests : migration, links CRUD/chiffrement/validation/auth/isolation, pull/push/idempotence/conflit LWW + notif, 502 token expiré, parseur CalDAV, freebusy + validations, upload + flow manuel + meeting.summarized → automation, validations audio, 404)
  • Version — 7.1.0 (VERSION + app/main.py) · ruff check OK

v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)

Objectif : provisioning auto, durcissement auth, audit exploitable. Parité Notion SCIM + audit + domain verification. Doc : docs/V72_Enterprise_SCIM_2FA.md

  • SCIM 2.0 — GET/POST/PUT/PATCH/DELETE /scim/v2/Users (Bearer scim_tokens, mapping userName→login, active→is_active) ; auto-suspend (is_active=0 → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 application/scim+json + exemption CSRF (clients IdP sans cookie)
  • 2FA — TOTP (users.totp_secret_enc chiffré Fernet, backup codes SHA-256 à usage unique, défi pending signé 5 min ; local-login → 2fa_required sans session puis local-verify) + passkeys WebAuthn (webauthn_credentials, attestation vérifiée, connexion sans mot de passe avec anti-rejeu sign_count) ; sso_only + 2FA combinables
  • Domain claim — domain_claims (domain normalisé, txt_token, auto_join_role, enforce_sso) ; vérification GET https://<domain>/.well-known/flowdeck-verify.txt ; SSO forcé par domaine dans local_login (admins exemptés) ; jeton jamais renvoyé par le listing
  • Audit UI — GET /api/v2/audit/logs fusionne api_audit_log + permission_audit_log + sso_login_history (schéma commun), filtres source/actor/action, pagination, export ?format=csv (admin ou Bearer read:admin) — UI Settings → Audit : voir follow-up « Polish » v7.3
  • Gouvernance agents — agent_policies (scope outils/workspace, max_steps, require_approval avant write) consultée par AgentEngine avant les ACL, file agent_approvals + décision /api/v2/agent-approvals/{id}/decide, événement agent.run.approval_requested
  • Migrations 28 — scim_tokens, domain_claims, webauthn_credentials, agent_policies, agent_approvals, colonnes users.totp_secret_enc/totp_backup_hashes (is_active existait)
  • Tests — tests/test_v72_enterprise.py (52 tests : migration 28, SCIM tokens/CRUD/suspend/doublon/404, 2FA setup-activate-verify-backup-chiffrement-désactivation + challenges, domain claims, WebAuthn, audit multi-source + filtres + CSV + pagination, politiques + gate d'approbation)
  • Version — 7.2.0 (VERSION + app/main.py) · ruff check app tests OK · test_agent.py+test_app.py 261 verts

v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)

Objectif : connaissance vérifiée + finition collaborative. Doc : docs/V73_Wiki_Teamspaces_Polish.md

  • Teamspaces — teamspaces (workspace_id, name, private) + teamspace_members (rôles owner/editor/commenter/viewer) ; pages.teamspace_id + collections.teamspace_id ; private → 404 (comme collections restricted) ; un teamspace public reste cantonné à son workspace
  • Verified pages — page_verifications (badge ✅, verified_by, note, expires_at 90 j) ; index /api/v2/wiki/verified (exclut expirés + teamspaces privés) ; sweep admin /api/v2/wiki/verify-expiry-sweep → notif page.verification_expiring à J-7 ; Wiki Home /api/v2/wiki/home (teamspaces + verified + recents)
  • Blocs manquants — mermaid (SVG inline via mmdc si présent, sinon <pre class="mermaid"> rendu client), equation_inline (KaTeX, source sanitizée : </>/\ retirés), progress (barre % agrégée sur property_values_json) ; rendus par export.blocks_to_html (donc export HTML/PDF) + POST /api/v2/wiki/blocks/preview
  • Collab polish — comment_reactions (agrégation emoji + toggle), page_follows (toggle + followers), guest_shares → GET /g/{token> sans compte (vue enregistrée, 404 HTML si expiré/révoqué), page_views (compteurs journaliers, séries sans trou)
  • Migrations 29 — teamspaces, teamspace_members, page_verifications, comment_reactions, page_follows, guest_shares, page_views + colonnes teamspace_id
  • Tests — tests/test_v73_wiki_polish.py (58 tests)
  • Version — 7.3.0 (VERSION + app/main.py) · ruff check app tests OK · v7.2 + v7.3 = 110 verts
  • Sidebar par teamspace — section Teamspaces dans base.html (état Alpine + loadTeamspaces/openTeamspace), GET /api/v2/wiki/teamspaces sans workspace_id (liste cross-workspace + workspace_name), page HTML GET /wiki/teamspaces/{id}, entrée DEFAULT_CONFIG sidebar
  • Notif page.updated aux followers — notify_followers_of_page_update (fenêtre 10 min), hooks fire_event + payloads actor_id (update_page/save_page_blocks), auto-follow à la création d'un commentaire
  • Charts avancés — type number (KPI count/sum/avg/min/max), dashboards multi-DB GET /db/{id}/dashboards/{did} (widgets à collection_id, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug val if val else 1), sélection du view config selon view_type
  • Link previews riches — unfurl gitea:owner/repo / github:owner/repo dans POST /board/api/og/metadata (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (url/title/description/image/site_name dans l'autosave)
  • UI Settings → Audit — settings.html onglet admin-audit branché sur /api/v2/audit/logs (filtres source/actor/action + pagination + export CSV)
  • SSO 21 casses — install python3-saml==1.16.0 + authlib==1.8.0 (xmlsec/isodate/joserfc) → tests/test_v67_sso.py 38/38
  • Tests follow-ups — tests/test_v73_wiki_polish.py (58 → 72 tests) : sidebar cross-workspace, page teamspace (owner 200 / outsider 404 API), auto-follow comment, notif throttlée, unfurl gitea/github + endpoint, KPI + dashboards multi-DB
  • Suite complète — python -m pytest -n auto → 1016 passed (était 981 passed / 21 failed en SSO)

v7.62.0 — Menu contextuel au highlight de texte ✅ (2026-10-08)

Objectif : menu façon Notion (mode sombre) au surlignage de texte dans l'éditeur. COMPLETED.

  • Toolbar de sélection 4 lignes — T + type de bloc dynamique + › (sous-menu Turn into existant) / A B I U T barré Tx / 🔗 S </> √x … / 💬 ✨, section grise Skills (Improve writing, Proofread, Explain, Reformat → runSkill) et pied Edit with AI Alt+⇧+E — affichée sous la sélection (au-dessus si pas de place), clampée au viewport, fermeture au clic hors sélection
  • … → menu contextuel de bloc déjà existant (recherche « Search actions… », groupe Text, 10 actions + raccourcis, pied métadonnées auteur/heure/taille) — pas de doublon de menu
  • Persistance du formatage inline — sérialiseur gtMd() (DOM → source markdown) branché sur sync() ; mdEsc() rend __…__, ==…==, $$…$$ (KaTeX inline) — avant, sync() relisait du texte brut et perdait tout le formatage au prochain rendu
  • Info-bulle claire à droite — data-tooltip-html + .tooltip-light dans le tooltip global (app.js)
  • Fix askAI(idx) écrasé par askAI() — « Ask AI » / Ctrl+J ouvraient l'IA de page → askAIBlock(idx)
  • Fix « toolbar muette sous Alpine v3 » — ed.__x (API v2) n'existe plus : showFmt/hideCommentBtn/mentions ne s'exécutaient jamais → accès via _xed() (_x_dataStack[0]) ; ancre de sélection élément gérée ; checkMd() recevait un id au lieu d'un index (exception à chaque espace)
  • Tests — tests/test_v762_selection_toolbar.py (roundtrip gtMd/mdEsc réel sous node) + e2e Playwright e2e/v762_selection_toolbar.spec.js (structure, sous-menus, géométrie, persistance bold/highlight après reload) ; VERSION + app/main.py = 7.62.0

v7.63.0 — Menus mobiles façon Notion ✅ (2026-10-08)

Objectif : parité des menus mobiles (Android sombre) — barre contextuelle au « / » + menu « Insert block » plein écran. COMPLETED.

  • Barre horizontale défilante sous le curseur (mobile ≤ 768 px) — 15 boutons dans l'ordre Notion : +▾, Turn into▾, @, 💬, 🖼, 🗑, ←→↑↓, ↩↪, Color▾, More▾, ⌨ ; overflow-x:auto, fermeture auto quand le / disparaît, taps exclus du handler « clic hors-zone »
  • Feuille plein écran « Insert block » (+) — titre centré + « Cancel » bleu, groupes CMDS avec raccourcis markdown (items partagés avec la popup desktop) ; Turn into / Color réutilisent les générateurs extraits (_blockTurnItems, _blockColorItems) ; More ouvre le menu de bloc existant
  • moveBlock(bid, dir) — ↑↓ ordre (racine + enfants toggle/columns), → indent dans le frère précédent, ← outdent, garde-fous toast
  • Raccourcis slash alignés sur les préfixes markdown (-, [], >, ---)
  • Tests — tests/test_v763_mobile_menus.py (+ moveBlock exécuté sous node), e2e mobile e2e/v763_mobile_menus.spec.js (390×844) ; VERSION + app/main.py = 7.63.0

v7.64.0 — Commentaires & réactions façon Notion ✅ (2026-10-08)

Objectif : affichage/prise de commentaire identique à Notion + réaction emoji sur le texte sélectionné. COMPLETED.

  • Réaction sur texte — bouton 🙂+ (« React to selected text ») dans la toolbar de sélection → sélecteur d'émoji existant en mode réaction (en-tête = texte sélectionné + ✋, onglets masqués, Recent au-dessus de People, Filter…, catégories + + custom) ; plage jaune + pastille (emoji + compte), clic pastille = rouvrir le sélecteur pour la plage
  • Backend — migration 37 text_reactions (UNIQUE plage × emoji × user) + GET/POST /api/pages/{id}/reactions (GET groupé, POST toggle) ; gtTok/gtMd ignorent les pastilles et _srcOffset exclut les annotations des offsets → le texte sauvé est identique
  • Ancres jaunes — _paintAnchors() après chaque render()/chargement ; clic sur l'ancre → tiroir ouvert + fil centré (flash) ; résolu = gris
  • Tiroir refondu — carte de fil (avatar, nom, horodatage, ✓ toggle resolve/reopen, ⋯ → Copy link #cmt-<id> / Delete), saisie « Add a comment... » avec @ et envoi ↑/Entrée ; resolveComment(c) corrige l'impossibilité de rouvrir
  • Tests — tests/test_v764_comments_reactions.py (8, dont toggle API réel), e2e e2e/v764_comments_reactions.spec.js ; VERSION + app/main.py = 7.64.0
  • v7.64.1 — Fix Delete sur bloc vide : la garde « texte vide → supprimer le bloc » couvre Backspace et Delete (une seule branche dans onKd), vide jugé via gtTok() (ignore les pastilles) ; e2e e2e/v7641_delete_key.spec.js

v7.65.0 — Comment ancré sur la sélection de texte ✅ (2026-10-08)

Objectif : le commentaire porte sur le texte sélectionné dans le bloc, pas sur le bloc entier, et le bouton bleu flottant disparaît. COMPLETED.

  • Ancrage sur la plage — commentBlock() prend la sélection vivante dans le bloc (repli : la plage mémorisée par la toolbar, qui survit au clic du bouton), sinon tout le bloc ; offsets calculés par _srcOffset() → anchor_start/anchor_end exacts dans le texte source
  • Bouton bleu « 💬 Comment » retiré — #_commentSelBtn, showCommentBtn(), hideCommentBtn() et leur listener mousedown supprimés ; déclencheurs restants = 💬 de la toolbar de sélection, menu de bloc, Ctrl+⇧+M
  • Tiroir — fermer remet _commentSel à zéro (pas d'ancrage périmé)
  • Tests — e2e e2e/v764_comments_reactions.spec.js : sous-sélection reaction a un → .fd-anchor[data-cid] = la plage seule, avant/après reload, #_commentSelBtn absent ; pytest 1383 passed, ruff OK ; VERSION + app/main.py = 7.65.0

v7.66.0 — Escape du menu slash + bouton commenter à droite ✅ (2026-10-08)

Objectif : Esc efface le / qui a ouvert le menu slash, et chaque bloc gagne un bouton de commentaire à droite. COMPLETED.

  • SM.escClose() — un seul implément, appelé par les deux chemins (focus dans le bloc → onKd, focus dans l'entrée du menu → listener document) ; retire le / d'ouverture seulement, remet le caret, sync + autoSave
  • Bouton à droite du bloc — .block-comment-btn (bulle + +, E.commentBlock(idx)), gouttière 64px, visible au survol, masqué mobile + page verrouillée ; remplace l'ancien .block-actions mort (opacity:0 sans survol → + invisible qui capait les clics)
  • fmtComment() restaure la plage de la toolbar avant d'ancrer ; commentBlock() ne se replie plus sur this._sel périmé
  • Tests — e2e e2e/v766_slash_esc_comment_btn.spec.js (Esc → menu fermé + bloc vide ; bouton à droite mesuré, ancre = bloc entier), tests/test_v766_slash_esc_comment_btn.py (3) ; pytest 1386 passed, ruff OK ; VERSION + app/main.py = 7.66.0

v7.67.0 — Réactions dans le tiroir + suppression + refonte du menu de sélection ✅ (2026-10-08)

Objectif : une réaction s'ajoute/supprime depuis le tiroir Comments, et le menu de sélection correspond à la maquette ASCII fournie. COMPLETED.

  • Section « Reactions » du tiroir — reactionList() + _anchorText() (texte de la plage lu dans le DOM, hors pastilles), compte si >1, état vide adapté ; removeReaction() = POST toggle
  • Suppression — ✕ dans le tiroir (mes réactions seulement) et croix ::after au survol de la pastille sur l'émoji que j'ai posé (aucun nœud ajouté → offsets source intacts)
  • Refonte du menu — ligne 4 scannée 💬 Comment | 🙂 réaction, réaction sortie de la ligne 3, entête Skills ≡, liste Skills scrollable, ✨ Ask AI retiré (doublon exact du pied)
    • écart volontaire à la maquette : le barré (T) de la ligne 2 est conservé (il n'y figure pas) pour ne pas perdre la fonction
  • Flake de gate corrigé — _is_public_host attrape OSError (le garde SSRF ne lève plus) + fixture stub_dns sur TestOGParser (le test faisait un vrai getaddrinfo("example.com"), échec sous -n auto)
  • Tests — tests/test_v767_reactions_drawer.py (3), e2e v764 étendu (réactions dans le tiroir + suppression A/B), e2e v762 (nouvelle ligne 4) ; pytest 1390 passed / 0 failed, ruff OK ; VERSION + app/main.py = 7.67.0

v7.68.0 — Colonnes rendues, tables IA, copier/couper/coller multi-blocs ✅ (2026-10-08)

Objectif : les colonnes (slash + Turn into) marchent, l'IA crée de vrais tableaux, et une sélection multi-blocs se copie/coupe/colle. COMPLETED.

  • Renderers morts branchés — renderBlock() n'appelait jamais renderColumnsBlock() / renderMathBlock() / renderTOC() → bloc colonnes, équation et TOC rendaient un paragraphe vide (bug rapporté) ; × de suppression de colonne visible au survol (était invisible mais cliquable)
  • Tables IA — _tblIsData/_tblIsSep acceptent le GFM sans pipe de tête (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ; md2b et paste2b alignés d'office
  • Coller un seul bloc structuré (table/code/titre) = vraie insertion ; « Copy link to block » réparé (link → copy-link)
  • Copier/couper multi-blocs — _clipState()/_clipMarkdown()/ clipCopy()/clipCut(), listeners copy/cut (Ctrl+C/X), groupe Clipboard dans le menu du clic droit (sélection figée à l'ouverture), clic droit sur un bloc sélectionné = sélection multi-blocs conservée
  • Tests — tests/test_v768_columns_clipboard.py (4), e2e v768_columns_clipboard.spec.js (4) ; pytest 1394 passed / 0 failed, ruff OK ; VERSION + app/main.py = 7.68.0

v7.69.0 — La sélection multi-blocs fonctionne enfin ✅ (2026-10-08)

Contexte : v7.68.0 livrait la mécanique Clipboard (plage multi-blocs, handlers copy/cut, groupe Clipboard du menu contextuel) mais elle était inutilisable en pratique — trois rapports d'usage : Ctrl+C ne copie rien, le glisser souris ne sélectionne qu'un bloc, le clic droit efface le surlignage.

Causes racines trouvées (trace d'événements à l'appui, pas de supposition) :

Symptôme Cause réelle
Ctrl+C/X muet le navigateur n'émet aucun événement copy quand la sélection traverse deux contenteditable : getSelection().toString() renvoie "", donc nos handlers n'étaient jamais atteints
Glisser = 1 bloc séquence mouseup → click → focusin → collapse : la plage reconstruite mourait dans la foulée
Clic droit efface .bm-search prenait le focus 10 ms après l'ouverture du menu

Corrections : interception du raccourci au keydown (capture document, garde closest('#_blocksCt')) ; plage reconstruite mise en attente (_mdSelRange) et restaurée au tour suivant, remise à zéro à chaque nouveau geste ; le menu ne vole plus le focus quand un texte est sélectionné ; preventDefault du clic droit remonté sur mousedown (sur contextmenu c'est trop tard).

Identité visuelle : bannière + logo intégrés — WebP 22,5 Ko / 5,3 Ko (les originaux faisaient 1,6 Mo / 1,1 Mo), JPEG de secours, icônes PWA régénérées à partir du vrai logo, <picture> sur la landing, og:image/twitter:card.

Vérification : tests/test_v769_selection_persistence.py (19 statiques) + e2e/v769_selection_persistence.spec.js (5, vrais gestes) ; le spec v768 a été corrigé pour remplacer execCommand et les plages fabriquées par de vrais gestes.

Piège de test relevé au passage : les ··· dans les échecs Playwright sont le marqueur de troncature du message d'erreur, pas un bug produit — une heure passée à chercher une fuite inexistante. Vérifier JSON.stringify() de la valeur réelle avant de croire un affichage tronqué.


v7.69.3 — Aide alignée sur Settings ✅ · double-clic close ⏳ ouvert (2026-10-08)

Livré : la section Aide partage maintenant le positionnement exact de la section Settings. help.html neutralisait son overlay en style inline (position:static, fond transparent, sans blur, sans padding) et forçait un panneau 1050px / 100vh-120px — les deux pages pourtant identiques au CSS près produisaient deux rendus. Surcharges retirées ; e2e compare les géométries calculées : identiques des deux côtés (overlay fixed avec fond, panneau 1050×765 centré, close à 13/13 px du coin).

Terminé en v7.69.4 — double-clic sur le bouton close de Settings.

Le diagnostic posé en v7.69.3 était FAUX : je pensais qu'Alpine ne liait pas le composant (_x_dataStack absent), donc que le handler était inerte. La question à l'utilisateur a tranché : « après le premier clic la page est réactive » → Alpine lie bien le composant, le handler s'exécute. Le scénario target="_blank" qui m'avait donné _x_dataStack absent était un artefact de mon propre banc d'essai, pas le parcours réel. Ne pas relire ce diagnostic.

Cause réelle : closeSettings() faisait history.back() puis window.location.reload() après un délai fixe de 100 ms. Entre les deux, le retour est une navigation asynchrone ; quand elle n'est pas terminée en 100 ms, le reload() retombe sur /settings — le panneau « se ferme puis revient ». Depuis /workspaces, la profondeur d'historique diffère et le retour arrive à temps : d'où l'absence de symptôme sur ce seul parcours.

Correctif : location.replace() vers la page d'origine. Une seule navigation déterministe, ni history.back(), ni popstate, ni rechargement différé. Effet secondaire assumé et souhaitable : « Retour » ne rouvre plus Settings.

Preuve avant/après (contrôle négatif fait) : même spec, deux builds.

  • ancien closeSettings() → le test du parcours menu-utilisateur ÉCHOUE (Received: "…/settings"), celui de /workspaces passe ;
  • correctif → les deux passent.

Piège relevé : ma « réfutation » de la thèse de course était fausse. J'avais interposé 400 ms via page.route(), qui intercepte les requêtes de sous-ressources — pas la navigation du document lors d'un history.back(), servie depuis le bfcache. Le délai n'a donc jamais été appliqué. Un contrôle négatif (rebuild avec l'ancien code) aurait tranché en un tour ; il a fallu plusieurs tours pour le faire.


✅ Fonctionnalités livrées hors roadmap (bonus détectés dans le code)

Feature Fichiers Note
Webhooks sortants services/webhook_outbound.py, routers/workspace.py (/workspace/webhooks) CRUD + dispatch d'événements — base pour automations/API publique
API publique + tokens routers/public.py / public_api.py, test test_public_api_token À formaliser dans v5.2.0 et documenter pour v6.0.0

🎯 Ordre de priorité (état 2026-09-28 — cycle v7 ouvert)

Cycle v6 — livré, pour mémoire

  1. v5.2.0 → Infrastructure & Polish ✅ livré
  2. v5.0.0 → Command palette + FTS5 ✅ livré
  3. v5.3.0 → Inline databases + templates + validation ✅ livré
  4. v5.13.0 → Realtime (WS + présence) ✅ livré
  5. v5.10.0 → Interactions de bloc ✅ livré
  6. v5.4.0 → Expérience éditeur ✅ livré
  7. v5.5.0 → Embeds & Média riche ✅ livré
  8. v5.6.0 → Import de données (6 phases) ✅ livré
  9. v5.7.0 → Database Avancée (Pt. 2) ✅ livré
  10. v5.8.0 → Calendrier & Rappels ✅ livré
  11. v5.11.0 → Wiki-links & mentions de page ✅ livré
  12. v5.12.0 → Templates & verrouillage de page ✅ livré
  13. v5.14.0 → Synced blocks ✅ livré · v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO ✅ livré

Cycle v7 — à livrer

Ordre Version Effort Impact
1 v6.8.0 → Sites & Forms publics M 🔴 publier + collecter
2 v6.9.0 → Search sémantique + Ask AI M 🔴 retrouver
3 v7.0.0 → Automations v2 + Workers L 🔴 automatiser
4 v7.1.0 → Calendar sync + Meeting Notes M 🟠
5 v7.2.0 → SCIM + 2FA + Audit UI M 🟠 enterprise
6 v7.3.0 → Wiki/Teamspaces + Polish S–M 🟢 finition

Résumé des phases

v0.x–v1.x ✅          v2.x ✅              v3.0.0 ✅       v4.0.0 ✅    v4.0.1 ✅
Base + Kanban         Éditeur + Gitea      UX Pro          MVP          Onboarding
+ DB + Auth           + Workspaces         + Multi-select  Accounts     & Polish
+ UI Notion           + Tags + Admin                       + Sharing    COMPLETED
                      + GitHub OAuth                       + Library

v4.0.2 ✅     v4.1–4.9 ✅     v4.10 ✅     v5.0–5.3 ✅     v5.13 ✅ · v5.10 ✅   v5.5 ✅ · v5.7 ✅   v5.14 ✅ · v6.0 ✅
Quality      DB views,      Agent IA     Palette →       Realtime +          Embeds &      Synced      PWA · Perms
& Tests      Templates &    COMPLETED    Automations     Interactions       Média riche   blocks      Clipper · API v2
             Collaboration               Realtime,      de bloc (undo/     (embed,       + DB
                                         DB avancée,    redo, drag&drop,   bookmark,     avancée
                                         Calendrier, AI duplicate)        lightbox…)    (Pt.2)      v6.1 ✅ v6.2 ✅ v6.3 ✅

*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*

---

## 🔴 Anomalies & dette technique — audit complet 2026-09-30

> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**

### 🔴 P0 — Critique (avant toute exposition réseau)

- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*

### 🟠 P1 — Hautes

- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
- [x] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
- [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [x] **A20 — CSP sans filet — ✅ TERMINÉ 2026-10-01 (v7.43.0)** : `script-src = 'self' 'nonce-…'` (**plus aucun `unsafe-eval`**) (`security.py:67`) → aucun nonce/hash ; combiné à A10, chaque sink XSS ci-dessus tourne sans violation CSP. *Fix : externaliser le JS inline (A27), passer à `'nonce-…'`, retirer `'unsafe-eval'`, resserrer `img-src`/`connect-src`.* — **fait 2026-10-01 (nonce)** : `script-src 'self' 'unsafe-eval' 'nonce-<per-request>'` — `unsafe-inline` retiré de script-src (fin des XSS injectés en JS) ; ContextVar `CSP_NONCE` posée par le middleware avant `call_next`, lue par `{{ csp_nonce() }}` (38 tags inline dans les templates + `_with_nonce()` pour la constante `LOCAL_LOGIN_HTML` + 3 scripts Python dans collections.py) ; htmx re-çoit le nonce via `<meta name="htmx-config">` (réponses boostées) ; les 74 handlers `onclick=` restent couverts par `script-src-attr 'unsafe-inline'` ; chart.js/leaflet (CDN, déjà utilisés par les vues chart/map et BLOQUÉS par CSP depuis toujours) ajoutés à `script-src`/`style-src` avec commentaire `ponytail:` (upgrade : vendoriser). **Phase 2 faite 2026-10-01** : **CDN vendorisé + connect-src fermé**. chart.js 4.5.1, leaflet 1.9 (js/css + 5 png) téléchargés vers `static/js/vendor/` (déjà ignoré par eslint) ; les 3 URL de `collections.py` pointent en local ; **`script-src` n'a plus aucun hôte tiers** (jsdelivr/unpkg retirés), idem `style-src` ; **`connect-src` = `'self' ws://{host} wss://{host}`** (Host de la requête, caractères filtrés — uvicorn rejette déjà les Host invalides) : le `https:` universel (canal d'exfil) et les `ws:`/`wss:` tout-hôtes disparaissent ; grep négatif = **0 fetch cross-origin côté front** ; Google Fonts étaient **morts** dans la CSP (0 ref) → retirés ; `img-src https:` **gardé volontairement** (unfurls YouTube/Vimeo… + tuiles OSM inénumérables, `ponytail:` commenté dans le code). Test `test_csp_no_cdn_and_vendor` (CSP sans CDN, connect-src exact, 4 assets vendor 200, source collections.py) + `test_view_chart_renders` mis à jour (vendor path) → 1090/1090. **Reste A20 → phase 3 scopée par probes (2026-10-01)** : htmx = **FINI** (`allowEval:false` dans le meta htmx-config — 0 `hx-on`/`hx-vars`/`hx-vals` grep) ; **`unsafe-eval` reste uniquement pour Alpine standard**. Le build `@alpinejs/csp` a été téléchargé et **testé** (0 `eval`/`new Function`, parseur maison, tourne sous CSP strict) mais est **bloqué** sur FlowDeck : (a) **13 expressions non parsables** par la grammaire restreinte (arrows ×2, `typeof` ×1, `new Date` ×4, optional-chaining ×6 — base, library, local_workspace, settings, gitea_workspace), (b) **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) = **interdits** par le build CSP (innerHTML), (c) le scope des expressions CSP = **données du composant uniquement** (0 variable globale ni `document` — probe : `Undefined variable: fmtDate`) → chaque site devient une méthode enregistrée via `Alpine.data`. **Plan** : migration composant par composant avec gate E2E dédiée par surface, puis retrait `unsafe-eval`.
**LOT 1 fait 2026-10-01 (v7.38.0)** : (1) **harnais `e2e/csp_preview.spec.js`** — sert le build CSP à la place de `alpine.min.js` par interception Playwright (0 déploiement, tout parse-error = pageerror) ; (2) **les 16 composants `x-data="fn()"` non enregistrés → `Alpine.data`** (registre = seule résolution du build CSP) ; (3) **shell base.html migré** : x-effect document → `syncSidebarClass()`, $nextTick arrow → `initSidebarSort()`, `window.FlowDeck.*` → méthodes, Object/Math/window dans x-for/:style → `sidebarSections()`/`sectionMenuPos()` ; (4) **x-html du shell migrés** → `x-init`+`Alpine.effect` (agent, carte projet, library ×3) + recherche library (`toggleSearch`) ; (5) **eslint 0/0 restauré** (globals `getCsrf` A38 + `;;` résiduels) ; **gate library VERT** (0 pageerror, icônes + recherche vérifiées) → 1093/1093.
**LOT 2 fait 2026-10-01 (v7.39.0)** : **settings + local_workspace VERTS** en csp_preview (3/5 surfaces) — voir CHANGELOG pour les 2 pièges structurels résolus (snapshot `ji` = objet `window` pré-boot banni → const lexicale partagée ; bloc preview hors racine → composant `wsPreview` déléguant, wrapper unique à cause des magics) + `RATE_LIMIT_REQUESTS=600` en `.env` local (rafales E2E vs 60/min).
**LOT 3a fait 2026-10-01 (v7.40.0)** : gate « surfaces simples » = **welcome, trash, accounts, workspace, import VERTS** (4 sans aucune modification — scan propre + registres lot 1) + **fix bug pré-existant `/import`** (x-text `report.relations` non garde → pageerror sous les 2 builds). **8 surfaces couvertes** au total.
**LOT 3b fait 2026-10-01 (v7.41.0)** : **gitea_workspace + agent_panel + page_editor VERTS** (éditeur = 15 sites : 12 `window.E` → délégués `edCall`/`edTimeAgo`/`edCommentCount`/`edShared`/`bindStar` dans appState + 3 dans `_page_editor_content` → `openBacklink`/`fmtImportSize`/`bindIconHtml` ; piège Jinja : quotes `\'` dans le `set`). **12 surfaces vertes** au total (7 gates).
**BASCULE RÉELLE 2026-10-01 (v7.43.0)** : `alpine.csp.min.js` servi partout (base/import/welcome + sw), `unsafe-eval` retiré de la CSP (assert test inversée), scan statique final = **0** (4 faux positifs dans des chaînes), E2E 7/7 **sous CSP réel** + suite 1094/1094. board/table_view/teamload/card_detail : scan propre, non gateés (gitea down) — à vérifier au premier usage avec gitea remonté.
**🐛 BUG TOPBAR — CORRIGÉ 2026-10-01 (v7.42.0)** : cause racine = `'literal' ~ fd_icon(…)` — `fd_icon` = macro → `Markup`, et `Markup.__radd__/__add__` **échappe les segments `str` littéraux** → boutons en entities partout ; `|safe` no-op sur Markup déjà échappé. Fix : **block-set** `{% set right_actions %}…{{ fd_icon() }}…{% endset %}` dans les 5 templates (gitea, page_editor ×2, workspace, workspaces) + **test permanent** `tests/test_topbar_right_actions.py` + assertion `.star-btn` rétablie dans le gate éditeur. Probable régression depuis A10 (autoescape). `img-src` : si un proxy d'images local arrive. Effort : **L** (reste : L, plan ci-dessus).
- [x] **A21 — `sqlite3` synchrone sur l'event loop — PARTIEL 2026-09-30 → phase 1 le 2026-10-01 : `PRAGMA busy_timeout=5000` ajouté au point d'entrée unique `get_conn()` (db.py)** ; reste le wrapper async `anyio.to_thread` + la migration des 510 call sites : `get_conn()` (`db.py:833-843`) est synchrone et **510 des 689 `async def` de routes** l'appellent (805 occurrences au total ; 0 `run_in_threadpool`, 1 seul `asyncio.to_thread` dans tout le dépôt : `semantic_search.py:262`) ; connexion neuve par requête (`connect` + 2 PRAGMA), **aucun `busy_timeout`**. Chaque requête bloque la boucle. *Fix : wrapper async (`anyio.to_thread.run_sync`) partagé, migrer d'abord `api_v2`/`dashboard`/`collections`/`board` + `PRAGMA busy_timeout=5000`.* — **phase 1 faite 2026-10-01** : **352 routes `async def` SANS aucun `await`** converties en `def` (scan corps par corps : ni `await`/`async with`/`async for`, ni `asyncio`) → FastAPI les exécute dans son threadpool, donc tout leur travail SQLite quitte l'event loop, sans changer une ligne de logique (api_v2 : 60, dashboard : 40, collections : 25, board : 23, + main.py : 6 ; aucune occurrence `asyncio`/`run_coroutine` dans les corps convertis). **Phase 2a faite 2026-10-01 (api_v2)** : les routes dont le SEUL await était `body = await request.json()` (36) → paramètre FastAPI `body: dict = Body(default={})` (parsing fait par FastAPI avant l'appel, équivalences vérifiées : corps absent → `{}` comme le try/except d'avant, JSON invalide → 422 au lieu d'un silencieux `{}`) + conversion en `def` → **api_v2 passe à 96/115 routes hors loop**. **Phase 2b faite 2026-10-01 (api_v2 bouclé)** : helper `run_event_sync(coro)` (automations.py) — `asyncio.run` sur une boucle dédiée dans le worker threadpool : l'événement est EXÉCUTÉ ET ATTENDU avant la réponse (déterministe comme l'await) mais ne bloque jamais la boucle ; les 15 routes dont les seuls awaits étaient `json`/`_fire_event`/`fire_published`/`fire_unpublished` passent en `def`. **api_v2 : 111/115 routes hors loop**, les 4 restantes ont de vrais awaits réseau (`import_csv_v2` multipart, `project_tree_v2` (gitea), `test_webhook_v2`, `retry_webhook_deliveries`). **Phase 2c faite 2026-10-01 (repo-wide)** : **283 → 93 routes async** (**86 % des 667 routes hors loop**, avant 61 %) en 4 passes — (A) racine auth : `get_current_user` (session.py) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) étaient `async` **sans aucun await** → `def`, **47 `await` supprimés** dont 3 via l'alias `gcu` (piège : grep littéral aveugle, rattrapé par la suite) ; (B) re-scan → 19 routes flipées ; (C/D) **155 routes** `request.json`/événements → `Body(default={})` (3 formes : try/except `body = {}` intact, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, **0 test sur le 400**), forme conditionnelle content-type ×54 → défaut `{}`) + `run_event_sync` → `def`. **Reste async (93, justifié)** : form/upload/file (22), gitea/llm/oidc réseau (~25), `_json_body` 9, 2 JSON inline en argument, 1 fallback logique, 1 lecture conditionnelle web_clipper, + mixtes json+réseau. **A21 : fait** (sauf l'idée initiale d'`anyio.to_thread` par bloc DB — **inutile** : les routes sont DÉJÀ hors loop, le SQLite synchrone n'est plus sur la boucle). Effort : **L** (fait).
- [x] **A22 — Validateur d'upload = code mort** : `validate_upload()` + `ALLOWED_EXTENSIONS` + `MAX_UPLOAD_SIZE` (`security.py:33-40`) n'ont **aucun appelant** ; `upload_local_workspace_file` (`dashboard.py:1507-1574`) fait `file_path.write_bytes(content)` après seulement `Path(filename).name` — ni taille, ni extension, ni auth, et `/api/local-workspace` est exclu du CSRF. Chemin aussi codé en dur `Path(f"/data/uploads/...")` au lieu de `FLOWDECK_DATA_DIR`. *Fix : appeler `validate_upload()` avant `read()` + session. Effort : **S**.*
- [x] **A23 — N+1 avérés** : `dashboard.py:905` (`COUNT(*) FROM pages` par page dans une boucle de 20), `board.py:2141-2146` (INSERT + `_extract_ai_keywords` par issue), `collections.py:378` (INSERT par propriété en boucle). *Fix : une agrégation `GROUP BY` / un `executemany`. Effort : **S**.*
- [x] **A24 — 2 routes silencieusement mortes (vérifié en servant l'app)** : `GET /workspace` → `dashboard.py:820` (HTML, inclus 1er, `main.py:158`) écrase `workspace.py:30` **et** `library.py:537` (JSON, inclus après) ; `GET /api/projects` → `projects.py:24` (inclus 161) écrase `api.py:82` (handler Gitea). *Fix : supprimer/renommer les doublons + assertion au boot « pas deux routes même méthode+chemin ». Effort : **XS**.*
- [x] **A25 — 92 `except Exception: pass` dont 68 dans des handlers async, 0 loggé** (471 `except Exception` au total, 102 dans `api_v2.py` seul) ; pire : `api_v2.py:504-511` avale l'échec de `materialize_properties` **dans la transaction de création de collection** (collection commitée sans schéma), `api_v2.py:859`, et tous les `await _fire_event(...)` (`1750`, `885`, `903`) → pipeline d'événements/webhooks cassé = invisible. *Fix : `logger.exception` sur les 68 sites ; supprimer le try autour des 2 writes de `create_collection_v2`. Effort : **M**.*

### 🟡 P2 — Moyennes

- [x] **A26 — Config piège** : (a) `FLOWDECK_STANDALONE` documenté dans `config.py:26` mais **jamais lu** (le champ réel est `STANDALONE`, pas de `env_prefix`, `extra="ignore"` avale la coquille) ; (b) `.env.example` documente `postgresql://…` mais `db_path` (`config.py:133`) retombe silencieusement sur SQLite ; (c) `db_path` fait `Path("/" + p)` (`config.py:132`) → pour `sqlite:////data/flowdeck.db` le résultat est le chemin UNC `\\data\flowdeck.db` (vérifié : inexistant) ; (d) `app_secret_key="change-me-to-random"` (`config.py:36`) + `gitea_oauth_client_secret="test-secret"` sans aucun garde au boot alors qu'il signe `flowdeck_session` (`session.py:14`). *Fix : 1 normalisation de `db_path` + `raise` au boot si secret par défaut + corriger le commentaire/`env_prefix`. Effort : **S**.*
- [x] **A27 — 13 900 lignes de JS inline — TERMINÉ 2026-10-01 (extraction 11 874 L / -85 % + eslint 0/0) — reste : structurel seul (base 1 523 L)**, ~3 800 livrées sur **chaque** page (`base.html` 1520 + `agent_panel` 1805 + `_icon_picker` 297 + `_header` 124 + `_notification_bell` 69), et **0 linté** : `eslint.config.mjs:50` ne couvre que `static/js/**/*.js` (soit `app.js` + `offline.js`), 2 blocs se neutralisent avec `/* eslint-disable */`. Grosseurs : `_page_editor_scripts` 2517, `local_workspace` 2030, `agent_panel` 1805, `base` 1520, `_database_table_scripts` 1323, `settings` 1093, `library` 1039. *Fix : extraire les gros partials vers `/static/js/*.js` (ils ne sont pas Jinja-interpolés) + ajouter les templates à eslint.* — **phase 1 faite 2026-10-01** : **7 templates sans Jinja → 9 fichiers `static/js/*.js` (4 243 lignes, -30 % du JS inline)** : agent_panel_1/2 (4 243… dont le 1 788 L livré sur CHAQUE page), library 1039, gitea_workspace 626, _icon_picker_1/2, _ctx_menu, import, workspaces — **un fichier par bloc** (ordre/timing identique, pas de defer, attributs conservés, `?v={{ asset_version }}`), `node --check` vert sur les 9, 0 script inline restant dans les cibles, suite 1089/1089. **Lint** : eslint **installé globalement** (`npm i -g eslint`, la config était déjà flat v9 sans dépendances) → `eslint static/js` = **0 erreur / 120 warnings** (no-unused-vars 69, no-empty 36, no-undef 15 — baseline à nettoyer opportunistiquement). **Phase 2a faite 2026-10-01 (l'éditeur, le plus gros bloc interpolé)** : `_page_editor_scripts` **2 516 L extraites** vers `static/js/page_editor_scripts.js` — recette « config JSON » : les 8 interpolations Jinja lisent `PD` = `JSON.parse(#page-data)` (le bloc JSON **existait déjà** juste avant le script) ; la route enrichit `page_data` de `updated_at`, `created_at`, `user_id`, `is_shared` (**dérivé hoisté : une seule expression pour le ctx ET le JSON**) et `clip_icon` (macro `fd_icon` rendue côté serveur) ; `workspace_key` reste vide comme avant (jamais défini dans ce ctx — parité). **Cumul A27 : 6 759 L extraites** (13 904 → 7 145 inline). **8 tests adaptés** (ils lisaient le template source → lisent maintenant `static/js/page_editor_scripts.js` ; `pageIsShared:true` → parsing du JSON `#page-data` → `is_shared is True`). **Phase 2b faite 2026-10-01** : **+3 801 L** extraites avec la même recette config JSON : `local_workspace` (2031 — `lw-config` : current_folder_id/workspace_id), `settings` (1093 — `st-config` : avatar/user/is_admin/auth_method, **2 routes rendent ce template**, expressions `or ""` préservées pour Undefined), `_page_editor_realtime` (531 — `rt-config` : SELF id/login/full_name/color), `board` (146 — `bd-config` : owner/repo/initial_view). BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON) au lieu d'être interpolées dans des strings JS. **Cumul A27 : 10 560 L extraites** (13 904 → **3 344 restantes**), `node --check` vert ×4, suite 1089/1089, eslint **0 erreur / 279 warnings** (12 fichiers). **Phase 2c faite 2026-10-01 (BILAN : extraction TERMINÉE)** : `database_table` **1 314 L extraites** — le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `null`-vs-objet (`#db-config`), le JS appelle `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` : les 2 branches Jinja disparaissent ; 2 tests adaptés (source → `database_table.js`) + `FlowDeckDB` dans src. **BILAN A27 : 11 874 L extraites en 4 phases** (4 243 + 2 516 + 3 801 + 1 314), **inline 13 904 → 2 022 L (-85 %)**, 22 fichiers `static/js/*.js`, `node --check` vert partout, suite 1089/1089. **Lint A27 TERMINÉ 2026-10-01 : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers). 3 familles traitées : (1) **no-empty ×70** = tous des `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 + commentaire : passe no-empty ET no-unused-vars, zéro changement de comportement) ; (2) **no-unused-vars ×171** = bindings de catch retirés + 24 lignes mortes déterministes (suppressions avec assert sur le texte exact : `var self = this` ×8, compteurs jamais lus `restored++`/`resolved++`/`acc`/`today`, `uid()`/`propName()` sans 1 appel, etc.) + `/* exported */` sur les **10 fonctions appelées depuis les attributs HTML** (vérifiées par grep, 1 template chacune) ; (3) **no-undef ×44** = globaux réels déclarés dans `eslint.config.mjs` (`getSvgIcon` = script inline de `base.html`, `TextDecoder` = API navigateur, `Prism` = CDN) + **2 vrais correctifs** : `settings.js` utilisait `typeof toast === 'function'` (guard toujours faux → les toasts timezone/SAML ne s'affichaient JAMAIS) → `window.showToast`, et `_wsInitData = window._wsInitData` (auto-affectation sans effet, global implicite) supprimé. **Reste A27** : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles. Effort : **L** (fait).
- [ ] **A28 — Dette de découpe (god files)** : `api_v2.py` 115 routes / 131 Ko, `dashboard.py` 63 / 116 Ko (27 pages HTMLResponse + 50 JSON + I/O fichiers, 16 `Environment(...)` locaux), `collections.py` 53 / 112 Ko, `board.py` 53 / 93 Ko (page CRUD + `zipfile` + sync Gitea). *Fix : scinder par **concern** (`pages_html`, `files`, sous-modules `api_v2/*`) — mécanique, 0 changement d'URL. Effort : **L**.* **Lot 1 fait 2026-10-01 (api_v2)** : le module `api_v2.py` (2 110 lignes, 115 routes) devient le **package `app/routers/api_v2/`** = 12 modules par concern (identity 7, workspaces 9, collections 23, properties 7, views 8, engagement 21, sharing 8, planning 7, templates_io 9, projects 4, admin 4, webhooks 8) + `_common.py` (`_hash`, `_v2_rate_check`) + `__init__.py` (aggrégat `APIRouter(prefix="/api/v2")` + `include_router` sans prefix). Preuve contractuelle : **`docs/openapi-v2.json` régénéré = IDENTIQUE byte-à-byte** (0 changement de chemin/tag/operation_id), seul importateur = `main.py` (`from app.routers.api_v2 import router` → le package l'expose) ; en-tête d'imports copié puis émondé par `ruff --fix`. **Lot 2 fait 2026-10-01 (dashboard)** : `dashboard.py` (2 735 lignes, 63 routes) → **package `app/routers/dashboard/`** = 8 modules par concern (pages_html 488 L/6 r., local_workspace 551/15, settings→`account_settings` 442/16, workspace 323/9, pages_api 243/6, workspaces 135/6, account_api 82/4, public 82/1) + `_common.py` (les **15 helpers intercalés** + état `logger`/`_VERSION`/`WORKSPACE_COOKIE`) + `__init__.py` (re-export complet : 7 importateurs — main, board ×3, my_tasks, web_clipper, wiki, sites `_dash._render_blocks_public`, tests). Pièges rencontrés : segment décorateur sans le `def` (corps perdus, assert `def in seg` ajouté) ; collision `settings` (section vs `from app.config import settings` dans le header → **`hasattr` du fromlist** : la section renommée `account_settings`) ; `WORKSPACE_COOKIE` utilisé sans import dans `workspaces.py` (F821 → ajout automatique) ; script `__all__` qui mangeait la queue du fichier (réécrit à la main). Preuve contractuelle : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (ordre d'enregistrement préservé). **Lot 3 fait 2026-10-01 (collections)** : `collections.py` (2 622 lignes, 53 endpoints / 52 fonctions — 1 paire de décorateurs empilés) → **package `app/routers/collections/`** = 10 modules par concern (crud 337, properties 322, linked 286, structure 267, dashboard_views 214, meta 197, views 187, pages 184, data_api 122, boards 61) + `_common.py` (8 helpers auth/permissions/validation, 220 L) + `_renderers.py` (**15 rendus de vues** + `CHART_MAX_GROUPS`, 667 L) + `__init__.py` (ré-exports : `_validate_page_properties` pour automations, 4 helpers de graphes pour les tests). Pièges : docstring d'origine dans le header → **F404 `from __future__` après un statement** (slice `[1:30]`), décorateurs empilés (`view_collection` ×2 → segments sans `def` → skip du 2ᵉ décorateur), `CHART_MAX_GROUPS` hors détection des helpers (F821 → import auto), test CDN lisant `collections.py` (balayage du package). Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte**. **Lot 4 fait 2026-10-01 (board) → A28 TERMINÉ** : `board.py` (2 101 lignes, 53 routes) → **package `app/routers/board/`** = 12 modules par concern (page_api 5 r., sharing 10, pages 7, synced 8, board_views 8, page_media 4, page_ops 3, wiki 2, import_ 2, embed 2, library 1, sync 1) + `_common.py` (les **23 helpers dont 4 async** + les 4 constantes — oubliées à la 1ʳᵉ passe → F821/ImportError, ré-insérées) + `__init__.py` (`__all__` complet : api.py ×4 top-level, webhooks, dashboard ×5 lazy, tests ×4 — importateurs **inchangés**). Pièges : docstring du header (F404 → slice `[1:21]`), décorateurs empilés, helpers **async** non détectés par `def ` seul. Preuve : **`docs/openapi-v2.json` IDENTIQUE byte-à-byte** (509 chemins). **A28 TERMINÉ en 4 lots** : api_v2 (7.29.0), dashboard (7.30.0), collections (7.31.0), board (7.32.0) — 0 changement d'URL sur les 4 → suite 1091/1091, version 7.32.0.
- [x] **A29 — Endpoints dupliqués 2-3×** : publish/unpublish existe en 3 endroits (`sharing.py:304/345`, `board.py:1020/1039`, `api_v2.py:1743/1761`) avec slug et auth **différents** ; listing collections ×3 (`/api/v1/collections`, `/db/api`, `/api/v2/collections`) ; `/api/users/me` ×2. *Fix : un `services/publish.py` partagé, les routers déléguent.* — **fait 2026-10-01** : `services/publish.py` (slugify unique, 404 partout, événements) ; les 3 paires publish/unpublish déléguent (sharing + board + v2), board gagne `_require_auth`, les bonus divergents (`share_mode='anyone'` / `is_shared=1`) supprimés — le share dialog reste propriétaire de ces drapeaux ; **byproduct sécurité** : `GET /api/users/me` (v1) et le contexte de `/accounts` faisaient `SELECT *` → `password_hash` exposé → colonnes whitelistées. **Décision** : `/api/users/me` ×2 et listing collections ×3 **restent** — contrats versionnés distincts (session+guest vs Bearer+scope, formes différentes). Effort : **M**.
- [x] **A30 — 16 fonctions top-level jamais référencées**, dont `require_scope` (`api_v2_helpers.py:213`, la factory FastAPI qui doit faire les scopes — les handlers font `has_scope(...)` à la main), `validate_upload`, `_get_user_or_redirect`, `_require_user_gitea`, `unsync_block`, `find_referring`… *Fix : câbler `validate_upload` (A22) + `require_scope`, supprimer le reste. Effort : **S**.*
- [x] **A31 — Dette migrations** : `migrations.py` 1 522 lignes / 66 Ko, 28 migrations (versions 2-29, contiguës, bien version-gated), **25 copies du motif `PRAGMA table_info`** sans helper (`table_exists`/`column_exists` inexistants), 30 `ALTER TABLE`, et `fn(conn)` tourne **hors transaction** → un échec au milieu laisse du DDL partiel commité. *Fix : 3 helpers + transaction par migration.* — **fait 2026-10-01** : `_apply_one()` — BEGIN explicite par migration, rollback complet à l'échec (avant : DDL en autocommit → schéma partiel commité sans ligne `schema_version`, la reprise rejouait un DDL déjà appliqué) ; **1 helper au lieu de 3** : `columns(conn, table)` (valide l'identifiant) remplace les **25 copies** de `PRAGMA table_info` — `table_exists`/`column_exists` non livrés : aucune migration n'interroge `sqlite_master` et un contrôle unitaire se lit dans le set (YAGNI). Tests : rollback DDL + validation d'identifiant. Effort : **M**.
- [x] **A32 — Couverture de tests par trou — TERMINÉ 2026-10-01** : routers à **0 test** : `webhooks.py` (0/3), `notes.py` (0/2), `sidebar_config.py` (0/2), `github_routes.py` (0/2) ; quasi nuls : `library.py` 1/10, `api.py` 3/23 (move, col-mapping, board-config, CRUD issues), `dashboard.py` 17/63, `api_v2.py` 50/115. Points positifs vérifiés : 1 002 tests, **aucun sans `assert`**, aucun qui touche le réseau réel. *Fix : 1 smoke test par route non couverte (fixture TestClient existante).* — **phase 1 faite 2026-10-01** : les **4 routers à 0 test sont couverts** — `tests/test_smoke_uncovered.py`, 10 tests : webhooks (receive ok sans secret, 401 HMAC via monkeypatch, register 400 sans appel réseau, status avec `gitea.list_webhooks` stubbé), notes (GET HTML + roundtrip upsert avec échappement), sidebar_config (GET, PUT persisté relu en base, 400 sans config + remise en état), github_routes (status/disconnect) — **0 accès réseau réel**. **Phase 2a faite 2026-10-01** : `library.py` **1/10 → 8 routes couvertes** (5 listes en 1 test + private seedé, children avec parent/enfant seedé, repository vide/clé, + test de non-régression post-suppression). **Découverte** : `/api/library/local-workspace-children/{id}` (toujours 500) et `/api/library/local-workspace` (500 dès qu'un workspace existe) lisaient `local_workspace_items` — **une table inexistante nulle part dans le codebase**, zéro référence front → **2 routes + `_format_size` supprimés** (test 404 anti-réintroduction). **Phase 2b faite 2026-10-01 (partielle — api.py)** : **3/23 → 16/22 routes couvertes** (mesure sur les `@router` du fichier) : board-config (défauts + roundtrip création), col-mapping (404 sans board, upsert + suppression), card (404 + ok), collaborators (**`gitea.get_collaborators` stubbé**, zéro réseau), frontend-error (capture, JSON invalide → ignored, dédup `count=2`, lecture qui purge), checklist-items PATCH/DELETE + checklists DELETE (mutations **vérifiées en base** avec seed + cleanup). **Reste api.py** : 6 routes **Gitea** (issues ×4, checklists/checklist-items création avec owner/repo) → stub de transport httpx + formes de réponse par endpoint. **Phase 2c faite 2026-10-01 (api_v2)** : scan strict des 115 routes vs tests → **5 routes à 0 référence** couvertes : `evaluate-formula` (200 + 400 sans expression ; le moteur renvoie `1 + 2` tel quel — on teste le câble, pas le moteur), `compute-rollup` (400 `collection_id required` + 401 sans bearer), `admin/audit-logs` (**portail vérifié** : attendu calculé depuis `/users/me` car le tout premier utilisateur du worker est admin, + token scope `admin` → 200), `webhooks/events` (catalogue + wildcards), `webhooks/verify-signature` (**valid True avec `sign_payload`, False avec signature bidon**). **Phase 2d faite 2026-10-01 (dashboard, partiel)** : scan strict → 44 routes à 0 ref stricte sur 63 ; **10 couvertes** cette passe : tags CRUD complet (création lowercasée, liste, couleur relue, suppression), page content→rename(+400 titre vide)→trash (title/parent_section/deleted_at **relus en base**), sidebar workspace-tree (fragment HTML « No pages yet »), avatar-color (couleur **relue sur l'utilisateur de la session** puis restaurée), workspace members (shape). **Phase 2e faite 2026-10-01** : +**9 routes** (cumul dashboard : 27→36 des 63) : pages `/accounts` + `/accounts/settings` (HTML, « password_hash » **absent** du rendu — whitelist A2), `PUT /api/user/profile` (persistance relue + restauration), `PUT /api/user/password` (**403 sans mdp actuel correct** = A3 ; quirk assumé validé : longueur avant auth en 200+erreur), `POST /api/user/token` (format `fd_`+64 hex, `user_tokens` nettoyé), `DELETE /api/user/forge/{provider}`, `PUT /api/settings/account` (persistance + **400 mdp court**, restauration), `POST /api/workspaces/{id}/select` (cookie `Set-Cookie` vérifié), `GET /api/local-workspace/breadcrumb`. **Phase 2f faite 2026-10-01** : +**7 routes** (cumul dashboard **36→43/63**) centrées sur les **garde-fous A16** : `/api/files/{ws}/{path}` — traversal `%2e%2e%2f` → **403 « Path traversal denied »**, 404 inexistant, 200 + octets pour un vrai fichier écrit dans le data_dir de test (nettoyé) ; `download` (page markdown → 404 propre) et `file-content` (page « file » dont le chemin sort de la racine → **jamais 200**, 404/415) ; `local-workspace/page-content` (contenu relu + 404) ; `/api/avatar/{id}` — **302 avec `follow_redirects=False`** (aucune requête réelle vers l'URL externe) + 404 ; `collections/{id}/table-data` (404 + shape) et `POST collections/{id}/pages` (ligne créée **retrouvée dans table-data**). **Phase 2g faite 2026-10-01** : +**13 routes** (cumul dashboard **43→56/63**) : `/gitea-workspace` (HTML), projects GET/POST (shape + création retrouvée dans `builtin` + quirk `error` sans nom), **cycle complet des items local-workspace** (POST → rename relu → move → soft-delete `deleted_at` relu → restore `NULL` relu = 5 routes), **cycle des tags d'item** (POST lowercasé → lecture item → liste workspace → search → suppression, avec utilisateur + workspace **créés et nettoyés dans le test** : `/api/local-workspace/tags` exige un workspace actif, fallback « premier workspace du user »). **Phase 2h faite 2026-10-01 → DASHBOARD BLOQUÉ** : +5 routes (members invite **avec quirk tuple FastAPI documenté** → rôle invalides/emails inconnus répondent 200+tableau, role relu en base, membre supprimé ; upload-folder **validations seules** via structure absente/cassée = 400, zéro fichier écrit ; convert-to-database = collection + propriété title + vue Table + page en `content_format='collection'` vérifiés, cleanup dans l'ordre FK). **Recoupement final** : les 44 routes strictement à 0 ref de `dashboard.py` sont TOUTES exercées — le scan résiduel affiche 19 faux positifs (paths en f-string dans les tests : `/api/workspace/1/…`, `f"/api/pages/{id}/…"`, etc.) rapprochés manuellement un par un. **TERMINÉ 2026-10-01** : les **6 routes Gitea** d'`api.py` stubbées à la main (`gitea_client.gitea.create_issue/update_issue/get_issue/get_issue_comments` → canevas **mutable partagé** car le PATCH re-fetch via `get_issue`) : create avec **carte insérée sur le board**, PATCH avec colonne recalculée sans perdre la carte, détail JSON + **HTML** (`?format=html` — le segment `/html` ne fixe pas le paramètre, le handler le lit dans la query), 404 quand le stub lève, checklists POST ×2 vérifiées en base + 404 sans board. **BUG PROD TROUVÉ ET CORRIGÉ** : `card_detail.html` utilisait `fd_icon` sans l'importer → **500 systématique** sur `GET /api/issues/...?format=html` (route unique à rendre ce template) → `{% from '_icons.html' import fd_icon %}` ajouté. **A32 complet** : 0-router-à-0-test restant (webhooks/notes/sidebar/github/library/api/dashboard/api_v2 tous couverts).
- [x] **A33 — Rate limit incomplet et mal câblé** : `security.py:98` ne couvre que `/api/`, `/board/api/`, `/auth/` — pas `/scim/v2`, `/workspace`, `/db/`, `/s/{slug}/auth` (brute force du mot de passe de site, `sites.py:599`), ni `/f/` ; `max_requests=100` codé en dur alors que `settings.rate_limit_requests=60` n'est **jamais lu** ; clé = `request.client.host` (tous les users derrière 1 proxy = 1 seau) ; `_store` (`security.py:113,134-145`) **jamais épuré** → croissance mémoire par IP. `config.py:44` ment donc sur la valeur. *Fix : lire le settings, ajouter les préfixes, épurage, `X-Forwarded-For`. Effort : **S**.*
- [x] **A34 — 10 schedulers sans observabilité** : `main.py:90-124` — les boucles **ont** bien un `try` interne (vérifié), mais **aucun `add_done_callback` ni restart** : une exception hors `try` tue la tâche en silence ; `calendar_sync.py:469` et `automations.py:484` loggent leurs échecs en `logger.debug` (invisibles à `LOG_LEVEL=INFO`) ; le `finally` (`main.py:136-139`) ne catch que `CancelledError` → un task mort re-raise à l'arrêt. *Fix : helper `spawn()` avec `add_done_callback` (log + recreé) + passer les 2 debug en warning. Effort : **S**.*
- [x] **A35 — Docs/périmètre dérivés** : `docs/openapi-v2.json` = `info.version 6.7.0`, **439 chemins vs 511 réels** (v6.8→v7.3 non documentés) · `README.md:5` = v6.7.0 alors que `VERSION=7.3.0` · `API_GUIDE_V6.md:8` = « 427 chemins » · **ROADMAP titre dupliqué** `## 🎯 Ordre de priorité (état 2026-09-28)

> **2026-10-01 — Fondations E2E posées** (`e2e/smoke.spec.js`, 2 gates verts
> contre l'instance de test : `cd e2e && node node_modules/@playwright/test/cli.js
> test smoke.spec.js`) — **2 bugs réels trouvés et corrigés au passage** :
> (1) les onglets de vues standalone émettaient `?view=` alors que la route
> lit `view_type` → **la bascule de vues ne marchait jamais** (l'onglet
> restait sur Table) → `?view_type=` + assertion comportementale dans
> `test_all_view_tabs_present` ; (2) `app.css` importait encore Google
> Fonts (`@import` Inter) **bloqué par la CSP** depuis v7.27 → Inter
> **auto-hébergé** (`static/fonts/`, 2 faces variables 100-900,
> latin+latin-ext). Le SW est bloqué dans le smoke (sa page « hors ligne »
> sert les navigations redirigées — `pwa_offline.spec.js` couvre le SW).
> **Portes** : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la
> bascule collection standalone (la bascule htmx des vues board reste non
> couverte → décision « rien » maintenue) ; A38 twins reste conditionné à
> une couverture élargie.` aux lignes 1052 (vide) et 1065 · drift Python : Dockerfile/CI/README = 3.12, venv local = 3.13, `uv.lock` ≥3.13, ruff target py312. *Fix : régénérer l'OpenAPI à chaque bump (`app.openapi()`), une passe README, dédoublonner la section, aligner 3.13 partout. Effort : **S**. — **fait 2026-10-01** : OpenAPI 511 chemins / 7.3.9, README, API_GUIDE, titre dupliqué retiré ; **reste le drift Python** (Docker/CI/README 3.12 vs venv 3.13 : alignement à valider par un rebuild d'image).* — **drift aligné + rebuild validé 2026-10-01 → A35 TERMINÉ** : `python:3.13-slim` (builder + runtime), CI `python-version: '3.13'` ×2, README ×2, ruff `target-version = "py313"` (0 retour ruff) — plus aucune référence 3.12 hors uv.lock (`requires-python >=3.13` déjà bon) ; **validation réelle** : `docker build` vert sur `python:3.13-slim` (image `flowdeck:a35-py313`) + `python -V` = **3.13.16** dans le conteneur + `import app.main` OK (v7.35.0) → les wheels de `requirements.txt` se construisent bien sur 3.13. → suite 1093/1093, version 7.35.0.
- [x] **A36 — Chaîne de dépendances cassée** : `pyproject.toml` **sans `[project]` ni `dependencies`** (35 lignes, que pytest+ruff), `uv.lock` gitignoré (`.gitignore:19`) et réduit à 3 lignes → aucun verrouillage reproductible ; deps mortes dans `requirements.txt` : **`aiosqlite`, `slowapi`, `loguru`, `packaging` = 0 import** (le rate-limit maison a remplacé slowapi). *Fix : purger les 4 mortes, soit `[project].dependencies`, soit un lock réel. Effort : **S**.*
- [x] **A37 — CORS `allow_origins=["*"], allow_methods=["*"], allow_headers=["*"]`** (`main.py:154`) alors que l'auth est cookie de session (avec A19 qui désactive le CSRF sur la plupart des routes) — et `allow_credentials` n'est pas posé. *Fix : origines explicites (`app_base_url` + frontends connus). Effort : **XS**.*

### 🟢 P3 — Basses / hygiène

- [ ] **A38 — Duplication front systémique** : helper CSRF réimplémenté **10× sous 5 noms** (`getCsrfToken` ×3 dont 2 corps différents dans `base.html:852,1732`, `getCsrf` ×2, `_getCsrf`, `csrf()`, `csrfTok()`) + ~25 `document.cookie.match(/csrf_token=…)` en dur ; 12 `function` définies dans 2+ templates (`onDoc` ×5, `escHtml`, `esc`, `getCsrf`, `openCardDetail`…) sans IIFE systématique → ombre silencieuse entre partials ; `library.html` et `local_workspace.html` partagent **21 noms de méthodes identiques** (1 039 + 2 030 lignes quasi jumelles). *Fix : un `window.FlowDeck.getCsrf` + wrappeur IIFE/`type="module"` + un `workspace-tree.js` partagé. Effort : **M**.* **Phase 1 faite 2026-10-01 (CSRF unifié)** : helper **unique `window.getCsrf()`** dans le `<head>` de `base.html` (le plus tôt possible) ; **76 lectures brutes du cookie → `getCsrf()`** (47 `(…||[])[1]||''` + 25 déclarations `const X = match(…)` avec leurs usages `X?X[1]:''` → `X` + 4 formes espacées) dans 9 fichiers statiques + 4 partials ; les 2 définitions dupliquées supprimées (`card_detail.html`, `database_table.js`) ; les **3 variantes de base.html** (IIFE `getCsrf`, `FlowDeck.getCsrfToken` ×2) unifiées sur `return getCsrf()` ; `welcome.html` garde sa lecture locale (page autonome sans base, documenté). **Phase 2 faite 2026-10-01 (dédoublonnage)** : inventaire exhaustif des **13 noms définis 2+ fois** (templates + static/js) avec scan de profondeur de brace (strings/comments/backticks gérés) → **12 sont déjà scopés** dans des IIFEs (post-A27 : escHtml/flush/emit/setMeta/initials/up/esc/show/close… vivent en profondeur ≥1 — aucun conflit de page), et le **seul doublon GLOBALE restant = `openCardDetail`** (corps byte-identiques ×2 dans `board_fragment` + `detailed_board`, fragments de vues mutuellement exclusifs) → **dédupliquée dans `static/js/app.js`**, 2 copies supprimées, garde-fou **`test_no_duplicate_global_functions`** (0 doublon global, scanner naïf commenté `ponytail:`). **Reste A38** : les méthodes jumelles library/local_workspace (~9-21 noms communs, **corps divergents**) → fusion `workspace-tree.js` **reportée** : réconciliation de deux fichiers 1 039 + 2 031 L sans couverture E2E (même logique de report que A39 htmx et A20 unsafe-eval). → suite 1092/1092, version 7.34.0.
- [x] **A39 — HTMX chargé (49,7 Ko) pour 10 attributs réels** vs 265 `fetch(` manuels. *Fix : soit drop `htmx.min.js` et convertir les 10, soit rien.* — **décision 2026-10-01 : rien** (option proposée par l'audit) : 32 attributs `hx-*` réels répartis dans 6 templates (view-switch board/dashboard/notes, `hx-boost`) ; les convertir = refonte du view-switching en JS sans couverture automatisée, coût/risque > gain de 49,7 Ko. **Reconsidérer** dès qu'un test E2E couvre le view-switch.
- [x] **A40 — Assets** : `?v=` incohérent (`app.css?v=5.1.1` mais CSS modifié le 2026-09-14 > dernier bump 2026-09-12 → servi depuis le cache), la même liste d'assets est **dupliquée** dans `sw.js:19-31`, htmx/alpine/prism vendored **sans bannière de version ni SRI**, 3 `<script src>` sans `?v=` (`base.html:116-118`). *Fix : une source unique `{{ asset_version }}` lue par `base.html` et `sw.js` + versions notées dans `static/js/VENDOR.md`. Effort : **S**.*
- [x] **A41 — ~10 Ko de CSS mort** : 75 classes d'`app.css` jamais référencées (97 règles = 10 082 octets) — `.sidebar-invite*`, `.skeleton-*`, `.toast-error|info`, `.slash-group*`, `.block-h1..h4`, `.ftable-*` (18 revérifiées une à une). *Fix : purge one-shot contre `app/templates/**` + `app.js`. Effort : **XS**.*
- [x] **A42 — Duplication backend résiduelle** : `Jinja Environment` réinstancié **29 fois** dans 10 routers (16 dans `dashboard.py` seul) — même diff que A10 ; 52 `httpx.AsyncClient` créés à la demande (aucun client partagé) ; cache Gitea sans évacuation des entrées expirées (`gitea_client.py:26-38`) ; `_data_dir()` copié 7 fois (`board.py:1696`, `dashboard.py:1127,1478`, `emoji.py:25`, `export.py:96`, `pipeline.py:30`, `meetings.py:32,58`) + 2 `/data` codés en dur (`dashboard.py:1535,1874`). *Fix : `app/templating.py` + `settings.data_dir` + un client httpx partagé.* — **fait 2026-10-01 (partiel)** : les 29 `Environment(...)` = A10 ✓ ; les 9 copies de `Path(os.environ.get("FLOWDECK_DATA_DIR", "/data"))` → `settings.data_dir` (property, lecture à chaque accès car les tests monkeypatchent l'env) ; cache Gitea : évacuation des entrées expirées à chaque écriture. **Reste → fait 2026-10-01 (A42 TERMINÉ)** : `app/services/http_client.py` — `async with shared_client(timeout=15) as client:` (context manager no-op à la sortie) remplace les **49 créations `async with httpx.AsyncClient(` de 14 fichiers** ; cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` → un client jamais partagé entre deux loops (piège des tests : « Event loop is closed » évité, une boucle par test = un client propre collecté avec elle) ; clé = kwargs triés avec `repr()` pour les valeurs non hashables (`headers=` dict). **Laissés délibérément** : `github_adapter` (transport MockTransport injecté), `webhook_outbound` (client « own_client » fermé par la fonction). Test `test_http_client_shared_and_loop_scoped` (réutilisation mêmes kwargs, cloisonné kwargs, cloisonné loop) ; le stub des webhooks patche maintenant aussi la fabrique `http_client.httpx` + purge du cache. Suite 1091/1091. Effort : **M** (fait).
- [x] **A43 — Dette mineure — PARTIEL** : `utcnow()` ✓ (15/15), health loggé ✓ (via A25) ; **TERMINÉ 2026-10-01** : (1) `__CSRF_PLACEHOLDER__` **supprimé** — ContextVar `CSRF_TOKEN` posée par `CSRFMiddleware` avant `call_next` (même mécanique que le nonce CSP), global `{{ csrf_token() }}` dans templating, `base.html` sert `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` (vide si cookie absent — `htmx:configRequest` re-lit le cookie au moment de l'appel) ; (2) la palette ne re-parse plus le JSON du body **à chaque frappe** : `fetch('/api/search?...')` **sans header** (GET = `SAFE_METHODS`, le CSRF ne s'applique pas) ; (3) `datetime.utcnow` déprécié = **0** dans `app/**/*.py` (wiki a un `_utcnow()` = `datetime.now(datetime.UTC)`) ; (4) `health` : `logger.exception` déjà posé par A25 + les booléens `db`/`gitea` SONT la raison — l'aller-retour réseau par probe est **voulu** (c'est le test de connectivité Gitea). Test `test_csrf_server_rendered_no_placeholder` (pas de placeholder + token == cookie). → suite 1092/1092, version 7.33.0.

### ✅ Vérifié non-problème (ne pas re-checker)

`sort`/`direction` de l'`ORDER BY` f-string **whitelisté** (`api_v2.py:696-703`) · `permissions._grant_common(table, …)` ne reçoit que des littéraux de ses 3 appelants · `admin.py` utilise bien `Depends(admin_required)` · `/api/v2/*` = `get_bearer_user` + scopes · SCIM Bearer-only (commenté `csrf.py:19-20`) · `serve_uploaded_file` et `_file_page_disk_path` ont la bonne garde de traversal · `url_fetch._is_public_host` est un vrai garde SSRF (à réutiliser) · aucun `password_hash`/valeur de token dans les réponses API (les tests le couvrent) · `.env` bien gitignoré, aucun secret en log · les 10 schedulers ont un `try` interne · 0 test sans `assert`, 0 test sur le réseau réel.

### ⚡ Correctifs immédiats (avant le prochain cycle — ~30 min au total)

1. **A2** : commit + push + tag du cycle v7 (sinon tout le reste risque de partir avec une réinit).
2. **A1** : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` → les 13 échecs deps disparaissent, le 14ᵉ reste à isoler.
3. **A9** : `git rm --cached flowdeck.db flowdeck_dev.db test-commit.md upload_test.txt` + `.gitignore`/`.dockerignore` + rotation de `app_secret_key`.

→ Puis **A3–A8** (le bloc « fallback admin ») d'un seul tenant, puis **A10** (autoescape) qui débloque A18/A20.

*Audit produit le 2026-09-30 · 43 items · aucun code modifié ( ROADMAP seul ).*
→ **A1–A9 corrigés le 2026-09-30** : deps réinstallées (`pyotp`/`webauthn`/`cbor2`), rebinding de `settings` supprimé dans `test_v54.py` → **suite 1016/1016 verts**, cycle committé (`1706ad1`) + tag `v7.3.0` poussé, `.db`/fichiers de test désindexés, `APP_SECRET_KEY` roté dans `.env` (sessions révoquées) · **A3–A8 : 401 sans session sur les routes de compte (mdp actuel exigé), tokens `/api/v1` + `/api/user` sans session → 401, CRUD membres d'espace sous session+role admin, `_require_view`/`_require_edit` sans session → 404/401, création/lecture de page sous session, `/board/api/pages` + `/api/user` sortis du CSRF exempt, seed admin sans mdp en dur (aléatoire ou `FLOWDECK_ADMIN_PASSWORD`). Tests : client connecte par defaut (`_TestSessionAuth`), helper `anon()` sur les 40 tests d'anonymat → suite 1016/1016 + ruff OK, commit `d125eb3` · **A10 : `app/templating.py` (ENV partagé + autoescape `select_autoescape(["html"])`) remplace les 29 instantiations, `|safe` retriés (corps d'issue/commentaires echappes, `sidebar_config` en `|tojson`) → suite 1016/1016, version 7.3.1 · **A11 (traversal avatar) + A18 (vue publique : 404 restricted/private + html.escape)** : `tests/test_audit_p0_fixes.py`, suite 1019/1019, version 7.3.2 · **A12/A13/A15/A17/A22/A23/A24** : garde SSRF par hop, routers automations + /api sous session/Bearer, webhooks admin+URL publique, uploads validés, N+1 en GROUP BY/executemany, 2 doublons de routes supprimés → suite 1025/1025, version 7.3.3 · **A16** : exports + pièces jointes sous session + `can_view_page` → suite 1026/1026, version 7.3.4 · **A19 (partiel)** : 12 préfixes CSRF retirés après scan front (12 préfixes = tous ceux dont les appels portent déjà le header), reste 5 préfixes / 49 fetchs → suite 1026/1026, version 7.3.5 · **A19 terminé** : 46 call sites front équipés, plus aucun préfixe cookie-auth exempté → suite 1026/1026, version 7.3.6 · **A14** : fallback `admin` supprimé de `_current_user_id`/`_current_admin` (401 sans session), `api_base` validé scheme http(s) sans identifiants (hôtes privés acceptés = Ollama localhost, commentaire `ponytail:`) → suite 1027/1027, version 7.3.7 · **A25** : 84 `except Exception: pass` remplacés par `logger.exception(fn)` (19 fichiers, +`logger` manquants), try supprimé sur `materialize_properties` dans `create_collection_v2` et `apply_db_template_v2` (rollback au lieu d'un commit sans schéma), test de rollback · **A21 (partiel)** : `busy_timeout=5000` dans `get_conn()` → suite 1028/1028, version 7.3.8 · **A26/A33/A34/A35/A36/A43** : secret par défaut refusé au boot, rate limit (préfixes + settings + XFF + épurage), `_spawn()` pour les 10 schedulers, OpenAPI 511 chemins + README, 4 deps mortes purgées, 15 `utcnow()` → `now(UTC)` naïf → suite 1028/1028, version 7.3.9. · **A30/A37/A39/A40/A41** : `require_scope` câblé sur 69 sites + 12 fonctions mortes supprimées, CORS sans `*` (origines de `app_base_url` + regex dev/extensions), assets versionnés depuis `VERSION` (source unique), `app.css` -10,2 Ko de règles mortes, htmx = décision « rien » documentée → suite 1031/1031, version 7.4.0. · **A29/A42** : `services/publish.py` partagé (3 routers déléguent, 404 partout, board sous session), fuite `password_hash` corrigée sur `GET /api/users/me` v1 + contexte `/accounts`, `settings.data_dir` remplace les 9 copies d'env, cache Gitea évacue les expirés ; `/users/me` ×2 + collections ×3 = contrats versionnés, on garde ; reste A42 = client httpx partagé → suite 1034/1034, version 7.5.0. · **A31** : transaction par migration (`_apply_one`, rollback tout-ou-rien du DDL) + helper `columns()` remplaçant 25 copies de `PRAGMA table_info` (1 helper au lieu de 3 — les 2 autres seraient mort-nés) → suite 1036/1036, version 7.6.0. · **A20 (partiel)** : CSP nonce par requête — `unsafe-inline` retiré de `script-src`, 38 scripts templates + login constant + 3 scripts Python noncés, meta `htmx-config` pour htmx, `script-src-attr` pour les 74 `onclick=`, CDN chart/leaflet débloqués (déjà cassés avant) → suite 1037/1037, version 7.7.0. · **A21 phase 1** : 352 routes `async def` sans `await` → `def` (threadpool FastAPI, SQLite hors loop, zéro changement de logique — scan corps par corps) ; reste phase 2 = 311 routes avec `await` → `anyio.to_thread.run_sync` par bloc DB → suite 1037/1037, version 7.8.0. **Phase 2c faite 2026-10-01** : **+190 routes hors loop** (283 → 93 async, **86 % des 667 routes**) en 4 passes : (A) **racine auth** — `get_current_user` (session.py) était `async def` SANS aucun await (cookie decode = synchrone) + ses clones async (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin`) → `def`, **47 `await` supprimés** (dont 3 via l'alias `gcu`) ; (B) re-scan → 19 routes sans await flipées ; (C/D) **155 routes** json/événements → `Body(default={})` (formes : try/except `body = {}`, try/except `raise HTTPException(400)` → `Body(...)` requis (422 FastAPI, aucun test ne couvrait le 400), forme conditionnelle content-type → défaut `{}`) + `run_event_sync`. **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (14+5+3, corps de requête réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` 9 (wrapper de validation), 2 JSON inline en argument, 1 fallback à logique (capture_frontend_error), 1 lecture conditionnelle dans web_clipper. Échecs : 3 seeds d'aliases `gcu` attrapés par la suite → corrigés → suite 1089/1089, version 7.26.0. · **A21 phase 2a** : `body` JSON → paramètre `Body(default={})` sur les 36 routes api_v2 dont c'était le seul `await` → conversion en `def` → api_v2 à 96/115 hors loop (19 async restantes : fire_event/form/gitea) → suite 1037/1037, version 7.9.0. · **A21 phase 2b** : `run_event_sync()` (asyncio.run dans le worker, événement attendu = déterministe) + les 15 routes json/événements en `def` → api_v2 bouclé à 111/115 (4 async = vrais awaits réseau) ; repo-wide 403 sync / 260 async → suite 1037/1037, version 7.10.0. · **A32 phase 1** : les 4 routers à 0 test couverts (webhooks/notes/sidebar_config/github_routes = 10 smokes, 0 réseau réel, échappement notes vérifié) ; reste quasi nuls library/api/dashboard/api_v2 → suite 1047/1047, version 7.11.0. · **A32 phase 2a** : library 1/10 → 8 routes couvertes ; découverte = 2 routes lisant la table fantôme `local_workspace_items` (500 systématique, 0 ref front) supprimées + `_format_size` mort → suite 1053/1053, version 7.12.0. · **A32 phase 2b** : api.py 3 → 16/22 routes couvertes (board-config, col-mapping, card, collaborators stubbé, frontend-error dédup, mutations checklist vérifiées en base) ; reste 6 routes gitea + dashboard/api_v2 → suite 1059/1059, version 7.13.0. · **A32 phase 2c** : api_v2 scan strict → 5 routes à 0 ref couvertes (formula, rollup, audit-logs avec portail vérifié, webhooks/events, verify-signature signé) → suite 1064/1064, version 7.14.0. · **A32 phase 2d** : dashboard scan strict = 44 routes à 0 ref, 10 couvertes (tags CRUD, page content→rename→trash vérifiés en base, tree HTML, avatar-color restauré, members) → suite 1069/1069, version 7.15.0. · **A32 phase 2e** : +9 routes dashboard (pages comptes sans hash, profile/password A3 403, token fd_+64hex, forge, settings/account 400, select cookie, breadcrumb) → suite 1075/1075, version 7.16.0. · **A32 phase 2f** : +7 routes dashboard centrées A16 (files traversal 403, download/file-content sans fuite, avatar 302 sans réseau, table-data + création de ligne) → suite 1079/1079, version 7.17.0. · **A32 phase 2g** : +13 routes dashboard (gitea-workspace HTML, projects CRUD, cycle items 5 routes, cycle tags d'item 5 routes avec ws dédié) → suite 1083/1083, version 7.18.0. · **A32 phase 2h → dashboard bloqué** : members (cycle complet + quirk tuple), upload-folder (validations seules), convert-to-database (vérifié en base, ordre FK) ; les 44 routes à 0 ref sont toutes exercées (faux positifs f-string rapprochés) → suite 1086/1086, version 7.19.0. · **A32 TERMINÉ** : 6 routes Gitea stubbées (canevas mutable, carte board, HTML `?format=html`, 404) + **bug prod `card_detail.html`/`fd_icon` corrigé** (500 garanti avant) → suite 1089/1089, version 7.20.0. · **A27 phase 1** : 7 templates sans Jinja → 9 fichiers static/js (4 243 L, -30 % du inline, node --check vert), eslint installé globalement → `eslint static/js` 0 erreur/120 warnings ; reste = blocs interpolés Jinja (~9 661 L) → suite 1089/1089, version 7.21.0. · **A27 phase 2a** : éditeur 2 516 L extrait via `#page-data` (PD.*), dérivé `is_shared` hoisté, 8 tests adaptés (source → static js / parsing JSON) → suite 1089/1089, version 7.22.0. · **A27 phase 2b** : local_workspace 2031 + settings 1093 + realtime 531 + board 146 extraits (recette config JSON, tojson au lieu d'interpolation JS) → suite 1089/1089, version 7.23.0. · **A27 phase 2c → extraction TERMINÉE** : database_table 1314 L (config JSON null-vs-objet, 2 branches Jinja remplacées par `DB_CONFIG`), bilan **11 874 L extraites, inline -85 % (13 904 → 2 022)** → suite 1089/1089, version 7.24.0. · **A27 lint TERMINÉ** : eslint 285 warnings → **0/0** (catch vides → binding optionnel, 24 lignes mortes, 10 fonctions `/* exported */` appelées par le HTML, 3 globaux réels en config) + 2 vrais correctifs (toasts settings jamais affichés → `window.showToast`, `_wsInitData` sans effet supprimé) → suite 1089/1089, version 7.25.0. · **A21 phase 2c → A21 TERMINÉ** : auth racine sync (47 await retirés, alias gcu rattrapé) + 155 routes json/événements → Body/run_event_sync + 19 flips → **283→93 async (86 % hors loop)** → suite 1089/1089, version 7.26.0. · **A20 phase 2** : chart/leaflet vendorisés (static/js/vendor, 8 fichiers), CSP sans aucun hôte CDN, connect-src `'self' ws://{host} wss://{host}` (exfil fermé), Google Fonts morts retirés, img-src https: gardé (unfurls) → suite 1090/1090, version 7.27.0 — reste A20 = unsafe-eval (Alpine+htmx, E2E d'abord). · **A42 TERMINÉ** : `shared_client` (cache par boucle+kwargs, WeakKeyDictionary) remplace 49 créations httpx dans 14 fichiers, 2 sites laissés (transport injecté / own_client), stub webhooks étendu à la fabrique → suite 1091/1091, version 7.28.0. · **A28 lot 1** : api_v2.py (2 110 L, 115 routes) → package de 14 fichiers par concern, openapi JSON IDENTIQUE byte-à-byte (0 changement d'URL) → suite 1091/1091, version 7.29.0 · **A28 lot 2** : dashboard.py (2 735 L, 63 routes) → package de 10 fichiers (15 helpers dans _common, re-exports intacts, collision `settings`→`account_settings`), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.30.0 · **A28 lot 3** : collections.py (2 622 L, 53 endpoints) → package de 13 fichiers (_renderers = 15 rendus, décorateurs empilés gérés, F404 docstring corrigé), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.31.0. · **A28 TERMINÉ (lot 4 : board)** : 2 101 L → package de 14 fichiers (23 helpers dont 4 async dans _common, constantes ré-insérées), openapi IDENTIQUE byte-à-byte → suite 1091/1091, version 7.32.0 — les 4 god files du audit sont découpés. · **A43 TERMINÉ + A38 phase 1** : CSRF rendu côté serveur (ContextVar, plus de placeholder), palette sans re-parse par frappe, 76 lectures cookie → `getCsrf()` global head, 2 déf dupliquées supprimées → suite 1092/1092, version 7.33.0 · **A38 phase 2** : 13 noms dupliqués audités (12 scopés IIFE), seul doublon global openCardDetail dédupliqué dans app.js + test garde-fou 0-doublon → suite 1092/1092, version 7.34.0 — reste : méthodes jumelles (E2E d'abord). · **A35 TERMINÉ** : drift 3.12→3.13 aligné (Dockerfile/CI/README/ruff py313), rebuild image validé (python 3.13.16 + import app OK dans le conteneur) → suite 1093/1093, version 7.35.0.