La section Aide et la section Settings partagent le même gabarit (`.settings-overlay` / `.settings-panel`) et la même feuille de style (`static/css/settings.css`), mais affichaient deux rendus différents. Cause : `help.html` neutralisait son overlay via un style INLINE sur la balise — `position:static`, `background:none`, `backdrop-filter:none`, `padding:0` — et imposait par-dessus un panneau `width:100%;max-width:1050px; height:calc(100vh - 120px);margin:0 auto`. Settings, lui, laissait `settings.css` faire son travail : overlay `fixed;inset:0` avec fond flouté, panneau `85vh` centré par `margin:auto`. Résultat perçu : Aide s'affichait comme un bloc posé dans la page, Settings comme une modale centrée floutant l'arrière-plan — malgré un code quasi identique. Correctif : les surcharges inline sont retirées de `help.html`. La géométrie vient désormais entièrement de `settings.css`, commun aux deux pages. Aucune valeur n'est dupliquée ; les deux balises sont strictement identiques. Vérifié en e2e en comparant les géométries CALCULÉES des deux pages (pas des valeurs de code) : overlay `fixed` avec fond opère des deux côtés, panneau 1050x765 centré à top:68px, bouton close à 13/13px du coin du panneau — les deux jeux de mesures sont identiques. Tests : `tests/test_v7693_help_positioning.py` (5) verrouille l'absence de surcharges inline et l'identité de balise avec Settings ; `e2e/v7693_help_positioning.spec.js` compare les géométries calculées. NON INCLUS — double-clic sur le bouton close de Settings : le bug a été REPRODUIT en e2e et sa cause racine identifiée, mais le correctif n'est volontairement PAS dans ce commit. Voir ROADMAP.md § v7.69.3 pour le relevé complet et la prochaine étape. pytest 1421 passed / 0 failed · ruff OK · e2e 15/15 verts sur l'instance déployée
201 KiB
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 automatiquedevelop: branche d'intégration, merges desfeat/*feat/<nom>: une branche par feature/fix, PR → code review → merge dansdevelop- Tags
vX.Y.Zsurmainpour 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_methodsur 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, colonnesis_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},selectetlocal-workspace/itemsrépondaient 200 sans session (repliuid = … else 1) : un anonyme pouvait renommer ou supprimer un espace. →_require_user_id()+_require_ws_owner() - Router
/workspaceauthentifié —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 unpublic_routersans dépendance password_hashretiré de/auth/useret du cookie de session (signé mais non chiffré) →app.auth.session.public_user()- OAuth Gitea — placeholders
test-id/test-secrettraités comme « provider non configuré » (avant : redirection Gitea avec un client_id invalide) ;GITEA_OAUTH_CLIENT_ID/SECRETdocumenté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_idexposé par les 2 sidebars (gitea_mirror_ws_idséparé) et prioritaire dansbase.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 unWARNINGjournalise 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é parWEB_SEARCH_PROVIDERfetch_url— télécharge une page et la rend en Markdown (titres, listes, code, tableaux) pour que l'IA puisse la citersearch_code— GitHubrepositories | code | issues, sans PAT (10 req/min) ou avecGITHUB_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, couchetransportinjectable pour les tests - Garde-fou SSRF réutilisé —
fetch_urls'appuie surimporters.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 vers169.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'agentDoc 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) + tableagent_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-insapp/services/block_templates.py+ personnels) etPOST /api/page-templates/{id}/useexistent déjà, jamais appelés depuis le panneau agent. - Connecteurs / Plugins / Mémoire : inexistants — 0 hit
connector|plugin|memorydansapp/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 parContextBuilder._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, jetonfolder:front+back) ; suite complète 1273 verts à la livraison,ruff0,eslint0 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) viaFD_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/DELETEsur/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éutiliseinstallGallerySkill) - 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_v751adapté (4 sections « bientôt ») ; suite complète 1280 verts,ruff0,eslint0 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 sousprefix="/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/useavec{key}dans le body — la clé en query est ignorée par la route), « Insérer dans le document ouvert » (GET …/blocksnouvelle route →ensureBlockIds()global → concat dansE.blocks+autoSave/render, désactivé sans document ouvert) et « Enregistrer le document ouvert comme canevas » (POST+page_id) - Canevas « design system » — preset
design_systemdansblock_templates.py(callout tokens, TOC, toggles composants, grille, checklist revue UI) ;design-tokens.cssréférencé dans le callout - Sauver le doc ouvert comme canevas —
POST /board/api/page-templates(existante) branchée depuis le menu, nom viaprompt()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-foukey-dans-le-body) ;test_v751adapté ; suite complète 1285 verts,ruff0,eslint0 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_idPK, upsert) + colonneagent_conversations.memory_enabled(migration 31, défaut1) ; 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 viaagent_memory.remember(); toute erreur est journalisée, un run ne peut pas échouer à cause de la mémoire - Injection —
AgentEngine.run()appelleagent_memory.context_for()aprèsContextBuilder.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_enabledajouté au whitelist), état lu à l'ouverture et à la création, défautAGENT_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_v753adaptés (2 sections « bientôt ») ; suite complète 1293 verts,ruff0,eslint0 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) ; leconfig_jsonpré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 packageconnectors/promis (3 probes + 1 fetch ne justifient pas une arborescence) ; 3 natifsgitea/github/webservis à la volée, statut sans réseau (native_state()) ;probe()etconnector_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_secretseulement), 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,ruff0,eslint0 - 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_verifierS256), échange du code, refresh automatique (60 s de marge,refresh_tokenconservé si absent de la réponse), déconnexion — tableconnector_tokens(migration 33, PK(kind, user_id)), tokens chiffrés Fernet réutilisantcalendar_sync._encrypt_tokens(zéro dépendance ajoutée) - OAuth2 Microsoft (Graph) —
Files.Read/Mail.Read/Calendars.Readoffline_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 parhistory.replaceState) - Tests —
tests/test_v756_oauth_connectors.py: 13 tests, 0 appel réseau réel (_post_form/_api_getmonkeypatché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_v755adapté (5 natifs) ; suite complète 1319 verts,ruff0,eslint0 - 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.Allajouté 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 1erdata:d'untext/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 : nommcp_<serveur>_<outil>+inputSchemaexposés au LLM,execute()dispatche surtools/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/_getmonkeypatchés) — presets,{secret}absent de la base, kind/auth invalides → 400, handshake + séquence d'appels, cachetools_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 viaGET/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_urlretirés du schéma et deexecute()(ToolRegistry._all()) ;- UI → nav « Extensions » / « Automations » rendues sous
{% if plugin_enabled(...) %}(global Jinja, élément absent du DOM), sections conditionnées enx-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 — templatelanding.html, design Notion dark - Page
/auth/registerGET — 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/pagesGET/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.mdPhase 1
- Table
collection_data_sources— multi-sources par collection (FKcollection_id+source_collection_id, flagis_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 sourceis_linked=1) - Full-page vs Inline —
POST /db/{id}/toggle-inline/api(toggle),POST /db/inline/api(créer inline avecparent_page_id) - Règle permissions — linked DB hérite du
workspace_idde la source → mêmes ACLs - Migration — ajout colonnes
workspace_id,created_bysurcollections - 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_templatesenrichie — colonnesdescription,is_recurring,recurrence_rule(daily/weekly/monthly/yearly) - Table
collection_dashboards— layout widgets (grille configurable :columns,widgetsavecview_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_tasksur 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 pagesfile(upload.md/code, contenu sur disque) etmarkdownsortaient 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
+ Rowsous 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) —
md2btransforme les tableaux pipe en bloctableau lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloctable.
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ésormaissplice(index, 0, ligne). - Bouton « + » à droite — ajoute une colonne à droite (équivalent « Add column »).
- Redimensionnement des colonnes — curseur
col-resizeau survol des bordures d'en-tête, drag = largeur ; persisté viacolsW.
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@logindans les blocs et commentaires, tablenotifications; endpointPOST /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, … viaPATCHconversation),/runutilise 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)
- Phase 1 — Core Agent ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
- Phase 2 — UI : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
- Phase 3 — Outils avancés ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
- Phase 4 — Autonomie ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
- 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+Ppalette 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
- Déclencheurs : événement (
- Buttons — boutons cliquables déclenchant des actions
- Bloc
buttondans l'éditeur (menu slash) + picker d'automation inline - Endpoint
/api/automations/{id}/run(exempt CSRF) + UI Settings → Automations
- Bloc
- 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èsapp.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, tableapi_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+ runnerapp/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 checketeslintsans erreur - Tests parallèles — pytest-xdist (
pytest -n autoen 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) —
GitHubAdapterviahttpx.MockTransport - Tests multi-user (permissions croisées) —
PermissionManagerowner/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 passedruff check app tests→ All checks passed! ·eslint static/js→ 0 problème- CI Gitea (commit
ba363ea; run push #1412 + PR #15 run #1413) → success surpushetpull_request: jobslint(≈43 s),test(-n auto, ≈4 min),dockermulti-stage (≈1 min) - Docker : stages
builder+runtimevérifiés par le job CIdocker(from app.main import appOK)
Complétion du 2026-09-11 :
- Isolation des tests corrigée :
tests/conftest.pymute le singletonsettings(au lieu de le remplacer), sinon les modules ayant importésettingsau 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 aussiwebhook_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 → blocembed_type:'collection'rendu parFlowDeckDB - 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/apiacceptenttemplateet 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_headerdéfaut actif + nouveaufirst_col_header), persistés + rendu<th>en preview et exports Markdown/HTML - Intégration realtime —
syncNow()poussé après chaque mutation programmatique ; 9 teststests/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 teststests/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 collectionsPOST /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(); endpointPOST /board/api/embed/resolve - Service
app/services/og_fetcher.py: parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpointPOST /board/api/og/metadata - Endpoints
POST/DELETE /board/api/pages/{id}/cover(JSON URL ou upload image) etPOST /board/api/pages/{id}/icon - Éditeur : résolution d'embed à la saisie (URL d'origine conservée +
embed_srcmis 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/bookmarkavecembed_srcré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 checkOK
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, viaimport_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ésrelation(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, listeerrors) ; 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_bycâblés : auto-valeurs calculées serveur (apply_auto_properties) à la création/mise à jour, sélecteurperson(membres du workspace viaGET /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) etPOST /db/{id}/property-groups/api - Vues sauvegardées par utilisateur —
collection_views.created_by;GET /db/{id}/views/apifiltre par propriétaire (les vues partagéesNULLrestent 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_limitspar colonne, alerte visuelle au dépassement (wip-exceeded) - Cartes configurables —
card_properties,card_size(compact/détaillé), couverturecover_mode(none/icon/color/property) viacover_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(), validationpersoncollections.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,viewsexposées danstable-dataet le contexte de page_database_table_scripts.htmlréé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 checkOK
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/apiavec 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 dansproperty_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 viaGET /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édupreminder_log, notifie les personnes assignées (repli admin), in-app + email (prefreminders) ; stockés dansproperty_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 parnotify_assignment()surPUT /db/pages/{id}/api(comparaison avant/après des propriétésperson) - Template Meeting Notes — propriétés
AgendaetNotesajouté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 checkOK - 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 auTab, rejet àEscape - AI properties — bouton ✨ par ligne de la table database :
POST /api/agent/writing/propertiespropose 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)
v5.11.0 — Wiki-links & mentions de page ✅ (2026-09-14)
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) ; moduleWMdans_page_editor_scripts.html; tokens[[fdpage:ID]]stockés dans le texte des blocs et relus intacts pargtTok()(autosave, drag, undo/redo préservés) - Mention de page
@— le menu@(utilisateurs v4.9.0) gagne la section « Pages » :@nomcherche les pages et insère le chip inline - Mention de date
@— section « Date » :@today,@tomorrow,@hierou@YYYY-MM-DDinsè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/titlesré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_publicrend les chips en page publiée (HTML échappé) ; serviceapp/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_templatesexistent (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 parcreated_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 surPUT /api/pages/{id}etPOST /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— tablesynced_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-Signaturesur 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_deliveriessuperseded→delivered) — fixturedb= SQLite isolée par test +httpx.MockTransport+RETRY_DELAYSneutralisé ; 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+ colonnessync_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, E2Ee2e/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_permissionsrole 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}/apimasqué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/apifiltré parget_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 pargroup_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, auditGET /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 colonnespermission_type+ indexes (idempotent) - Tests —
tests/test_v60_granular_permissions.py21 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.zipservi à/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 → tokenfd_…montré une fois),GET /devices,DELETE /devices/{id}+ page HTMLGET /extensions; auth triple (session OU Bearerapi_tokensOU Bearerextension_devicesOU legacyuser_tokens), rate-limit 50/h/device - Tables migration 19 —
extension_devices,extension_clips+ indexidx_ext_* - Settings UI — onglet Extensions (devices, clips count, revoke)
- 16 tests
tests/test_web_clipper.py;VERSION6.2.0 ; wiringapp/main.py:50,158
v6.2.1 — Web Clipper polish ✅ (2026-09-20)
- Bouton flottant rond transparent draggable — toggle d'affichage persistant,
clipper:clippedrefresh auto sidebar - Fix bloc bookmark —
create_page_from_clip()émet un blocbookmarkfidèle (OG +embed_srcrésolu), rendu/correct en éditeur +/p/<slug>+ exports - Fix
duplicate innerSyntaxError dans_page_editor_scripts.html flowdeck-clipper.zipré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érarchiquesread < 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: colonnesscopes 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_atapi_audit_log:id, user_id, token_id, action, resource_type, resource_id, ip, created_atidempotency_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-keyaccepté seulement siTrue(dev local)app/services/api_v2_helpers.py:parse_pagination()(+X-Total-Count),to_iso8601(), handler RFC 7807application/problem+json,require_scope()(hiérarchieadmin ⊇ 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_tokenssupportés) ; handler d'erreurs unifié surStarletteHTTPException
Phase 3 — Tokens CRUD v2 ✅
POST /api/v2/tokens(name, scopes, expires_at) →fd_{urlsafe(32)}hashé, montré une foisGET /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", tagapi-v2) —GET /collections,GET /collections/{id},GET /pages/{id},GET /collections/{id}/pages?filter[]=&sort=&fields=&query=,GET /search?query=&workspace_id=&type=(FTS5), headerX-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 viagitea_client) - (reporté, livré v6.4.0) : migration de
sync.pyvers Bearer — authAuthorization: Bearer(scopes read/write) viaget_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.jsongé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: truerenvoyé dans l'acket le broadcast - Protocole
base— le client embarque dans chaqueupdatela version du bloc dont dérive sa saisie ; le serveur faitmerge_block_3way(base, current, incoming); sansbase→ LWW historique (rétro-compat ancien client) - Adoption côté client — l'
ackrenvoie le bloc fusionné ; le client met à jour sabase, 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_writerdédiée ;_broadcast()faitput_nowaitet n'attend plus le socket → un client lent ne fige plus la room - Coalescence des curseurs — le writer réduit les messages
selempilé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 + compteurslow_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éponseack stalesans application - Fix fuite de rooms — une connexion 4404 n'enregistre plus de
Roomorpheline en mémoire ;room_state()sur page inexistante retourne un dict vide au lieu de planter surNone - 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 fuitesapp/routers/realtime.py— endpointGET /api/realtime/statsapp/templates/_page_editor_realtime.html— envoi debase, 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 teststests/test_realtime.pypréservés ruff check app testsOK ·eslint static/js0 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) ;allowSingleLabelDomainspour les hôts homelab/LAN - OIDC + PKCE —
app/auth/providers/oidc_provider.py(authlib 1.8.0) :state+code_verifieren 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-replaysso_requests, historiquesso_login_history - Routeur —
app/routers/sso.py: parcours navigateur + API adminGET/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, extensionPermissionManager) - Version — 6.7.0 ·
ruff check app testsOK · 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 + scopesread/write, rate limit par token, idempotenceIdempotency-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çoit404(pas de fuite) - réponse de run :
{status, final, error, reasoning[], actions[], events[], duration_ms}
- agents CRUD, conversations + messages, run synchrone JSON (le flux SSE reste réservé à l'UI),
journal
- 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: truepour é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
- export portable
- Webhooks de cycle de vie agent —
agent.run.startedetagent.run.failedémis pour la 1re fois (seulagent.run.finishedl'était) ; les 3 sont dans le cataloguewebhook_outbound→ abonnementagent.* - Docs —
docs/API_GUIDE_V6.md(§2.4 + correction du nom d'événementagent.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 testsOK ·eslint static/js0 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 (pageblocksstandard) 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_idhérité pour que les ACL de collection s'appliquent au contenu - Endpoint
GET /db/pages/{row_id}/open/api— renvoie{page_id}(garde_require_viewde 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 tablepages→ 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, refspage_synced_blocksen cascade) - Exclusions des listings —
AND collection_row_id IS NULLsur : 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_countdes 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_contentpé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 leschildren(colonnes, toggles) - Suppression de source — ids des pages référentes collectés avant la cascade FK,
mark_synced_block_deleted()marque_synced_deleteddans 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
syncedajoutée à_render_blocks_public(avant : JSON brut en<p>) + état deleted - API v2 —
content_page_idsurGET /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_idv2)- Suite complète 742 verts (
pytest -n auto) ·ruff check app testsOK ·eslint static/js0 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), tablesite_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 viaHostheader - Forms publics —
form_config_jsonpar 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, validationvalidate_property_rule) ; chaque soumission =collection_pages+ logform_responses(ip_hash jour) - Embed & notifs —
?embed=1sans chrome, notif in-app auxnotify_user_ids, triggerform.submitted(branché v7.0) - Migrations 24 —
sites,site_pages,site_views,form_responses, colonnecollections.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 checkOK · 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 viasearch.py+ cosine, fusion RRF k=60,X-Total-Count, pagination) ; filtresworkspace_id, scope membership +PermissionManager(pages restreintes masquées),search_excludedrespecté 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, colonnepages.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 checkOK - UI — palette
Ctrl+K: ongletsPages / ✨ Réponses IAlivrés (v7.44.0) — onglet IA =POST /api/v2/search/ask(CSRF viagetCsrf(), 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é : ongletFichiers— aucun backend (/api/searchne renvoie quepages/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) ; triggersany(défaut) /all(fenêtre 5 min,PUT .../mode) ;form.submittedbranché de bout en bout ; conditions AND (réutilisematch_condition_props) ; actionsslack(incoming webhook, secret chiffré Fernet),email(viamailer,user:résolu),forge_issue(GiteaGiteaClient/ GitHub API, tokenuser_oauth_tokens),agent_trigger(conversation + runAgentEngine),delay(sleep plafonné 300s) - Bouton DB natif — type
buttondansPROPERTY_TYPES, colonnebutton_automation_id,POST /api/automations/press-button(CSRF-exempt, contexte ligne, erreurs 400 explicites) - Workers lite —
app/services/workers.py: lint AST (importsos/sys/subprocess/socket,open/exec/eval, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalierdaily_budget_s, fork des partagés ;app/routers/workers.py: CRUD/api/v2/workers*(session ou Bearerwrite, 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 checkOK - É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 scantokens) → 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 scannerscan_semiajouté 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 lueopaqueredirect→ rejet ; rejoué enredirect:'follow'+ 302 synthétique vers l'URL finale, Chromium refusant les responsesredirectedservies à une navigation) et drag & drop de fichiers/dossiers réparé (_doUpload+toggleFavoritesansX-CSRF-Token= oublis A19, derniers appels mutants du front).timeoutFetchannule enfin ses requêtes. Gatee2e/regression_logout_dnd.spec.js(2 tests verts). - Navigation partielle Alpine (v7.45.2) — les swaps fdLoad initialisaient
.main-wrapperavant 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 dansapp.js:x-ignoresur la zone swapée (nœud remplacé uniquement) → démontage unique à tous<script src>chargés viaAlpine.initTreeidempotent ; garde de fichier sur 5 scripts àconstde 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 dansapp.js:opacity:0+pointer-events:noneposés danshtmx:afterSwap(même task que le swap → aucun paint intermédiaire), retirés aprèsinitTreesur 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 maisone2e/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êtrex-ignorede ~150–300 ms sur /settings (coût des scripts de page avantinitTree, 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-cloakposé 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é.
- Suivis restants (mesurés, non traités) : CLS résiduel 0.058 sur /library (états
- 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 lucsrf?.[1]→ 403 CSRF silencieux), (2) « Move to Trash » de l'éditeur = 404 (route/board/api/pages/{id}/trashinexistante,.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 classesflowdeck-modal*déjà présentes dansbase.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 danstrashData():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_encFernet,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 + notifcalendar.conflict, résolution par édition manuelle) ; passe pull marquéetouched(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()) → transcriptionSTT_COMMANDou transcript manuel (client-side STT) → résuméai_writing.summarize(offline-capable) → triggermeeting.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, colonnecollection_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 checkOK
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(Bearerscim_tokens, mappinguserName→login,active→is_active) ; auto-suspend (is_active=0→ sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404application/scim+json+ exemption CSRF (clients IdP sans cookie) - 2FA — TOTP (
users.totp_secret_encchiffré Fernet, backup codes SHA-256 à usage unique, défipendingsigné 5 min ;local-login→2fa_requiredsans session puislocal-verify) + passkeys WebAuthn (webauthn_credentials, attestation vérifiée, connexion sans mot de passe avec anti-rejeusign_count) ;sso_only+ 2FA combinables - Domain claim —
domain_claims(domainnormalisé,txt_token,auto_join_role,enforce_sso) ; vérificationGET https://<domain>/.well-known/flowdeck-verify.txt; SSO forcé par domaine danslocal_login(admins exemptés) ; jeton jamais renvoyé par le listing - Audit UI —
GET /api/v2/audit/logsfusionneapi_audit_log+permission_audit_log+sso_login_history(schéma commun), filtressource/actor/action, pagination, export?format=csv(admin ou Bearerread:admin) — UI Settings → Audit : voir follow-up « Polish » v7.3 - Gouvernance agents —
agent_policies(scope outils/workspace,max_steps,require_approvalavant write) consultée parAgentEngineavant les ACL, fileagent_approvals+ décision/api/v2/agent-approvals/{id}/decide, événementagent.run.approval_requested - Migrations 28 —
scim_tokens,domain_claims,webauthn_credentials,agent_policies,agent_approvals, colonnesusers.totp_secret_enc/totp_backup_hashes(is_activeexistait) - 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 testsOK ·test_agent.py+test_app.py261 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_at90 j) ; index/api/v2/wiki/verified(exclut expirés + teamspaces privés) ; sweep admin/api/v2/wiki/verify-expiry-sweep→ notifpage.verification_expiringà J-7 ; Wiki Home/api/v2/wiki/home(teamspaces + verified + recents) - Blocs manquants —
mermaid(SVG inline viammdcsi présent, sinon<pre class="mermaid">rendu client),equation_inline(KaTeX, source sanitizée :</>/\retirés),progress(barre % agrégée surproperty_values_json) ; rendus parexport.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+ colonnesteamspace_id - Tests —
tests/test_v73_wiki_polish.py(58 tests) - Version — 7.3.0 (
VERSION+app/main.py) ·ruff check app testsOK · v7.2 + v7.3 = 110 verts - Sidebar par teamspace — section
Teamspacesdansbase.html(état Alpine +loadTeamspaces/openTeamspace),GET /api/v2/wiki/teamspacessansworkspace_id(liste cross-workspace +workspace_name), page HTMLGET /wiki/teamspaces/{id}, entréeDEFAULT_CONFIGsidebar - Notif
page.updatedaux followers —notify_followers_of_page_update(fenêtre 10 min), hooksfire_event+ payloadsactor_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-DBGET /db/{id}/dashboards/{did}(widgets àcollection_id, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bugval if val else 1), sélection du view config selonview_type - Link previews riches — unfurl
gitea:owner/repo/github:owner/repodansPOST /board/api/og/metadata(GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (url/title/description/image/site_namedans l'autosave) - UI Settings → Audit —
settings.htmlongletadmin-auditbranché 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.py38/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) /ABIUTbarréTx/ 🔗S</>√x…/ 💬✨, section grise Skills (Improve writing, Proofread, Explain, Reformat →runSkill) et pied Edit with AIAlt+⇧+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é sursync();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-lightdans le tooltip global (app.js) - Fix
askAI(idx)écrasé paraskAI()— « Ask AI » /Ctrl+Jouvraient 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(roundtripgtMd/mdEscréel sous node) + e2e Playwrighte2e/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/Colorréutilisent les générateurs extraits (_blockTurnItems,_blockColorItems) ;Moreouvre 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(+moveBlockexécuté sous node), e2e mobilee2e/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/gtMdignorent les pastilles et_srcOffsetexclut les annotations des offsets → le texte sauvé est identique - Ancres jaunes —
_paintAnchors()après chaquerender()/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), e2ee2e/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
BackspaceetDelete(une seule branche dansonKd), vide jugé viagtTok()(ignore les pastilles) ; e2ee2e/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_endexacts dans le texte source - Bouton bleu « 💬 Comment » retiré —
#_commentSelBtn,showCommentBtn(),hideCommentBtn()et leur listenermousedownsupprimé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électionreaction a un→.fd-anchor[data-cid]= la plage seule, avant/après reload,#_commentSelBtnabsent ;pytest1383 passed,ruffOK ;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 → listenerdocument) ; 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-actionsmort (opacity:0 sans survol →+invisible qui capait les clics) fmtComment()restaure la plage de la toolbar avant d'ancrer ;commentBlock()ne se replie plus surthis._selpé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) ;pytest1386 passed,ruffOK ;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
::afterau 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êteSkills ≡, liste Skills scrollable,✨ Ask AIretiré (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_hostattrapeOSError(le garde SSRF ne lève plus) + fixturestub_dnssurTestOGParser(le test faisait un vraigetaddrinfo("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) ;pytest1390 passed / 0 failed,ruffOK ;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 jamaisrenderColumnsBlock()/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/_tblIsSepacceptent le GFM sans pipe de tête (séparateur ≥2 cellules pour ne pas avaler « texte » + « --- ») ;md2betpaste2baligné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(), listenerscopy/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), e2ev768_columns_clipboard.spec.js(4) ;pytest1394 passed / 0 failed,ruffOK ;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).
Non livré — double-clic sur le bouton close de Settings. Cause racine trouvée, correctif NON appliqué (non vérifié sur le parcours réel).
Relevé d'investigation, à ne pas refaire :
- Cause racine : Alpine ne LIE PAS le composant
x-data="settingsInit()"dans certains contextes. Mesure sur un onglet où Settings a été ouvert viatarget="_blank":window.Alpineexiste, maisbutton.settings-close._x_dataStackest absent →@click="closeSettings()"est inerte. Le premier clic ne fait rien du tout — ce qui correspond au symptôme rapporté. Ce n'est PAS la logique de navigation qui est en cause. - Le bug se reproduit en e2e : ouvrir
/settingsdepuis un onglet distinct (target="_blank"avecrel="opener"), cliquerbutton.settings-close→ l'URL reste/settings. Un test l'a confirmé (APRES 1 clic= /settings), sanspageerrorni message console : le handler ne s'exécute simplement pas. - Ce qu'il ne faut PAS faire : réécrire
closeSettings(). L'ancienne implémentation (window.opener→window.close(), sinonhistory.back()+reload()après 100 ms) a été modifiée puis annulée : elle n'avait aucun effet sur le cas reproduit. Lereturninconditionnel de la branchewindow.openerreste un défaut réel (window.close()échoue en silence si l'onglet n'a pas été ouvert par du script), mais il n'est pas la cause rapportée. - Thèse de la course réfutée : un retour plus lent que 100 ms ne provoque pas le symptôme (testé avec 400 ms de latence, un seul clic suffit).
- Parcours de référence qui, lui, fonctionne en un clic :
/local-workspace→ menu avatar → Settings (chargement de page complet).
Prochaine étape concrète : déterminer pourquoi settingsInit() échoue à
s'initialiser — settings.js est-il chargé dans ce contexte ? Vérifier
x-data au chargement et la présence d'une erreur CSP/nonce sur le script.
✅ 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
v5.2.0 → Infrastructure & Polish✅ livrév5.0.0 → Command palette + FTS5✅ livrév5.3.0 → Inline databases + templates + validation✅ livrév5.13.0 → Realtime (WS + présence)✅ livrév5.10.0 → Interactions de bloc✅ livrév5.4.0 → Expérience éditeur✅ livrév5.5.0 → Embeds & Média riche✅ livrév5.6.0 → Import de données (6 phases)✅ livrév5.7.0 → Database Avancée (Pt. 2)✅ livrév5.8.0 → Calendrier & Rappels✅ livrév5.11.0 → Wiki-links & mentions de page✅ livrév5.12.0 → Templates & verrouillage de page✅ livré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.