Files
flowdeck/ROADMAP.md
T
bruno 6ff88237fc
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Successful in 15m32s
FlowDeck CI / docker (push) Successful in 1m51s
feat: A20 phase 3 LOT 1 — shell + library migres, harnais csp_preview vert (v7.38.0)
Ajout :
- e2e/csp_preview.spec.js — apercu CSP strict SANS deployer : le build
  officiel @alpinejs/csp (e2e/fixtures/alpine.csp.js, 0 eval) est servi a
  la place de alpine.min.js par interception Playwright ; toute expression
  que le parseur maison ne digere pas = pageerror (filet). Premiere
  surface VERTE : library (composant lie, icones SVG via Alpine.effect,
  recherche ouverte + focalisee, 0 erreur).

Changed :
- 16 composants x-data="fn()" enregistres via Alpine.data (registre =
  seule resolution du build CSP, probe « Undefined variable » ;
  scripts classiques executes pendant le parsing => alpine:init toujours
  joint) : appState, libraryPage, workspacesPage, editorState, board x4,
  settings/import/table_view/team_load/trash/workspace/welcome/accounts/
  card_detail.
- base.html (shell) migre : x-effect document.* -> syncSidebarClass(),
  $nextTick(arrow) -> initSidebarSort(), window.FlowDeck.* ->
  fdCreatePage/fdCreateFolder/fdGwRefresh, Object.keys/Math.min/
  window.innerWidth dans x-for et :style -> sidebarSections()/
  sectionMenuPos() — tout = simple appel de methode.
- x-html restants du shell -> x-init + Alpine.effect : icone agent,
  carte projet, library x3 ; recherche library -> toggleSearch()
  (Alpine.nextTick) ; openMoveSelected() pour Object.keys en expression.
- eslint : 70 warnings -> 0/0 (globals getCsrf depuis A38 ph1,
  /* exported openCardDetail */ + /* global owner, repo */, 3 ;; residuels).

suite **1093/1093** · ruff OK · eslint 0/0 · E2E 3/3 (csp_preview + smoke x2)
· docs a jour (ROADMAP ph3 LOT 1, CHANGELOG, WORKLOAD, VERSION)
2026-10-02 12:08:18 -04:00

1203 lines
155 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Roadmap FlowDeck — Notion Clone
> **Vision**: Copie conforme de l'UI/UX Notion, intégrée à Gitea/GitHub.
> **MVP en production** : v4.0.0 — authentification locale/OAuth, pages blocs, sidebar, library, share/publish, intégrations.
> **Branche principale** : `main` (production) · `develop` (intégration) · `feat/*` (features).
## 🏗️ Stratégie de branches
```
main ──●────────────●────── production (tags vX.Y.Z)
\ /
develop ●──●──●──●────── intégration continue
\ \ \
feat/xxx ● ● ●──── feature branches
```
- **`main`** : code en production, déploiement Docker automatique
- **`develop`** : branche d'intégration, merges des `feat/*`
- **`feat/<nom>`** : une branche par feature/fix, PR → code review → merge dans `develop`
- **Tags** `vX.Y.Z` sur `main` pour chaque release
**Workflow** :
```bash
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
- [x] FastAPI lifespan (deprecation fix)
- [x] 28 tests — 5 vues, filtres, tris, APIs, CSRF, CORS, DB
- [x] CI/CD Gitea Actions (`.gitea/workflows/ci.yml`)
- [x] CHANGELOG.md, ARCHITECTURE.md à jour
- [x] Version 1.0.0 partout (main.py, api.py, health)
- [x] Tag git v1.0.0
### v1.1.0 ✅ — Pages & Sidebar
- [x] Pages Markdown avec arbre imbriqué (parent_id, sort_order)
- [x] Block editor mode (content_format='blocks', JSON storage)
- [x] Soft delete → Trash → Restore → Permanent delete
- [x] Drag & drop réorganisation pages
- [x] Sidebar Notion-style avec sections Recents/Private/Shared
- [x] "+ New page" footer button
- [x] Workspace header avec avatar + chevron toggle
### v1.2.0 ✅ — Éditeur Notion
- [x] Slash menu (/) 15 commandes fonctionnelles
- [x] Navigation clavier (flèches, Enter, Escape)
- [x] Placeholders Notion-style (Heading 1/2/3/4, Toggle, Quote, Code)
- [x] Suppression outline blanc sur les blocs
- [x] Heading 4 support
- [x] Breadcrumbs contextuels (workspace > projet > page)
### v1.3.0 ✅ — Database Concept
- [x] Tables `collections`, `collection_pages`, `collection_views`
- [x] Rétrocompatibilité Gitea via `GiteaBoardCompat`
- [x] Router `/db` — API CRUD collections + pages (12 endpoints)
- [x] 34/34 tests passent
### v1.4.0 ✅ — Propriétés Avancées
- [x] Table `collection_properties` — 21 types Notion
- [x] Service `property_types.py` — validation, formatage, auto-values
- [x] 38/38 tests passent
### v1.5.0 ✅ — Relations & Rollups
- [x] Type `relation` — lien bidirectionnel entre collections
- [x] Type `rollup` — aggregation via relation (COUNT, SUM, AVG, etc.)
- [x] Type `formula` — moteur d'expressions (19 fonctions)
- [x] Formula engine + Rollup engine
- [x] 43/43 tests passent
### v1.6.0 ✅ — Vues Manquantes
- [x] Calendar, Gallery, List, Timeline, Table views
- [x] View tabs navigation
- [x] 49/49 tests passent
### v1.7.0 ✅ — Vues Améliorées
### v1.8.0 ✅ — Sub-items & Dépendances
- [x] View config API, Save view as
- [x] Sub-items (parent_id auto-référence)
- [x] Status aggregate, Dependencies (blocking constraint)
- [x] 56/56 tests passent
### v1.9.0 ✅ — My Tasks & Dashboard Unifié
- [x] Vue `/my-tasks` — agrège pages assignées à l'utilisateur
- [x] Groupement par collection, filtres globaux
- [x] 60/60 tests passent
### v2.0.0 ✅ — Éditeur Complet & Multi-Utilisateurs
- [x] Workspaces, Comments, Page History, Favorites
- [x] Database Templates, Page Templates, CSV Import/Export
- [x] Public Sharing
- [x] 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
- [x] Créer fichiers dans le repo (New file UI + API PUT)
- [x] Upload fichiers binaires (POST /upload, drag-drop)
- [x] Syntax highlighting via Prism.js CDN
- [x] Labels Gitea → tags FlowDeck (POST /sync-labels, 16 labels importés)
- [x] Historique commits par fichier (GET /commits?path=)
### v2.9.0 ✅ — GitHub OAuth Provider
- [x] GitHubProvider dans `app/auth/providers.py`
- [x] GitHubAdapter dans `app/services/github_adapter.py`
- [x] Config GitHub dans `.env.example`
### v3.0.0 ✅ — UX Professionnelle
- [x] Multi-sélection (checkboxes + Shift/Ctrl-click dans le sidebar)
- [x] Raccourcis clavier (Ctrl+N, F2, Delete, Escape, Ctrl+S)
- [x] Inline rename (double-clic → champ inline)
- [x] Loading skeletons (animations pulse dans workspaces + file viewer)
- [x] Grid/card view (mode grille/vignettes)
- [x] Table sorting (tri par colonnes)
- [x] Sélecteur de vue: Tree, List, Details, Title, Content (5 modes)
- [x] Preview Panel latéral (contenu, métadonnées, tags, actions)
- [x] Tri par date (Oldest/Newest)
### v4.0.0 ✅ — Accounts, Integrations & Sharing (MVP)
- [x] `auth_method` sur users (local/gitea/github)
- [x] Badge OAuth dans sidebar
- [x] Settings page avec Connect/Disconnect Gitea + GitHub
- [x] Sidebar rules (Private = distant seulement, Trash local-only)
- [x] Library page avec tabs + filtres source (All/Local/Gitea/GitHub)
- [x] Modale Share (2 tabs: Share + Publish)
- [x] Page publique `/p/<slug>` (no auth, rendu HTML)
- [x] Table `page_shares`, colonnes `is_published`, `is_shared`, `share_mode`
- [x] Table `recents` — tracking automatique
- [x] Édition unifiée local/distant (même UI, bouton Commit pour Gitea)
- [x] 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**.
- [x] **Landing page `/` pour visiteurs non-auth** — template `landing.html`, design Notion dark
- [x] **Page `/auth/register` GET** — formulaire d'inscription dédié (tab register actif)
- [x] **Redirection `/` intelligente** — non-auth → landing, auth sans Gitea → local workspace, auth+Gitea → dashboard
- [x] **Empty states** — déjà présents dans local workspace (« Welcome to your Workspace »)
- [x] **Page 404 stylisée** — thème Notion sombre, JSON pour `/api/*`, HTML pour le reste
- [x] **Unifier les routes API** — `/api/pages` GET/POST → 307 redirect vers `/board/api/pages`
- [x] **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.
- [x] **Gestion d'erreurs** — try/catch dans create_page, message user-friendly (500 JSON)
- [x] **Validation inputs** — login/register déjà validés, titre vide → "Untitled"
- [x] **Rate limiting** — 100 req/min/IP déjà en place, testé OK
- [x] **Session expiry UX** — redirect avec ?expired=1, bannière sur la page login
- [x] **CSRF token refresh** — auto-refresh sur 403, endpoint `/api/csrf-token`
- [x] **Mobile responsive** — sidebar slide-in, modales centrées, landing adaptative
- [x] **Tests de non-régression** — +10 tests (landing, register, 404, validation, duplicate, expiry)
- [x] **143 tests passent**
---
### v4.1.0 — Database: Data Sources & Linked Databases ✅ (2026-07-21)
> **Objectif** : Collections multi-sources, databases liées (linked). **COMPLETED**.
> **Doc** : [`NOTION_DATABASE_TASKS_GUIDE.md` Phase 1](docs/NOTION_DATABASE_TASKS_GUIDE.md#14-plan-implémentation)
- [x] **Table `collection_data_sources`** — multi-sources par collection (FK `collection_id` + `source_collection_id`, flag `is_linked`)
- [x] **API sources** — `GET /db/{id}/sources/api` (liste), `POST /db/{id}/sources/api` (ajouter), `DELETE /db/{id}/sources/{sid}/api` (retirer)
- [x] **API linked DB** — `POST /db/{id}/linked/api` (crée une collection liée : copie vues + propriétés + data source `is_linked=1`)
- [x] **Full-page vs Inline** — `POST /db/{id}/toggle-inline/api` (toggle), `POST /db/inline/api` (créer inline avec `parent_page_id`)
- [x] **Règle permissions** — linked DB hérite du `workspace_id` de la source → mêmes ACLs
- [x] **Migration** — ajout colonnes `workspace_id`, `created_by` sur `collections`
- [x] **153 tests passent** (+10 nouveaux)
### v4.2.0 — Database: Templates & Dashboards ✅ (2026-07-21)
> **Objectif** : Templates réutilisables + dashboards combinant vues/widgets. **COMPLETED**.
- [x] **Table `page_templates` enrichie** — colonnes `description`, `is_recurring`, `recurrence_rule` (daily/weekly/monthly/yearly)
- [x] **Table `collection_dashboards`** — layout widgets (grille configurable : `columns`, `widgets` avec `view_id`, `x`, `y`, `width`, `height`)
- [x] **API templates CRUD complet** — `PUT /workspace/collections/{id}/templates/page/{tid}` (update), `DELETE` (delete)
- [x] **API dashboards CRUD** — `GET/POST/PUT/DELETE /workspace/collections/{id}/dashboards`
- [x] **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**.
- [x] **Chart** — barres, courbes, camemberts, donuts, scatter (Chart.js CDN)
- [x] **Form** — formulaire HTML auto-généré depuis les propriétés → POST création page
- [x] **Map** — Leaflet/OSM pour propriété Place (lat,lng parsing)
- [x] **Feed** — vue chronologique type fil d'actualité (newest first)
- [x] **Timeline/Gantt** — barres horizontales avec group_by et plages de dates
- [x] **View tabs** — 11 onglets de navigation (table, board, calendar, gallery, list, timeline, gantt, chart, form, map, feed)
- [x] **Layout options** — card_size, chart_type, group_by, place_property via config_json
- [x] **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**.
- [x] **Flag `is_task`** sur collections — PUT /db/{id}/toggle-task/api (Turn into Tasks)
- [x] **Table `page_dependencies`** — bloque/bloqué par/related avec auto_shift
- [x] **API dependencies** — GET/POST/DELETE /db/{c}/pages/{p}/dependencies/api
- [x] **Auto-shift dates** — POST /db/{c}/pages/{p}/auto-shift/api (skip_weekends option)
- [x] **171 tests passent** (+5 nouveaux)
### v4.5.0 — Sprints & My Tasks ✅ (2026-07-21)
> **Objectif** : Sprints agiles + vue My Tasks cross-databases. **COMPLETED**.
- [x] **Tables `sprints`, `sprint_pages`** — sprints agiles avec velocity_points
- [x] **API sprints** — CRUD + assignation/retrait pages
- [x] **Burndown chart** — GET /workspace/collections/{id}/sprints/burndown/{sid} (total/completed/remaining/ideal)
- [x] **My Tasks** — agrégation cross-databases avec filtre (all/today/overdue/upcoming)
- [x] **175 tests passent** (+4 nouveaux)
### v4.6.0 — Content Blocks enrichis ✅ (2026-09-02)
> **Objectif** : parité d'édition avec Notion.
- [x] **Callout blocks** — boîtes colorées avec sélecteur d'emoji/icône
- [x] **Table of contents** — auto-généré depuis les headings avec ancres
- [x] **Math equations** — LaTeX / KaTeX (self-hosted) block
- [x] **Toggle lists** — contenu expandable/collapsible avec enfants
- [x] **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.
- [x] **Export Markdown** — avec images et sous-pages (récursif)
- [x] **Export PDF** — via xhtml2pdf (fidèle, sans CDN/lib système)
- [x] **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)
- [x] **Share / More / Activity / Move-to popovers** — rendus sous le viewport (absolute + parent scrollable) → repositionnés fixed sous la topbar
- [x] **Sous-menu Export** — l'itém Export fermait le menu More ; devient un toggle, formats accessibles
- [x] **Cache CSS** — query string app.css bumpé v4.7.1
- [x] Vérifié Playwright headless + 190/190 tests
### v4.7.2 — Fix Export : contenu absent ✅ (2026-09-03)
- [x] **Export MD/HTML/PDF des documents** — le service ne lisait que les pages `blocks` ; les pages `file` (upload `.md`/code, contenu sur disque) et `markdown` sortaient avec le seul titre. Résolution de la vraie source pour les 3 formats + rendu HTML/PDF correct des headings/listes.
- [x] 5 nouveaux tests → 195/195 ; vérifié sur les vraies données
### v4.7.3 — Fix export PDF/HTML : tableaux + émojis ✅ (2026-09-03)
- [x] **Tableaux** — parseur GFM (bloc `table`) + rendu `<table>` HTML (thead/tbody, alignements, bordures `.ftable`) dans les exports HTML/PDF ; roundtrip markdown pipe valide.
- [x] **É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).
- [x] Dockerfile : libs pango/harfbuzz/gdk-pixbuf + polices ; requirements : + weasyprint==69.0
- [x] 4 nouveaux tests → 199/199 ; PDF README.md vérifié : tableau structuré + émojis colorés
### v4.8.0 — Bloc Tableau simple ✅ (2026-09-03)
- [x] **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.
- [x] **Lignes** — bouton `+ Row` sous le tableau (clic = ajouter) ; clic droit sur une cellule de corps → Insérer dessus/dessous, Dupliquer, Effacer, Supprimer la ligne, Supprimer le tableau.
- [x] **Colonnes** — poignée (⋮⋮) au-dessus de chaque colonne → Insérer à gauche/droite, Dupliquer, Effacer le contenu, Supprimer la colonne, Supprimer le tableau.
- [x] **Import Markdown (GFM)** — `md2b` transforme les tableaux pipe en bloc `table` au lieu de les aplatir en paragraphes ; roundtrip Markdown (JS + export) et sortie MD/HTML/PDF correcte d'un bloc `table`.
### v4.8.1 — Fix bloc Tableau ✅ (2026-09-03)
- [x] **« + Row » (bas) corrigé** — l'insertion de ligne utilisait `splice(index, ligne)` (mauvaise signature) → aucune ligne ajoutée ; désormais `splice(index, 0, ligne)`.
- [x] **Bouton « + » à droite** — ajoute une colonne à droite (équivalent « Add column »).
- [x] **Redimensionnement des colonnes** — curseur `col-resize` au survol des bordures d'en-tête, drag = largeur ; persisté via `colsW`.
### v4.9.0 — Collaboration ✅ (2026-09-04)
> **Objectif** : commentaires inline, mentions @, notifications in-app + email. **COMPLETED**.
> **Doc** : [`docs/Guide_Complet_Notion_sharing_collaborartion.md`](docs/Guide_Complet_Notion_sharing_collaborartion.md)
- [x] **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
- [x] **@mentions** — autocomplétion `@` (recherche utilisateurs temps réel), insertion `@login` dans les blocs et commentaires, table `notifications` ; endpoint `POST /api/pages/{id}/mentions` (notif des mentions de contenu)
- [x] **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
- [x] **Centre de notifications in-app** — cloche topbar (badge non-lus, polling 30s), panneau déroulant, mark as read / mark all read
- [x] **Settings** — toggles réels dans Settings → Notifications (Commentaires / Mentions)
- [x] **208 tests passent** (+9 v4.9.0)
### v4.11.0 — Agent IA : clés API par utilisateur & commandes slash ✅
> **Livré (2026-09-05)** : credentials par utilisateur (table `user_llm_keys`, plusieurs providers),
> chargement dynamique des modèles depuis le fournisseur (`POST /keys/{p}/models`),
> commandes slash dans le chat (`/provider`, `/model`, `/keys`, … via `PATCH` conversation),
> `/run` utilise la clé de l'utilisateur. 239 tests verts.
### v4.10.1 — Agent IA : config LLM dans l'UI ✅
> **Livré (2026-09-05)** : sélecteur provider/modèle dans le panneau agent (persisté par
> conversation), config runtime DB-backed (`llm_config`), écran admin *Agent & IA* avec
> test de connexion (`LLMClient.ping()`, sans fallback mock). 232 tests verts.
### v4.10.0 — FlowDeck Agent (Agent IA natif) ✅
> **Livré (2026-09-05)** : agent conversationnel complet — boucle ReAct, streaming SSE,
> 18 outils avec snapshots rollback, ACL par rôle, contexte automatique, custom agents,
> déclencheurs planifiés + skills, multi-LLM (offline mock + 8 providers). 18 tests dédiés.
> Voir [`docs/Flowdeck_Agent_integration.md`](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`](docs/Flowdeck_Agent_integration.md) (702 lignes) ·
[`docs/Guide_Complet_Notion_Agent_2026.md`](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
- [x] **Interface conversationnelle** — panneau agent dans le sidebar (🤖 Agents) + popup chat
- [x] **Boucle ReAct** — Raisonnement → Action → Observation → itération (max 12 tours)
- [x] **Streaming SSE** — affichage temps réel du raisonnement et des actions
- [x] **10+ outils actionnables** — search, read/write collections/pages, views, properties, Gitea sync
- [x] **Permissions** — même ACL que l'utilisateur (`PermissionManager`), audit log + rollback
- [x] **Contexte automatique** — workspace actif, pages mentionnées, fichiers uploadés
- [x] **Custom agents** — instructions, modèle, outils, scope par agent
- [x] **Déclencheurs** — planifiés, webhooks, événements workspace
- [x] **Skills réutilisables** — templates d'instructions + outils (ex: "Rédiger rapport hebdo")
- [x] **Multi-LLM** — GPT, Claude, Gemini, modèles locaux (Ollama), deepseek, qwencloud, Nvidia, openrouter
#### Architecture (nouveaux composants)
```
app/
├── routers/agent.py — /api/agent/* (run, conversations, tools)
├── services/agent_engine.py — Orchestrateur ReAct + boucle tool calls
├── services/llm_client.py — Abstraction multi-fournisseur LLM
├── services/tool_registry.py — 10+ outils wrappant les API existantes
├── services/context_builder.py — Collecte contexte workspace
└── templates/
├── agent_panel.html — Panneau conversation Alpine.js
└── agent_message.html — Fragment message (streaming)
```
#### Nouvelles tables
| Table | Rôle |
|-------|------|
| `agents` | Config agent (nom, instructions, modèle, tools scope) |
| `agent_conversations` | Sessions (titre, agent, workspace, statut) |
| `agent_messages` | Messages (role, content, tool_calls, artifacts) |
| `agent_actions` | Journal d'audit (tool, args, result, undo_snapshot) |
| `agent_skills` | Skills réutilisables (nom, prompt template, outils) |
| `agent_triggers` | Déclencheurs (cron, webhook, event, agent cible) |
#### Cas d'usage FlowDeck
- **Créer un CRM complet** — agent crée collection, propriétés, vues, exemples
- **Résumer les notes de réunion** — lit les pages, synthétise, crée une page résumé
- **Générer des OKR** — crée la database OKR avec relations et vues
- **Analyser un repo Gitea** — lit les issues, crée un dashboard de suivi
- **Rédiger un rapport hebdomadaire** — agrège les pages modifiées, génère le rapport
#### Plan de migration (5 phases)
1. **Phase 1 — Core Agent** ✅ (v4.10.0) : AgentEngine + LLMClient + 3 outils (search, read, create) + SSE streaming
2. **Phase 2 — UI** : agent_panel.html, historique conversations, sélecteur de modèle ✅ (v4.10.1)
3. **Phase 3 — Outils avancés** ✅ (v4.10.0) : 7 outils supplémentaires (views, properties, Gitea, uploads)
4. **Phase 4 — Autonomie** ✅ (v4.10.0) : custom agents, skills, déclencheurs planifiés
5. **Phase 5 — Plateforme** ✅ (v6.6.0) : API publique agent → intégrations tierces, marketplace skills
#### Limitations (v1)
- Pas de modification concurrente (lock optimiste DB)
- Pas d'appels API externes non-FlowDeck (sandbox strict)
- Budget tokens max par conversation (500k tokens)
- Timeout 5 minutes par run
### v5.0.0 — Command Palette & Recherche ✅ (2026-09-06)
> **Objectif** : `Ctrl+K` / `Ctrl+P` palette de commandes universelle + recherche full-text. **COMPLETED**.
- [x] **Command palette** — Ctrl+K / Ctrl+P recherche universelle (modale, fuzzy, navigation clavier)
- [x] **Quick actions** — navigation, création, commandes
- [x] **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**.
- [x] **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
- [x] **Buttons** — boutons cliquables déclenchant des actions
- Bloc `button` dans l'éditeur (menu slash) + picker d'automation inline
- Endpoint `/api/automations/{id}/run` (exempt CSRF) + UI Settings → Automations
- [x] Hooks événements dans collections.py / board.py / workspace.py (apply_page_template)
- [x] **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**
- [x] **Design tokens** — `static/css/design-tokens.css` (couleurs, espacements, typo, ombres, z-index) chargé après `app.css`
- [x] **Composants réutilisables** — `static/css/components.css` (btn/input/modal/dropdown/toast/card/badge/empty/table)
**Sécurité & Utilisateur**
- [x] **API Tokens** — générer/lister/révoquer dans Settings → API tokens (`app/routers/security.py`, table `api_tokens`, bearer `/api/v1`)
- [x] **Sessions actives** — liste + révocation (`user_sessions`, `SessionManager.list_sessions/revoke_session`)
- [x] **Onboarding wizard** — `/welcome` (workspace → forge → premier projet) + `welcome.html`
**Infrastructure**
- [x] **🥇 Migrations versionnées** — table `schema_version` + runner `app/migrations.py` (baseline v1, indexes v2, FTS5 v3)
- [x] **Backup automatique** — snapshot SQLite quotidien (`app/services/backup.py`, scheduler + API admin, rétention configurable)
- [x] **Index manquants** — `users.email`, `user_oauth_tokens(user_id, provider)`, `collections/pages(workspace_id)`, `pages(deleted_at)`
- [x] **Linting** — ruff (Python, `pyproject.toml`) + eslint flat (`eslint.config.mjs`) ; `ruff check` et `eslint` sans erreur
- [x] **Tests parallèles** — pytest-xdist (`pytest -n auto` en local et en CI ; DB + dossier backup isolés par test)
- [x] **Build Docker multi-stage** — builder + runtime (libs WeasyPrint), image allégée
**Forge integration**
- [x] **DB: table `projects`** — type (builtin/gitea/github), forge_id, clone_url, default_branch, language
- [x] **Cron: sync périodique des projets** — `project_sync_scheduler` (défaut : chaque heure)
- [x] **GitHubAdapter** complet — API GitHub v3 → interface `ForgeAdapter`
**Tests (cibles)**
- [x] **397 tests** — dont backups, projets, adapters forge (mock HTTP), OAuth (mock), multi-user ✅
- [x] Tests d'intégration auth (OAuth mock) — login/callback Gitea + GitHub, mode `link`, rejet d'état invalide
- [x] Tests des adapters forge (mock HTTP) — `GitHubAdapter` via `httpx.MockTransport`
- [x] Tests multi-user (permissions croisées) — `PermissionManager` owner/editor/viewer
**✅ Validation (2026-09-11)** :
- `pytest tests/test_v52_infra.py -v` → **18/18 passed** (tokens, sessions, onboarding, backups, projets, adapters forge, OAuth mock, multi-user)
- `pytest -q` (suite complète) → **397 passed**
- `ruff check app tests` → **All checks passed!** · `eslint static/js` → **0 problème**
- CI Gitea (commit `ba363ea` ; run push #1412 + PR #15 run #1413) → **success** sur `push` **et** `pull_request` : jobs `lint` (≈43 s), `test` (`-n auto`, ≈4 min), `docker` multi-stage (≈1 min)
- Docker : stages `builder` + `runtime` vérifiés par le job CI `docker` (`from app.main import app` OK)
**Complétion du 2026-09-11** :
- Isolation des tests corrigée : `tests/conftest.py` **mute** le singleton `settings` (au lieu de le remplacer), sinon les modules ayant importé `settings` au chargement (ex. `backup.py`) gardaient les valeurs par défaut → backups flaky. DB temporaire unique par test, sûr en xdist.
- Backups réellement testés (snapshot + prune + API admin) : les 2 skips supprimés.
- `init_db()` crée désormais aussi `webhook_subscriptions` (schéma complet sans dépendre du lifespan FastAPI).
- Tests OAuth mock (flux complet) ajoutés dans `tests/test_v52_infra.py`.
- CI : job `lint` (ruff + eslint) + tests parallèles (`-n auto`), déclenché sur toutes les branches.
### v5.3.0 — Database Avancée ✅ (2026-09-06)
> **Objectif** : databases inline + templates prédéfinis + validation propriétés. **COMPLETED**.
- [x] **Inline databases dans n'importe quelle page** — slash command `/database` (groupe DATA) → sélecteur de templates → bloc `embed_type:'collection'` rendu par `FlowDeckDB`
- [x] **Templates de database prédéfinis** — 6 templates seedés (CRM, Project tracker, Task list, Content calendar, Meeting notes, Reading list) + galerie au clic « Database » (Get Started) et `/database` ; `POST /db/api` & `/db/inline/api` acceptent `template` et matérialisent les propriétés
- [x] **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**.
- [x] **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`)
- [x] **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
- [x] **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…)
- [x] **Duplicate block** — `Ctrl+D`, menu bloc ou slash command « Duplicate » ; copie profonde (enfants columns/toggle) en multi-sélection
- [x] **Slash command étendue** — groupe « Actions » : Turn into, Duplicate, Copy link to block, Delete block
- [x] **En-têtes de tableau** — toggles « Header row » / « First col » dans la barre du tableau (`has_header` défaut actif + nouveau `first_col_header`), persistés + rendu `<th>` en preview et exports Markdown/HTML
- [x] **Intégration realtime** — `syncNow()` poussé après chaque mutation programmatique ; **9 tests** `tests/test_block_interactions.py` ; suite complète 307 verte (+3 PDF pré-existants)
### v5.13.0 — Collaboration temps réel ✅ (2026-09-08)
> **Objectif** : édition collaborative en direct (parité Notion en équipe). **COMPLETED**.
> Chantier n°1 de la parité Notion ; socle pour v5.14.0 (synced blocks).
- [x] **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
- [x] **Présence** — avatars des utilisateurs connectés (topbar), couleur par utilisateur, join/leave broadcasté ; `welcome` = self + peers
- [x] **Curseurs live** — position curseur/sélection des autres éditeurs (block + offset), calque dédié, label avec nom, mise à jour à l'édition/au scroll
- [x] **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
- [x] **Fallback polling** — si WS indisponible, rafraîchissement diff toutes les 10 s (adopté seulement sans brouillon local) + reconnexion automatique
- [x] CSP `connect-src` étendu à `ws:` ; **12 tests** `tests/test_realtime.py` ; suite complète 298 verte (+3 PDF pré-existants)
### v5.4.0 — Expérience éditeur ✅ (2026-09-11)
> **Objectif** : parité éditeur Notion — backlinks, duplication, corbeille, versions, import. **COMPLETED**.
- [x] **Backlinks** — `GET /board/api/pages/{id}/backlinks` (scan des liens internes `/pages/<id>`) + popover « Lié depuis… »
- [x] **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 `...`
- [x] **Corbeille globale améliorée** — vue cross-workspace `GET /board/api/trash` + `app/services/trash.py` (purge automatique > 30 jours, scheduler quotidien)
- [x] **Historique de version UI** — snapshots `page_versions` à chaque sauvegarde modifiée, `GET /api/pages/{id}/versions`, `POST .../versions/{vid}/restore`, popover « Version history »
- [x] **Import** — `POST /api/pages/import` (markdown) + `POST /api/pages/import/file` (.md/.txt/.zip d'export Notion) ; import CSV collections `POST /workspace/collections/{id}/import/csv`
- [x] **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**.
- [x] **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…
- [x] **Bookmark cards** — aperçu riche des URLs (métadonnées OG : titre, image, description, site, favicon)
- [x] **Image lightbox** — clic pour agrandir, navigation clavier (←/→) + plein écran dans l'éditeur **et** les pages publiques
- [x] **Previews inline** — PDF, vidéo, audio rendus directement dans la page
- [x] **Cover & icône de page** — upload image de couverture (fichier ou URL) + emoji/icône custom
Détails livrés :
- Service `app/services/embeds.py` : détection multi-provider + réécriture d'URL, `resolve_embed()`, `inline_kind()`, `embed_html()` ; endpoint `POST /board/api/embed/resolve`
- Service `app/services/og_fetcher.py` : parseur OG robuste (ordre d'attributs libre), favicon, repli sans réseau ; endpoint `POST /board/api/og/metadata`
- Endpoints `POST/DELETE /board/api/pages/{id}/cover` (JSON URL ou upload image) et `POST /board/api/pages/{id}/icon`
- Éditeur : résolution d'embed à la saisie (URL d'origine conservée + `embed_src` mis en cache), lightbox multi-images navigable, préviews vidéo/audio/PDF
- Pages publiques `/p/<slug>` : cover + icône, embed résolu, lightbox clavier
- Export Markdown/HTML/PDF : nouveau bloc `embed`/`bookmark` avec `embed_src` résolu
- **Fix chemins API** : l'éditeur appelait `/api/pages/...` alors que les routes sont `/board/api/pages/...` (cover, icon, versions, backlinks, import, move, OG) — corrigé
- **47 tests** `tests/test_v55.py` ; suite complète **444 verte** ; `ruff check` OK
### v5.6.0 — Import de données (étendu) ✅ (2026-09-13)
> **Objectif** : faire de FlowDeck la cible d'import universelle pour les outils réellement utilisés
> (notes Markdown, bureautique, tableaux, signets, dev), en complétant l'import de base
> (Markdown/CSV/Notion, déjà dans v5.4.0). **COMPLETED**.
> **Sources retenues** : Obsidian, Notion, Logseq/Roam, Apple Notes/Bear/Ulysses, Google Keep,
> OneNote, Word, Google Docs, HTML, PDF, CSV/Excel/Sheets/JSON, Gitea/GitHub, Raindrop, Pocket,
> Readwise, Shaarli, `.ics`, OPML, Standard Notes.
> **Sources exclues** : Confluence, Evernote, Asana, Trello (non utilisées).
#### Phase 0 — Socle d'import unifié (prérequis) ✅
> Toutes les sources partagent le même pipeline ; à livrer avant les imports.
- [x] **Service `app/services/importers/`** — interface commune (`detect()`, `parse()`, `to_pages()`) + résultat normalisé (`ImportResult` : pages, pièces jointes, warnings, stats)
- [x] **Pipeline commun** — upload → parse → normalisation blocs + métadonnées → création (pages + hiérarchie `parent_id`) → rapport d'import
- [x] **Pièces jointes** — extraction (images/fichiers), upload, réécriture des liens dans les blocs
- [x] **Frontmatter YAML → propriétés** (title, tags, dates, champs custom)
- [x] **Assistant UI d'import** — choix de la source, mapping colonnes→types, preview/dry-run, barre de progression
- [x] **Import asynchrone** (job en arrière-plan + polling) pour les gros volumes (vaults, zips)
- [x] **Idempotence / déduplication** + reprise sur erreur partielle
- [x] **Tests** — harnais d'import + fixtures par source
- [x] **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 ✅
- [x] **Obsidian** (vault `.zip`/dossier) — frontmatter YAML, `[[wikilinks]]`, `![[embeds]]`, attachments, hiérarchie de dossiers *(prépare v5.11.0)*
- [x] **Notion** (améliorer l'existant) — hiérarchie complète, databases (CSV) → collections, images
- [x] **Logseq / Roam Research** — markdown outliné (puces imbriquées), `((block refs))`, pages journal
- [x] **Apple Notes / Bear / Ulysses** — import export HTML/Markdown
- [x] **Google Keep** — Takeout JSON + HTML
- [x] **OneNote** — export HTML/PDF, best-effort (fidélité limitée)
#### Phase 2 — Données & tableaux (fort ROI databases) — effort faible/moyen ✅
- [x] **CSV/TSV typé** — inférence auto (texte/nombre/date/bool/select/multi_select) + UI mapping + options
- [x] **Excel `.xlsx`** (openpyxl) — multi-feuilles → collections
- [x] **Google Sheets** — export CSV/XLSX
- [x] **JSON générique** — mapping configurable (JSONPath → propriétés)
#### Phase 3 — Documents & bureautique — effort moyen ✅
- [x] **Word `.docx`** (mammoth/pandoc) — titres, listes, tableaux, images
- [x] **Google Docs** — Takeout `.docx`/HTML
- [x] **HTML** (fichiers/dossier, web clipper) — conversion HTML → blocs
- [x] **PDF** — extraction texte + images (fidélité limitée)
#### Phase 4 — Signets, dev & divers ✅
- [x] **Gitea / GitHub issues + labels + milestones** → collection (via API — quasi natif)
- [x] **Raindrop.io** (CSV/HTML) → bookmark cards (réutilise v5.5.0)
- [x] **Pocket** (CSV/HTML)
- [x] **Readwise** (highlights CSV/Markdown)
- [x] **Shaarli** (API/export JSON) → bookmark cards
- [x] **Calendrier `.ics`** (Google/Outlook/Apple)
- [x] **OPML** (flux/outlines)
- [x] **Standard Notes / autres**
#### Phase 5 — Durcissement & finition ✅
> **Objectif** : industrialiser l'import (ré-import, lots, URL, forge, relations, rapports).
- [x] **Import incrémental / re-sync** — modes `skip` / `update` (upsert des pages + lignes par titre) / `duplicate`, via `import_items`
- [x] **Import de dépôt forge** — arborescence de fichiers Gitea/GitHub → pages (hiérarchie de dossiers, fichiers texte, code en blocs)
- [x] **Import par URL / web clipper** — `POST /api/import/url` : fetch (garde SSRF) + OG metadata → page (carte bookmark + contenu)
- [x] **Lot multi-fichiers + file d'attente** — `POST /api/import/run-batch` + file d'attente UI (statut par fichier)
- [x] **Relations Notion** — `POST /api/import/relations/resolve` : colonnes texte référençant une autre collection → propriétés `relation` (ids de pages) ; auto-exécuté après un import Notion
- [x] **Reprise / erreurs partielles + rapport exportable** — import qui continue par page en cas d'erreur (`status: partial`, liste `errors`) ; rapport JSON téléchargeable (`GET /api/import/jobs/{id}/report` + bouton UI)
- [x] **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**.
- [x] **Types propriété** — `person`, `created_time`, `created_by`, `last_edited_time`, `last_edited_by` câblés : auto-valeurs calculées serveur (`apply_auto_properties`) à la création/mise à jour, sélecteur `person` (membres du workspace via `GET /db/{id}/members/api`), rendu chips/avatars et édition dédiée dans l'UI
- [x] **Groupes de propriétés** — colonne `collection_properties.group_name`, header de table avec sections pliables (`db-group-row` + toggle) et `POST /db/{id}/property-groups/api`
- [x] **Vues sauvegardées par utilisateur** — `collection_views.created_by` ; `GET /db/{id}/views/api` filtre par propriétaire (les vues partagées `NULL` restent visibles), `save-as`/`duplicate`/`DELETE` + renommage
- [x] **Swimlanes Kanban** — `sub_group_by` (2e dimension de groupement) : une rangée de colonnes par lane
- [x] **WIP limits** — `wip_limits` par colonne, alerte visuelle au dépassement (`wip-exceeded`)
- [x] **Cartes configurables** — `card_properties`, `card_size` (compact/détaillé), couverture `cover_mode` (`none`/`icon`/`color`/`property`) via `cover_property`
- [x] **Calendar avec drag & drop** — grille mensuelle, `date_property`, navigation mois/Today, drop d'une carte sur un jour = reschedule
- [x] **Gallery avec couvertures** — cartes avec vignette (image de propriété, icône, couleur, ou cover de ligne), tailles small/medium/large
Détails livrés :
- Migration 10 : `collection_properties.group_name`, `collection_views.created_by`/`updated_at`, `collection_pages.cover_url`
- `app/services/property_types.py` : `user_ref()`, `apply_auto_properties()`, validation `person`
- `collections.py` : auto-props create/update/sub-item, PATCH partiel fusionné, membres, groupes, vues per-user (list/save/duplicate/delete), config de vue étendue (`sub_group_by`, `wip_limits`, `card_size`, `cover_mode`, `card_properties`, `date_property`, `property_groups`)
- `dashboard.py` : auto-props à la création de ligne, `views` exposées dans `table-data` et le contexte de page
- `_database_table_scripts.html` réécrit en composant multi-vues (table/board/calendar/gallery/list) + barre de vues, éditeurs de cellules par type, picker personne, popovers de configuration
- **12 tests** `tests/test_v57_db_advanced.py` ; suite complète **503 verte** ; `ruff check` OK
### v5.8.0 — Calendrier & Rappels ✅ (2026-09-13)
> **Objectif** : calendrier complet + notifications proactives. **COMPLETED**.
- [x] **Vues Jour / Semaine / Mois** — sélecteur de mode dans la vue calendar (persisté en config de vue), navigation ‹/› et Today adaptées, vue Jour en agenda horodaté, Semaine en 7 colonnes ; événements servis par `GET /db/{id}/calendar/api` avec expansion serveur des occurrences
- [x] **Récurrence d'événements** — moteur `app/services/recurrence.py` (daily/weekly/monthly, intervalle, count, until, byweekday lundi=0, timezone) ; règle stockée dans `property_values_json.__recurrence__` ; popover événement (double-clic) avec Repeat/Every/Ends ; validation serveur (400)
- [x] **Support timezone** — colonne `users.timezone` + picker dans Settings → Notifications (`GET/POST /api/notifications/timezone`, zones via `GET /db/timezones/api`) ; timezone par événement (`__timezone__`) > règle de récurrence > préférence utilisateur
- [x] **Rappels** — `app/services/reminders.py` : lead minutes/heures/jours avant chaque occurrence, scan toutes les 60 s (`reminder_scheduler`), dédup `reminder_log`, notifie les personnes assignées (repli admin), in-app + email (pref `reminders`) ; stockés dans `property_values_json.__reminder__`, édités dans le popover événement
- [x] **Centre de notifications** — la cloche (v4.9.0) couvre désormais rappels, assignations et commentaires ; nouvelles préférences `reminders` + `assignments` ; notification d'assignation émise par `notify_assignment()` sur `PUT /db/pages/{id}/api` (comparaison avant/après des propriétés `person`)
- [x] **Template Meeting Notes** — propriétés `Agenda` et `Notes` ajoutées au template seed (migration 12, préserve les personnalisations) ; le schéma Attendees/Date/Status/Action items était déjà là
Détails livrés :
- Migrations 11 (`reminder_log`, `users.timezone`) et 12 (template Meeting notes)
- `app/config.py` : `reminders_enabled`, `reminder_scan_interval_seconds`
- `_database_table_scripts.html` : modes jour/semaine/mois, chips d'occurrences (badge ↻ + heure), popover Time/Timezone/Repeat/Remind, badges ↻/🔔 dans les cellules date, drag & drop de reschedule préservé
- **20 tests** `tests/test_v58_calendar_reminders.py` ; suite complète **523 verte** ; `ruff check` OK
- **Version** — 5.11.7
### v5.9.0 — AI Writing Assist (éditeur) ✅ (2026-09-10)
> **Objectif** : l'IA Notion dans l'éditeur, au-dessus du moteur v4.10.0 (Agent IA). **COMPLETED**.
- [x] **Slash AI commands** — groupe « AI » dans le menu `/` : Write with AI, Summarize, Translate, Continue writing (`E.aiSlash`)
- [x] **Autocomplétion** — module `AIAC` : suggestion courte après ~900 ms d'inactivité, pastille « Tab » ancrée au bloc, insertion au `Tab`, rejet à `Escape`
- [x] **AI properties** — bouton ✨ par ligne de la table database : `POST /api/agent/writing/properties` propose Status/Priority/Résumé et applique les valeurs
- [x] **Service** `app/services/ai_writing.py` — 6 actions sans outils, replis déterministes hors-ligne
- [x] **Endpoints** — `POST /api/agent/writing` + `POST /api/agent/writing/properties`
- [x] **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**.
- [x] **Wiki-links `[[`** — taper `[[` ouvre le picker de pages (`GET /board/api/wiki/pages`, recherche substring + sous-séquence floue), Entrée insère un lien interne rendu comme chip atomique (icône + titre, cliquable) ; module `WM` dans `_page_editor_scripts.html` ; tokens `[[fdpage:ID]]` stockés dans le texte des blocs et relus intacts par `gtTok()` (autosave, drag, undo/redo préservés)
- [x] **Mention de page `@`** — le menu `@` (utilisateurs v4.9.0) gagne la section « Pages » : `@nom` cherche les pages et insère le chip inline
- [x] **Mention de date `@`** — section « Date » : `@today`, `@tomorrow`, `@hier` ou `@YYYY-MM-DD` insèrent `[[fddate:…]]`, rendus comme chips de date lisibles (« Fri 25 Dec 2026 »)
- [x] **Renommage propagé** — le token ne stocke que `page_id` ; `GET /board/api/wiki/titles` résout les libellés au rendu (editeur + page publique) → renommer une page met à jour tous ses liens ; page supprimée → « Deleted page »
- [x] Bonus — le scanner de backlinks v5.4.0 reconnaît les tokens wiki ; `_render_blocks_public` rend les chips en page publiée (HTML échappé) ; service `app/services/wiki_links.py` ; 15 tests dédiés (voir v5.12.0)
## v5.12.0 — Templates & verrouillage de page ✅ (2026-09-14)
> **Objectif** : démarrage rapide productif et protection des pages stabilisées.
> Les `page_templates` existent (v2.0.0/v4.2.0) mais uniquement côté collections. **COMPLETED**.
- [x] **Template picker global** — « + New page » (sidebar, footer, Ctrl+K) ouvre la galerie : 5 templates built-in (`app/services/block_templates.py` — Empty, Meeting notes, Weekly report, To-do list, Project doc) + templates custom utilisateur (`page_global_templates`, migration 13, cloisonnés par `created_by`)
- [x] **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
- [x] **Page lock** — toggle 🔒 dans le menu « … » : bannière sticky read-only, blocs/titre non éditables ; serveur `POST /board/api/pages/{id}/lock` + garde `_ensure_page_editable` → 423 sur `PUT /api/pages/{id}` et `POST /api/pages/{id}/blocks` ; déverrouillage réservé au poseur du lock (`locked_by`) ou admin (403 sinon) ; indicateur visuel = bannière en tête de page
- [x] **Full-width mode** — toggle dans le menu « … », persisté par page (`pages.full_width`), `POST /board/api/pages/{id}/options`, classe CSS `.full-width`
- [x] **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**.
- [x] **Bloc `synced_block`** — table `synced_blocks` (source de vérité) + références par page ; slash command `/synced`
- [x] **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)
- [x] **Unsync** — action « Unsync » qui convertit l'instance en copie indépendante
- [x] **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**.
- [x] **Signature HMAC SHA-256** — header `X-FlowDeck-Signature` sur chaque payload, vérification côté receveur
- [x] **Retries avec backoff** — 4 tentatives max, délais 2s / 10s / 60s, logs détaillés
- [x] **20+ nouveaux événements** — total ~50 événements (pages, blocs, utilisateurs, collections, workspaces, etc.)
- [x] **API v2 endpoints** — `/api/v2/webhooks/test-signature` (test HMAC), `/api/v2/webhooks/retry` (relancer les échecs)
- [x] **Tests** — 21 dédiés (`tests/test_webhooks_v2.py`) ; **7 tests d'intégration câblés** (retry échec→succès, échec après MAX_ATTEMPTS, event inconnu ignoré, aucun abonné, fan-out wildcard, flow complet signé bout en bout, `retry_due_deliveries` superseded→delivered) — fixture `db` = SQLite isolée par test + `httpx.MockTransport` + `RETRY_DELAYS` neutralisé ; suite complète **749 verts, 0 skip**
---
## v6.0.0 — PWA : Progressive Web App, offline ✅ (2026-09-18)
> **Objectif** : support hors ligne complet (manifest, service worker, IndexedDB,
> queue de mutations, sync serveur + résolution de conflits). **COMPLETED**.
- [x] **Manifest & icônes PWA** — `static/manifest.json`, `static/icons/*`, `scripts/generate_pwa_icons.py`
- [x] **Service Worker** — `static/sw.js` (precache shell, network-first HTML/API, page offline, Background Sync)
- [x] **IndexedDB client** — `static/js/offline.js` (`window.FlowOffline`) : queue, delta, flush, marqueurs dirty
- [x] **Endpoints sync** — `app/routers/sync.py` + `app/services/sync_engine.py` (`/api/v2/sync/delta|batch|status`)
- [x] **Migrations** — table `offline_sync_queue` + colonnes `sync_version` (triggers AFTER UPDATE)
- [x] **Conflits** — edit-edit (last-write-wins), edit-delete (page orpheline), create-create (« copie offline »)
- [x] **UI** — banner offline + pending count, badge de synchronisation, toasts, icône ⟳ sur pages dirty
- [x] **Durcissement** — max 100 mutations/batch, timeout 30 s, rétention queue 30 j
- [x] **Tests** — `test_sync.py`, `test_sync_migrations.py`, `test_service_worker.py`, `test_pwa_offline.py`, E2E `e2e/pwa_offline.spec.js`
- [x] **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**.
- [x] **Page permissions** — modes `inherit|restricted|private` (`pages.permission_type`, `collections.permission_type`, `collection_pages.permission_type`), grants explicites user/group (`page_permissions` role viewer/commenter/editor/owner), héritage page→collection→workspace, 404 masqué pour non-grantees, owner/admin bypass
- [x] **Collection permissions** — `collection_permissions` + permission_type, `/db/{id}` et `/db/{id}/api` masqués (404→302), création page et delete collection gatés (viewer 403), editor bypass
- [x] **Property-level visibility** — `property_permissions` (viewer|editor), `GET /db/{id}/properties/api` filtré par `get_visible_properties()`, `GET /api/v2/collections/{id}/properties/visible` (visible/hidden), grant = owner collection uniquement
- [x] **Groupes réutilisables** — tables `user_groups` + `group_members` (workspace-scoped, UNIQUE(name)), CRUD `/api/v2/groups` + `/groups/{id}/members`, grant par `group_id` (page/collection/property), retrait membre révoque l'accès
- [x] **API** — `app/routers/permissions.py` : pages (list/mine, grant, batch, revoke, permission-type), collections (list/grant/revoke/type + visible), properties (list/grant/revoke), groups (list/create/update/delete + members), users picker, audit `GET /api/v2/audit/permissions`
- [x] **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)
- [x] **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()`
- [x] **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)
- [x] **Migration 18** — `migrations.py` : création 6 tables + 3 colonnes `permission_type` + indexes (idempotent)
- [x] **Tests** — `tests/test_v60_granular_permissions.py` **21 tests** (inherit/restricted/private, grant viewer/editor, revoke, batch, type via API, collection restricted+grant, property visibility/hidden, group inherits + revoke, audit, auth 401, validation 400/404)
## v6.2.0 — Web Clipper : extension navigateur ✅ (2026-09-19)
> **Objectif** : capturer n'importe quelle page web en page FlowDeck (article, sélection, bookmark, screenshot) depuis une extension Manifest V3 + API directe. **COMPLETED**.
- [x] **Extension Manifest V3** — `extension/` + `static/extension/` (manifest, `background.js`, `content.js`, `popup.html/js`, `clipper.css`, icônes 16/32/48/128, `flowdeck-clipper.zip` servi à `/static/extension/`)
- [x] **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
- [x] **Serveur** — `POST /api/v2/web-clipper/clip`, `GET /status`, `POST /auth/verify` (register device → token `fd_…` montré une fois), `GET /devices`, `DELETE /devices/{id}` + page HTML `GET /extensions` ; auth triple (session OU Bearer `api_tokens` OU Bearer `extension_devices` OU legacy `user_tokens`), rate-limit 50/h/device
- [x] **Tables migration 19** — `extension_devices`, `extension_clips` + index `idx_ext_*`
- [x] **Settings UI** — onglet Extensions (devices, clips count, revoke)
- [x] **16 tests** `tests/test_web_clipper.py` ; `VERSION` 6.2.0 ; wiring `app/main.py:50,158`
### v6.2.1 — Web Clipper polish ✅ (2026-09-20)
- [x] **Bouton flottant rond transparent draggable** — toggle d'affichage persistant, `clipper:clipped` refresh auto sidebar
- [x] **Fix bloc bookmark** — `create_page_from_clip()` émet un bloc `bookmark` fidèle (OG + `embed_src` résolu), rendu/correct en éditeur + `/p/<slug>` + exports
- [x] Fix `duplicate inner` SyntaxError dans `_page_editor_scripts.html`
- [x] `flowdeck-clipper.zip` régénéré
## v6.3.0 — API publique complète v2 ✅ (2026-09-21)
> **Objectif** : REST API documentée OpenAPI, CRUD complet, un seul chemin de code (wrappers sur les services internes). Parité `docs/API_GUIDE_V6.md` §4 (~80 endpoints) + scopes hiérarchiques `read < write < admin`. **COMPLETED**.
> **Route** : `feat/v6-api-v2` → `develop` → `main` — livraison **en une fois** (tous domaines).
> **Doc** : [`docs/API_GUIDE_V6.md`](/docs/API_GUIDE_V6.md) · OpenAPI : `/docs` + `docs/openapi-v2.json` (402 chemins)
#### Phase 0 — Roadmap & doc catch-up ✅
- [x] Tagguer v6.2.0/v6.2.1 dans `CHANGELOG.md` + `ROADMAP.md` + `docs/V6_Web_Clipper.md` → `COMPLETED`
- [x] Détailler v6.3.0 phases 1-8 dans `ROADMAP.md` (plan gelé)
#### Phase 1 — Migrations socles (v20) ✅
- [x] `api_tokens` : colonnes `scopes TEXT DEFAULT 'read,write'`, `expires_at TIMESTAMP` (idempotent, rétro-compat)
- [x] `webhook_deliveries` : `id, webhook_id FK, status, http_code, error, duration_ms, attempt, created_at`
- [x] `api_audit_log` : `id, user_id, token_id, action, resource_type, resource_id, ip, created_at`
- [x] `idempotency_keys` : `key TEXT PRIMARY KEY, user_id, response_json, created_at`
#### Phase 2 — Helpers & auth v2 unifiée (scopes hiérarchiques) ✅
- [x] `app/config.py` : `public_api_insecure_ok: bool = False` — `fd-public-key` accepté seulement si `True` (dev local)
- [x] `app/services/api_v2_helpers.py` : `parse_pagination()` (+`X-Total-Count`), `to_iso8601()`, handler RFC 7807 `application/problem+json`, `require_scope()` (hiérarchie `admin ⊇ write ⊇ read`), `resolve_bearer_token()` / `get_bearer_user()` (hash sha256, `revoked` + `expires_at` + scopes, `last_used_at`, `extension_devices`)
- [x] Auth Bearer unifié partagé (clipper + legacy `user_tokens` supportés) ; handler d'erreurs unifié sur `StarletteHTTPException`
#### Phase 3 — Tokens CRUD v2 ✅
- [x] `POST /api/v2/tokens` (`name, scopes, expires_at`) → `fd_{urlsafe(32)}` hashé, montré une fois
- [x] `GET /api/v2/tokens` (prefix only), `DELETE /api/v2/tokens/{id}` (revoke), `POST /api/v2/tokens/{id}/rotate`
#### Phase 4 — Wrappers read (pagination/filtres/tri/fields) ✅
- [x] `app/routers/api_v2.py` (`prefix="/api/v2"`, tag `api-v2`) — `GET /collections`, `GET /collections/{id}`, `GET /pages/{id}`, `GET /collections/{id}/pages?filter[]=&sort=&fields=&query=`, `GET /search?query=&workspace_id=&type=` (FTS5), header `X-Total-Count`, `filter[]` AND, `sort=prop/-prop`
#### Phase 5 — Wrappers write critiques (`Idempotency-Key`) ✅
- [x] Collections : `POST/GET/PATCH/DELETE /collections/{id}` + `/linked`, `/task`, `/sources`
- [x] Pages : `POST/GET/PATCH/DELETE /pages/{id}` + `/restore`, `/move`, `/sub-items`, `/dependencies`
- [x] Properties : `GET/POST /collections/{id}/properties`, `PATCH/DELETE /properties/{id}`, `POST .../relation`, `evaluate-formula`, `compute-rollup`
- [x] 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 ✅
- [x] `GET/POST/PATCH/DELETE /api/v2/webhooks`, `POST /api/v2/webhooks/{id}/test` (ping)
- [x] `GET /api/v2/webhooks/{id}/deliveries` (journal basique)
- [x] *(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 ✅
- [x] Sprints, dashboards, templates, export/import, workspaces/members, users, admin
- [x] Forges : `GET /projects`, `/projects/{owner}/{repo}/tree` (best-effort via `gitea_client`)
- [x] *(reporté, livré v6.4.0)* : migration de `sync.py` vers Bearer — auth `Authorization: Bearer` (scopes read/write) via `get_bearer_user()` + repli cookie session conservé pour compat PWA offline (`app/routers/sync.py`)
#### Phase 8 — OpenAPI, tests & docs ✅
- [x] `docs_url="/docs"` + `redoc_url="/redoc"` activés ; `docs/openapi-v2.json` généré (402 chemins)
- [x] `tests/test_public_api_v2.py` — **24 tests** (auth scopes, pagination, filtres, RFC 7807, idempotency, webhooks deliveries, CRUD multi-domaines)
- [x] 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
- [x] **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
- [x] **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
- [x] **Chevauchement réel → LWW par champ + drapeau** — un conflit n'est plus « tout le bloc perdu » mais limité au champ concerné ; `conflict: true` renvoyé dans l'`ack` et le broadcast
- [x] **Protocole `base`** — le client embarque dans chaque `update` la version du bloc dont dérive sa saisie ; le serveur fait `merge_block_3way(base, current, incoming)` ; sans `base` → LWW historique (rétro-compat ancien client)
- [x] **Adoption côté client** — l'`ack` renvoie le bloc fusionné ; le client met à jour sa `base`, adopte le résultat (hors bloc en cours d'édition) et affiche un toast en cas de conflit
- [x] **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
- [x] **Broadcast non bloquant** — chaque connexion a une file sortante (`asyncio.Queue`) + une tâche `_writer` dédiée ; `_broadcast()` fait `put_nowait` et n'attend plus le socket → un client lent ne fige plus la room
- [x] **Coalescence des curseurs** — le writer réduit les messages `sel` empilés à la position la plus récente (seule la dernière compte), tout en préservant l'ordre des messages importants
- [x] **Déconnexion des clients trop lents** — file pleine (`MAX_OUT_QUEUE=512`) → fermeture 4413 + compteur `slow_disconnects` (évite qu'une room entière stagne sur un pair mortel)
- [x] **Anti-flood** — budget d'opérations par connexion (`OP_WINDOW_MAX=400` / 10 s) ; au-delà, réponse `ack stale` sans application
- [x] **Fix fuite de rooms** — une connexion 4404 n'enregistre plus de `Room` orpheline en mémoire ; `room_state()` sur page inexistante retourne un dict vide au lieu de planter sur `None`
- [x] **Observabilité** — `GET /api/realtime/stats` (authentifié) : rooms, connexions, ops, merges, conflits, déconnexions lentes + détail par page
Détails livrés :
- `app/services/realtime_merge.py` — merge 3-voix (nouveau, ~170 lignes)
- `app/services/realtime_server.py` — `RTConn` (file + writer + budget), `_apply()` avec merge, `_evict_slow()`, `stats()`, fix fuites
- `app/routers/realtime.py` — endpoint `GET /api/realtime/stats`
- `app/templates/_page_editor_realtime.html` — envoi de `base`, adoption du bloc fusionné, toast de conflit
- **26 tests** `tests/test_realtime_v64.py` (merge purs, protocole WS, convergence 2 clients, rétro-compat LWW, fuite 4404, coalescence, stats, anti-flood) ; **14 tests** `tests/test_realtime.py` préservés
- `ruff check app tests` OK · `eslint static/js` 0 problème
- [x] **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`](docs/V6_SSO_SAML_Enterprise_Auth.md).
- [x] **SAML 2.0 SP** — `app/auth/providers/saml_provider.py` (python3-saml 1.16.0) : login SP-initié
(`/auth/saml/login`), ACS signé (signature/audience/destination/InResponseTo validés),
SP metadata public (`/auth/saml/metadata`), SLO (`/auth/saml/logout`) ; `allowSingleLabelDomains`
pour les hôts homelab/LAN
- [x] **OIDC + PKCE** — `app/auth/providers/oidc_provider.py` (authlib 1.8.0) : `state` +
`code_verifier` en DB, ID token vérifié via JWKS (aud/iss/nonce/exp), discovery + JWKS cachés 1 h
- [x] **Provisioning** — `app/services/sso_provisioning.py` : auto-provision au 1er login
(`auth_method=saml|oidc`, lien par email), rôle par défaut + `default_workspace_id`,
mapping groupes IdP → rôles workspace (re-synchronisé à chaque login), mode **SSO only**
(login local refusé, admins exceptés — design §7.1), secrets Fernet, anti-replay `sso_requests`,
historique `sso_login_history`
- [x] **Routeur** — `app/routers/sso.py` : parcours navigateur + API admin
`GET/POST/DELETE /api/v2/sso/config` (admin + CSRF), `GET /api/v2/sso/workspaces`,
`POST /api/v2/sso/sync` ; CSRF exclut `/auth/saml/*` + `/auth/oidc/*`
- [x] **Migration 23** — tables `sso_config`, `sso_login_history`, `sso_requests`
- [x] **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
- [x] **`PermissionManager`** (design §7.2) — `is_sso_only_workspace()`, `get_sso_roles()`,
`sync_sso_permissions()`
- [x] **Help** — section « SSO (Enterprise) » + badge `.help-badge.sso`
- [x] **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`
- [x] **Tests** — `tests/test_v67_sso.py` : **38 tests** (config admin + CSRF + secret préservé,
métadonnées SP, flots SAML/OIDC complets vs IdP mockés, négatifs aud/iss/nonce/replay/tamper,
group mapping, `sso_only`, SLO, historique, rate limit, extension `PermissionManager`)
- [x] **Version** — 6.7.0 · `ruff check app tests` OK · **suite 802 verts, 0 skip**
---
## v6.6.0 — Agent phase 5 : API publique agent & skill marketplace ✅ (2026-09-24)
> **Objectif** : dernière étape du plan d'igration en 5 phases de l'agent — « Plateforme » :
> exposer l'agent en API publique pour les intégrations tierces et rendre les skills
> partageables (marketplace). **COMPLETED**. Voir [`docs/API_GUIDE_V6.md` §2.4](docs/API_GUIDE_V6.md).
- [x] **API publique agent** — `app/routers/api_v2_agent.py` (15 routes `/api/v2/agents/*`,
Bearer + scopes `read`/`write`, rate limit par token, idempotence `Idempotency-Key`, `api_audit_log`) :
- agents CRUD, conversations + messages, **run synchrone JSON** (le flux SSE reste réservé à l'UI),
journal `agent_actions` + `POST /agents/actions/{id}/undo`, `POST /agents/{id}/trigger`
- propriété des conversations vérifiée par `user_id` : un token tiers reçoit `404` (pas de fuite)
- réponse de run : `{status, final, error, reasoning[], actions[], events[], duration_ms}`
- [x] **Marketplace skills** — `app/services/skill_gallery.py` (source unique interne + v2) :
- **export portable** `{format: "flowdeck-skill", version: 1, skill{name, description, prompt_template, allowed_tools}}`
(aucun id/workspace/auteur local ne fuit) + **import** avec contrôle de collision (`409`, `overwrite: true` pour écraser)
- **galerie de 6 presets** installables : rapport hebdo, CR de réunion, base CRM, OKR, analyse repo Gitea, résumé de document
(`GET /api/v2/skills/gallery`, `POST /api/v2/skills/gallery/{slug}/install`)
- routes jumelles côté session : `GET /api/agent/skills/gallery`, `POST .../gallery/{slug}/install`,
`POST /api/agent/skills/import`, `GET /api/agent/skills/{id}/export`, `DELETE /api/agent/skills/{id}`
(comble le trou CRUD : pas de suppression de skill avant)
- **UI branchée** : section « Galerie » dans la palette `/` du panneau agent → installation + épinglage automatique du chip
- [x] **Webhooks de cycle de vie agent** — `agent.run.started` et `agent.run.failed` émis pour la 1re fois
(seul `agent.run.finished` l'était) ; les 3 sont dans le catalogue `webhook_outbound` → abonnement `agent.*`
- [x] **Docs** — `docs/API_GUIDE_V6.md` (§2.4 + correction du nom d'événement `agent.run.completed` → `agent.run.finished`),
OpenAPI régénéré : `docs/openapi-v2.json` → **427 chemins**, `info.version = 6.6.0`
- [x] **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)
- [x] **Version** — 6.6.0 · `ruff check app tests` OK · `eslint static/js` 0 problème · **suite 764 verts, 0 skip**
---
## v6.5.0 — Synced blocks production (databases & vues) ✅ (2026-09-24)
> **Objectif** : passer les synced blocks en « production » (résolution +
> propagation fiables partout) et les faire vivre dans les databases/vues —
> le « reste en v6 » du point **v5.14.0**. **COMPLETED**.
#### Contenu des lignes de database (le point « databases »)
- [x] **Migration 22** — `pages.collection_row_id INTEGER REFERENCES collection_pages(id) ON DELETE CASCADE` + index partiel ; chaque ligne de database gagne une **page contenu** (page `blocks` standard) portant son éditeur complet
- [x] **Service `app/services/row_pages.py`** — `ensure_row_page()` : création lazy/réparation (page trashée restaurée, `parent_section='DbRow'`), workspace hérité de la page hôte (DB full-page → page parente inline → `collections.gitea_owner/repo`), `collection_id` hérité pour que les ACL de collection s'appliquent au contenu
- [x] **Endpoint `GET /db/pages/{row_id}/open/api`** — renvoie `{page_id}` (garde `_require_view` de la collection) ; utilisé par le peek de **toute** vue (table/board/gallery/list/calendar)
- [x] **Fix bug d'ouverture de ligne** — le peek appelait `/pages/{rowId}` qui lit la table `pages` → avec des ids croisés il ouvrait une **page au hasard** (ligne id 5 « Ask AI » → page id 5 « Données ») ; désormais résolution via l'endpoint puis iframe `/pages/{pageId}`
- [x] **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
- [x] **Cascade** — supprimer une ligne supprime sa page contenu (FK `ON DELETE CASCADE`, refs `page_synced_blocks` en cascade)
- [x] **Exclusions des listings** — `AND collection_row_id IS NULL` sur : arbre sidebar (`_build_page_tree`), racines workspace (`_load_workspace_pages`), liste des pages du board, projets builtins, arbre Library (`_build_tree_children` + API tree), `page_count` des workspaces ; `parent_section='DbRow'` les tient hors des sections Private/Trash
#### Résolution serveur + propagation (le point « production »)
- [x] **Résolution à chaque lecture** — `resolve_content_json()` (`app/services/synced_blocks.py`) appliqué sur : les 2 routes de rendu `/pages/{id}` (dashboard + board, dual-route), `GET /api/pages/{id}/content`, `GET /api/local-workspace/page-content/{id}`, page publique `/p/{slug}`, `GET /api/v2/pages/{id}`, et les rooms realtime (`load_room`) → le cache `_synced_content` périmé n'est plus jamais servi
- [x] **Propagation écrite réelle** — `PUT /api/synced-blocks/{id}` réécrit le contenu stocké de **chaque** page référente (`sync_synced_blocks_in_page`, jusqu'ici jamais appelé = code mort depuis v5.14.0) **avant** le broadcast WS ; récursif dans les `children` (colonnes, toggles)
- [x] **Suppression de source** — ids des pages référentes collectés **avant** la cascade FK, `mark_synced_block_deleted()` marque `_synced_deleted` dans le stocké, broadcast dédié `manager._broadcast_synced_to()`
- [x] **É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)
- [x] **Rendu public des synced blocks** — branche `synced` ajoutée à `_render_blocks_public` (avant : JSON brut en `<p>`) + état deleted
- [x] **API v2** — `content_page_id` sur `GET /api/v2/pages/{id}` pour une ligne (sans création lazy en lecture)
#### Tests
- [x] **`tests/test_v65_synced_db.py` — 17 tests** (migration, création/idempotence/cascade/restauration de la page contenu, sync des titres, exclusions de listings, résolution lecture éditeur/content/public, propagation écrite + récursivité + deleted, synced block dans une ligne de DB de bout en bout, `content_page_id` v2)
- [x] Suite complète **742 verts** (`pytest -n auto`) · `ruff check app tests` OK · `eslint static/js` 0 problème
- [x] **Version** — 6.5.0 (VERSION + `app/main.py`)
---
## v6.0.0 — Pro ✅ (2026-09-24 — cycle COMPLETED)
- [x] **PWA** — Progressive Web App, offline support ✅ (livré) — [📄 Conception détaillée](/docs/V6_PWA_Progressive_Web_App.md)
- [x] **Granular permissions** — page-level, property-level access control ✅ (livré v6.1.0) — [📄 Conception détaillée](/docs/V6_Granular_Permissions.md)
- [x] **Web Clipper** — extension navigateur ✅ (livré v6.2.0/6.2.1) — [📄 Conception détaillée](/docs/V6_Web_Clipper.md)
- [x] **API publique complète** — REST API documentée (OpenAPI) ✅ (livré v6.3.0) — [📄 API Guide v2](/docs/API_GUIDE_V6.md) · [📄 OpenAPI](/docs/openapi-v2.json)
- [x] **SSO/SAML** — enterprise authentication ✅ (livré v6.7.0) — [📄 Conception détaillée](/docs/V6_SSO_SAML_Enterprise_Auth.md)
- [x] **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)
- [x] **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`](docs/V68_Sites_Forms.md)
- [x] **Sites multi-pages** — table `sites` (`slug UNIQUE`, `root_page_id`, `title`, `theme light/dark`, `custom_domain`, `password_hash`, `expires_at`, `noindex`, `analytics_id`), table `site_pages` (arbre public ordonné) ; nav latérale auto ; réutilise `_render_blocks_public` (synced + wiki résolus)
- [x] **SEO & partage** — OG/Twitter cards par site/page, sitemap `/s/<slug>/sitemap.xml`, meta robots, preview sociale
- [x] **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)
- [x] **Routes** — `GET /s/<slug>` (home), `GET /s/<slug>/<page-slug>` (résolution slug + id), `GET|POST /s/<slug>/auth`, `GET/PATCH/DELETE /api/v2/sites*` (+ pages + stats), domaine custom via `Host` header
- [x] **Forms publics** — `form_config_json` par collection (`public_token f_*`, `fields`, `required`, `success_message`, `notify_user_ids`) ; `GET /f/<token>` (no-auth) + `POST /f/<token>` (anonyme, rate-limit 20/h/IP, honeypot, validation `validate_property_rule`) ; chaque soumission = `collection_pages` + log `form_responses` (ip_hash jour)
- [x] **Embed & notifs** — `?embed=1` sans chrome, notif in-app aux `notify_user_ids`, trigger `form.submitted` (branché v7.0)
- [x] **Migrations 24** — `sites`, `site_pages`, `site_views`, `form_responses`, colonne `collections.form_config_json`
- [x] **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)
- [x] **Version** — 6.8.0 (`VERSION` + `app/main.py`) · `ruff check` OK · CSRF exempt `/s/` + `/f/`
### v6.9.0 — Recherche sémantique + Ask AI ✅ (2026-09-28)
> **Objectif** : retrouver (hybride FTS + vectoriel) et demander (RAG avec citations).
> Parité Notion Enterprise Search + AI Q&A. **Doc** : [`docs/V69_Search_Ask_AI.md`](docs/V69_Search_Ask_AI.md)
- [x] **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()`
- [x] **Recherche hybride** — `GET /api/v2/search/hybrid` (lexical FTS5/LIKE via `search.py` + cosine, fusion RRF k=60, `X-Total-Count`, pagination) ; filtres `workspace_id`, scope membership + `PermissionManager` (pages restreintes masquées), `search_excluded` respecté partout
- [x] **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
- [x] **Observabilité** — `GET /api/v2/search/index-status` (ressources indexées, vecteurs, modèle)
- [x] **Migrations 25** — `semantic_embeddings`, `semantic_index_state`, colonne `pages.search_excluded`
- [x] **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)
- [x] **Version** — 6.9.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **UI** — palette `Ctrl+K` onglets `Pages / 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`](docs/V70_Automations_Workers.md)
- [x] **Automations multi-étapes** — table `automation_steps` (`automation_id`, `kind`, `position`, `config_json`) ; triggers `any` (défaut) / `all` (fenêtre 5 min, `PUT .../mode`) ; `form.submitted` branché de bout en bout ; conditions AND (réutilise `match_condition_props`) ; actions `slack` (incoming webhook, secret chiffré Fernet), `email` (via `mailer`, `user:` résolu), `forge_issue` (Gitea `GiteaClient` / GitHub API, token `user_oauth_tokens`), `agent_trigger` (conversation + run `AgentEngine`), `delay` (sleep plafonné 300s)
- [x] **Bouton DB natif** — type `button` dans `PROPERTY_TYPES`, colonne `button_automation_id`, `POST /api/automations/press-button` (CSRF-exempt, contexte ligne, erreurs 400 explicites)
- [x] **Workers lite** — `app/services/workers.py` : lint AST (imports `os/sys/subprocess/socket`, `open/exec/eval`, dunders), builtins restreints, thread + timeout 30s, stdout capturé, budget journalier `daily_budget_s`, fork des partagés ; `app/routers/workers.py` : CRUD `/api/v2/workers*` (session ou Bearer `write`, code masqué aux non-owners), run/runs/fork/usage ; crons dans la boucle 60s existante
- [x] **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
- [x] **Migrations 26** — `automation_steps`, `workers`, `worker_runs`, `automations.trigger_mode`, `collection_properties.button_automation_id`
- [x] **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)
- [x] **Version** — 7.0.0 (`VERSION` + `app/main.py`) · `ruff check` OK
- [ ] **É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`](docs/V71_Calendar_Meetings.md)
- [x] **Sync externe** — table `calendar_links` (`user_id`, `provider google/caldav`, `tokens_enc` Fernet, `calendar_id`, `collection_id`, `date_property`, `sync_token`, `last_sync`) ; pull (event → ligne, date + `external_event_id`) + push (ligne → event) ; boucle 15 min dans le lifespan ; conflits (édité des 2 côtés → LWW + notif `calendar.conflict`, résolution par édition manuelle) ; passe pull marquée `touched` (jamais repoussée)
- [x] **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
- [x] **Meeting Notes v2** — upload audio (`.mp3/.wav/.m4a/.ogg/.flac/.aac`, 100 MB, `meetings_dir()`) → transcription `STT_COMMAND` ou transcript manuel (client-side STT) → résumé `ai_writing.summarize` (offline-capable) → trigger `meeting.summarized` (agents v7.0 : update tracker, post Slack, file tickets)
- [x] **Free/busy** — `GET /db/{id}/calendar/freebusy?from=&to=` (jours busy/free weekdays, récurrences expandues serveur, weekends exclus, 1..370 j)
- [x] **Migrations 27** — `calendar_links`, `meeting_transcripts`, colonne `collection_pages.external_event_id` + index
- [x] **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)
- [x] **Version** — 7.1.0 (`VERSION` + `app/main.py`) · `ruff check` OK
### v7.2.0 — Enterprise Admin : SCIM + 2FA + Audit UI ✅ (2026-09-29)
> **Objectif** : provisioning auto, durcissement auth, audit exploitable.
> Parité Notion SCIM + audit + domain verification. **Doc** : [`docs/V72_Enterprise_SCIM_2FA.md`](docs/V72_Enterprise_SCIM_2FA.md)
- [x] **SCIM 2.0** — `GET/POST/PUT/PATCH/DELETE /scim/v2/Users` (Bearer `scim_tokens`, mapping `userName→login`, `active→is_active`) ; auto-suspend (`is_active=0` → sessions révoquées, 401) ; tokens SHA-256 (affichés une fois, révocables) ; 404 `application/scim+json` + exemption CSRF (clients IdP sans cookie)
- [x] **2FA** — TOTP (`users.totp_secret_enc` chiffré Fernet, backup codes SHA-256 à usage unique, défi `pending` signé 5 min ; `local-login` → `2fa_required` sans session puis `local-verify`) + passkeys WebAuthn (`webauthn_credentials`, attestation vérifiée, connexion sans mot de passe avec anti-rejeu `sign_count`) ; `sso_only` + 2FA combinables
- [x] **Domain claim** — `domain_claims` (`domain` normalisé, `txt_token`, `auto_join_role`, `enforce_sso`) ; vérification `GET https://<domain>/.well-known/flowdeck-verify.txt` ; SSO forcé par domaine dans `local_login` (admins exemptés) ; jeton jamais renvoyé par le listing
- [x] **Audit UI** — `GET /api/v2/audit/logs` fusionne `api_audit_log` + `permission_audit_log` + `sso_login_history` (schéma commun), filtres `source`/`actor`/`action`, pagination, export `?format=csv` (admin ou Bearer `read:admin`) — *UI Settings → Audit : voir follow-up « Polish » v7.3*
- [x] **Gouvernance agents** — `agent_policies` (scope outils/workspace, `max_steps`, `require_approval` avant write) consultée par `AgentEngine` **avant** les ACL, file `agent_approvals` + décision `/api/v2/agent-approvals/{id}/decide`, événement `agent.run.approval_requested`
- [x] **Migrations 28** — `scim_tokens`, `domain_claims`, `webauthn_credentials`, `agent_policies`, `agent_approvals`, colonnes `users.totp_secret_enc`/`totp_backup_hashes` (`is_active` existait)
- [x] **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)
- [x] **Version** — 7.2.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · `test_agent.py`+`test_app.py` 261 verts
### v7.3.0 — Wiki / Teamspaces + Polish ✅ (2026-09-29)
> **Objectif** : connaissance vérifiée + finition collaborative. **Doc** : [`docs/V73_Wiki_Teamspaces_Polish.md`](docs/V73_Wiki_Teamspaces_Polish.md)
- [x] **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
- [x] **Verified pages** — `page_verifications` (badge ✅, `verified_by`, `note`, `expires_at` 90 j) ; index `/api/v2/wiki/verified` (exclut expirés + teamspaces privés) ; sweep admin `/api/v2/wiki/verify-expiry-sweep` → notif `page.verification_expiring` à J-7 ; Wiki Home `/api/v2/wiki/home` (teamspaces + verified + recents)
- [x] **Blocs manquants** — `mermaid` (SVG inline via `mmdc` si présent, sinon `<pre class="mermaid">` rendu client), `equation_inline` (KaTeX, source sanitizée : `<`/`>`/`\` retirés), `progress` (barre % agrégée sur `property_values_json`) ; rendus par `export.blocks_to_html` (donc export HTML/PDF) + `POST /api/v2/wiki/blocks/preview`
- [x] **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)
- [x] **Migrations 29** — `teamspaces`, `teamspace_members`, `page_verifications`, `comment_reactions`, `page_follows`, `guest_shares`, `page_views` + colonnes `teamspace_id`
- [x] **Tests** — `tests/test_v73_wiki_polish.py` (**58 tests**)
- [x] **Version** — 7.3.0 (`VERSION` + `app/main.py`) · `ruff check app tests` OK · v7.2 + v7.3 = **110 verts**
- [x] **Sidebar par teamspace** — section `Teamspaces` dans `base.html` (état Alpine + `loadTeamspaces`/`openTeamspace`), `GET /api/v2/wiki/teamspaces` sans `workspace_id` (liste cross-workspace + `workspace_name`), page HTML `GET /wiki/teamspaces/{id}`, entrée `DEFAULT_CONFIG` sidebar
- [x] **Notif `page.updated` aux followers** — `notify_followers_of_page_update` (fenêtre 10 min), hooks `fire_event` + payloads `actor_id` (update_page/save_page_blocks), auto-follow à la création d'un commentaire
- [x] **Charts avancés** — type `number` (KPI count/sum/avg/min/max), dashboards multi-DB `GET /db/{id}/dashboards/{did}` (widgets à `collection_id`, 40 widgets max, 200 lignes/cap), 0 conservé dans les données (bug `val if val else 1`), sélection du view config selon `view_type`
- [x] **Link previews riches** — unfurl `gitea:owner/repo` / `github:owner/repo` dans `POST /board/api/og/metadata` (GiteaClient / GitHubAdapter, fallback API publique), champs bookmark persistés (`url/title/description/image/site_name` dans l'autosave)
- [x] **UI Settings → Audit** — `settings.html` onglet `admin-audit` branché sur `/api/v2/audit/logs` (filtres source/actor/action + pagination + export CSV)
- [x] **SSO 21 casses** — install `python3-saml==1.16.0` + `authlib==1.8.0` (xmlsec/isodate/joserfc) → `tests/test_v67_sso.py` **38/38**
- [x] **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
- [x] **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
1. ~~**v5.2.0 → Infrastructure & Polish**~~ ✅ livré
2. ~~**v5.0.0 → Command palette + FTS5**~~ ✅ livré
3. ~~**v5.3.0 → Inline databases + templates + validation**~~ ✅ livré
4. ~~**v5.13.0 → Realtime (WS + présence)**~~ ✅ livré
5. ~~**v5.10.0 → Interactions de bloc**~~ ✅ livré
6. ~~**v5.4.0 → Expérience éditeur**~~ ✅ livré
7. ~~**v5.5.0 → Embeds & Média riche**~~ ✅ livré
8. ~~**v5.6.0 → Import de données (6 phases)**~~ ✅ livré
9. ~~**v5.7.0 → Database Avancée (Pt. 2)**~~ ✅ livré
10. ~~**v5.8.0 → Calendrier & Rappels**~~ ✅ livré
11. ~~**v5.11.0 → Wiki-links & mentions de page**~~ ✅ livré
12. ~~**v5.12.0 → Templates & verrouillage de page**~~ ✅ livré
13. ~~**v5.14.0 → Synced blocks**~~ ✅ livré · ~~**v6.0–v6.7 → PWA, Perms, Clipper, API v2, Realtime prod, Synced prod, Agent API, SSO**~~ ✅ livré
### Cycle v7 — à livrer
| Ordre | Version | Effort | Impact |
|---|---|---|---|
| 1 | **v6.8.0 → Sites & Forms publics** | M | 🔴 publier + collecter |
| 2 | **v6.9.0 → Search sémantique + Ask AI** | M | 🔴 retrouver |
| 3 | **v7.0.0 → Automations v2 + Workers** | L | 🔴 automatiser |
| 4 | **v7.1.0 → Calendar sync + Meeting Notes** | M | 🟠 |
| 5 | **v7.2.0 → SCIM + 2FA + Audit UI** | M | 🟠 enterprise |
| 6 | **v7.3.0 → Wiki/Teamspaces + Polish** | S–M | 🟢 finition |
---
## Résumé des phases
```
v0.x–v1.x ✅ v2.x ✅ v3.0.0 ✅ v4.0.0 ✅ v4.0.1 ✅
Base + Kanban Éditeur + Gitea UX Pro MVP Onboarding
+ DB + Auth + Workspaces + Multi-select Accounts & Polish
+ UI Notion + Tags + Admin + Sharing COMPLETED
+ GitHub OAuth + Library
v4.0.2 ✅ v4.1–4.9 ✅ v4.10 ✅ v5.0–5.3 ✅ v5.13 ✅ · v5.10 ✅ v5.5 ✅ · v5.7 ✅ v5.14 ✅ · v6.0 ✅
Quality DB views, Agent IA Palette → Realtime + Embeds & Synced PWA · Perms
& Tests Templates & COMPLETED Automations Interactions Média riche blocks Clipper · API v2
Collaboration Realtime, de bloc (undo/ (embed, + DB
DB avancée, redo, drag&drop, bookmark, avancée
Calendrier, AI duplicate) lightbox…) (Pt.2) v6.1 ✅ v6.2 ✅ v6.3 ✅
*Dernière mise à jour: 2026-09-29 — **cycle v7 ouvert et backlog planifié** : v6.8.0 Sites & Forms ✅ → v6.9.0 Search + Ask AI ✅ → v7.0.0 Automations v2 + Workers ✅ → v7.1.0 Calendar sync + Meetings ✅ → **v7.2.0 SCIM + 2FA + Audit + gouvernance agents ✅ (52 tests)** → **v7.3.0 Wiki/Teamspaces + Polish ✅ (72 tests, suite 1016 verts)**. Version courante **7.3.0**. Follow-ups v7.3 livrés : sidebar teamspaces, notif `page.updated`, charts `number`/multi-DB, unfurl `gitea:`/`github:`, page Settings → Audit. Voir docs `V68`–`V73`.*
---
## 🔴 Anomalies & dette technique — audit complet 2026-09-30
> **Méthode** : `ruff check app tests` (clean) · `pytest -n auto` (524 s) · introspection des routes réelles (**680 routes / 40 routers**) · 3 audits parallèles (sécurité, backend, frontend) — **chaque item ci-dessous relu file:line dans le code**, rien n'est rapporté sur oui-dire.
> **Tests réels : 1002 passed / 14 failed** (les docs annoncent « 1016 verts » → A1).
> **Cause racine de la moitié des items sécurité** : pas de middleware d'auth global (`main.py` ne monte que Session/CSRF/CSP/RateLimit/CORS), `get_current_user` (`auth/session.py:178`) **renvoie `None` au lieu de lever**, et 4 fallbacks « single-user legacy » transforment un anonymous en admin : `dashboard.py:687` (`else 1`), `workspace.py:23` (`or {"login":"admin","id":1}`), `agent.py:95-101` et `agent.py:115-131` (ligne `admin`). **Supprimer ces 4 fallbacks + un garde de route partagé corrige ~15 items d'un coup.**
### 🔴 P0 — Critique (avant toute exposition réseau)
- [x] **A1 — 13 tests en échec = deps manquantes** : `pyotp`, `webauthn`, `cbor2` listés dans `requirements.txt` mais absents du `.venv` → 6 tests 2FA (`ModuleNotFoundError: No module named 'pyotp'`), 7 tests WebAuthn (501 « WebAuthn library not installed »). Le 14ᵉ échec **n'est pas fixe** (run 1 = `test_v67_sso::test_env_config_fallback_when_table_empty`, run 2 = `test_v69_search_ask::test_ask_rate_limit` « assert 200 == 429 ») → isolation cassée : compteurs de rate-limit en mémoire partagés par worker + `tests/test_v54.py:208` fait `app.config.settings = app.config.Settings()` (rebinding explicitement interdit par `conftest.py:36-41`), alors que 17 modules font l'import précoce. *Fix : `uv pip install -r requirements.txt -r requirements-dev.txt --python .venv` · virer le rebinding de test_v54 · compteur de rate-limit par test. Effort : **XS**.*
- [x] **A2 — Cycle v6.8→v7.3 jamais committé** : 22 fichiers modifiés + 33 nouveaux (≈ 8 580 lignes non suivies), dernier commit `v6.7.0` (2026-09-24), alors que `VERSION=7.3.0` et CHANGELOG/ROADMAP/WORKLOAD annoncent « livré ». *Fix : commit + push + tag `v7.3.0`. Effort : **XS**.*
- [x] **A3 — Takeover admin non authentifié** : `PUT /api/user/password` (`dashboard.py:703`) passe par `_get_user_id` (`dashboard.py:687-689`) qui finit en `... else 1` → sans aucun cookie : `UPDATE users SET password_hash=? WHERE id=1` = l'admin seedé. `/api/user` est en plus **exclu du CSRF**. *Fix : 401 sans session + exiger le mot de passe actuel ; supprimer le `else 1`. Effort : **S**.*
- [x] **A4 — Mint de tokens API non authentifié (×2)** : `POST /api/user/token` (`dashboard.py:717`) renvoie `fd_<hex>` lié à l'id 1 sans session ; `POST /api/v1/token` (`public_api.py:57-79`) écrit une ligne `user_tokens` valable sur tout `/api/v1/*` même sans cookie (« legacy shared token »), et `/api/v1` est exclu du CSRF. *Fix : 401 sans session/Bearer `write` ; supprimer le chemin `user_id=0`. Effort : **S**.*
- [x] **A5 — CRUD membres d'espace sans auth + création d'admin** : `POST /workspace/{id}/members` (`workspace.py:69-83`) n'a **aucune vérif de session** et fait `INSERT OR IGNORE INTO users (id, login, full_name, is_admin) VALUES (?,?,?,1)` ; idem `PUT .../members/{user_id}` (85) et `DELETE` (98) ; `/workspace` est exclu du CSRF. Un anonymous s'ajoute à n'importe quel espace et crée un admin. *Fix : session + rôle admin espace sur tout le router ; ne plus écrire `is_admin=1` par là. Effort : **S**.*
- [x] **A6 — ACL collections no-op pour anonymous** : `_require_edit` (`collections.py:59-65`) et `_require_view` commencent par `if not user: return` → l'absence de session = accès total en écriture ; utilisé par la création (2637) et la modif/suppression (544, 617) de pages ; `/db/` est exclu du CSRF. *Fix : `if not user: raise 403/404`. Effort : **XS**.*
- [x] **A7 — Création de pages sans session, CSRF-exempt** : `POST /board/api/pages` (`board.py:1381-1404`) lit la session mais **ne vérifie jamais `if not user`** (contrairement à `set_page_lock`, `board.py:128-130`), et `/board/api/pages` est exclu du CSRF ; même pattern « No session → legacy single-user behaviour » en lecture `board.py:1420-1424`. *Fix : 401 sans session + sortir `/board/api/pages` de la liste CSRF. Effort : **S**.*
- [x] **A8 — Mot de passe admin codé en dur, re-seedé à chaque boot** : `app/main.py:80` `hash_password("FlowDeck2026!")` puis `INSERT OR IGNORE ... 'admin' ... is_admin=1` (80-85). Literal commité + scannable + réappliqué si le hash est effacé. *Fix : mot de passe aléatoire au premier boot (affiché une fois) ou `FLOWDECK_ADMIN_PASSWORD` ; ne re-hasher qu'au premier démarrage. Effort : **XS**.*
- [x] **A9 — DB de prod trackée dans git** : `flowdeck.db` (11 users, e-mails, `password_hash`, 9 sessions actives), `flowdeck_dev.db`, `test-commit.md`, `upload_test.txt` sont dans l'index **et** absents de `.gitignore` **et** de `.dockerignore` → `COPY . .` les embarque dans l'image. *Fix : `git rm --cached` + ajouter `*.db`, `*.db-*`, `test-commit.md`, `upload_test.txt`, `e2e/node_modules/`, `e2e/shots/` à `.gitignore` **et** `.dockerignore` + rotation du `app_secret_key` (les sessions sont révoquées). Effort : **S**.*
- [x] **A10 — Jinja2 `autoescape` désactivé partout** : les 29 sites construisent `Environment(loader=FileSystemLoader("app/templates"))` sans `autoescape` (vérifié à l'exécution : `autoescape = False`, jinja2 3.1.6 ; `grep autoescape app/*.py` → 0 hit). Résultat : 326 interpolations `{{ … }}` brutes dans 39 templates, et **tous les `|safe` du codebase sont des no-op**. Pannes concrètes : `notes.html:16,25` (`<textarea>{{ content }}</textarea>` + preview), `public_page.html:7,161` (titre non échappé sur les pages publiques `/s/`), `card_detail.html:48,85` (`issue.body|safe`, `comment.body|safe`), `base.html:140` (nom de page injecté en JS inline → exécution), `base.html:1789` (`innerHTML + item.name` depuis l'arbre Gitea), `base.html:1333` (`safeName` n'échappe que les guillemets, pas `<`/`>`). *Fix **à la racine** : un seul `app/templating.py` avec `ENV = Environment(loader=..., autoescape=select_autoescape(["html"]))`, remplacer les 29 instantiations, puis re-trier les `|safe`. Effort : **M**.*
- [x] **A11 — Path traversal en lecture** : `GET /api/settings/avatar/{filename:path}` (`dashboard.py:1870-1877`) fait `Path("/data/avatars") / filename` puis `FileResponse` **sans `.resolve()` ni `relative_to()`** alors que le bon motif existe 40 lignes plus bas (`dashboard.py:1479-1484`). Le `:path` Starlette accepte les `/`. *Fix : copier la garde de `serve_uploaded_file`. Effort : **XS**.*
### 🟠 P1 — Hautes
- [x] **A12 — SSRF avec exfiltration via unfurl OG** : `POST /api/og/metadata` (`board.py:1887,1908`) sans auth, `fetch_og_metadata` (`og_fetcher.py:124-129`) fait `client.get(src, follow_redirects=True)` **sans contrôle d'hôte**, renvoie title/description (~400 car.) → `169.254.169.254`, `localhost` atteignables et partiellement lisibles. Le garde existe déjà : `app/services/importers/url_fetch.py:22 _is_public_host`. *Fix : le réutiliser + re-vérifier après redirection. Effort : **S**.*
- [x] **A13 — Automations CRUD sans auth + SSRF webhook** : `create_automation` (`automations.py:74`), `update_automation` (113), `delete_automation` (142), `run_automation_endpoint` (160) n'ont **aucune auth** ; `services/automations.py:167-179` poste `context` (données de page) vers `action["url"]` sans validation d'hôte ; `/api/automations` et `/workspace/automations` sont exclus du CSRF. *Fix : session (admin pour CUD) + `_is_public_host` sur l'action webhook. Effort : **S**.*
- [x] **A14 — Fallback ligne `admin` dans le router agent → SSRF `api_base`** : `_current_admin` (`agent.py:115-131`) et `_current_user_id` (95-101) retombent sur la row `admin` sans session ; `PATCH /api/agent/providers` et `POST .../providers/test` (1000-1043) envoient `api_base` dans `LLMClient(...).ping()` → anonymous = pointer le serveur vers une URL interne ; `/api/agent` exclu du CSRF. *Fix : 403 sans session + validation d'`api_base` (bloquer les hôtes privés). Effort : **M**.*
- [x] **A15 — Webhooks sortants créés sans auth** : `POST /workspace/webhooks` (`workspace.py:672-686`) : aucune auth, aucune validation d'URL, `DELETE` (689) idem → + le retry scheduler, le serveur POSTe chaque événement (titres, contenu) vers l'URL d'un attaquant. *Fix : session admin + `_is_public_host`. Effort : **S**.*
- [x] **A16 — Lectures de pages/export sans aucune ACL** : `export.py:53` (`_load_page_or_404` = simple `SELECT ... WHERE id=?`), `dashboard.py:1141-1186` (`download_page_file`, `page_file_content`), et la lecture legacy `board.py:1420-1424` → contenu de **toute** page énumérable par id, sans session. *Fix : passer par `PermissionManager.can_view_page` + 401 anonymous. Effort : **M**.*
- [x] **A17 — Router legacy `/api` qui mute sans auth** : `move_card` (`api.py:98`), `set_col_mapping` (177), `delete_col_mapping` (207), `create_issue`/`update_issue` (281/322), `delete_checklist[_item]` (522/531), `PUT /users/me` (558) → seul garde = `_check_rate_limit`. *Fix : un `dependencies=[Depends(...)]` au niveau du router (session **ou** Bearer). Effort : **S**.*
- [x] **A18 — Collection publiée quelconque + stocké XSS** : `GET /workspace/public/{collection_id}` (`workspace.py:699-719`) « no auth required », **ignore les flags `restricted/private`**, et interpole `coll['name']`/`p['title']` dans un `HTMLResponse(f"""…""")` sans `html.escape`. *Fix : respecter les flags de partage + `html.escape`. Effort : **S**.*
- [x] **A19 — Liste CSRF trop large (34 préfixes) — TERMINÉ 2026-09-30 : 17 préfixes retirés, aucun préfixe cookie-auth n'est plus exempté** (12 en v7.3.5 après scan + 5 en v7.3.6 après équipement des 46 call sites) (`/db/`, `/workspace`, `/workspace/automations`, `/board/api/favorites`, `/api/pages`, `/api/recents`, `/api/notifications`, `/api/comments`, `/api/automations`, `/api/github`, `/api/admin`, `/api/onboarding`) puis les 5 derniers en v7.3.6 (46 call sites équipés de `X-CSRF-Token` dans agent_panel, settings, local_workspace, library, gitea_workspace, workspace, workspaces, welcome) — il ne reste que du Bearer/callback/public/infra : `csrf.py:21,25` couvre `/api/v2`, `/api/admin`, `/db/`, `/workspace`, `/api/user`, `/api/settings`, `/board/api/pages`, `/api/local-workspace`, `/api/comments`, `/api/agent`, `/api/automations`, `/auth/2fa` — tous **cookie-auth**. Seul `/scim/v2` est justifié par le commentaire de la ligne 19-20. Bonus : `/api/workspace` exempt aussi `/api/workspaces/*`. Filet restant = `SameSite=Lax` par défaut (jamais déclaré explicitement dans `main.py:150`). *Fix : garder un petit ensemble SAFE (webhooks, `/api/v1`, `/api/v2` Bearer, `/scim/v2`, callbacks OAuth/SSO) + ancrer les préfixes ; ajouter le header sur les 49 `fetch()` concernés (helper `csrfFetch` existe déjà : `base.html:892`). Effort : **M**.*
- [x] **A20 — CSP sans filet — PARTIEL : `script-src 'unsafe-inline' '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.
**Reste ph3** : surfaces settings, local_workspace, gitea_workspace, page_editor, board, agent_panel, import, welcome/accounts/trash/team_load/workspace/table_view/card_detail (partiels) → csp_preview vert partout, puis bascule réelle. `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.