# Changelog - FlowDeck ## v7.38.0 (2026-10-01) — A20 phase 3 LOT 1 : shell + library migres ### Added - **`e2e/csp_preview.spec.js`** — aperçu CSP strict SANS déployer : le build `@alpinejs/csp` (fichier officiel, `e2e/fixtures/alpine.csp.js`) est servi **à la place** de `alpine.min.js` par interception Playwright ; tout échec du parseur maison = `pageerror` (filet). **Première surface verte : library** (composant lié, icônes SVG rendues via `Alpine.effect`, recherche ouverte + focalisée, 0 erreur console/page). ### Changed - **Composants `x-data="fn()"` → registre `Alpine.data(...)`** (15 + `appState` + `libraryPage`) : le build CSP ne résout que le registre (probe : globale window → `Undefined variable`) — scripts/classiques chargés pendant le parsing = `alpine:init` toujours joint à temps. - **base.html (shell) migré** : `x-effect document.*` → `syncSidebarClass()`, `$nextTick(arrow)` → `initSidebarSort()`, `window.FlowDeck.*` → `fdCreatePage/fdCreateFolder/fdGwRefresh`, `Object.keys`/`Math.min`/ `window.innerWidth` dans `x-for`/`:style` → `sidebarSections()`/ `sectionMenuPos()` — toutes les formes = simple appel de méthode. - **x-html restants du shell** → `x-init` + `Alpine.effect` : icône agent (`bindAgentIcon`), carte projet (`bindProjectIcon`), library ×3 (`bindHtmlIcon`/`bindHtmlItem`), recherche library (`toggleSearch` avec `Alpine.nextTick`), `openMoveSelected` (library). - **eslint : 70 warnings → 0/0** : `getCsrf` (helper A38 ph1) déclaré dans les globals du config, `/* exported openCardDetail */` + `/* global owner, repo */` (app.js), 3 `;;` résiduels de la conversion A38 supprimés. ### Notes - Portes A20 ph3 : surfaces restantes = settings, local_workspace, gitea_workspace, page_editor, board (partiels), agent_panel, import, welcome, accounts, trash, team_load, workspace, table_view, card_detail ; **bascule réelle** (retrait `unsafe-eval`) = quand csp_preview est vert sur toutes les pages principales. ## v7.37.0 (2026-10-01) — A20 : htmx allowEval off + plan Alpine CSP (phase 3) ### Changed - **htmx `allowEval: false`** dans le `` : htmx ne peut plus évaluer de JS (`hx-on`/`hx-vars`/`hx-vals`) — grep = **0 usage** dans les templates, donc zéro régression possible. `unsafe-eval` reste **uniquement** pour Alpine standard. - **Gate E20 renforcé** : le smoke vérifie désormais que `Alpine.$data()` lie un vrai composant `[x-data]` de la page (le lien composant = le cœur de tout basculement CSP). ### Notes — A20 phase 3 (unsafe-eval, scopé par probes) Le build `@alpinejs/csp` a été **testé empiriquement** (fichier 72 Ko, **0 `eval`/`new Function`**, parseur d'expressions maison) : il tourne sous CSP strict, mais **bloqué sur FlowDeck** par deux familles d'usages : - **13 expressions non parsables** par la grammaire restreinte : arrows (`$nextTick(() => …)` ×2), `typeof` ×1, `new Date(…)` ×4, optional-chaining `?.` ×6 (base, library, local_workspace, settings, gitea_workspace) ; - **24 `x-html` réactifs** (icônes SVG `getSvgIcon`/`_fileIcon`, markdown agent, preview) — **interdits par le build CSP** (innerHTML) : nécessitent de reposer les icônes sur des composants `Alpine.data`. - Portée : 0 variable globale/`document` accessible dans les expressions du build CSP (scope = données du composant + magics) → chaque site devient une méthode de composant enregistrée via `Alpine.data`. **Plan phase 3** : migrer composant par composant (library → settings → local_workspace → gitea → base) avec gate E2E dédiée, puis retirer `unsafe-eval`. En attendant : `unsafe-eval` conservé (Alpine standard). ## v7.36.0 (2026-10-01) — Fondations E2E + 2 bugs trouvés au passage ### Added - **`e2e/smoke.spec.js`** — 2 gates vert contre l'instance de test : · **gate A39** : bascule de vues d'une collection (clic onglet Calendar → `?view_type=calendar`, grille `.calendar` + `.cal-header` rendue, collection créée puis **supprimée** = répétable) · **gate A20** : palette Ctrl+K (ouverture Alpine `.open`, recherche GET rend `.cmd-palette-item`, fermeture Échap) · **filet console** : 0 erreur JS/CSP (les violations atterrissent ici ; le bruit `Failed to load resource` 401/403 est filtré) · **Service Workers bloqués** dans le smoke : `/sw.js` sert sa page « hors ligne » sur les navigations redirigées (`redirect:'manual'`) — bruit PWA hors sujet, `pwa_offline.spec.js` couvre le SW · bootstrap autonome : login OU création du compte e2e documenté, workspace si absent — lecture seule sur les données existantes - Commande : `cd e2e && node node_modules/@playwright/test/cli.js test smoke.spec.js` ### Fixed (trouvés par les gates) - **Bascule de vues standalone jamais fonctionnelle** : les onglets émettaient `?view=…` mais la route lit `view_type` (FastAPI) → l'onglet restait sur Table quel que soit le clic (bug pré-existant, non introduit par A28). Onglets → `?view_type=` + assertion comportementale ajoutée à `test_all_view_tabs_present` - **Inter bloqué par la CSP depuis v7.27** : `app.css` importait encore Google Fonts (`@import` raté par le grep de v7.27) → violation `style-src` sur chaque page + police tombée en fallback. Inter **auto-hébergé** : 2 faces variables (100-900, latin + latin-ext) dans `static/fonts/`, `@import` supprimé ### Notes - Suite complète : **1093/1093** · ruff OK · E2E **2/2** - Portes : A20 (unsafe-eval) attaquable avec ce filet ; A39 couvre la bascule collection (pas la bascule htmx board → décision « rien » maintenue) ; A38 twins reste conditionné à une couverture élargie ## v7.35.0 (2026-10-01) — Audit : A35 TERMINÉ (Python 3.13 aligné + rebuild) ### Changed - **Drift Python résolu** (dernier reliquat A35) : tout le projet est en **3.13** — `Dockerfile` (`python:3.13-slim` builder + runtime), CI Gitea (`python-version: '3.13'` ×2), `README.md` ×2, ruff `target-version = "py313"` (0 nouvelle remarque ruff). Restait 3.12 aux 6 endroits alors que `uv.lock` = `requires-python >=3.13` et le venv = 3.13.14. Zéro référence 3.12 résiduelle. ### Validation (le point que l'audit laissait « à faire par rebuild ») - `docker build` **vert** sur `python:3.13-slim` (image `flowdeck:a35-py313`) - dans le conteneur : `python -V` = **3.13.16**, `import app.main` OK (version 7.35.0) → wheels `requirements.txt` construits et importables sur 3.13 - Suite locale complète : **1093/1093** · ruff OK (target py313) ### Notes - A35 = **TERMINÉ** (OpenAPI/README/titre faits en 7.3.9 + drift aujourd'hui) ## v7.34.0 (2026-10-01) — Audit : A38 phase 2 (0 doublon de fonction globale) ### Changed - **Inventaire exhaustif de la duplication de fonctions** : scan de profondeur de brace sur les 13 noms définis 2+ fois (templates + static/js, strings/comments/backticks gérés) → **12 sont déjà scopés** dans des IIFEs depuis A27 (aucun conflit de page possible) - **Seul doublon global = `openCardDetail`** (corps byte-identiques ×2 dans `board_fragment` + `detailed_board`, fragments de vues mutuellement exclusifs) → dédupliquée vers `static/js/app.js`, les 2 copies supprimées (les onclick/@click des deux fragments appellent la même définition) ### Tests - `test_no_duplicate_global_functions` : garde-fou — 0 `function NAME` globale définie 2+ fois entre templates et static/js (scanner naïf, plafond `ponytail:` commenté : un faux positif se lit au nom signalé) - Suite complète : **1092/1092** · ruff OK · node --check vert ### Notes - Reste A38 : méthodes jumelles library/local_workspace (~9-21 noms communs, corps divergents) → fusion `workspace-tree.js` reportée (réconciliation sans E2E — même logique que A39/A20) ## v7.33.0 (2026-10-01) — Audit : A43 TERMINÉ + A38 phase 1 (CSRF unifié) ### Fixed - **A43-1 — plus de `__CSRF_PLACEHOLDER__` servi** : ContextVar `CSRF_TOKEN` posée par `CSRFMiddleware` **avant** `call_next` (même mécanique que le nonce CSP), global `{{ csrf_token() }}` ajouté à templating ; `base.html` rend `{"X-CSRF-Token":{{ csrf_token()|tojson }}}` — vide si le cookie est absent sur cette 1ʳᵉ requête, mais `htmx:configRequest` re-lit le cookie à chaque appel → jamais de jeton factice. Test `test_csrf_server_rendered_no_placeholder` (pas de placeholder + token == cookie) - **A43-2 — palette : plus de re-parse par frappe** : `fetch('/api/search…')` sans header (GET ∈ `SAFE_METHODS` → le CSRF ne s'applique pas) — le `JSON.parse(document.body.getAttribute('hx-headers'))` par frappe disparaît ### Changed - **A38 phase 1 — helper CSRF unique** : `window.getCsrf()` défini dans le `` de `base.html` (le plus tôt possible) ; **76 lectures brutes du cookie → `getCsrf()`** dans 13 fichiers (47 formes `(…||[])[1]||''`, 25 déclarations `const X = match(…)` + leurs usages `X?X[1]:''` → `X`, 4 formes espacées) ; définitions dupliquées supprimées (`card_detail`, `database_table`) ; les 3 variantes de `base.html` (IIFE + 2 `getCsrfToken`) unifiées sur `return getCsrf()` ; `welcome.html` garde sa lecture locale (page autonome documentée) ### Notes - A43 = **TERMINÉ** (4/4 : utcnow déprécié = 0 dans app/**.py, health loggé, placeholder, palette) - A38 reste : 12 fonctions dupliquées entre templates (wrappeur) + 21 méthodes jumelles library/local_workspace - Suite complète : **1092/1092** · ruff OK · node --check vert ## v7.32.0 (2026-10-01) — Audit : A28 TERMINÉ (board, dernier lot) ### Changed - **A28 lot 4** : `app/routers/board.py` (**2 101 lignes, 53 routes**) devient le package `app/routers/board/` : · 12 modules : `pages` 271 L (7), `page_api` 229 (5), `board_views` 223 (8), `page_ops` 176 (3), `sharing` 175 (10), `synced` 144 (8), `page_media` 122 (4), `import_` 85 (2), `wiki` 76 (2), `library` 66 (1), `embed` 64 (2), `sync` 51 (1) · `_common.py` (878 L) : les **23 helpers dont 4 async** + les 4 constantes (STATUS_COLORS, STATUS_LABELS, AI_KEYWORD_COLORS, _REPO_REF_RE) · `__init__.py` : `__all__` complet — importateurs **inchangés** (api.py ×4 top-level, webhooks top-level, dashboard ×5 lazy, tests ×4) - Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique byte-à-byte** ### Fixed - Constantes d'état oubliées dans `_common` à la 1ʳᵉ passe (F821 + ImportError au chargement) → ré-insérées avec leurs valeurs exactes (git show) - Docstring du header copié → F404 (`from __future__` après un statement) → slice `[1:21]` ; helpers **async** non détectés par `def ` seul ### Notes - **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 complète : **1091/1091** · ruff OK ## v7.31.0 (2026-10-01) — Audit : A28 lot 3 (collections → package 13 fichiers) ### Changed - **A28 lot 3** : `app/routers/collections.py` (**2 622 lignes, 53 endpoints / 52 fonctions**) devient le package `app/routers/collections/` : · 10 modules de routes : `crud` 337 L (6), `properties` 322 (8), `linked` 286 (7), `structure` 267 (8), `dashboard_views` 214 (3), `meta` 197 (5), `views` 187 (6), `pages` 184 (4), `data_api` 122 (2), `boards` 61 (3) · `_common.py` (220 L) : 8 helpers auth/permissions/validation · `_renderers.py` (667 L) : **15 rendus HTML des vues** (_render_chart, _render_map, …) + `CHART_MAX_GROUPS` · `__init__.py` : ré-exports connus — `_validate_page_properties` (automations), `_chart_values`/`_chart_aggregate`/`_fmt_number`/ `_render_chart` (tests) - Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique byte-à-byte** ### Fixed (pièges de la découpe) - docstring d'origine gardée dans le header copié → **F404** (`from __future__` après un statement) : slice `[1:30]` - décorateurs **empilés** (`view_collection` ×2) : segment sans `def` → skip du 2ᵉ décorateur (53 endpoints = 52 unités) - `CHART_MAX_GROUPS` hors détection des helpers (F821 dans `dashboard_views`) → import `._renderers` ajouté - `test_csp_no_cdn_and_vendor` lisait `collections.py` → balayage du package ### Notes - Reste A28 : `board.py` 2 101 L (lot 4) - Suite complète : **1091/1091** · ruff OK ## v7.30.0 (2026-10-01) — Audit : A28 lot 2 (dashboard → package 10 fichiers) ### Changed - **A28 lot 2** : `app/routers/dashboard.py` (**2 735 lignes, 63 routes**) devient le package `app/routers/dashboard/` : · `local_workspace` 559 L (15 routes), `pages_html` 485 (6), `account_settings` 439 (16), `workspace` 321 (9), `pages_api` 240 (6), `workspaces` 131 (6), `public` 78 (1), `account_api` 77 (4) · `_common.py` (774 L) : les **15 helpers top-level intercalés** dans l'ancien fichier + état (`logger`, `_VERSION`, `WORKSPACE_COOKIE`) · `__init__.py` : re-export complet (les 7 importateurs existants — main, board ×3, my_tasks, web_clipper, wiki, sites, tests — n'ont rien changé) + `__all__` - Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique byte-à-byte** (ordre d'enregistrement des routes préservé) ### Fixed (pièges de la découpe, rattrapés) - segment décorateur sans sa fonction (`def`) → corps perdus en silence : assert `def in seg` ajoutée + récupération depuis git - collision `settings` : la section `settings.py` entrait en conflit avec `from app.config import settings` du header (`hasattr` du fromlist de Python → la section était ignorée) → renommée `account_settings` - `WORKSPACE_COOKIE` utilisé dans `workspaces.py` sans import (F821) → import `._common` ajouté - script `__all__` tronquant la fin du fichier → `__init__.py` réécrit ### Notes - Reste A28 : `collections.py` 2 622 L, `board.py` 2 101 L - Suite complète : **1091/1091** · ruff OK ## v7.29.0 (2026-10-01) — Audit : A28 lot 1 (api_v2 → package 14 fichiers) ### Changed - **A28 lot 1** : `app/routers/api_v2.py` (**2 110 lignes, 115 routes**) devient le package `app/routers/api_v2/` — un module par concern : · `collections` 566 L (23 routes), `engagement` 338 (21), `workspaces` 230 (9), `templates_io` 205 (9), `webhooks` 195 (8), `identity` 195 (7), `views` 164 (8), `sharing` 160 (8), `properties` 151 (7), `planning` 148 (7), `projects` 93 (4), `admin` 91 (4) · `_common.py` : helpers partagés (`_hash`, `_v2_rate_check`) · `__init__.py` : `router = APIRouter(prefix="/api/v2")` + agrégat `include_router` (section routers sans prefix, tags `api-v2`) - Preuve contractuelle : **`docs/openapi-v2.json` régénéré = identique byte-à-byte** (0 changement de chemin, tag ni operation_id) - Seul importateur (`app/main.py` → `from app.routers.api_v2 import router`) fonctionne via le package ; en-tête d'imports copié par module, émondé par `ruff --fix` (143 imports morts supprimés automatiquement) ### Notes - Reste A28 : `dashboard.py` 2 735 L, `collections.py` 2 622 L, `board.py` 2 101 L (même recette, lots suivants) - Suite complète : **1091/1091** · ruff OK ## v7.28.0 (2026-10-01) — Audit : A42 TERMINÉ (client httpx partagé) ### Changed - **`app/services/http_client.py`** : `async with shared_client(timeout=15) as client:` remplace les **49 créations `async with httpx.AsyncClient(`** réparties dans 14 fichiers (gitea ×21, providers ×11, calendar ×4, automations ×3, …) — le pool de connexions est réutilisé au lieu d'être recréé à chaque appel - Cache **par (boucle d'event, kwargs)** en `WeakKeyDictionary` : un `AsyncClient` n'est jamais partagé entre deux loops (le piège des tests : « Event loop is closed ») — une boucle par test = un client propre, collecté avec elle. Clé = kwargs triés, `repr()` pour les valeurs non hashables (`headers=` dict) - Context manager no-op à la sortie (pas de fermeture du client partagé) ; `ponytail:` documenté : pas d'`aclose` explicite, plafond = pools non fermés à la main (GC des sockets), upgrade = lifespan - **Laissés délibérément** : `github_adapter` (transport MockTransport injecté), `webhook_outbound` (client « own_client » fermé par la fonction) ### Tests - `test_http_client_shared_and_loop_scoped` : réutilisation (mêmes kwargs), cloisonnement par kwargs, cloisonnement par boucle (2× `asyncio.run`) - Stub webhooks : patch étendu à la fabrique `http_client.httpx` + purge du cache (les tests patchaient `webhook_outbound.httpx`, contourné par la fabrique partagée) - Suite complète : **1091/1091** ## v7.27.0 (2026-10-01) — Audit : A20 phase 2 (CDN retiré, connect-src fermé) ### Changed - **Vendorisation** : chart.js 4.5.1 + leaflet 1.9 (js, css, 5 images marker/layer) téléchargés vers `static/js/vendor/` ; les 3 URL CDN des vues chart/map (`collections.py`) pointent en local → **la CSP n'a plus aucun hôte tiers** dans `script-src` ni `style-src` - **`connect-src` fermé** : `'self' ws://{host} wss://{host}` (Host de la requête, caractères hors base URL filtrés) — le `https:` universel (canal d'exfil JS) et les `ws:`/`wss:` tout-hôtes disparaissent. Grep négatif : 0 fetch cross-origin côté front - **Google Fonts** : entrées CSP mortes (0 référence dans le code) retirées de `style-src`/`font-src` - `img-src https:` **conservé volontairement** (unfurls YouTube/Vimeo… et tuiles OSM inénumérables) — `ponytail:` commenté dans `security.py` ### Tests - `test_csp_no_cdn_and_vendor` : CSP sans CDN/Google, connect-src exact (`'self' ws://testserver wss://testserver`), 4 assets vendor servis (200), source `collections.py` sans référence CDN - `test_view_chart_renders` : assert sur le chemin vendor - Suite complète : **1090/1090** ### Notes - Reste A20 : `unsafe-eval` (Alpine x-data + htmx hx-on/hx-vars = eval) → build `@alpinejs/csp` + couverture E2E des vues d'abord (même logique que la décision A39) ## v7.26.0 (2026-10-01) — Audit : A21 phase 2c (190 routes hors loop) ### Changed - **A21 phase 2c** — **283 → 93 routes async** (**86 % des 667 routes** en threadpool, avant : 61 %) en 4 passes : · **Racine auth** : `get_current_user` (session.py) était `async def` **sans aucun await** (cookie decode = synchrone) ; idem ses clones (`agent._current_user_id/_workspace_id/_current_admin`, `sso._require_admin` au corps 0 await) → `def` + **47 `await` supprimés** (dont 3 via l'alias `gcu` — le piège : le grep littéral ne les voyait pas, la suite les a attrapés) · Re-scan : 19 routes devenues sans await → `def` · **155 routes** dont les seuls awaits étaient `request.json()` / événements → `Body(default={})` + `run_event_sync(...)` puis `def` : - try/except `body = {}` → défaut `{}` (même tolérance, laissée intacte) - try/except `raise HTTPException(400)` → `Body(...)` **requis** (422 FastAPI — **aucun test ne couvrait le 400**, aucun call front n'envoie de JSON invalide) - forme conditionnelle `request.json() if content-type else {}` (54 sites) → défaut `{}` : sans corps = `{}` dans les 2 cas - Import `Body`/`run_event_sync` ajoutés aux routers convertis ### Notes - **Reste async (93, justifié)** : `request.form`/`upload.read`/`file.read` (corps réellement asynchrone), gitea/llm/oidc (réseau), `_json_body` (9 — wrapper de validation), 2 JSON inline en argument d'appel, 1 fallback à logique (`capture_frontend_error`), 1 lecture conditionnelle de taille (web_clipper) - Suite complète : **1089/1089** · ruff OK ## v7.25.0 (2026-10-01) — Audit : A27 CLOSED (eslint 0/0) ### Fixed - **Toasts settings jamais affichés** : `settings.js` appelait `typeof toast === 'function'` — guard **toujours faux** (le `toast` global n'existait pas) → les retours « Timezone saved » / « SP metadata URL copiée » ne s'affichaient jamais. → `window.showToast` (global `base.html`) - `_wsInitData = window._wsInitData` (auto-affectation d'un nom non déclaré, sans effet observable car `window._wsInitData` existe depuis L4) supprimé ### Changed - **A27 lint terminé : `eslint static/js` = 0 erreur / 0 warning** (285 → 0, 22 fichiers) : · no-empty ×70 = `catch (x) {}` vides → `catch { /* volontaire */ }` (binding optionnel ES2019 ; zéro changement de comportement) · no-unused-vars ×171 = bindings de catch retirés + 24 lignes mortes déterministes (assert sur texte exact avant suppression) + `/* exported */` sur les 10 fonctions appelées depuis les attributs HTML des templates (vérifiées par grep) · no-undef ×44 = vrais globaux déclarés dans `eslint.config.mjs` (getSvgIcon = inline base.html, TextDecoder = API, Prism = CDN) ### Notes - `node --check` vert sur tous les fichiers ; suite complète **1089/1089** - A27 reste ouvert uniquement pour : `base` 1 523 L structurel (inline par nature, décision assumée) + ~500 L de petits blocs hors cibles ## v7.24.0 (2026-10-01) — Audit : A27 extraction TERMINÉE (bilan -85 %) ### Changed - **A27 (phase 2c)** — `_database_table_scripts.html` : **1 314 L extraites** vers `static/js/database_table.js`. Le Jinja du bloc était confiné à la construction de l'objet de config (4 clés + `{% if collection_data %}`) → config JSON `#db-config` **null-vs-objet** : `new DBInstance(container, PAGE_COLLECTION_ID, DB_CONFIG)` remplace les 2 branches Jinja - 2 tests adaptés (lisaient le template source → `database_table.js`) ; `FlowDeckDB` plus dans le HTML → assert sur le JS ### Notes - **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, eslint **0 erreur / 285 warnings** - Reste : `base` 1 523 L structurel (`{% block %}`/`{% for %}` — reste inline par nature, décision assumée), ~500 L de petits blocs hors cibles, nettoyage des 285 warnings - Suite complète : **1089/1089** ## v7.23.0 (2026-10-01) — Audit : A27 phase 2b (+3 801 L) ### Changed - **A27 (phase 2b)** — 4 blocs interpolés extraits avec la recette config JSON (identique à la 2a) : · `local_workspace.html` → `local_workspace.js` (2 031 L, `lw-config` : current_folder_id, workspace_id) · `settings.html` → `settings.js` (1 093 L, `st-config` : avatar_url, avatar_color, user full_name/login/email, is_admin, auth_method — **2 routes rendent ce template**, les expressions `or ""` sont préservées pour les valeurs Undefined, `is_admin` reste un booléen) · `_page_editor_realtime.html` → `page_editor_realtime.js` (531 L, `rt-config` : SELF id/login/full_name/color) · `board.html` → `board.js` (146 L, `bd-config` : owner/repo/initial_view) - BONUS sécurité : les valeurs passent par `|tojson` (échappement JSON explicite) au lieu d'être interpolées dans des strings JS - Tags : config JSON inline (nonce conservé) + `