- `run_event_sync(coro)` (app/services/automations.py) : exécute une coroutine
d'événement depuis un handler synchrone — `asyncio.run` sur une boucle
dédiée dans le worker threadpool : le worker est bloqué, JAMAIS la boucle
d'event, et la réponse n'est produite qu'une fois l'événement terminé
(déterministe, équivalent sémantique de l'await). ponytail: les clients
httpx sont créés à chaque appel partout → aucun lien de boucle ; sinon
run_coroutine_threadsafe + boucle du lifespan.
- 15 routes api_v2 dont les SEULS awaits étaient `request.json`,
`_fire_event`, `fire_published`, `fire_unpublished` →
`Body(default={})` + `run_event_sync(...)` + conversion en `def` (script
: wrapping par appariement de parenthèses chaîne-aware, assert de flip
« plus aucun await »).
- api_v2 : **111/115 routes hors loop**. Les 4 restantes ont de vrais awaits
réseau et restent async volontairement : import_csv_v2 (multipart),
project_tree_v2 (gitea), test_webhook_v2 (delivery), retry_webhook_deliveries.
- Repo-wide : 403 routes sync (hors loop) / 260 async (phase 2c).
tests : ciblé public_api_v2 + v65 + webhooks_v2 + audit = 90/90 (les webhooks
prouvent la détermination de run_event_sync) ; suite complète **1037/1037**
(228 s) · `ruff check app tests` OK · docs à jour
- Conversion `async def` → `def` de TOUTES les routes dont le corps ne contient
ni `await`, ni `async with`, ni `async for`, ni `asyncio` (scan automatique
corps par corps sur app/ : 352 converties, 0 dangereuses, vérifié
`asyncio`/`run_coroutine`/`.result()` absents). FastAPI exécute ces handlers
dans son threadpool → tout leur SQLite (`get_conn()` + `conn.execute`) quitte
l'event loop, sans changer une ligne de logique.
- Répartition : api_v2 60, dashboard 40, collections 25, board 23,
workspace 19, wiki 17, permissions 14, api 14, main.py 6, + 35 fichiers.
- Les 4 routers prioritaires de l'audit sont couverts par ce lot :
api_v2 60 + dashboard 40 + collections 25 + board 23 = 148 conversions
(le reste de leurs routes attend la phase 2 : elles ont de vrais `await`).
- Reste (phase 2) : les 311 routes avec de vrais `await` → enrouler les blocs
DB dans `await anyio.to_thread.run_sync(...)` ; pas de wrapper partagé livré
(rien ne l'appellerait — YAGNI jusqu'au premier usage).
suite **1037/1037** (233 s) · `ruff check app tests` OK · docs à jour
- ContentSecurityPolicyMiddleware : nonce aléatoire par requête dans la
ContextVar `CSP_NONCE` (posée avant `call_next` → visible des templates),
`script-src 'self' 'unsafe-eval' 'nonce-…'` — plus aucun script inline
sans nonce ne tourne (fin des XSS injectés en JS)
- 38 tags `<script>` des templates : `nonce="{{ csp_nonce() }}"` (passage
scripté, vérifié : 0 restant) ; `LOCAL_LOGIN_HTML` (constante de module) :
helper `_with_nonce()` au rendu ; collections.py : 3 scripts Python
(chart/form/map) noncés
- `<meta name="htmx-config" content='{"inlineScriptNonce": …}'>` dans base.html
: htmx ré-injecte les <script> des réponses boostées avec le bon nonce
- `script-src-attr 'unsafe-inline'` : les 74 handlers `onclick=` inline
restent couverts (le nonce les aurait désactivés aussi)
- chart.js (cdn.jsdelivr.net) et leaflet (unpkg) ajoutés à script-src/style-src
: vues chart/map déjà BLOQUÉES par la CSP depuis toujours
(commentaire ponytail: upgrade = vendoriser puis retirer les hôtes)
- reste d'A20 : unsafe-eval (Alpine x-data → @alpinejs/csp), externalisation
JS (A27), resserrer img-src/connect-src
test : test_csp_nonce_per_request (page base.html + page hors template,
nonce unique par requête)
suite **1037/1037** · `ruff check app tests` OK · docs à jour
- A26 — champ `standalone` mort supprimé (0 lecteur) ; `sqlite:////data/…` ne
produit plus de chemin UNC sous Windows ; `.env.example` ne promet plus
PostgreSQL ; raise au boot si `APP_SECRET_KEY` vaut la valeur par défaut
- A33 — rate limit : préfixes manquants (`/scim/v2/`, `/workspace/`, `/db/` +
non-GET sur `/s/` et `/f/`), limite lue dans `settings.rate_limit_requests`
(60 annoncés / 100 codés en dur), clé `X-Forwarded-For` seulement derrière un
proxy local (anti-spoof), `_store` épuré (mémoire bornée) + test dédié
- A34 — `_spawn()` : les 10 schedulers loggent leur exception et redémarrent
après 10 s au lieu de mourir en silence ; 2 `logger.debug` de scheduler → warning
- A35 — OpenAPI régénéré 439 → 511 chemins (`info.version 7.3.9`), README
(était v6.7.0), compteur API_GUIDE, titre dupliqué retiré du ROADMAP ;
le drift Python 3.12/3.13 reste noté (rebuild d'image à valider)
- A36 — 4 dépendances mortes purgées de requirements.txt (aiosqlite, slowapi,
loguru, packaging = 0 import) ; pyproject reste sans [project] : Docker et la
CI installent requirements.txt, dupliquer les 22 deps créerait 2 sources
- A43 — 15 `datetime.utcnow()` → `now(UTC).replace(tzinfo=None)` (format ISO
naïf identique, warnings de dépréciation divisés : 2374 → 926)
suite **1029/1029** · `ruff check app tests` OK · docs/ROADMAP/CHANGELOG/WORKLOAD à jour
- A25 — 84 `except Exception: pass/…` → `logger.exception("<fonction>")`
(19 fichiers : api_v2 30, dashboard 10, board 7, sites 5, workspace 5,
api_v2_helpers 5, …) ; `logger` ajouté là où il manquait (api_v2_helpers,
sites + `import logging`)
- A25 critique — les `try` autour de `materialize_properties` supprimés dans
`create_collection_v2` ET `apply_db_template_v2` : un échec interrompt la
transaction au lieu de commiter une collection sans schéma
- test `test_collection_rollback_when_materialize_fails` (Bearer v2, monkeypatch
qui lève, assertions : RuntimeError + 0 ligne commitée)
- A21 partiel — `PRAGMA busy_timeout=5000` dans `get_conn()` (point d'entrée
unique) ; commentaire `ponytail:` : le wrapper async + les 510 call sites
restent à migrer module par module
- suite **1028/1028** · `ruff check app tests` OK
- `_current_user_id` : 401 sans session (24 sites) au lieu de retomber sur
`SELECT id FROM users WHERE login='admin'`
- `_current_admin` : suppression du même fallback — `PATCH /api/agent/providers`
et `POST /api/agent/providers/test` (donc `LLMClient.ping(api_base=…)`)
exigent une session admin : 401 sans session, 403 non-admin
- `_check_api_base()` sur les 2 routes : scheme http(s), pas d'identifiants
dans l'URL (400) ; hôtes privés maintenus — Ollama `localhost:11434` est le
provider par défaut du produit (commentaire `ponytail:` pour la fermeture)
- +1 test de non-régression → suite **1027/1027**, `ruff check app tests` OK
- A11 : `GET /api/settings/avatar/{filename:path}` → `resolve()` + `relative_to()` (motif de `serve_uploaded_file`), 403 hors de `/data/avatars`
- A18 : `GET /workspace/public/{id}` → 404 HTML explicite pour `permission_type` restricted/private, `html.escape` sur le nom, l'icône et les titres de lignes (le f-string HTML ne passe pas par Jinja2)
- `tests/test_audit_p0_fixes.py` : 3 tests de non-régression (traversal, échappement, hidden restricted)
- ROADMAP A11/A18 cochés · CHANGELOG/WORKLOAD/VERSION → 7.3.2 · suite **1019/1019** · `ruff check app tests` OK
- `app/templating.py` : `ENV = Environment(loader=FileSystemLoader("app/templates"), autoescape=select_autoescape(["html"]))`
- les 29 instantiations `Environment(loader=FileSystemLoader("app/templates"))` (9 routers) remplacées par `env = ENV` — plus aucune interpolation `{{ … }}` servie crue, les `|safe` redeviennent efficaces
- re-tri des `|safe` : `card_detail.html` corps d'issue et commentaires échappés (XSS stocké), placeholder de description sorti du ternaire, `sidebar_config` passé en dict + `|tojson` (échappement `</script>` en contexte script)
- `|safe` conservé sur `right_actions` (HTML fabriqué dans les templates, fiable)
- `ruff check app tests` OK · suite **1016/1016 verts**
- A3 : helper `_require_user_id()` (401 sans session) sur profile/password/token/forge ; `PUT /api/user/password` exige `current_password` vérifié ; `/api/user` sorti de la liste CSRF exemptée
- A4 : `POST /api/v1/token` → 401 sans session, chemin legacy `user_id=0` supprimé
- A5 : CRUD membres d'espace (POST/PUT/DELETE) : session + rôle admin de l'espace (ou admin global), placeholder user créé en `is_admin=0`, GET membres sans session → 401
- A6 : `_require_view` → 404 et `_require_edit` → 401 quand il n'y a pas de session (fin du legacy single-user sur les collections)
- A7 : création ET lecture de page → 401 sans session (PermissionManager conservé) ; `/board/api/pages` sorti de `EXCLUDED_PATHS` ; header CSRF manquant ajouté sur setItemIcon (local_workspace)
- A8 : seed admin sans mot de passe codé en dur — aléatoire au premier boot loggé une fois, ou `FLOWDECK_ADMIN_PASSWORD` ; re-seed seulement si absent
- tests : client connecté par défaut via `_TestSessionAuth` (session + CSRF injectés à la volée, jamais dans le cookie jar → plus de CookieConflict), helper `anon()` sur les 40 tests d'anonymat ; 1016/1016 verts, `ruff check app tests` OK
- Add HMAC SHA-256 signature verification for webhook payloads
- Implement retry logic with delays (2s, 10s, 60s) and max 4 attempts
- Add 20+ new events (total ~50 events) covering pages, blocks, users, etc.
- Add API v2 endpoints for testing HMAC signature and retrying deliveries
- Add comprehensive test suite for webhooks v2 functionality
Generated by opencode.
- app/db.py: ajout colonne shared_with_group_id (FK user_groups, migration
backfill v5.x) dans page_shares
- app/routers/sharing.py: POST /api/pages/{id}/share accepte group_id
(upsert, verif FK groupe), GET /shares expose kind/group_name, synchro
bidirectionnelle avec page_permissions (mirror grant/revoke) pour que
PermissionManager donne un acces effectif (view/comment/edit) aux membres
du groupe; PUT/DELETE gardent le miroir a jour
- app/routers/board.py, library.py: received/made incluent les partages via
groupes (JOIN group_members)
- app/templates/_page_editor_content.html, _page_editor_scripts.html:
dialogue Share — invite groups (fetch /api/v2/groups, filtre deja partages),
pickInviteGroup, shareInvite(group_id), rendu accessList avec avatar groupe
- tests/test_share_groups.py: 10 tests (CRUD groupe, kind, miroir ACL)
Chore: inclut evolutions v6.4.0 deja en working copy (webhooks prod,
migrations, sync, config/main) pour garder l'arbre coherent.
Un api_base stocke (ex. https://api.mistral.ai/v2) ecrasait l'URL par defaut corrigee et faisait echouer le test de connexion. Desormais: normalisation a l'ecriture (une base egale au defaut n'est pas stockee), possibilite de vider le champ (api_base='' vs None), et migration 14 qui efface les bases obsoletes/redondantes dans user_llm_keys et llm_config.
Tests de non-regression ajoutes.
- creation fichier/dossier a la racine du dossier courant ou d'un dossier cible (context-menu, boutons de survol) via createPage/showCreateFolderModal(parentId)
- instances de modeles en tant qu'enfant d'un dossier (parent_id) ; nom vide -> 'Untitled'
- sidebar de la librairie rafraichi apres delete/move/duplicate/rename (_syncSidebar)
- editeur: le titre se synchronise en direct dans le sidebar, le breadcrumb et l'onglet (pages et fichiers) et persiste via PUT /board/api/pages/{id}
Template-created pages (weekly report, project doc, meeting notes, to-do
list, ...) were persisted without block ids. The editor assigned ids
client-side, but the realtime room loaded the raw id-less content and sent
it back on 'sync'; applySync then merged id-less server blocks with local
blocks, producing data-bid='undefined' collisions and duplicated/shuffled
lines as soon as the user edited. Editing an empty page was unaffected
because the server state was empty.
Fixes:
- board.use_page_template: materialize unique block ids (recursively) when
instantiating built-in or user templates.
- realtime_server: unique block_id() (uuid) + ensure_block_ids() on room
load and on insert ops.
- editor: recursive ensureBlockIds() in init; gtTok() now restores
[[fddate:...]] tokens (date chips survived as labels before).
- realtime client: applySync() normalizes ids, no longer appends unknown
local blocks (duplication), and keeps local content when server is empty.
Tests: pytest (templates + realtime) and Playwright e2e covering to-do
list, weekly report date chip, Enter ordering and legacy id-less repair.
Pages created via the page-template picker (weekly report, project doc,
meeting notes, ...) previously got workspace_id=NULL and workspace=login,
so they never appeared in the active local/Gitea workspace tree — they
looked like they 'didn't save' even though the row existed.
Frontend: _useTemplate now sends the active workspace context (workspace_id
or workspace key), mirroring _createPlainPage.
Backend: use_page_template resolves/validates the workspace and persists
both workspace and workspace_id on the new page.
v5.11.0 Wiki-links & mentions de page :
- tokens [[fdpage:ID]] / [[fddate:ISO]] dans le texte des blocs,
service app/services/wiki_links.py (labels, rendu HTML, extraction)
- taper [[ ouvre le picker de pages (recherche floue, clavier) ;
le menu @ gagne les sections Pages et Date (today/tomorrow/YYYY-MM-DD)
- chips atomiques contenteditable=false relues en tokens par gtTok()
(autosave/drag/undo preservent les liens) ; renommage propage via
GET /board/api/wiki/titles ; backlinks reconnaissent les tokens ;
page publique rend les chips (echopee)
v5.12.0 Templates & verrouillage :
- template picker global sur + New page : 5 built-in
(app/services/block_templates.py) + templates perso
(table page_global_templates, migration 13)
- POST /board/api/page-templates (save current page) + /{id}/use
(instantiate, id 0 = built-in par cle)
- page lock : POST /api/pages/{id}/lock, garde _ensure_page_editable
-> 423 en ecriture pour les non-privileged, deblocage par
locked_by ou admin seulement (403 sinon), banniere + read-only UI
- full-width / small text par page (pages.full_width/font_small,
POST /api/pages/{id}/options, classes CSS)
- migration 13 : is_locked, locked_by, full_width, font_small,
page_global_templates
Tests : tests/test_v511_v512_wiki_templates.py (15) ; suite complete
538 verte ; ruff OK ; node --check des templates JS OK.
tests/conftest.py: mutate the settings singleton (instead of rebinding) so DB + backup dir are isolated per test -> pytest-xdist safe.
Real backup tests (snapshot/prune/admin API) and OAuth mock tests (Gitea/GitHub/link) replace the previous skips.
init_db() now also creates webhook_subscriptions (full schema without the FastAPI lifespan).
ruff check is clean; .eslintrc.json migrated to eslint.config.mjs (flat config).
CI: lint job (ruff + eslint), parallel tests (-n auto), run on every branch push.
VERSION 5.11.1.
- library: expose page tags (batch), render tag chips per row and add a tag
filter bar; register tag create/associate/remove in the shared context menu
exactly like local-workspace
- library: restore monochrome SVG icons (folder/edit/image/file) matching
local-workspace, with optional custom page_icon
- workspace: refresh tag chips/filter live on tag add/remove (reassign arrays
+ tagsVersion) instead of requiring a page reload
- ctxmenu: measure the rendered menu and clamp it to the viewport on open
- library.html: remove leftover syntax error + orphan </template></div> that
broke libraryPage() (page showed 'undefined' and no files), call the correct
fdCtx.openMenu and use self instead of a throwaway libraryPage() instance
- _ctx_menu.html: full conventional menu with shortcuts, tags + colour picker,
edit-icon picker; closes on outside click and Escape
- local_workspace.html: migrate onContextMenu to the shared fdCtx store with
complete handlers; drop legacy window._ctxMenuData DOM hacks
- dashboard.py: expose page_icon + favorited in the workspace tree
- board/dashboard: helper _load_shared_sidebar_pages intégrant les pages marquées is_shared directement
- _workspace_tree_macro: badge 👥 N&B sur les fichiers partagés du tree du sidebar
- local_workspace: badge 👥 en noir & blanc
- static/css/app.css: style .page-shared-badge