La bascule A20 phase 3 : - static/js/alpine.csp.min.js (build officiel @alpinejs/csp, 0 eval/new Function, parseur maison) servi partout : base.html, import.html, welcome.html + entree sw.js (cache bump v8). - CSP : script-src 'self' 'nonce-…' — unsafe-eval SUPPRIMÉ (ne servait plus qu'Alpine standard). htmx allowEval:false deja pose (v7.37). - Assertion test inversée : assert "'unsafe-eval'" not in script_src. - Scan statique final sur TOUS les templates : 0 expression incompatible (4 residus = faux positifs dans des chaines de texte). Pré-requis réunis par les lots 1-3 : 12 surfaces migrées + gateées (csp_preview), registres Alpine.data, x-html → x-init+Alpine.effect, délégués window.E, partage d'état lexical, bug topbar corrigé. Verifs : suite **1094/1094** · ruff OK · eslint 0/0 · **E2E 7/7 sous CSP reel** (script-src sans unsafe-eval verifie sur l'instance). Hors gate (scan propre, gitea down) : board/table_view/teamload/ card_detail → à vérifier au premier usage avec gitea remonté (noté ROADMAP/CHANGELOG).
157 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
🔜 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+KongletsPages / Fichiers / ✨ Réponses IA(reporté : backend livré, frontend en follow-up)
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 — canvas Settings → Automations (reporté : API steps livrée, UI en follow-up)
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)
✅ 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.