diff --git a/CHANGELOG.md b/CHANGELOG.md index ad5dbe6..97e1f54 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,37 @@ # Changelog - FlowDeck +## 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 diff --git a/ROADMAP.md b/ROADMAP.md index 99ecaa5..f3e6c34 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1138,7 +1138,7 @@ Quality DB views, Agent IA Palette → Realtime + E - [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-'` — `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 `` (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). **Reste** : `unsafe-eval` (Alpine x-data en string → build `@alpinejs/csp`), externalisation JS (A27), resserrer `img-src`/`connect-src`, vendoriser les 2 CDN. Effort : **L**. -- [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`). **Reste (phase 2c)** : **260 routes async repo-wide** (dashboard/collections/board/…) → mêmes deux recettes (Body param, `run_event_sync` ou `to_thread` par bloc DB). Effort : **L**. +- [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**.* @@ -1181,4 +1181,4 @@ Quality DB views, Agent IA Palette → Realtime + E → 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. · **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. +→ **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. diff --git a/VERSION b/VERSION index bc34d81..045169b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.25.0 +7.26.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 1688866..0336b0d 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.25.0 (audit — A27 CLOSED : eslint 0/0, 285 warnings nettoyés) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.26.0 (audit — A21 phase 2c : 190 routes hors loop, 86 % total) | **Statut**: EN COURS 🔄 > **Cible**: parité Notion + intégration forge · **Follow-ups v7.3 livrés**: sidebar teamspaces, notif `page.updated`, charts `number` + dashboards multi-DB, unfurl forge, UI Settings → Audit — voir `ROADMAP.md § v7.3.0` ## Avancement Global diff --git a/app/auth/session.py b/app/auth/session.py index c17947a..6ad2039 100644 --- a/app/auth/session.py +++ b/app/auth/session.py @@ -175,7 +175,7 @@ def _touch_session(sid: str) -> None: # FastAPI dependency -async def get_current_user(request) -> dict | None: +def get_current_user(request) -> dict | None: """FastAPI dependency: extract current user from session cookie.""" session = request.cookies.get("flowdeck_session") if session: diff --git a/app/main.py b/app/main.py index 7846379..03f8cb1 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.25.0", + version="7.26.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/admin.py b/app/routers/admin.py index c24fd42..a13acc1 100644 --- a/app/routers/admin.py +++ b/app/routers/admin.py @@ -1,5 +1,5 @@ """FlowDeck — Admin API: users, roles, stats, audit.""" -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request from fastapi.responses import JSONResponse router = APIRouter(tags=["admin"], prefix="/api/admin") @@ -8,7 +8,7 @@ router = APIRouter(tags=["admin"], prefix="/api/admin") # ── Dependency ── async def admin_required(request: Request): from app.auth.session import get_current_user - user = await get_current_user(request) + user = get_current_user(request) if not user: raise HTTPException(status_code=403, detail="Admin access required") # Also check DB directly (session cookie may be stale) @@ -46,15 +46,11 @@ def list_users(_admin=Depends(admin_required)): @router.post("/users") -async def create_user(request: Request, _admin=Depends(admin_required)): +def create_user(request: Request, _admin=Depends(admin_required), body: dict = Body(default={})): """Create a new user (admin only).""" from app.db import get_conn from app.password_utils import hash_password - try: - body = await request.json() - except Exception: - body = {} login = body.get("login", "").strip() name = body.get("name", login) email = body.get("email", login) @@ -78,15 +74,11 @@ async def create_user(request: Request, _admin=Depends(admin_required)): @router.put("/users/{user_id:int}") -async def update_user(user_id: int, request: Request, _admin=Depends(admin_required)): +def update_user(user_id: int, request: Request, _admin=Depends(admin_required), body: dict = Body(default={})): """Update a user: name, email, password, admin status, active status.""" from app.db import get_conn from app.password_utils import hash_password - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: user = conn.execute("SELECT id FROM users WHERE id=?", (user_id,)).fetchone() if not user: diff --git a/app/routers/agent.py b/app/routers/agent.py index 7fcbdb0..22fa658 100644 --- a/app/routers/agent.py +++ b/app/routers/agent.py @@ -9,7 +9,7 @@ import json import logging from datetime import UTC -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import StreamingResponse from app.auth.session import get_current_user @@ -93,16 +93,16 @@ async def agent_scheduler(interval_seconds: int = 60): logger.exception("Agent scheduler tick failed") -async def _current_user_id(request: Request) -> int: +def _current_user_id(request: Request) -> int: """A14 : plus de fallback sur la row `admin` — 401 sans session.""" - user = await get_current_user(request) + user = get_current_user(request) if not user or not user.get("id"): raise HTTPException(status_code=401, detail="Authentication required") return user["id"] -async def _workspace_id(request: Request) -> int | None: - user = await get_current_user(request) +def _workspace_id(request: Request) -> int | None: + user = get_current_user(request) if user and user.get("workspace_id"): return user["workspace_id"] try: @@ -112,11 +112,11 @@ async def _workspace_id(request: Request) -> int | None: return None -async def _current_admin(request: Request) -> dict: +def _current_admin(request: Request) -> dict: """A14 : session obligatoire, puis admin. L'ancien fallback « row admin » laissait un anonymous diriger `PATCH /api/agent/providers` (et donc le `ping()` vers un `api_base` de son choix = SSRF).""" - user = await get_current_user(request) + user = get_current_user(request) if not user: raise HTTPException(status_code=401, detail="Authentication required") if not user.get("is_admin"): @@ -146,9 +146,9 @@ def _default_agent(conn, user_id: int) -> dict: @router.get("") -async def list_agents(request: Request): - user_id = await _current_user_id(request) - ws = await _workspace_id(request) +def list_agents(request: Request): + user_id = _current_user_id(request) + ws = _workspace_id(request) with get_conn() as conn: _default_agent(conn, user_id) rows = conn.execute("SELECT * FROM agents WHERE workspace_id IS ? OR workspace_id=? ORDER BY agent_type, name", (ws, ws)).fetchall() @@ -156,10 +156,9 @@ async def list_agents(request: Request): @router.post("") -async def create_agent(request: Request): - user_id = await _current_user_id(request) - ws = await _workspace_id(request) - body = await request.json() if request.headers.get("content-type") else {} +def create_agent(request: Request, body: dict = Body(default={})): + user_id = _current_user_id(request) + ws = _workspace_id(request) name = (body.get("name") or "").strip() or "Custom Agent" with get_conn() as conn: try: @@ -184,8 +183,8 @@ async def create_agent(request: Request): @router.get("/conversations") -async def list_conversations(request: Request): - user_id = await _current_user_id(request) +def list_conversations(request: Request): + user_id = _current_user_id(request) with get_conn() as conn: rows = conn.execute( "SELECT * FROM agent_conversations WHERE user_id=? ORDER BY updated_at DESC", @@ -195,10 +194,9 @@ async def list_conversations(request: Request): @router.post("/conversations") -async def create_conversation(request: Request): - user_id = await _current_user_id(request) - body = await request.json() if request.headers.get("content-type") else {} - ws = await _workspace_id(request) +def create_conversation(request: Request, body: dict = Body(default={})): + user_id = _current_user_id(request) + ws = _workspace_id(request) with get_conn() as conn: agent = _default_agent(conn, user_id) cur = conn.execute( @@ -236,10 +234,9 @@ def delete_conversation(request: Request, conversation_id: int): @router.patch("/conversations/{conversation_id}") -async def patch_conversation(request: Request, conversation_id: int): +def patch_conversation(request: Request, conversation_id: int, body: dict = Body(default={})): """Update a conversation's title / provider / model (slash-command support).""" - user_id = await _current_user_id(request) - body = await request.json() if request.headers.get("content-type") else {} + user_id = _current_user_id(request) with get_conn() as conn: conv = conn.execute( "SELECT id FROM agent_conversations WHERE id=? AND user_id=?", @@ -262,10 +259,9 @@ async def patch_conversation(request: Request, conversation_id: int): @router.post("/conversations/{conversation_id}/run") -async def run_conversation(request: Request, conversation_id: int): - user_id = await _current_user_id(request) - ws = await _workspace_id(request) - body = await request.json() if request.headers.get("content-type") else {} +async def run_conversation(request: Request, conversation_id: int, body: dict = Body(default={})): + user_id = _current_user_id(request) + ws = _workspace_id(request) objective = (body.get("message") or "").strip() if not objective: raise HTTPException(status_code=400, detail="message est requis") @@ -323,7 +319,7 @@ async def agent_generate(request: Request): Because no tool schema is offered, the model answers with plain text based on the provided document context instead of issuing search_workspace / tools. """ - user_id = await _current_user_id(request) + user_id = _current_user_id(request) body = await request.json() if request.headers.get("content-type") else {} prompt = (body.get("prompt") or "").strip() if not prompt: @@ -383,7 +379,7 @@ async def agent_writing(request: Request): """ from app.services.ai_writing import WRITING_ACTIONS, AIWritingService - user_id = await _current_user_id(request) + user_id = _current_user_id(request) body = await request.json() if request.headers.get("content-type") else {} action = (body.get("action") or "").strip().lower() if not action: @@ -422,7 +418,7 @@ async def agent_writing_properties(request: Request): """ from app.services.ai_writing import AIWritingService - user_id = await _current_user_id(request) + user_id = _current_user_id(request) body = await request.json() if request.headers.get("content-type") else {} properties = body.get("properties") or [] if not isinstance(properties, list) or not properties: @@ -474,18 +470,17 @@ def undo(request: Request, action_id: int): @router.get("/skills") -async def list_skills(request: Request): - ws = await _workspace_id(request) +def list_skills(request: Request): + ws = _workspace_id(request) with get_conn() as conn: rows = conn.execute("SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=? ORDER BY name", (ws, ws)).fetchall() return {"skills": [dict(r) for r in rows]} @router.post("/skills") -async def create_skill(request: Request): - user_id = await _current_user_id(request) - ws = await _workspace_id(request) - body = await request.json() if request.headers.get("content-type") else {} +def create_skill(request: Request, body: dict = Body(default={})): + user_id = _current_user_id(request) + ws = _workspace_id(request) name = (body.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name est requis") @@ -504,10 +499,10 @@ async def create_skill(request: Request): @router.post("/skills/{skill_id}/apply") -async def apply_skill(request: Request, skill_id: int): +def apply_skill(request: Request, skill_id: int): """Create a conversation pre-loaded with a skill, ready to run.""" - user_id = await _current_user_id(request) - ws = await _workspace_id(request) + user_id = _current_user_id(request) + ws = _workspace_id(request) with get_conn() as conn: skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone() if not skill: @@ -535,13 +530,12 @@ def skills_gallery(request: Request): @router.post("/skills/gallery/{slug}/install") -async def install_gallery_skill(request: Request, slug: str): - user_id = await _current_user_id(request) - ws = await _workspace_id(request) +def install_gallery_skill(request: Request, slug: str, body: dict = Body(default={})): + user_id = _current_user_id(request) + ws = _workspace_id(request) preset = skill_gallery.get_gallery(slug) if not preset: raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}") - body = await request.json() if request.headers.get("content-type") else {} try: row, created = skill_gallery.upsert_skill( skill_gallery.parse_payload(preset), @@ -555,11 +549,10 @@ async def install_gallery_skill(request: Request, slug: str): @router.post("/skills/import") -async def import_skill(request: Request): +def import_skill(request: Request, body: dict = Body(default={})): """Importe un skill portable (JSON exporté depuis une autre instance).""" - user_id = await _current_user_id(request) - ws = await _workspace_id(request) - body = await request.json() if request.headers.get("content-type") else {} + user_id = _current_user_id(request) + ws = _workspace_id(request) payload = body.get("payload") if isinstance(body.get("payload"), dict) else body try: fields = skill_gallery.parse_payload(payload) @@ -601,7 +594,7 @@ def delete_skill(request: Request, skill_id: int): @router.get("/mentions") -async def list_mentions(request: Request, q: str = ""): +def list_mentions(request: Request, q: str = ""): """Éléments mentionnables dans le panneau agent (commande « @ » / bouton « + »). Retourne des sections d'objets FlowDeck que l'utilisateur peut épingler au @@ -624,7 +617,7 @@ async def list_mentions(request: Request, q: str = ""): except (TypeError, ValueError): ws = None if not ws: - ws = await _workspace_id(request) + ws = _workspace_id(request) def dedupe(items: list[dict]) -> list[dict]: seen: set = set() @@ -729,10 +722,9 @@ async def list_mentions(request: Request, q: str = ""): @router.post("/feedback") -async def add_feedback(request: Request): +def add_feedback(request: Request, body: dict = Body(default={})): """Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent.""" - user_id = await _current_user_id(request) - body = await request.json() if request.headers.get("content-type") else {} + user_id = _current_user_id(request) rating = (body.get("rating") or "").strip().lower() if rating not in ("up", "down"): raise HTTPException(status_code=400, detail="rating doit être 'up' ou 'down'") @@ -766,8 +758,8 @@ async def add_feedback(request: Request): async def trigger_agent(request: Request, agent_id: int): """Manually fire a custom agent: create a conversation and run it with the agent's instructions as the objective (falls back to a generic prompt).""" - user_id = await _current_user_id(request) - ws = await _workspace_id(request) + user_id = _current_user_id(request) + ws = _workspace_id(request) with get_conn() as conn: agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() if not agent: @@ -814,7 +806,7 @@ async def list_providers(request: Request): - ``verified`` : the last connection test / model fetch succeeded. - ``functional`` : the provider is ready to chat (verified, or `offline`). """ - user_id = await _current_user_id(request) + user_id = _current_user_id(request) llm = LLMClient() cfg = get_llm_config() keys = list_user_llm_keys(user_id) @@ -869,20 +861,19 @@ async def list_providers(request: Request): @router.get("/keys") -async def list_llm_keys(request: Request): +def list_llm_keys(request: Request): """The user's saved provider keys + API keys (masked).""" - user_id = await _current_user_id(request) + user_id = _current_user_id(request) return {"keys": list_user_llm_keys(user_id)} @router.put("/keys/{llm_provider}") -async def save_llm_key(request: Request, llm_provider: str): +def save_llm_key(request: Request, llm_provider: str, body: dict = Body(default={})): """Upsert a provider key for the current user (masked in responses).""" - user_id = await _current_user_id(request) + user_id = _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") - body = await request.json() if request.headers.get("content-type") else {} api_base_raw = body.get("api_base") raw = upsert_user_llm_key( user_id, @@ -898,9 +889,9 @@ async def save_llm_key(request: Request, llm_provider: str): @router.delete("/keys/{llm_provider}") -async def delete_llm_key(request: Request, llm_provider: str): +def delete_llm_key(request: Request, llm_provider: str): """Remove a saved provider key for the current user.""" - user_id = await _current_user_id(request) + user_id = _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") @@ -915,7 +906,7 @@ async def test_user_llm_key(request: Request, llm_provider: str): On success the provider is flagged ``verified`` so it can be offered in the Agent panel; on failure the stored error is kept for display in Settings. """ - user_id = await _current_user_id(request) + user_id = _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") @@ -970,7 +961,7 @@ async def fetch_llm_models(request: Request, llm_provider: str): A successful fetch proves connectivity, so when it used the *stored* key the provider is flagged ``verified`` (functional) for the Agent panel. """ - user_id = await _current_user_id(request) + user_id = _current_user_id(request) provider = llm_provider.lower() if provider not in PROVIDERS: raise HTTPException(status_code=400, detail=f"Provider inconnu: {provider}") @@ -1019,7 +1010,7 @@ def _check_api_base(value: str) -> str: @router.patch("/providers") async def update_provider_config(request: Request): - await _current_admin(request) + _current_admin(request) body = await request.json() if request.headers.get("content-type") else {} provider = (body.get("provider") or "").strip().lower() if provider and provider not in PROVIDERS: @@ -1049,7 +1040,7 @@ async def test_provider_config(request: Request): A successful test flags the workspace default provider as ``verified`` so it becomes available (functional) for every user in the Agent panel. """ - await _current_admin(request) + _current_admin(request) body = await request.json() if request.headers.get("content-type") else {} provider = (body.get("provider") or "").strip().lower() or None if provider and provider not in PROVIDERS: @@ -1090,8 +1081,7 @@ def get_agent(request: Request, agent_id: int): @router.put("/{agent_id}") -async def update_agent(request: Request, agent_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def update_agent(request: Request, agent_id: int, body: dict = Body(default={})): with get_conn() as conn: existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone() if not existing: diff --git a/app/routers/auth.py b/app/routers/auth.py index 7571b60..1b57136 100644 --- a/app/routers/auth.py +++ b/app/routers/auth.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging import secrets -from fastapi import APIRouter, Query, Request +from fastapi import APIRouter, Body, Query, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.session import SessionManager @@ -220,15 +220,11 @@ def login(request: Request, provider: str = Query("gitea")): @router.post("/register") -async def register(request: Request): +def register(request: Request, body: dict = Body(default={})): """Register a new local account.""" from app.db import get_conn from app.password_utils import hash_password - try: - body = await request.json() - except Exception: - body = {} email = body.get("email", "").strip() password = body.get("password", "").strip() name = body.get("name", email.split("@")[0] if "@" in email else email) @@ -277,7 +273,7 @@ async def register(request: Request): @router.post("/local-login") -async def local_login(request: Request): +def local_login(request: Request, body: dict = Body(default={})): """Login with email + password.""" import time @@ -285,10 +281,6 @@ async def local_login(request: Request): from app.db import get_conn from app.password_utils import is_locked, verify_password - try: - body = await request.json() - except Exception: - body = {} email = body.get("email", "").strip() password = body.get("password", "").strip() @@ -410,7 +402,7 @@ async def callback( oauth_mode = request.session.pop("oauth_mode", "") if oauth_mode == "link": from app.auth.session import get_current_user as gcu - current = await gcu(request) + current = gcu(request) if not current: return HTMLResponse("

Not logged in — please log in first

", status_code=400) from app.db import get_conn as _gc @@ -480,10 +472,10 @@ def logout(request: Request): @router.get("/user") -async def current_user(request: Request): +def current_user(request: Request): """Return current user info as JSON.""" from app.auth.session import get_current_user as gcu - user = await gcu(request) + user = gcu(request) if not user: return {"authenticated": False} return {"authenticated": True, "user": user} @@ -492,16 +484,12 @@ async def current_user(request: Request): # ── v7.2.0 — TOTP 2FA ───────────────────────────────────────────────────── @router.post("/local-verify") -async def local_verify(request: Request): +def local_verify(request: Request, body: dict = Body(default={})): """Exchange a 2FA ``pending`` token + TOTP/backup code for a session.""" from fastapi.responses import JSONResponse from app.db import get_conn from app.services import two_factor as _2fa - try: - body = await request.json() - except Exception: - body = {} user_id = _2fa.redeem_pending(body.get("pending", "")) if not user_id: return JSONResponse({"error": "Challenge expired — log in again"}, status_code=401) @@ -544,15 +532,11 @@ def twofa_setup(request: Request): @router.post("/2fa/activate") -async def twofa_activate(request: Request): +def twofa_activate(request: Request, body: dict = Body(default={})): from fastapi.responses import JSONResponse from app.services import two_factor as _2fa user = _session_user_or_401(request) - try: - body = await request.json() - except Exception: - body = {} try: codes = _2fa.activate_secret(user["id"], body.get("secret", ""), body.get("code", "")) diff --git a/app/routers/automations.py b/app/routers/automations.py index 53dc385..44893e3 100644 --- a/app/routers/automations.py +++ b/app/routers/automations.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import logging -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Body, Depends, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn @@ -79,8 +79,7 @@ def list_automations(request: Request): @router.post("/workspace/automations") -async def create_automation(request: Request): - body = await request.json() if request.headers.get("content-type") else {} +def create_automation(request: Request, body: dict = Body(default={})): _validate_payload(body) user = _current_user(request) by = user["id"] @@ -118,8 +117,7 @@ def get_automation(request: Request, auto_id: int): @router.put("/workspace/automations/{auto_id}") -async def update_automation(request: Request, auto_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def update_automation(request: Request, auto_id: int, body: dict = Body(default={})): _validate_payload(body) with get_conn() as conn: row = conn.execute("SELECT id FROM automations WHERE id=?", (auto_id,)).fetchone() @@ -233,11 +231,10 @@ def list_steps(request: Request, auto_id: int): @router.post("/workspace/automations/{auto_id}/steps") -async def create_step(request: Request, auto_id: int): +def create_step(request: Request, auto_id: int, body: dict = Body(default={})): _require_session(request) if _get_auto(auto_id) is None: return _auto_404() - body = await request.json() if request.headers.get("content-type") else {} kind = body.get("kind", "") config = body.get("config", {}) or {} validate_step(kind, config) @@ -256,9 +253,8 @@ async def create_step(request: Request, auto_id: int): @router.put("/workspace/automations/steps/{step_id}") -async def update_step(request: Request, step_id: int): +def update_step(request: Request, step_id: int, body: dict = Body(default={})): _require_session(request) - body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: row = conn.execute("SELECT * FROM automation_steps WHERE id=?", (step_id,)).fetchone() if not row: @@ -288,12 +284,11 @@ def delete_step(request: Request, step_id: int): @router.put("/workspace/automations/{auto_id}/mode") -async def set_trigger_mode(request: Request, auto_id: int): +def set_trigger_mode(request: Request, auto_id: int, body: dict = Body(default={})): """Set multi-trigger mode: any (default) or all (5-minute window).""" _require_session(request) if _get_auto(auto_id) is None: return _auto_404() - body = await request.json() if request.headers.get("content-type") else {} mode = (body.get("mode") or "any").lower() if mode not in ("any", "all"): raise HTTPException(status_code=400, detail="mode must be any or all") diff --git a/app/routers/board.py b/app/routers/board.py index 76aa60b..f95c917 100644 --- a/app/routers/board.py +++ b/app/routers/board.py @@ -6,7 +6,7 @@ import logging import re from pathlib import Path -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Body, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse from app.auth.session import SessionManager @@ -14,7 +14,7 @@ from app.config import settings from app.db import get_conn from app.routers.dashboard import _get_app_version from app.routers.sidebar_config import get_sidebar_config_sync -from app.services.automations import fire_event +from app.services.automations import fire_event, run_event_sync from app.services.gitea_client import gitea from app.services.permission_manager import PermissionManager from app.services.publish import fire_published, fire_unpublished, publish, unpublish @@ -124,16 +124,12 @@ def wiki_titles(request: Request, ids: str = Query(default="")): @router.post("/api/pages/{page_id}/lock") -async def set_page_lock(request: Request, page_id: int): +def set_page_lock(request: Request, page_id: int, body: dict = Body(default={})): """v5.12.0: lock/unlock a page (read-only for everyone except the locker, admins and the page creator).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") - try: - body = await request.json() - except Exception: - body = {} locked = bool(body.get("locked")) with get_conn() as conn: row = conn.execute("SELECT id, is_locked, locked_by, workspace FROM pages WHERE id=?", @@ -146,21 +142,17 @@ async def set_page_lock(request: Request, page_id: int): conn.execute("UPDATE pages SET is_locked=?, locked_by=? WHERE id=?", (1 if locked else 0, user["id"] if locked else None, page_id)) conn.commit() - await fire_event("page.locked" if locked else "page.unlocked", - {"page_id": page_id, "by": user["id"]}) + run_event_sync(fire_event("page.locked" if locked else "page.unlocked", + {"page_id": page_id, "by": user["id"]})) return {"status": "ok", "is_locked": int(locked)} @router.post("/api/pages/{page_id}/options") -async def set_page_options(request: Request, page_id: int): +def set_page_options(request: Request, page_id: int, body: dict = Body(default={})): """v5.12.0: page layout options — full-width and compact typography.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") - try: - body = await request.json() - except Exception: - body = {} updates = {} for key in ("full_width", "font_small"): if key in body: @@ -199,16 +191,12 @@ def list_page_templates_api(request: Request): @router.post("/api/page-templates") -async def create_page_template(request: Request): +def create_page_template(request: Request, body: dict = Body(default={})): """v5.12.0: save the current page (or a raw block list) as a personal global template: {name, icon?, description?, page_id? | blocks?}.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user or not user.get("id"): raise HTTPException(401, "Authentication required") - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") @@ -239,17 +227,13 @@ async def create_page_template(request: Request): @router.post("/api/page-templates/{template_id}/use") -async def use_page_template(request: Request, template_id: int): +def use_page_template(request: Request, template_id: int, body: dict = Body(default={})): """v5.12.0: instantiate a page from a template (built-in or user). Body: {key?} for built-ins OR uses the row id for user templates. Creates 'blocks'-format page in the caller's workspace and returns its id. """ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) - try: - body = await request.json() - except Exception: - body = {} title = (body.get("title") or "").strip() blocks_json = None if template_id == 0: @@ -331,8 +315,8 @@ async def use_page_template(request: Request, template_id: int): ) conn.commit() page_id = cur.lastrowid - await fire_event("page.created", {"page_id": page_id, "title": title or name, - "workspace": ws_key, "from_template": name}) + run_event_sync(fire_event("page.created", {"page_id": page_id, "title": title or name, + "workspace": ws_key, "from_template": name})) return {"status": "ok", "id": page_id, "title": title or name} @@ -955,7 +939,7 @@ def list_favorites(request: Request): @router.post("/api/favorites/{page_id:int}") -async def add_favorite(request: Request, page_id: int): +def add_favorite(request: Request, page_id: int): """Add a page to favorites.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 @@ -973,14 +957,14 @@ async def add_favorite(request: Request, page_id: int): ) conn.commit() try: - await fire_event("favorite.added", {"page_id": page_id, "user_id": uid}) + run_event_sync(fire_event("favorite.added", {"page_id": page_id, "user_id": uid})) except Exception: logger.exception("add_favorite") return {"status": "added", "page_id": page_id} @router.delete("/api/favorites/{page_id:int}") -async def remove_favorite(request: Request, page_id: int): +def remove_favorite(request: Request, page_id: int): """Remove a page from favorites.""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = user["id"] if user and user.get("id") else 1 @@ -988,7 +972,7 @@ async def remove_favorite(request: Request, page_id: int): conn.execute("DELETE FROM favorites WHERE user_id=? AND page_id=?", (uid, page_id)) conn.commit() try: - await fire_event("favorite.removed", {"page_id": page_id, "user_id": uid}) + run_event_sync(fire_event("favorite.removed", {"page_id": page_id, "user_id": uid})) except Exception: logger.exception("remove_favorite") return {"status": "removed", "page_id": page_id} @@ -996,9 +980,8 @@ async def remove_favorite(request: Request, page_id: int): # ═══════════ Share API ═══════════ @router.post("/api/share/{page_id:int}") -async def update_share(request: Request, page_id: int): +def update_share(request: Request, page_id: int, body: dict = Body(default={})): """Save share settings for a page.""" - body = await request.json() mode = body.get("mode", "private") published = body.get("published", False) with get_conn() as conn: @@ -1011,22 +994,22 @@ async def update_share(request: Request, page_id: int): @router.post("/api/pages/{page_id:int}/publish") -async def publish_page(request: Request, page_id: int): +def publish_page(request: Request, page_id: int): """Publish a page to the web (generates publish_slug).""" if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): raise HTTPException(401, "Authentication required") slug, title = publish(page_id) - await fire_published(page_id, slug) + run_event_sync(fire_published(page_id, slug)) return {"is_published": True, "publish_slug": slug, "title": title} @router.delete("/api/pages/{page_id:int}/publish") -async def unpublish_page(request: Request, page_id: int): +def unpublish_page(request: Request, page_id: int): """Unpublish a page from the web.""" if not SessionManager.decode_session(request.cookies.get("flowdeck_session", "")): raise HTTPException(401, "Authentication required") unpublish(page_id) - await fire_unpublished(page_id) + run_event_sync(fire_unpublished(page_id)) return {"is_published": False} @@ -1040,12 +1023,12 @@ def list_trash(request: Request): @router.post("/api/trash/{page_id}/restore") -async def restore_page(request: Request, page_id: int): +def restore_page(request: Request, page_id: int): with get_conn() as conn: conn.execute("UPDATE pages SET deleted_at=NULL WHERE id=?", (page_id,)) conn.commit() try: - await fire_event("page.restored", {"page_id": page_id}) + run_event_sync(fire_event("page.restored", {"page_id": page_id})) except Exception: logger.exception("restore_page") return {"status": "ok", "restored": page_id} @@ -1080,12 +1063,8 @@ def list_synced_blocks_api(request: Request, workspace: str = Query(default="")) @router.post("/api/synced-blocks") -async def create_synced_block_api(request: Request): +def create_synced_block_api(request: Request, body: dict = Body(...)): """Create a new synced block.""" - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) from app.services.synced_blocks import create_synced_block sid = create_synced_block( @@ -1157,12 +1136,8 @@ def get_synced_block_api(sid: int): @router.post("/api/pages/{page_id}/synced") -async def add_synced_to_page(request: Request, page_id: int): +def add_synced_to_page(request: Request, page_id: int, body: dict = Body(...)): """Add a synced block reference to a page.""" - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None from app.services.synced_blocks import add_page_synced, get_synced_block sid = body.get("synced_block_id") sb = get_synced_block(sid) @@ -1357,7 +1332,7 @@ async def extract_ai_keywords(owner: str, repo: str): # ═══════════ Pages Markdown ═══════════ @router.post("/api/pages") -async def create_page(request: Request, title: str = Query(default=""), +def create_page(request: Request, title: str = Query(default=""), section: str = Query(default="Private"), project: str = Query(default=""), parent_id: int = Query(default=0)): @@ -1385,8 +1360,8 @@ async def create_page(request: Request, title: str = Query(default=""), ) conn.commit() page_id = cur.lastrowid - await fire_event("page.created", {"page_id": page_id, "title": page_title, - "workspace": ws_key, "parent_id": parent_id}) + run_event_sync(fire_event("page.created", {"page_id": page_id, "title": page_title, + "workspace": ws_key, "parent_id": parent_id})) return {"status": "ok", "id": page_id, "title": page_title, "workspace": ws_key, "parent_id": parent_id} except Exception as e: logger.error("create_page failed: %s", e) @@ -1411,7 +1386,7 @@ def get_page(request: Request, page_id: int): @router.put("/api/pages/{page_id}") -async def update_page(request: Request, page_id: int, title: str = Query(default=""), +def update_page(request: Request, page_id: int, title: str = Query(default=""), content: str = Query(default=""), content_format: str = Query(default="")): """Update a page's title and/or content. Accepts JSON body for blocks.""" @@ -1437,24 +1412,20 @@ async def update_page(request: Request, page_id: int, title: str = Query(default if content_format: conn.execute("UPDATE pages SET content_format=?, updated_at=CURRENT_TIMESTAMP WHERE id=?", (content_format, page_id)) conn.commit() - await fire_event("page.updated", {"page_id": page_id, "title": title, + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title, "content_format": content_format or "markdown", - "actor_id": user.get("id")}) + "actor_id": user.get("id")})) return {"status": "ok"} @router.post("/api/pages/{page_id}/blocks") -async def save_page_blocks(request: Request, page_id: int): +def save_page_blocks(request: Request, page_id: int, body: dict = Body(...)): """Save blocks JSON content (Notion-style block editor). v5.4.0: a version snapshot is recorded (if the block content actually changed) so the UI can browse the version history and restore any of them. v5.14.0: synced block references are tracked in page_synced_blocks. """ - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None blocks = body.get("blocks", []) blocks_json = json.dumps(blocks) title = body.get("title", "") @@ -1504,9 +1475,9 @@ async def save_page_blocks(request: Request, page_id: int): (page_id, sid), ) conn.commit() - await fire_event("page.updated", {"page_id": page_id, "title": title or "", + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": title or "", "content_format": "blocks", - "actor_id": uid}) + "actor_id": uid})) return {"status": "ok", "id": page_id} @@ -1605,7 +1576,7 @@ def page_versions(request: Request, page_id: int): @router.post("/api/pages/{page_id}/versions/{version_id}/restore") -async def restore_version(request: Request, page_id: int, version_id: int): +def restore_version(request: Request, page_id: int, version_id: int): """v5.4.0: restore a page from a version snapshot.""" with get_conn() as conn: ver = conn.execute( @@ -1619,8 +1590,8 @@ async def restore_version(request: Request, page_id: int, version_id: int): (ver["blocks_json"], ver["title"] or "", page_id), ) conn.commit() - await fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", - "content_format": "blocks"}) + run_event_sync(fire_event("page.updated", {"page_id": page_id, "title": ver["title"] or "", + "content_format": "blocks"})) return {"status": "ok", "restored": version_id} @@ -1628,7 +1599,7 @@ async def restore_version(request: Request, page_id: int, version_id: int): @router.post("/api/pages/{page_id}/duplicate") -async def duplicate_page(request: Request, page_id: int): +def duplicate_page(request: Request, page_id: int): """Duplicate a page (block/markdown content included) as a sibling.""" SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) with get_conn() as conn: @@ -1664,8 +1635,8 @@ async def duplicate_page(request: Request, page_id: int): with get_conn() as conn: conn.execute("UPDATE pages SET title=? WHERE id=?", (title, new_id)) conn.commit() - await fire_event("page.created", {"page_id": new_id, "title": title, - "workspace": page.get("workspace")}) + run_event_sync(fire_event("page.created", {"page_id": new_id, "title": title, + "workspace": page.get("workspace")})) return {"status": "ok", "id": new_id, "title": title} @@ -1758,9 +1729,8 @@ def remove_page_cover(request: Request, page_id: int): @router.post("/api/pages/{page_id}/icon") -async def set_page_icon(request: Request, page_id: int): +def set_page_icon(request: Request, page_id: int, body: dict = Body(default={})): """v5.5.0: set a page emoji/icon label (or a custom-emoji image URL).""" - body = await request.json() icon = (body.get("icon") or "").strip() if len(icon) > 512: raise HTTPException(400, "icon too long") @@ -1935,16 +1905,12 @@ async def _unfurl_repo(forge: str, owner: str, repo: str): @router.post("/api/embed/resolve") -async def resolve_embed(request: Request): +def resolve_embed(request: Request, body: dict = Body(...)): """v5.5.0: rewrite a pasted URL to its provider embed src. Powers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps, Docs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…). """ - try: - body = await request.json() - except Exception: - raise HTTPException(400, "Invalid JSON body") from None url = (body.get("url") or "").strip() if not url: raise HTTPException(400, "url required") @@ -1955,7 +1921,7 @@ async def resolve_embed(request: Request): @router.put("/api/pages/{page_id}/move") -async def move_page(request: Request, page_id: int): +def move_page(request: Request, page_id: int, body: dict = Body(default={})): """Move a page to another workspace or reorder within tree. Body (JSON): { workspace_id?: int, parent_id?: int, new_order?: int } @@ -1963,10 +1929,6 @@ async def move_page(request: Request, page_id: int): - parent_id: change parent (0 = root level) - new_order: position among siblings (0 = append) """ - try: - body = await request.json() - except Exception: - body = {} new_ws_id = body.get("workspace_id") new_parent_id = body.get("parent_id", 0) new_order = body.get("new_order", 0) @@ -1997,13 +1959,13 @@ async def move_page(request: Request, page_id: int): ) conn.commit() - await fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, - "parent_id": new_parent_id}) + run_event_sync(fire_event("page.moved", {"page_id": page_id, "workspace_id": new_ws_id or 0, + "parent_id": new_parent_id})) return {"status": "ok", "id": page_id} @router.delete("/api/pages/{page_id}") -async def delete_page(request: Request, page_id: int): +def delete_page(request: Request, page_id: int): """Move a page to trash (soft delete).""" user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) uid = (user or {}).get("id") @@ -2019,7 +1981,7 @@ async def delete_page(request: Request, page_id: int): import datetime conn.execute("UPDATE pages SET deleted_at=? WHERE id=?", (datetime.datetime.now(datetime.UTC).replace(tzinfo=None).isoformat(), page_id,)) conn.commit() - await fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""}) + run_event_sync(fire_event("page.deleted", {"page_id": page_id, "title": row["title"] or ""})) return {"status": "ok", "deleted": page_id, "title": row["title"]} diff --git a/app/routers/collaboration.py b/app/routers/collaboration.py index 9478da4..67ff54d 100644 --- a/app/routers/collaboration.py +++ b/app/routers/collaboration.py @@ -8,12 +8,13 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn from app.services import notifications as notif from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync logger = logging.getLogger(__name__) router = APIRouter(tags=["collaboration"], prefix="/api") @@ -70,10 +71,9 @@ def list_comments(request: Request, page_id: int): @router.post("/pages/{page_id}/comments") -async def add_comment(request: Request, page_id: int): +def add_comment(request: Request, page_id: int, body: dict = Body(default={})): """Create a page or inline comment. Mentions (@login) notify users.""" user = _current_user(request) - body = await request.json() if request.headers.get("content-type") else {} text = (body.get("body") or "").strip() if not text: raise HTTPException(400, "body required") @@ -124,10 +124,10 @@ async def add_comment(request: Request, page_id: int): conn.commit() try: - await _fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid}) + run_event_sync(_fire_event("comment.added", {"comment_id": comment_id, "page_id": page_id, "user_id": uid})) mentioned_ids = notif.extract_mentions(text) if mentioned_ids: - await _fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)}) + run_event_sync(_fire_event("mention.added", {"page_id": page_id, "comment_id": comment_id, "count": len(mentioned_ids)})) except Exception: logger.exception("add_comment") @@ -135,14 +135,13 @@ async def add_comment(request: Request, page_id: int): @router.post("/pages/{page_id}/mentions") -async def notify_page_mentions(request: Request, page_id: int): +def notify_page_mentions(request: Request, page_id: int, body: dict = Body(default={})): """Notify users @-mentioned in a page's content (called on save). Accepts {"text": "..."} containing @login handles. Deduplicated server-side against a per-page cache so repeated auto-saves don't spam notifications. """ user = _current_user(request) - body = await request.json() if request.headers.get("content-type") else {} text = body.get("text") or "" with get_conn() as conn: page = conn.execute("SELECT id, title FROM pages WHERE id=?", (page_id,)).fetchone() @@ -157,17 +156,16 @@ async def notify_page_mentions(request: Request, page_id: int): conn.commit() if mentioned: try: - await _fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)}) + run_event_sync(_fire_event("mention.added", {"page_id": page_id, "user_ids": mentioned, "count": len(mentioned)})) except Exception: logger.exception("notify_page_mentions") return {"mentioned": mentioned} @router.put("/comments/{comment_id}") -async def update_comment(request: Request, comment_id: int): +def update_comment(request: Request, comment_id: int, body: dict = Body(default={})): """Update a comment body or resolve/unresolve it.""" user = _current_user(request) - body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: row = conn.execute( "SELECT * FROM comments WHERE id=?", (comment_id,) @@ -188,7 +186,7 @@ async def update_comment(request: Request, comment_id: int): conn.commit() if body.get("resolved") and not was_resolved: try: - await _fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) + run_event_sync(_fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})) except Exception: logger.exception("update_comment") return {"id": comment_id, "status": "updated"} diff --git a/app/routers/collections.py b/app/routers/collections.py index f57e3f9..3a99455 100644 --- a/app/routers/collections.py +++ b/app/routers/collections.py @@ -6,12 +6,12 @@ import json import logging import sqlite3 -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse from app.auth.session import SessionManager from app.db import get_conn -from app.services.automations import fire_event +from app.services.automations import fire_event, run_event_sync from app.services.db_templates import materialize_properties from app.services.permission_manager import PermissionManager from app.services.property_types import ( @@ -222,12 +222,8 @@ def list_collections_api(request: Request): @router.post("/api") -async def create_collection_api(request: Request): +def create_collection_api(request: Request, body: dict = Body(default={})): """API: create a new collection, optionally from a database template.""" - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "").strip() if not name: @@ -277,7 +273,7 @@ async def create_collection_api(request: Request): ) conn.commit() - await fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon}) + run_event_sync(fire_event("collection.created", {"collection_id": collection_id, "name": name, "icon": icon})) return {"id": collection_id, "name": name, "status": "created"} @@ -285,12 +281,8 @@ async def create_collection_api(request: Request): @router.put("/api/{collection_id}") -async def update_collection_api(request: Request, collection_id: int): +def update_collection_api(request: Request, collection_id: int, body: dict = Body(default={})): """API: update a collection.""" - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: existing = conn.execute( @@ -316,12 +308,12 @@ async def update_collection_api(request: Request, collection_id: int): ) conn.commit() - await fire_event("collection.updated", {"collection_id": collection_id, "name": name}) + run_event_sync(fire_event("collection.updated", {"collection_id": collection_id, "name": name})) return {"id": collection_id, "status": "updated"} @router.delete("/api/{collection_id}") -async def delete_collection_api(request: Request, collection_id: int): +def delete_collection_api(request: Request, collection_id: int): """API: delete a collection and its pages (CASCADE).""" # v6.0.0: granular collection permissions — owner/admin only. user = _session_user(request) @@ -340,13 +332,13 @@ async def delete_collection_api(request: Request, collection_id: int): conn.execute("DELETE FROM collections WHERE id=?", (collection_id,)) conn.commit() - await fire_event("collection.deleted", {"collection_id": collection_id, - "name": existing["name"] if existing else ""}) + run_event_sync(fire_event("collection.deleted", {"collection_id": collection_id, + "name": existing["name"] if existing else ""})) return {"id": collection_id, "status": "deleted"} @router.post("/{collection_id}/duplicate") -async def duplicate_collection_api(request: Request, collection_id: int): +def duplicate_collection_api(request: Request, collection_id: int): """v5.4.0: deep-duplicate a database (views + properties + pages + data sources) into a new collection named ' (copy)'.""" with get_conn() as conn: @@ -494,7 +486,7 @@ async def duplicate_collection_api(request: Request, collection_id: int): conn.commit() - await fire_event("collection.created", {"collection_id": new_id, "name": new_name}) + run_event_sync(fire_event("collection.created", {"collection_id": new_id, "name": new_name})) return {"id": new_id, "name": new_name, "status": "duplicated"} @@ -542,12 +534,8 @@ def open_row_page_api(request: Request, page_id: int): @router.put("/pages/{page_id}/api") -async def update_page_api(request: Request, page_id: int): +def update_page_api(request: Request, page_id: int, body: dict = Body(default={})): """API: update a page's properties.""" - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: existing = conn.execute( @@ -599,18 +587,18 @@ async def update_page_api(request: Request, page_id: int): sync_row_title_to_page(conn, page_id) conn.commit() - await fire_event("page.updated", { + run_event_sync(fire_event("page.updated", { "page_id": page_id, "collection_id": existing["collection_id"], "title": title, "icon": icon, "properties": props, - }) - await fire_event("collection.page.updated", { + })) + run_event_sync(fire_event("collection.page.updated", { "page_id": page_id, "collection_id": existing["collection_id"], "title": title, - }) + })) # Notify newly assigned people (person properties) — v5.8.0. from app.services.notifications import notify_assignment user = _current_user(request) @@ -620,7 +608,7 @@ async def update_page_api(request: Request, page_id: int): @router.delete("/pages/{page_id}/api") -async def delete_page_api(request: Request, page_id: int): +def delete_page_api(request: Request, page_id: int): """API: delete a page from its collection.""" with get_conn() as conn: existing = conn.execute( @@ -634,15 +622,15 @@ async def delete_page_api(request: Request, page_id: int): conn.execute("DELETE FROM collection_pages WHERE id=?", (page_id,)) conn.commit() - await fire_event("page.deleted", { + run_event_sync(fire_event("page.deleted", { "page_id": page_id, "collection_id": existing["collection_id"], "title": existing["title"], - }) - await fire_event("collection.page.deleted", { + })) + run_event_sync(fire_event("collection.page.deleted", { "page_id": page_id, "collection_id": existing["collection_id"], - }) + })) return {"id": page_id, "status": "deleted"} @@ -851,16 +839,12 @@ def timezones_api(request: Request): @router.post("/{collection_id}/property-groups/api") -async def set_property_groups_api(request: Request, collection_id: int): +def set_property_groups_api(request: Request, collection_id: int, body: dict = Body(default={})): """API: (re)assign properties to collapsible groups in the table header. Body: ``{"groups": [{"name": "Basics", "property_ids": [1, 2]}]}``. Properties omitted from any group have their group cleared. Empty group names clear. """ - try: - body = await request.json() - except Exception: - body = {} groups = body.get("groups", []) with get_conn() as conn: @@ -887,12 +871,8 @@ async def set_property_groups_api(request: Request, collection_id: int): @router.post("/{collection_id}/properties/api") -async def create_property_api(request: Request, collection_id: int): +def create_property_api(request: Request, collection_id: int, body: dict = Body(default={})): """API: create a new property on a collection.""" - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "").strip() if not name: @@ -934,12 +914,8 @@ async def create_property_api(request: Request, collection_id: int): @router.put("/properties/{prop_id}/api") -async def update_property_api(request: Request, prop_id: int): +def update_property_api(request: Request, prop_id: int, body: dict = Body(default={})): """API: update a property.""" - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: existing = conn.execute( @@ -991,12 +967,8 @@ def delete_property_api(request: Request, prop_id: int): @router.post("/{collection_id}/properties/relation") -async def create_relation_property(request: Request, collection_id: int): +def create_relation_property(request: Request, collection_id: int, body: dict = Body(default={})): """Create a relation property between two collections.""" - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "").strip() related_collection_id = body.get("related_collection_id") @@ -1043,12 +1015,8 @@ async def create_relation_property(request: Request, collection_id: int): @router.post("/{collection_id}/properties/relation/link") -async def link_pages(request: Request, collection_id: int): +def link_pages(request: Request, collection_id: int, body: dict = Body(default={})): """Link two pages via a relation property.""" - try: - body = await request.json() - except Exception: - body = {} property_id = body.get("property_id") source_page_id = body.get("source_page_id") @@ -1117,12 +1085,8 @@ async def link_pages(request: Request, collection_id: int): @router.post("/rollup/compute") -async def compute_rollup(request: Request): +def compute_rollup(request: Request, body: dict = Body(default={})): """Compute a rollup aggregation.""" - try: - body = await request.json() - except Exception: - body = {} collection_id = body.get("collection_id") relation_property_id = body.get("relation_property_id") @@ -1143,12 +1107,8 @@ async def compute_rollup(request: Request): @router.post("/formula/evaluate") -async def evaluate_formula(request: Request): +def evaluate_formula(request: Request, body: dict = Body(default={})): """Evaluate a formula expression.""" - try: - body = await request.json() - except Exception: - body = {} expression = body.get("expression", "") context = body.get("context", {}) @@ -1177,12 +1137,8 @@ def get_view_api(request: Request, view_id: int): @router.put("/views/{view_id}/config") -async def update_view_config(request: Request, view_id: int): +def update_view_config(request: Request, view_id: int, body: dict = Body(default={})): """API: update view configuration (group_by, card_size, visible_properties, etc.).""" - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() @@ -1209,12 +1165,8 @@ async def update_view_config(request: Request, view_id: int): @router.post("/{collection_id}/views/save-as") -async def save_view_as(request: Request, collection_id: int): +def save_view_as(request: Request, collection_id: int, body: dict = Body(default={})): """API: save current view state as a new named view.""" - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "New View") config = body.get("config", {}) @@ -1241,12 +1193,12 @@ async def save_view_as(request: Request, collection_id: int): conn.commit() new_view_id = cur.lastrowid - await fire_event("collection.view.created", { + run_event_sync(fire_event("collection.view.created", { "view_id": new_view_id, "collection_id": collection_id, "name": name, "view_type": view_type, - }) + })) return {"id": new_view_id, "name": name, "view_type": view_type, "config_json": json.dumps(config), "created_by": user_id, "status": "saved"} @@ -1289,12 +1241,8 @@ def delete_view_api(request: Request, view_id: int): @router.post("/views/{view_id}/duplicate") -async def duplicate_view_api(request: Request, view_id: int): +def duplicate_view_api(request: Request, view_id: int, body: dict = Body(default={})): """API: duplicate a view (config + type), owned by the current user.""" - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: existing = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() @@ -1319,12 +1267,12 @@ async def duplicate_view_api(request: Request, view_id: int): conn.commit() dup_view_id = cur.lastrowid - await fire_event("collection.view.created", { + run_event_sync(fire_event("collection.view.created", { "view_id": dup_view_id, "collection_id": existing["collection_id"], "name": name, "view_type": existing["view_type"], - }) + })) return {"id": dup_view_id, "name": name, "view_type": existing["view_type"], "status": "duplicated"} @@ -1344,12 +1292,8 @@ def list_sub_items(request: Request, collection_id: int, page_id: int): @router.post("/{collection_id}/pages/{page_id}/sub-items") -async def create_sub_item(request: Request, collection_id: int, page_id: int): +def create_sub_item(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: create a sub-item under a page.""" - try: - body = await request.json() - except Exception: - body = {} title = body.get("title", "New sub-item").strip() if not title: @@ -1380,18 +1324,18 @@ async def create_sub_item(request: Request, collection_id: int, page_id: int): conn.commit() new_id = cur.lastrowid - await fire_event("page.created", { + run_event_sync(fire_event("page.created", { "page_id": new_id, "collection_id": collection_id, "parent_id": page_id, "title": title, "properties": body.get("properties", {}), - }) - await fire_event("collection.page.created", { + })) + run_event_sync(fire_event("collection.page.created", { "page_id": new_id, "collection_id": collection_id, "title": title, - }) + })) return {"id": new_id, "title": title, "parent_id": page_id, "status": "created"} @@ -1420,12 +1364,8 @@ def aggregate_child_status(request: Request, collection_id: int, page_id: int): @router.post("/{collection_id}/pages/{page_id}/dependencies") -async def set_dependencies(request: Request, collection_id: int, page_id: int): +def set_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: set blocking dependencies for a page (stored as 'blocks' property).""" - try: - body = await request.json() - except Exception: - body = {} blocks_ids = body.get("blocks", []) @@ -1447,12 +1387,8 @@ async def set_dependencies(request: Request, collection_id: int, page_id: int): @router.post("/{collection_id}/pages/{page_id}/check-deps") -async def check_dependencies(request: Request, collection_id: int, page_id: int): +def check_dependencies(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: check if a page can transition to a new status.""" - try: - body = await request.json() - except Exception: - body = {} body.get("new_status", "Done") @@ -1510,12 +1446,8 @@ def list_data_sources(request: Request, collection_id: int): @router.post("/{collection_id}/sources/api") -async def add_data_source(request: Request, collection_id: int): +def add_data_source(request: Request, collection_id: int, body: dict = Body(default={})): """API: add a data source to a collection.""" - try: - body = await request.json() - except Exception: - body = {} source_collection_id = body.get("source_collection_id") if not source_collection_id: @@ -1572,15 +1504,11 @@ def remove_data_source(request: Request, collection_id: int, source_id: int): @router.post("/{collection_id}/linked/api") -async def create_linked_database(request: Request, collection_id: int): +def create_linked_database(request: Request, collection_id: int, body: dict = Body(default={})): """API: create a linked database view from a source collection. A linked database copies the structure (views, filters, sorts) of a source but shares the same pages — edits to pages propagate to the source. """ - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "").strip() body.get("workspace_id") @@ -1693,12 +1621,8 @@ def toggle_inline(request: Request, collection_id: int): @router.post("/inline/api") -async def create_inline_database(request: Request): +def create_inline_database(request: Request, body: dict = Body(default={})): """API: create an inline database within a parent page (optionally from a template).""" - try: - body = await request.json() - except Exception: - body = {} name = body.get("name", "").strip() if not name: @@ -1792,12 +1716,8 @@ def list_page_dependencies(request: Request, collection_id: int, page_id: int): @router.post("/{collection_id}/pages/{page_id}/dependencies/api") -async def add_page_dependency(request: Request, collection_id: int, page_id: int): +def add_page_dependency(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: add a dependency (blocks/blocked_by/related) between two pages.""" - try: - body = await request.json() - except Exception: - body = {} dependency_id = body.get("dependency_id") if not dependency_id: raise HTTPException(status_code=400, detail="dependency_id is required") @@ -1834,15 +1754,11 @@ def remove_page_dependency(request: Request, collection_id: int, page_id: int, d @router.post("/{collection_id}/pages/{page_id}/auto-shift/api") -async def auto_shift_dates(request: Request, collection_id: int, page_id: int): +def auto_shift_dates(request: Request, collection_id: int, page_id: int, body: dict = Body(default={})): """API: auto-shift dates based on blocking dependencies.""" from datetime import date as dt_date from datetime import timedelta - try: - body = await request.json() - except Exception: - body = {} skip_weekends = body.get("skip_weekends", False) with get_conn() as conn: @@ -2646,15 +2562,11 @@ def get_collection_api(request: Request, collection_id: int): @router.post("/{collection_id}/pages/api") -async def create_page_api(request: Request, collection_id: int): +def create_page_api(request: Request, collection_id: int, body: dict = Body(default={})): """API: create a page in a collection.""" # v6.0.0: granular collection permissions — viewer/commenter cannot create. _require_view(collection_id, _session_user(request)) _require_edit(collection_id, _session_user(request)) - try: - body = await request.json() - except Exception: - body = {} title = body.get("title", "").strip() if not title: @@ -2695,16 +2607,16 @@ async def create_page_api(request: Request, collection_id: int): conn.commit() page_id = cur.lastrowid - await fire_event("page.created", { + run_event_sync(fire_event("page.created", { "page_id": page_id, "collection_id": collection_id, "title": title, "icon": icon, "properties": property_values, - }) - await fire_event("collection.page.created", { + })) + run_event_sync(fire_event("collection.page.created", { "page_id": page_id, "collection_id": collection_id, "title": title, - }) + })) return {"id": page_id, "title": title, "status": "created"} diff --git a/app/routers/dashboard.py b/app/routers/dashboard.py index d4b2f2b..ca7c768 100644 --- a/app/routers/dashboard.py +++ b/app/routers/dashboard.py @@ -4,7 +4,7 @@ from __future__ import annotations import logging from datetime import UTC -from fastapi import APIRouter, HTTPException, Query, Request +from fastapi import APIRouter, Body, HTTPException, Query, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager @@ -720,8 +720,7 @@ def _require_user_id(request: Request) -> int: @router.put("/api/user/profile") -async def update_profile(request: Request): - body = await request.json() +def update_profile(request: Request, body: dict = Body(default={})): full_name = body.get("full_name", "").strip() uid = _require_user_id(request) with get_conn() as conn: @@ -731,9 +730,8 @@ async def update_profile(request: Request): @router.put("/api/user/password") -async def update_password(request: Request): +def update_password(request: Request, body: dict = Body(default={})): from app.password_utils import hash_password, verify_password - body = await request.json() password = body.get("password", "").strip() if len(password) < 6: return {"error": "Password must be at least 6 characters"} @@ -972,8 +970,7 @@ async def list_workspace_projects(request: Request): @router.post("/api/workspace/projects") -async def create_workspace_project(request: Request): - body = await request.json() +def create_workspace_project(request: Request, body: dict = Body(default={})): name = body.get("name", "").strip() if not name: return {"error": "Name required"} @@ -1002,9 +999,8 @@ def list_members(request: Request, ws_id: int): @router.post("/api/workspace/{ws_id:int}/members") -async def invite_member(request: Request, ws_id: int): +def invite_member(request: Request, ws_id: int, body: dict = Body(default={})): """Invite a user to a workspace by email.""" - body = await request.json() email = body.get("email", "").strip() role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): @@ -1025,9 +1021,8 @@ async def invite_member(request: Request, ws_id: int): @router.put("/api/workspace/{ws_id:int}/members/{user_id:int}") -async def update_member_role(request: Request, ws_id: int, user_id: int): +def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})): """Change a member's role.""" - body = await request.json() role = body.get("role", "editor") if role not in ("owner", "admin", "editor", "viewer"): return {"error": "Invalid role"} @@ -1468,9 +1463,8 @@ def nav_menu(request: Request, workspace_id: int = None, parent_id: int = None): @router.post("/api/local-workspace/items") -async def create_local_workspace_item(request: Request): +def create_local_workspace_item(request: Request, body: dict = Body(default={})): """Create a new file in the active workspace.""" - body = await request.json() name = (body.get("name") or "").strip() or "Untitled" item_type = body.get("type", "page") parent_id = body.get("parent_id") @@ -1491,9 +1485,8 @@ async def create_local_workspace_item(request: Request): @router.put("/api/local-workspace/items/{item_id:int}") -async def rename_local_workspace_item(request: Request, item_id: int): +def rename_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): """Rename a file.""" - body = await request.json() name = body.get("name", "Untitled").strip() with get_conn() as conn: conn.execute("UPDATE pages SET title=? WHERE id=?", (name, item_id)) @@ -1547,9 +1540,8 @@ def serve_uploaded_file(ws_id: int, filename: str): @router.put("/api/local-workspace/items/{item_id:int}/move") -async def move_local_workspace_item(request: Request, item_id: int): +def move_local_workspace_item(request: Request, item_id: int, body: dict = Body(default={})): """Move an item to a new parent (drag & drop).""" - body = await request.json() new_parent_id = body.get("parent_id") # None = move to root with get_conn() as conn: conn.execute( @@ -1829,9 +1821,8 @@ def list_workspaces(request: Request): @router.post("/api/workspaces") -async def create_workspace(request: Request): +def create_workspace(request: Request, body: dict = Body(default={})): """Create a new workspace.""" - body = await request.json() name = body.get("name", "New Workspace").strip() if not name: return {"error": "Name required"} @@ -1861,9 +1852,8 @@ async def create_workspace(request: Request): @router.put("/api/workspaces/{ws_id:int}") -async def rename_workspace(request: Request, ws_id: int): +def rename_workspace(request: Request, ws_id: int, body: dict = Body(default={})): """Rename a workspace.""" - body = await request.json() name = body.get("name", "").strip() if not name: return {"error": "Name required"} @@ -1971,9 +1961,8 @@ def get_avatar(user_id: int): @router.post("/api/settings/avatar-color") -async def set_avatar_color(request: Request): +def set_avatar_color(request: Request, body: dict = Body(default={})): """Set the user's avatar background color.""" - body = await request.json() color = body.get("color", "#3A3A3A") user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: @@ -1987,9 +1976,8 @@ async def set_avatar_color(request: Request): # ═══════════ Tag Management API (per-user) ═══════════ @router.post("/api/settings/tags") -async def create_tag_global(request: Request): +def create_tag_global(request: Request, body: dict = Body(default={})): """Create a tag for the current user.""" - body = await request.json() tag_name = body.get("name", "").strip().lower() color = body.get("color", "#787774") uid = _get_user_id(request) @@ -2007,9 +1995,8 @@ async def create_tag_global(request: Request): @router.put("/api/settings/tags/{tag_id:int}") -async def update_tag_global(tag_id: int, request: Request): +def update_tag_global(tag_id: int, request: Request, body: dict = Body(default={})): """Update a tag (name or color) — only if owned by user.""" - body = await request.json() uid = _get_user_id(request) with get_conn() as conn: if "name" in body: @@ -2087,9 +2074,8 @@ def get_item_tags(item_id: int): @router.post("/api/local-workspace/items/{item_id:int}/tags") -async def add_item_tag(request: Request, item_id: int): +def add_item_tag(request: Request, item_id: int, body: dict = Body(default={})): """Add a tag to an item (creates tag if new, scoped to user).""" - body = await request.json() tag_name = body.get("name", "").strip().lower() tag_color = body.get("color", "#787774") uid = _get_user_id(request) @@ -2175,16 +2161,12 @@ def search_by_tags(request: Request, tags: str = ""): # ── Account update ── @router.put("/api/settings/account") -async def update_account(request: Request): +def update_account(request: Request, body: dict = Body(default={})): """Update current user's profile: full_name, login, email, password.""" from app.password_utils import hash_password user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return JSONResponse({"error": "Not authenticated"}, status_code=401) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: uid = user["id"] if "full_name" in body: @@ -2563,9 +2545,8 @@ def api_page_content(page_id: int): @router.put("/api/pages/{page_id:int}/rename") -async def api_rename_page(page_id: int, request: Request): +def api_rename_page(page_id: int, request: Request, body: dict = Body(default={})): """Inline rename a page title.""" - body = await request.json() title = (body.get("title") or "").strip() if not title: return JSONResponse({"error": "Title required"}, status_code=400) @@ -2594,17 +2575,13 @@ def api_trash_page(page_id: int): @router.post("/api/pages/{page_id:int}/convert-to-database") -async def api_convert_to_database(page_id: int, request: Request): +def api_convert_to_database(page_id: int, request: Request, body: dict = Body(default={})): """Convert a page into a full-page database (Notion-style). Creates a collection linked to this page, adds the default 'Name' property, and sets the page's content_format to 'collection'. """ import json as _json - try: - body = await request.json() - except Exception: - body = {} db_name = (body.get("name") or "").strip() with get_conn() as conn: @@ -2699,13 +2676,9 @@ def api_collection_table_data(collection_id: int): @router.post("/api/collections/{collection_id:int}/pages") -async def api_create_collection_page(collection_id: int, request: Request): +def api_create_collection_page(collection_id: int, request: Request, body: dict = Body(default={})): """Create a new page (row) in a collection.""" import json as _json - try: - body = await request.json() - except Exception: - body = {} title = body.get("title", "New page").strip() or "New page" icon = body.get("icon", "file") diff --git a/app/routers/gitea.py b/app/routers/gitea.py index e09e831..580f8ad 100644 --- a/app/routers/gitea.py +++ b/app/routers/gitea.py @@ -1,5 +1,5 @@ """FlowDeck — Gitea integration API routes.""" -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse router = APIRouter(tags=["gitea"], prefix="/api/gitea") @@ -202,7 +202,7 @@ def list_private_pages(owner: str, repo: str, request: Request): @router.post("/projects/{owner}/{repo}/private-pages") -async def create_private_page(owner: str, repo: str, request: Request): +def create_private_page(owner: str, repo: str, request: Request, body: dict = Body(default={})): """Create a new private page for this Gitea project.""" from app.auth.session import SessionManager @@ -210,10 +210,6 @@ async def create_private_page(owner: str, repo: str, request: Request): user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return JSONResponse({"error": "Not authenticated"}, status_code=401) - try: - body = await request.json() - except Exception: - body = {} title = body.get("title", "Untitled").strip() or "Untitled" with get_conn() as conn: cursor = conn.execute( @@ -243,7 +239,7 @@ def get_private_page(owner: str, repo: str, page_id: int, request: Request): @router.put("/projects/{owner}/{repo}/private-pages/{page_id}") -async def update_private_page(owner: str, repo: str, page_id: int, request: Request): +def update_private_page(owner: str, repo: str, page_id: int, request: Request, body: dict = Body(default={})): """Update a private page.""" from app.auth.session import SessionManager @@ -251,10 +247,6 @@ async def update_private_page(owner: str, repo: str, page_id: int, request: Requ user = SessionManager.decode_session(request.cookies.get("flowdeck_session", "")) if not user: return JSONResponse({"error": "Not authenticated"}, status_code=401) - try: - body = await request.json() - except Exception: - body = {} title = body.get("title", "").strip() content = body.get("content", "") with get_conn() as conn: @@ -306,7 +298,7 @@ async def sync_labels(request: Request, owner: str, repo: str): """Sync Gitea labels to FlowDeck tags for the current user.""" from app.auth.session import get_current_user as gcu from app.db import get_conn - user = await gcu(request) + user = gcu(request) if not user: return JSONResponse({"error": "Not authenticated"}, status_code=401) gitea = _require_gitea(request) diff --git a/app/routers/governance.py b/app/routers/governance.py index 92fa34f..31bcf0d 100644 --- a/app/routers/governance.py +++ b/app/routers/governance.py @@ -3,7 +3,7 @@ from __future__ import annotations import json -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -45,12 +45,8 @@ def list_policies(request: Request): @router.post("/api/v2/agent-policies") -async def upsert_policy(request: Request): +def upsert_policy(request: Request, body: dict = Body(default={})): user = _owner_or_admin(request) - try: - body = await request.json() - except Exception: - body = {} wid = body.get("workspace_id") tools = body.get("allowed_tools") if tools is not None and not isinstance(tools, list): @@ -84,12 +80,8 @@ def list_approvals(request: Request): @router.post("/api/v2/agent-approvals/{approval_id}/decide") -async def decide_approval(approval_id: int, request: Request): +def decide_approval(approval_id: int, request: Request, body: dict = Body(default={})): user = _owner_or_admin(request) - try: - body = await request.json() - except Exception: - body = {} out = policies.decide_approval(approval_id, user["id"], bool(body.get("approve"))) if out is None: raise HTTPException(404, "Pending approval not found") diff --git a/app/routers/imports.py b/app/routers/imports.py index 70b66fc..0a308cb 100644 --- a/app/routers/imports.py +++ b/app/routers/imports.py @@ -39,7 +39,7 @@ def _current_user(request: Request) -> dict: @page_router.get("/import", response_class=HTMLResponse) -async def import_page(request: Request): +def import_page(request: Request): """Standalone import wizard (source picker, dry-run, mapping, progress).""" user = _current_user(request) if not user: diff --git a/app/routers/meetings.py b/app/routers/meetings.py index c8d9f2e..e424784 100644 --- a/app/routers/meetings.py +++ b/app/routers/meetings.py @@ -11,7 +11,7 @@ from __future__ import annotations import secrets -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -46,12 +46,8 @@ def _auth_user(request: Request, *, require_write: bool = False) -> dict: # ── calendar links ───────────────────────────────────────────────────────── @router.post("/api/v2/calendar-links") -async def create_link(request: Request): +def create_link(request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} provider = (body.get("provider") or "").lower() if provider not in cal.PROVIDERS: raise HTTPException(400, "provider must be google|caldav") @@ -171,13 +167,9 @@ async def upload_and_transcribe(request: Request): @router.post("/api/v2/meetings/transcripts/{transcript_id}/text") -async def set_transcript_text(transcript_id: int, request: Request): +def set_transcript_text(transcript_id: int, request: Request, body: dict = Body(default={})): """Store a client-side (manual) transcript on an existing row.""" _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} text = (body.get("transcript") or "").strip() if not text: raise HTTPException(400, "transcript required") diff --git a/app/routers/notifications.py b/app/routers/notifications.py index ea44e00..d176368 100644 --- a/app/routers/notifications.py +++ b/app/routers/notifications.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn @@ -56,10 +56,9 @@ def unread_count(request: Request): @router.post("/read") -async def mark_read(request: Request): +def mark_read(request: Request, body: dict = Body(default={})): """Mark one notification as read (id) or all (id omitted).""" user = _current_user(request) - body = await request.json() if request.headers.get("content-type") else {} nid = body.get("id") with get_conn() as conn: if nid: @@ -91,11 +90,10 @@ def get_prefs(request: Request): @router.post("/prefs") -async def set_prefs(request: Request): +def set_prefs(request: Request, body: dict = Body(default={})): """Update the current user's notification email preferences.""" user = _current_user(request) from app.services import notifications as notif - body = await request.json() if request.headers.get("content-type") else {} prefs = notif.get_user_prefs(user["id"]) for key in ("comments", "mentions", "reminders", "assignments"): if key in body: @@ -116,10 +114,9 @@ def get_timezone(request: Request): @router.post("/timezone") -async def set_timezone(request: Request): +def set_timezone(request: Request, body: dict = Body(default={})): """Update the current user's IANA timezone (empty string = UTC).""" user = _current_user(request) - body = await request.json() if request.headers.get("content-type") else {} tz = (body.get("timezone") or "").strip() from app.services.recurrence import is_valid_timezone if tz and not is_valid_timezone(tz): diff --git a/app/routers/onboarding.py b/app/routers/onboarding.py index a1f0f35..2df3e1b 100644 --- a/app/routers/onboarding.py +++ b/app/routers/onboarding.py @@ -8,7 +8,7 @@ from __future__ import annotations import json import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, RedirectResponse from app.auth.session import SessionManager @@ -63,13 +63,9 @@ def _forge_configured(provider: str) -> bool: @router.post("/api/onboarding/workspace") -async def onboarding_create_workspace(request: Request): +def onboarding_create_workspace(request: Request, body: dict = Body(default={})): """Step 1 — create the first local workspace.""" user = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() or "My Workspace" with get_conn() as conn: @@ -90,13 +86,9 @@ async def onboarding_create_workspace(request: Request): @router.post("/api/onboarding/project") -async def onboarding_create_project(request: Request): +def onboarding_create_project(request: Request, body: dict = Body(default={})): """Step 3 — create the first project: a welcome page in the workspace.""" user = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} title = (body.get("title") or "").strip() or "Welcome to FlowDeck" workspace_id = body.get("workspace_id") diff --git a/app/routers/permissions.py b/app/routers/permissions.py index 8c1dc2d..e2739a4 100644 --- a/app/routers/permissions.py +++ b/app/routers/permissions.py @@ -9,7 +9,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn @@ -197,22 +197,20 @@ def _page_type(page_id: int) -> str: @router.post("/pages/{page_id}/permissions") -async def grant_page_permission(page_id: int, request: Request): +def grant_page_permission(page_id: int, request: Request, body: dict = Body(default={})): pm = _pm(request) if not pm.can_manage_page_permissions(page_id): raise HTTPException(403, "Only a page owner can manage its permissions") - body = await request.json() return _grant_common(request, pm, "page", page_id, body, "page_permissions", "page_id", PAGE_ROLES) @router.post("/pages/{page_id}/permissions/batch") -async def batch_page_permissions(page_id: int, request: Request): +def batch_page_permissions(page_id: int, request: Request, body: dict = Body(default={})): """Grant several permissions in one call: {grants: [{user_id|group_id, role}, ...]}.""" pm = _pm(request) if not pm.can_manage_page_permissions(page_id): raise HTTPException(403, "Only a page owner can manage its permissions") - body = await request.json() grants = body.get("grants") or [] if not isinstance(grants, list) or not grants: raise HTTPException(400, "grants must be a non-empty list") @@ -266,11 +264,10 @@ def _collection_type(collection_id: int) -> str: @router.post("/collections/{collection_id}/permissions") -async def grant_collection_permission(collection_id: int, request: Request): +def grant_collection_permission(collection_id: int, request: Request, body: dict = Body(default={})): pm = _pm(request) if not pm.can_manage_collection_permissions(collection_id): raise HTTPException(403, "Only a collection owner can manage its permissions") - body = await request.json() return _grant_common(request, pm, "collection", collection_id, body, "collection_permissions", "collection_id", COLLECTION_ROLES) @@ -331,11 +328,10 @@ def list_property_permissions(collection_id: int, property_id: int, request: Req @router.post("/collections/{collection_id}/properties/{property_id}/permissions") -async def grant_property_permission(collection_id: int, property_id: int, request: Request): +def grant_property_permission(collection_id: int, property_id: int, request: Request, body: dict = Body(default={})): pm = _pm(request) if not pm.can_manage_collection_permissions(collection_id): raise HTTPException(403, "Only a collection owner can manage property permissions") - body = await request.json() with get_conn() as conn: prop = conn.execute( "SELECT id FROM collection_properties WHERE id=? AND collection_id=?", @@ -369,9 +365,8 @@ def list_groups(request: Request, workspace_id: int | None = None): @router.post("/groups") -async def create_group(request: Request): +def create_group(request: Request, body: dict = Body(default={})): pm = _pm(request) - body = await request.json() ws_id = body.get("workspace_id") gid = pm.create_group(ws_id, body.get("name") or "", body.get("description") or "", created_by=pm.user_id) @@ -382,9 +377,8 @@ async def create_group(request: Request): @router.put("/groups/{group_id}") -async def update_group(group_id: int, request: Request): +def update_group(group_id: int, request: Request, body: dict = Body(default={})): pm = _pm(request) - body = await request.json() name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") @@ -428,9 +422,8 @@ def list_group_members(group_id: int, request: Request): @router.post("/groups/{group_id}/members") -async def add_group_member(group_id: int, request: Request): +def add_group_member(group_id: int, request: Request, body: dict = Body(default={})): pm = _pm(request) - body = await request.json() user_id = body.get("user_id") if not user_id or not isinstance(user_id, int): raise HTTPException(400, "user_id is required") diff --git a/app/routers/projects.py b/app/routers/projects.py index f082f91..78dcfcb 100644 --- a/app/routers/projects.py +++ b/app/routers/projects.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.services import projects as projects_svc @@ -29,9 +29,8 @@ def list_projects(request: Request): @router.post("") -async def create_project(request: Request): +def create_project(request: Request, body: dict = Body(default={})): """Register a standalone (builtin) project.""" - body = await request.json() name = (body.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name required") @@ -51,7 +50,7 @@ async def sync_projects(request: Request): @backups_router.post("/api/settings/backups/run") -async def run_backup_now(request: Request): +def run_backup_now(request: Request): """Admin: create a database backup immediately.""" _require_admin(request) filename = backup_db() @@ -61,7 +60,7 @@ async def run_backup_now(request: Request): @backups_router.get("/api/settings/backups") -async def admin_list_backups(request: Request): +def admin_list_backups(request: Request): """Admin: list stored backups.""" _require_admin(request) return {"backups": list_backups()} diff --git a/app/routers/scim.py b/app/routers/scim.py index e8ceb6d..05e58ae 100644 --- a/app/routers/scim.py +++ b/app/routers/scim.py @@ -12,7 +12,7 @@ from __future__ import annotations import hashlib import secrets -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -70,12 +70,8 @@ def scim_list(request: Request): @router.post("/scim/v2/Users") -async def scim_create(request: Request): +def scim_create(request: Request, body: dict = Body(default={})): _scim_guard(request) - try: - body = await request.json() - except Exception: - body = {} username = (body.get("userName") or "").strip() if not username: raise HTTPException(400, "userName required") @@ -133,12 +129,8 @@ def _apply_scim_update(conn, user_id: str, body: dict) -> None: @router.put("/scim/v2/Users/{user_id}") -async def scim_replace(user_id: str, request: Request): +def scim_replace(user_id: str, request: Request, body: dict = Body(default={})): _scim_guard(request) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: _apply_scim_update(conn, user_id, body) row = conn.execute("SELECT * FROM users WHERE id=?", (user_id,)).fetchone() @@ -146,12 +138,8 @@ async def scim_replace(user_id: str, request: Request): @router.patch("/scim/v2/Users/{user_id}") -async def scim_patch(user_id: str, request: Request): +def scim_patch(user_id: str, request: Request, body: dict = Body(default={})): _scim_guard(request) - try: - body = await request.json() - except Exception: - body = {} flat: dict = {} for op in body.get("Operations") or []: path = (op.get("path") or "").lower() @@ -180,12 +168,8 @@ def scim_delete(user_id: str, request: Request): # ── SCIM token management (admin, session) ───────────────────────────────── @router.post("/api/v2/scim/tokens") -async def create_scim_token(request: Request): +def create_scim_token(request: Request, body: dict = Body(default={})): admin = _admin_session(request) - try: - body = await request.json() - except Exception: - body = {} raw = f"scim_{secrets.token_urlsafe(32)}" digest = hashlib.sha256(raw.encode()).hexdigest() with get_conn() as conn: @@ -234,12 +218,8 @@ def list_domains(request: Request): @router.post("/api/v2/domain-claims") -async def create_domain(request: Request): +def create_domain(request: Request, body: dict = Body(default={})): admin = _admin_session(request) - try: - body = await request.json() - except Exception: - body = {} domain = (body.get("domain") or "").strip().lower() if not domain or "." not in domain or "/" in domain: raise HTTPException(400, "valid domain required") diff --git a/app/routers/security.py b/app/routers/security.py index 4f97c45..38b4cdd 100644 --- a/app/routers/security.py +++ b/app/routers/security.py @@ -8,7 +8,7 @@ import hashlib import logging from secrets import token_urlsafe -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn @@ -45,10 +45,9 @@ def list_tokens(request: Request): @router.post("/tokens") -async def create_token(request: Request): +def create_token(request: Request, body: dict = Body(default={})): """Create an API token for the current user. The secret is returned once.""" uid = _current_user_id(request) - body = await request.json() name = (body.get("name") or "").strip() or "API token" token = f"fd_{token_urlsafe(24)}" with get_conn() as conn: diff --git a/app/routers/sharing.py b/app/routers/sharing.py index 446a0a6..4621302 100644 --- a/app/routers/sharing.py +++ b/app/routers/sharing.py @@ -4,11 +4,12 @@ from __future__ import annotations import logging from datetime import UTC, datetime -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn from app.services.automations import fire_event as _fire_event +from app.services.automations import run_event_sync from app.services.publish import fire_published, fire_unpublished, publish, unpublish logger = logging.getLogger(__name__) @@ -27,10 +28,9 @@ def _require_auth(request: Request) -> dict: @router.post("/pages/{page_id}/share") -async def share_page(page_id: int, request: Request): +def share_page(page_id: int, request: Request, body: dict = Body(default={})): """Invite a user, an email, or a group to a page.""" user = _require_auth(request) - body = await request.json() if request.headers.get("content-type") else {} target_user_id = body.get("user_id") target_group_id = body.get("group_id") email = body.get("email", "") @@ -119,7 +119,7 @@ async def share_page(page_id: int, request: Request): conn.commit() try: - await _fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission}) + run_event_sync(_fire_event("page.shared", {"page_id": page_id, "share_id": share_id, "permission": permission})) except Exception: logger.exception("share_page") @@ -164,11 +164,10 @@ def _mirror_share_revoke(conn, page_id: int, group_id: int) -> None: @router.put("/pages/{page_id}/share/{share_id}", description="Update a share's permission.") -async def update_share_permission(page_id: int, share_id: int, request: Request): +def update_share_permission(page_id: int, share_id: int, request: Request, body: dict = Body(default={})): """Change the permission level of an existing share entry.""" user = _require_auth(request) - body = await request.json() if request.headers.get("content-type") else {} permission = body.get("permission", "") if permission not in ("view", "comment", "edit"): @@ -293,11 +292,11 @@ def list_shares(page_id: int, request: Request): @router.post("/pages/{page_id}/publish") -async def publish_page(page_id: int, request: Request): +def publish_page(page_id: int, request: Request): """Publish a page (is_published=1) with a URL slug.""" _require_auth(request) slug, _title = publish(page_id) - await fire_published(page_id, slug) + run_event_sync(fire_published(page_id, slug)) return { "page_id": page_id, "is_published": True, @@ -307,11 +306,11 @@ async def publish_page(page_id: int, request: Request): @router.delete("/pages/{page_id}/publish") -async def unpublish_page(page_id: int, request: Request): +def unpublish_page(page_id: int, request: Request): """Unpublish a page.""" _require_auth(request) unpublish(page_id) - await fire_unpublished(page_id) + run_event_sync(fire_unpublished(page_id)) return { "page_id": page_id, "is_published": False, @@ -323,10 +322,9 @@ async def unpublish_page(page_id: int, request: Request): @router.post("/recents/track") -async def track_recent(request: Request): +def track_recent(request: Request, body: dict = Body(default={})): """Record a page access in recents.""" user = _require_auth(request) - body = await request.json() if request.headers.get("content-type") else {} page_id = body.get("page_id") workspace = body.get("workspace", "") source_type = body.get("source_type", "local") diff --git a/app/routers/sidebar_config.py b/app/routers/sidebar_config.py index a3f58d9..adc4796 100644 --- a/app/routers/sidebar_config.py +++ b/app/routers/sidebar_config.py @@ -4,7 +4,7 @@ from __future__ import annotations import json import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from app.auth.session import SessionManager from app.db import get_conn @@ -81,13 +81,9 @@ def get_sidebar_config_sync(user_id: int) -> dict: @router.put("/config") -async def save_sidebar_config(request: Request): +def save_sidebar_config(request: Request, body: dict = Body(...)): """Save the current user's sidebar customization config.""" user = _get_user(request) - try: - body = await request.json() - except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON body") from None config = body.get("config") if not config or not isinstance(config, dict): diff --git a/app/routers/sites.py b/app/routers/sites.py index f659df2..3424dc2 100644 --- a/app/routers/sites.py +++ b/app/routers/sites.py @@ -19,7 +19,7 @@ import time import unicodedata from datetime import UTC, datetime -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse, PlainTextResponse from app.auth.session import SessionManager @@ -256,12 +256,8 @@ def _check_form_rate(ip: str) -> None: # ── Sites CRUD (session or Bearer) ───────────────────────────────────────── @router.post("/api/v2/sites") -async def create_site(request: Request): +def create_site(request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} root_page_id = body.get("root_page_id") if not root_page_id: raise HTTPException(400, "root_page_id is required") @@ -349,12 +345,8 @@ def get_site(site_id: int, request: Request): @router.patch("/api/v2/sites/{site_id}") -async def update_site(site_id: int, request: Request): +def update_site(site_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM sites WHERE id=?", (site_id,)).fetchone() if not row: @@ -432,12 +424,8 @@ def list_site_pages(site_id: int, request: Request): @router.post("/api/v2/sites/{site_id}/pages") -async def add_site_page(site_id: int, request: Request): +def add_site_page(site_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} page_id = body.get("page_id") if not page_id: raise HTTPException(400, "page_id is required") @@ -622,12 +610,8 @@ def get_form_config(collection_id: int, request: Request): @router.put("/api/v2/collections/{collection_id}/form") -async def put_form_config(collection_id: int, request: Request): +def put_form_config(collection_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: info = _form_config(conn, collection_id) cfg = info["config"] if isinstance(info["config"], dict) else {} diff --git a/app/routers/sso.py b/app/routers/sso.py index 19c15da..c716ee3 100644 --- a/app/routers/sso.py +++ b/app/routers/sso.py @@ -17,7 +17,7 @@ import logging import secrets import time -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, RedirectResponse from app.auth.providers import oidc_provider, saml_provider @@ -496,7 +496,7 @@ async def oidc_logout_post(request: Request, next: str = "/auth/login?provider=l # ═══════════════════════ Admin configuration API ══════════════════════════ -async def _require_admin(request: Request, *, write: bool) -> dict: +def _require_admin(request: Request, *, write: bool) -> dict: """Admin identity: Bearer token (scope read/write) or an admin session. Session-authenticated writes also need the CSRF header — ``/api/v2`` is @@ -558,22 +558,18 @@ def sso_providers(request: Request): @router.get("/api/v2/sso/config") -async def get_sso_config_api(request: Request): +def get_sso_config_api(request: Request): """Read the current SSO configuration (secrets never returned).""" - await _require_admin(request, write=False) + _require_admin(request, write=False) cfg = _sso_config_or_error() return sso.public_config_view(cfg) @router.post("/api/v2/sso/config") @router.put("/api/v2/sso/config") -async def save_sso_config_api(request: Request): +def save_sso_config_api(request: Request, payload: dict = Body(...)): """Create/replace the SSO configuration (admin, scope write).""" - admin = await _require_admin(request, write=True) - try: - payload = await request.json() - except Exception as err: - raise HTTPException(status_code=400, detail="Invalid JSON body") from err + admin = _require_admin(request, write=True) try: saved = sso.save_sso_config(payload, created_by=admin.get("id")) except sso.SSOConfigError as err: @@ -586,9 +582,9 @@ async def save_sso_config_api(request: Request): @router.delete("/api/v2/sso/config") -async def delete_sso_config_api(request: Request): +def delete_sso_config_api(request: Request): """Disable SSO — local logins keep working (design §8 « SSO disable »).""" - admin = await _require_admin(request, write=True) + admin = _require_admin(request, write=True) removed = sso.delete_sso_config() from app.services.api_v2_helpers import audit_log @@ -597,9 +593,9 @@ async def delete_sso_config_api(request: Request): @router.get("/api/v2/sso/workspaces") -async def sso_workspaces(request: Request): +def sso_workspaces(request: Request): """Workspaces available for default assignment / group mapping.""" - await _require_admin(request, write=False) + _require_admin(request, write=False) from app.db import get_conn with get_conn() as conn: @@ -616,9 +612,9 @@ async def sso_workspaces(request: Request): @router.post("/api/v2/sso/sync") -async def sso_sync(request: Request): +def sso_sync(request: Request): """Re-apply group → workspace role mapping for every SSO user.""" - admin = await _require_admin(request, write=True) + admin = _require_admin(request, write=True) try: result = sso.force_sync_all_groups() except sso.SSOProvisioningError as err: @@ -630,9 +626,9 @@ async def sso_sync(request: Request): @router.get("/api/v2/sso/history") -async def sso_history(request: Request, limit: int = 50): +def sso_history(request: Request, limit: int = 50): """Audit trail of SSO login attempts (successes and rejections).""" - await _require_admin(request, write=False) + _require_admin(request, write=False) from app.db import get_conn limit = max(1, min(int(limit or 50), 200)) diff --git a/app/routers/web_clipper.py b/app/routers/web_clipper.py index b3bc01e..abe14c4 100644 --- a/app/routers/web_clipper.py +++ b/app/routers/web_clipper.py @@ -15,7 +15,7 @@ from __future__ import annotations import hashlib import logging -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse from app.auth.session import SessionManager @@ -88,7 +88,7 @@ def _require_user(request: Request) -> dict: # ── API: status ── @api_router.get("/status") -async def clipper_status(request: Request): +def clipper_status(request: Request): user = _user_from_request(request) if not user: return {"authenticated": False} @@ -101,12 +101,8 @@ async def clipper_status(request: Request): # ── API: auth verify / device registration ── @api_router.post("/auth/verify") -async def auth_verify(request: Request): +def auth_verify(request: Request, body: dict = Body(default={})): user = _require_user(request) - try: - body = await request.json() - except Exception: - body = {} device_id = (body.get("device_id") or request.headers.get("x-device-id") or "").strip() device_name = (body.get("device_name") or body.get("deviceName") or "").strip()[:200] extension_name = (body.get("extension_name") or body.get("extensionName") or "chrome").strip()[:20].lower() @@ -124,7 +120,7 @@ async def auth_verify(request: Request): @api_router.post("/clip") -async def clip_page(request: Request): +def clip_page(request: Request, body: dict = Body(...)): user = _require_user(request) # Enforce max body size early (10 MB) clen = request.headers.get("content-length") @@ -134,10 +130,6 @@ async def clip_page(request: Request): raise HTTPException(status_code=413, detail="Clip too large (max 10 MB)") except ValueError: pass - try: - body = await request.json() - except Exception: - raise HTTPException(status_code=400, detail="Invalid JSON") from None # Device identification for rate limiting and logging device_id = (body.get("device_id") or request.headers.get("x-device-id") or "web").strip()[:128] or "web" @@ -209,14 +201,14 @@ async def clip_page(request: Request): @api_router.get("/devices") -async def list_extension_devices(request: Request): +def list_extension_devices(request: Request): user = _require_user(request) devices = list_devices(user["id"]) return {"devices": devices} @api_router.delete("/devices/{device_id}") -async def revoke_extension_device(device_id: int, request: Request): +def revoke_extension_device(device_id: int, request: Request): user = _require_user(request) ok = revoke_device(user["id"], device_id) if not ok: diff --git a/app/routers/webauthn.py b/app/routers/webauthn.py index f4eba95..8164de7 100644 --- a/app/routers/webauthn.py +++ b/app/routers/webauthn.py @@ -10,7 +10,7 @@ from __future__ import annotations import secrets import time -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -76,15 +76,11 @@ def register_begin(request: Request): @router.post("/register/finish") -async def register_finish(request: Request): +def register_finish(request: Request, body: dict = Body(default={})): if not _require_lib(): raise HTTPException(501, "WebAuthn library not installed") from webauthn import verify_registration_response user = _session_user(request) - try: - body = await request.json() - except Exception: - body = {} challenge = _take_challenge(f"reg:{user['id']}") if not challenge: raise HTTPException(400, "Challenge expired — begin again") @@ -115,14 +111,10 @@ async def register_finish(request: Request): @router.post("/login/begin") -async def login_begin(request: Request): +def login_begin(request: Request, body: dict = Body(default={})): if not _require_lib(): raise HTTPException(501, "WebAuthn library not installed") from webauthn import generate_authentication_options, options_to_json - try: - body = await request.json() - except Exception: - body = {} login = (body.get("login") or "").strip() if not login: raise HTTPException(400, "login required") @@ -144,14 +136,10 @@ async def login_begin(request: Request): @router.post("/login/finish") -async def login_finish(request: Request): +def login_finish(request: Request, body: dict = Body(default={})): if not _require_lib(): raise HTTPException(501, "WebAuthn library not installed") from webauthn import verify_authentication_response - try: - body = await request.json() - except Exception: - body = {} login = (body.get("login") or "").strip() challenge = _take_challenge(f"login:{login}") if not login or not challenge: diff --git a/app/routers/wiki.py b/app/routers/wiki.py index 9be7ea7..0b074b6 100644 --- a/app/routers/wiki.py +++ b/app/routers/wiki.py @@ -7,7 +7,7 @@ from __future__ import annotations import html -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, JSONResponse from app.auth.session import SessionManager @@ -172,12 +172,8 @@ def teamspace_page(teamspace_id: int, request: Request): @router.post("/api/v2/wiki/teamspaces") -async def create_teamspace(request: Request): +def create_teamspace(request: Request, body: dict = Body(default={})): user = _user(request) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name or len(name) > 120: raise HTTPException(400, "name required (max 120 chars)") @@ -225,15 +221,11 @@ def list_members(teamspace_id: int, request: Request): @router.put("/api/v2/wiki/teamspaces/{teamspace_id}/members/{member_id}") -async def set_member(teamspace_id: int, member_id: int, request: Request): +def set_member(teamspace_id: int, member_id: int, request: Request, body: dict = Body(default={})): user = _user(request) _teamspace_or_404(teamspace_id, user["id"]) if not wiki.can_write_teamspace(user["id"], teamspace_id): raise HTTPException(403, "Editor role required") - try: - body = await request.json() - except Exception: - body = {} role = body.get("role") if role not in wiki.TEAMSPACE_ROLES: raise HTTPException(400, f"role must be one of {', '.join(wiki.TEAMSPACE_ROLES)}") @@ -275,15 +267,11 @@ def get_verification(page_id: int, request: Request): @router.post("/api/v2/wiki/pages/{page_id}/verify") -async def verify_page(page_id: int, request: Request): +def verify_page(page_id: int, request: Request, body: dict = Body(default={})): user = _user(request) page = _page_or_404(page_id) if not _can_verify(user, page): raise HTTPException(403, "Editor role required to verify a page") - try: - body = await request.json() - except Exception: - body = {} out = wiki.verify_page(page_id, user["id"], days=body.get("days") or wiki.VERIFICATION_DAYS_DEFAULT, note=body.get("note") or "") @@ -355,12 +343,8 @@ def list_followers(page_id: int, request: Request): # ── comment reactions ────────────────────────────────────────────────────── @router.post("/api/v2/wiki/comments/{comment_id}/reactions") -async def react(comment_id: int, request: Request): +def react(comment_id: int, request: Request, body: dict = Body(default={})): user = _user(request) - try: - body = await request.json() - except Exception: - body = {} emoji = (body.get("emoji") or "").strip() if not emoji: raise HTTPException(400, "emoji required") @@ -380,15 +364,11 @@ def list_reactions(comment_id: int, request: Request): # ── guest shares ─────────────────────────────────────────────────────────── @router.post("/api/v2/wiki/pages/{page_id}/guests") -async def create_guest(page_id: int, request: Request): +def create_guest(page_id: int, request: Request, body: dict = Body(default={})): user = _user(request) page = _page_or_404(page_id) if page.get("teamspace_id") and not wiki.can_write_teamspace(user["id"], page["teamspace_id"]): raise HTTPException(403, "Editor role required to share") - try: - body = await request.json() - except Exception: - body = {} try: share = wiki.create_guest_share(page_id, body.get("email") or "", body.get("role") or "viewer", @@ -519,13 +499,9 @@ def sweep_expiry(request: Request): # ── blocks (mermaid / equation_inline / progress) ─────────────────────────── @router.post("/api/v2/wiki/blocks/preview") -async def preview_blocks(request: Request): +def preview_blocks(request: Request, body: dict = Body(default={})): """Render v7.3 blocks to HTML (same renderer used by the export pipeline).""" _user(request) - try: - body = await request.json() - except Exception: - body = {} blocks = body.get("blocks") if not isinstance(blocks, list): raise HTTPException(400, "blocks must be a list") diff --git a/app/routers/workers.py b/app/routers/workers.py index 2cc0ad3..45ea1ff 100644 --- a/app/routers/workers.py +++ b/app/routers/workers.py @@ -1,7 +1,7 @@ """FlowDeck — Workers API (v7.0.0): CRUD, manual run, history, fork, usage.""" from __future__ import annotations -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import JSONResponse from app.auth.session import SessionManager @@ -41,12 +41,8 @@ def _row_to_api(row) -> dict: @router.post("/api/v2/workers") -async def create_worker(request: Request): +def create_worker(request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "Untitled worker").strip()[:200] code = body.get("code_py") or "" try: @@ -101,12 +97,8 @@ def get_worker(worker_id: int, request: Request): @router.patch("/api/v2/workers/{worker_id}") -async def update_worker(worker_id: int, request: Request): +def update_worker(worker_id: int, request: Request, body: dict = Body(default={})): user = _auth_user(request, require_write=True) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM workers WHERE id=?", (worker_id,)).fetchone() if not row: diff --git a/app/routers/workspace.py b/app/routers/workspace.py index c35afa4..5216fe9 100644 --- a/app/routers/workspace.py +++ b/app/routers/workspace.py @@ -8,12 +8,12 @@ import json import logging import sqlite3 -from fastapi import APIRouter, HTTPException, Request +from fastapi import APIRouter, Body, HTTPException, Request from fastapi.responses import HTMLResponse, StreamingResponse from app.auth.session import SessionManager from app.db import get_conn -from app.services.automations import fire_event +from app.services.automations import fire_event, run_event_sync logger = logging.getLogger(__name__) router = APIRouter(tags=["workspace"], prefix="/workspace") @@ -57,8 +57,7 @@ def _require_ws_admin(request: Request, ws_id: int) -> None: # ── Workspaces ── @router.post("") -async def create_workspace(request: Request): - body = await request.json() if request.headers.get("content-type") else {} +def create_workspace(request: Request, body: dict = Body(default={})): name = body.get("name", "Default Workspace") user = _current_user(request) uid = user.get("id", 1) @@ -91,9 +90,8 @@ def list_members(request: Request, ws_id: int): @router.post("/{ws_id}/members") -async def add_member(request: Request, ws_id: int): +def add_member(request: Request, ws_id: int, body: dict = Body(default={})): _require_ws_admin(request, ws_id) - body = await request.json() if request.headers.get("content-type") else {} user_id = body.get("user_id") role = body.get("role", "editor") if role not in ROLES: @@ -108,9 +106,8 @@ async def add_member(request: Request, ws_id: int): @router.put("/{ws_id}/members/{user_id}") -async def update_member_role(request: Request, ws_id: int, user_id: int): +def update_member_role(request: Request, ws_id: int, user_id: int, body: dict = Body(default={})): _require_ws_admin(request, ws_id) - body = await request.json() if request.headers.get("content-type") else {} role = body.get("role", "editor") if role not in ROLES: raise HTTPException(400, f"Invalid role: {role}") @@ -143,8 +140,7 @@ def list_comments(request: Request, page_id: int): @router.post("/pages/{page_id}/comments") -async def add_comment(request: Request, page_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def add_comment(request: Request, page_id: int, body: dict = Body(default={})): b = body.get("body", "").strip() if not b: raise HTTPException(400, "body required") @@ -158,15 +154,14 @@ async def add_comment(request: Request, page_id: int): (page_id, uid, b, parent_id)) conn.commit() try: - await fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid}) + run_event_sync(fire_event("comment.added", {"comment_id": cur.lastrowid, "page_id": page_id, "user_id": uid})) except Exception: logger.exception("add_comment") return {"id": cur.lastrowid, "status": "created"} @router.put("/comments/{comment_id}") -async def update_comment(request: Request, comment_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def update_comment(request: Request, comment_id: int, body: dict = Body(default={})): b = body.get("body") resolved = body.get("resolved") with get_conn() as conn: @@ -178,7 +173,7 @@ async def update_comment(request: Request, comment_id: int): conn.commit() if resolved and row and not int(row["resolved"] or 0): try: - await fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]}) + run_event_sync(fire_event("comment.resolved", {"comment_id": comment_id, "page_id": row["page_id"]})) except Exception: logger.exception("update_comment") return {"status": "updated"} @@ -197,8 +192,7 @@ def page_history(request: Request, page_id: int): @router.post("/pages/{page_id}/history") -async def record_history(request: Request, page_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def record_history(request: Request, page_id: int, body: dict = Body(default={})): user = _current_user(request) uid = user.get("id", 1) with get_conn() as conn: @@ -230,8 +224,7 @@ def list_favorites(request: Request): @router.post("/favorites") -async def add_favorite(request: Request): - body = await request.json() if request.headers.get("content-type") else {} +def add_favorite(request: Request, body: dict = Body(default={})): user = _current_user(request) uid = user.get("id", 1) page_id = body.get("page_id") @@ -244,7 +237,7 @@ async def add_favorite(request: Request): ) conn.commit() try: - await fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid}) + run_event_sync(fire_event("favorite.added", {"page_id": page_id, "collection_id": collection_id, "user_id": uid})) except Exception: logger.exception("add_favorite") return {"status": "favorited"} @@ -268,8 +261,7 @@ def list_db_templates(request: Request): @router.post("/templates/database") -async def create_db_template(request: Request): - body = await request.json() if request.headers.get("content-type") else {} +def create_db_template(request: Request, body: dict = Body(default={})): cur = None with get_conn() as conn: cur = conn.execute( @@ -282,9 +274,8 @@ async def create_db_template(request: Request): @router.post("/templates/database/{tid}/apply") -async def apply_db_template(request: Request, tid: int): +def apply_db_template(request: Request, tid: int, body: dict = Body(default={})): from app.services.db_templates import create_from_template - body = await request.json() if request.headers.get("content-type") else {} name = body.get("name", "New Database") with get_conn() as conn: tmpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (tid,)).fetchone() @@ -305,8 +296,7 @@ def list_page_templates(request: Request, collection_id: int): @router.post("/collections/{collection_id}/templates/page") -async def create_page_template(request: Request, collection_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def create_page_template(request: Request, collection_id: int, body: dict = Body(default={})): with get_conn() as conn: cur = conn.execute( "INSERT INTO page_templates (collection_id, name, property_values_json) VALUES (?,?,?)", @@ -317,8 +307,7 @@ async def create_page_template(request: Request, collection_id: int): @router.post("/collections/{collection_id}/templates/page/{tid}/apply") -async def apply_page_template(request: Request, collection_id: int, tid: int): - body = await request.json() if request.headers.get("content-type") else {} +def apply_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})): with get_conn() as conn: tmpl = conn.execute("SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id)).fetchone() if not tmpl: @@ -331,19 +320,18 @@ async def apply_page_template(request: Request, collection_id: int, tid: int): (collection_id, body.get("title", "New Page"), max_pos, tmpl["property_values_json"]), ) conn.commit() - await fire_event("page.created", { + run_event_sync(fire_event("page.created", { "page_id": cur.lastrowid, "collection_id": collection_id, "title": body.get("title", "New Page"), "properties": json.loads(tmpl["property_values_json"]) if tmpl["property_values_json"] else {}, - }) + })) return {"id": cur.lastrowid, "status": "created"} @router.put("/collections/{collection_id}/templates/page/{tid}") -async def update_page_template(request: Request, collection_id: int, tid: int): +def update_page_template(request: Request, collection_id: int, tid: int, body: dict = Body(default={})): """Update a page template — name, properties, content, recurrence.""" - body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: tmpl = conn.execute( "SELECT * FROM page_templates WHERE id=? AND collection_id=?", (tid, collection_id) @@ -395,9 +383,8 @@ def list_dashboards(request: Request, collection_id: int): @router.post("/collections/{collection_id}/dashboards") -async def create_dashboard(request: Request, collection_id: int): +def create_dashboard(request: Request, collection_id: int, body: dict = Body(default={})): """Create a new dashboard for a collection.""" - body = await request.json() if request.headers.get("content-type") else {} name = body.get("name", "Dashboard").strip() layout = json.dumps(body.get("layout", {"columns": 1, "widgets": []})) @@ -414,9 +401,8 @@ async def create_dashboard(request: Request, collection_id: int): @router.put("/collections/{collection_id}/dashboards/{did}") -async def update_dashboard(request: Request, collection_id: int, did: int): +def update_dashboard(request: Request, collection_id: int, did: int, body: dict = Body(default={})): """Update a dashboard — name or layout (widgets grid).""" - body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: dash = conn.execute( "SELECT * FROM collection_dashboards WHERE id=? AND collection_id=?", (did, collection_id) @@ -471,9 +457,8 @@ def list_sprints(request: Request, collection_id: int): @router.post("/collections/{collection_id}/sprints") -async def create_sprint(request: Request, collection_id: int): +def create_sprint(request: Request, collection_id: int, body: dict = Body(default={})): """Create a new sprint.""" - body = await request.json() if request.headers.get("content-type") else {} name = body.get("name", "").strip() start_date = body.get("start_date", "") end_date = body.get("end_date", "") @@ -491,16 +476,15 @@ async def create_sprint(request: Request, collection_id: int): ) conn.commit() try: - await fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name}) + run_event_sync(fire_event("sprint.created", {"sprint_id": cur.lastrowid, "collection_id": collection_id, "name": name})) except Exception: logger.exception("create_sprint") return {"id": cur.lastrowid, "name": name, "status": "created"} @router.put("/collections/{collection_id}/sprints/{sid}") -async def update_sprint(request: Request, collection_id: int, sid: int): +def update_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})): """Update a sprint.""" - body = await request.json() if request.headers.get("content-type") else {} with get_conn() as conn: sprint = conn.execute( "SELECT * FROM sprints WHERE id=? AND collection_id=?", (sid, collection_id) @@ -521,7 +505,7 @@ async def update_sprint(request: Request, collection_id: int, sid: int): ) conn.commit() try: - await fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status}) + run_event_sync(fire_event("sprint.updated", {"sprint_id": sid, "collection_id": collection_id, "name": name, "status": status})) except Exception: logger.exception("update_sprint") return {"id": sid, "status": "updated"} @@ -542,9 +526,8 @@ def delete_sprint(request: Request, collection_id: int, sid: int): @router.post("/collections/{collection_id}/sprints/{sid}/assign") -async def assign_page_to_sprint(request: Request, collection_id: int, sid: int): +def assign_page_to_sprint(request: Request, collection_id: int, sid: int, body: dict = Body(default={})): """Assign a page to a sprint.""" - body = await request.json() if request.headers.get("content-type") else {} page_id = body.get("page_id") if not page_id: raise HTTPException(400, "page_id is required") @@ -634,8 +617,7 @@ def sprint_burndown(request: Request, collection_id: int, sid: int): # ── CSV Import/Export ── @router.post("/collections/{collection_id}/import/csv") -async def import_csv(request: Request, collection_id: int): - body = await request.json() if request.headers.get("content-type") else {} +def import_csv(request: Request, collection_id: int, body: dict = Body(default={})): csv_data = body.get("csv", "") if not csv_data: raise HTTPException(400, "csv field required") @@ -698,9 +680,8 @@ def list_webhooks(request: Request): @router.post("/webhooks") -async def create_webhook(request: Request): +def create_webhook(request: Request, body: dict = Body(default={})): _require_admin(request) - body = await request.json() if request.headers.get("content-type") else {} url = body.get("url", "").strip() event = body.get("event", "page.created") secret = body.get("secret", "") diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index ee6b666..6809570 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.25.0" + "version": "7.26.0" }, "paths": { "/auth/register": { @@ -31,6 +31,17 @@ "summary": "Register", "description": "Register a new local account.", "operationId": "register_auth_register_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -39,6 +50,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -93,6 +114,17 @@ "summary": "Local Login", "description": "Login with email + password.", "operationId": "local_login_auth_local_login_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -101,6 +133,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -203,6 +245,17 @@ "summary": "Local Verify", "description": "Exchange a 2FA ``pending`` token + TOTP/backup code for a session.", "operationId": "local_verify_auth_local_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -211,6 +264,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -260,6 +323,17 @@ ], "summary": "Twofa Activate", "operationId": "twofa_activate_auth_2fa_activate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -268,6 +342,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -663,6 +747,17 @@ "summary": "Save Sso Config Api", "description": "Create/replace the SSO configuration (admin, scope write).", "operationId": "save_sso_config_api_api_v2_sso_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Payload" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -671,6 +766,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } }, @@ -681,6 +786,17 @@ "summary": "Save Sso Config Api", "description": "Create/replace the SSO configuration (admin, scope write).", "operationId": "save_sso_config_api_api_v2_sso_config_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Payload" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -689,6 +805,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } }, @@ -1017,6 +1143,17 @@ ], "summary": "Update Profile", "operationId": "update_profile_api_user_profile_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1025,6 +1162,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1036,6 +1183,17 @@ ], "summary": "Update Password", "operationId": "update_password_api_user_password_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1044,6 +1202,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1188,6 +1356,17 @@ ], "summary": "Create Workspace", "operationId": "create_workspace_workspace_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1196,6 +1375,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1247,6 +1436,17 @@ ], "summary": "Create Workspace Project", "operationId": "create_workspace_project_api_workspace_projects_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1255,6 +1455,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1317,6 +1527,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1367,6 +1588,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1743,6 +1975,17 @@ "summary": "Create Local Workspace Item", "description": "Create a new file in the active workspace.", "operationId": "create_local_workspace_item_api_local_workspace_items_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1751,6 +1994,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -1774,6 +2027,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -1945,6 +2209,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2055,6 +2330,17 @@ "summary": "Create Workspace", "description": "Create a new workspace.", "operationId": "create_workspace_api_workspaces_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2063,6 +2349,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -2086,6 +2382,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2320,6 +2627,17 @@ "summary": "Set Avatar Color", "description": "Set the user's avatar background color.", "operationId": "set_avatar_color_api_settings_avatar_color_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2328,6 +2646,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -2340,6 +2668,17 @@ "summary": "Create Tag Global", "description": "Create a tag for the current user.", "operationId": "create_tag_global_api_settings_tags_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2348,6 +2687,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -2371,6 +2720,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2530,6 +2890,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2652,6 +3023,17 @@ "summary": "Update Account", "description": "Update current user's profile: full_name, login, email, password.", "operationId": "update_account_api_settings_account_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2660,6 +3042,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -2787,6 +3179,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2869,6 +3272,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -2951,6 +3365,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3076,6 +3501,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3117,6 +3553,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3165,6 +3612,17 @@ "summary": "Create Page Template", "description": "v5.12.0: save the current page (or a raw block list) as a personal\nglobal template: {name, icon?, description?, page_id? | blocks?}.", "operationId": "create_page_template_board_api_page_templates_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3173,6 +3631,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -3196,6 +3664,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3391,6 +3870,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3661,6 +4151,17 @@ "summary": "Create Synced Block Api", "description": "Create a new synced block.", "operationId": "create_synced_block_api_board_api_synced_blocks_post", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -3669,6 +4170,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -3811,6 +4322,17 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -4670,6 +5192,17 @@ } } ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -4963,6 +5496,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -5053,6 +5597,17 @@ "summary": "Resolve Embed", "description": "v5.5.0: rewrite a pasted URL to its provider embed src.\n\nPowers the universal ``/embed`` block (YouTube, Vimeo, Figma, Maps,\nDocs, Loom, CodePen, Miro, Spotify, SoundCloud, Twitch, X/Twitter…).", "operationId": "resolve_embed_board_api_embed_resolve_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -5061,6 +5616,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -5084,6 +5649,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -5325,6 +5901,17 @@ "summary": "Create Project", "description": "Register a standalone (builtin) project.", "operationId": "create_project_api_projects_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -5333,6 +5920,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -6826,6 +7423,17 @@ "summary": "Create Collection Api", "description": "API: create a new collection, optionally from a database template.", "operationId": "create_collection_api_db_api_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -6834,6 +7442,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -6857,6 +7475,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7017,6 +7646,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7317,6 +7957,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7490,6 +8141,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7531,6 +8193,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7611,6 +8284,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7652,6 +8336,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7682,6 +8377,17 @@ "summary": "Compute Rollup", "description": "Compute a rollup aggregation.", "operationId": "compute_rollup_db_rollup_compute_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7690,6 +8396,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -7702,6 +8418,17 @@ "summary": "Evaluate Formula", "description": "Evaluate a formula expression.", "operationId": "evaluate_formula_db_formula_evaluate_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7710,6 +8437,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -7813,6 +8550,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7854,6 +8602,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -7936,6 +8695,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8034,6 +8804,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8134,6 +8915,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8184,6 +8976,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8264,6 +9067,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8355,6 +9169,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8426,6 +9251,17 @@ "summary": "Create Inline Database", "description": "API: create an inline database within a parent page (optionally from a template).", "operationId": "create_inline_database_db_inline_api_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8434,6 +9270,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -8555,6 +9401,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8664,6 +9521,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -8903,6 +9771,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9087,6 +9966,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9136,6 +10026,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9261,6 +10162,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9301,6 +10213,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9379,6 +10302,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9425,6 +10359,17 @@ ], "summary": "Add Favorite", "operationId": "add_favorite_workspace_favorites_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9433,6 +10378,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -9501,6 +10456,17 @@ ], "summary": "Create Db Template", "operationId": "create_db_template_workspace_templates_database_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9509,6 +10475,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -9531,6 +10507,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9609,6 +10596,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9658,6 +10656,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9708,6 +10717,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9836,6 +10856,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -9886,6 +10917,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10014,6 +11056,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10064,6 +11117,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10162,6 +11226,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10311,6 +11386,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10397,6 +11483,17 @@ ], "summary": "Create Webhook", "operationId": "create_webhook_workspace_webhooks_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10405,6 +11502,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -10945,6 +12052,17 @@ "summary": "Create User", "description": "Create a new user (admin only).", "operationId": "create_user_api_admin_users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -10953,6 +12071,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -10976,6 +12104,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -11592,6 +12731,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -11708,6 +12858,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12239,6 +13400,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12289,6 +13461,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12488,6 +13671,17 @@ "summary": "Track Recent", "description": "Record a page access in recents.", "operationId": "track_recent_api_recents_track_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12496,6 +13690,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -12526,6 +13730,17 @@ "summary": "Save Sidebar Config", "description": "Save the current user's sidebar customization config.", "operationId": "save_sidebar_config_api_sidebar_config_put", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -12534,6 +13749,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -12768,6 +13993,17 @@ "summary": "Mark Read", "description": "Mark one notification as read (id) or all (id omitted).", "operationId": "mark_read_api_notifications_read_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12776,6 +14012,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -12826,6 +14072,17 @@ "summary": "Set Prefs", "description": "Update the current user's notification email preferences.", "operationId": "set_prefs_api_notifications_prefs_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12834,6 +14091,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -12864,6 +14131,17 @@ "summary": "Set Timezone", "description": "Update the current user's IANA timezone (empty string = UTC).", "operationId": "set_timezone_api_notifications_timezone_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12872,6 +14150,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -12942,6 +14230,17 @@ ], "summary": "Create Automation", "operationId": "create_automation_workspace_automations_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -12950,6 +14249,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -13010,6 +14319,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13256,6 +14576,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13296,6 +14627,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13375,6 +14717,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13475,6 +14828,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13516,6 +14880,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13557,6 +14932,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13741,6 +15127,17 @@ ], "summary": "Create Agent", "operationId": "create_agent_api_agent_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13749,6 +15146,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -13777,6 +15184,17 @@ ], "summary": "Create Conversation", "operationId": "create_conversation_api_agent_conversations_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13785,6 +15203,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -13884,6 +15312,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -13924,6 +15363,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14110,6 +15560,17 @@ ], "summary": "Create Skill", "operationId": "create_skill_api_agent_skills_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14118,6 +15579,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -14200,6 +15671,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14230,6 +15712,17 @@ "summary": "Import Skill", "description": "Importe un skill portable (JSON exporté depuis une autre instance).", "operationId": "import_skill_api_agent_skills_import_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14238,6 +15731,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -14373,6 +15876,17 @@ "summary": "Add Feedback", "description": "Enregistre le retour (👍 / 👎) porté sur une réponse de l'agent.", "operationId": "add_feedback_api_agent_feedback_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14381,6 +15895,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -14521,6 +16045,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14740,6 +16275,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14867,6 +16413,17 @@ "summary": "Create Token", "description": "Create an API token for the current user. The secret is returned once.", "operationId": "create_token_api_settings_tokens_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -14875,6 +16432,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -15011,6 +16578,17 @@ "summary": "Onboarding Create Workspace", "description": "Step 1 — create the first local workspace.", "operationId": "onboarding_create_workspace_api_onboarding_workspace_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -15019,6 +16597,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -15031,6 +16619,17 @@ "summary": "Onboarding Create Project", "description": "Step 3 — create the first project: a welcome page in the workspace.", "operationId": "onboarding_create_project_api_onboarding_project_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -15039,6 +16638,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -15556,6 +17165,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -15638,6 +17258,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -15805,6 +17436,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16031,6 +17673,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16163,6 +17816,17 @@ ], "summary": "Create Group", "operationId": "create_group_api_v2_groups_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16171,6 +17835,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -16193,6 +17867,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16309,6 +17994,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16506,6 +18202,17 @@ ], "summary": "Auth Verify", "operationId": "auth_verify_api_v2_web_clipper_auth_verify_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16514,6 +18221,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -16525,6 +18242,17 @@ ], "summary": "Clip Page", "operationId": "clip_page_api_v2_web_clipper_clip_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body" + } + } + }, + "required": true + }, "responses": { "200": { "description": "Successful Response", @@ -16533,6 +18261,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -24592,6 +26330,17 @@ ], "summary": "Create Site", "operationId": "create_site_api_v2_sites_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -24600,6 +26349,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -24660,6 +26419,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -24776,6 +26546,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25128,6 +26909,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25312,6 +27104,17 @@ ], "summary": "Create Worker", "operationId": "create_worker_api_v2_workers_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25320,6 +27123,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -25380,6 +27193,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25603,6 +27427,17 @@ ], "summary": "Create Link", "operationId": "create_link_api_v2_calendar_links_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25611,6 +27446,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -25773,6 +27618,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25859,6 +27715,17 @@ ], "summary": "Scim Create", "operationId": "scim_create_scim_v2_Users_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25867,6 +27734,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -25927,6 +27804,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -25965,6 +27853,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26049,6 +27948,17 @@ ], "summary": "Create Scim Token", "operationId": "create_scim_token_api_v2_scim_tokens_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26057,6 +27967,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26125,6 +28045,17 @@ ], "summary": "Create Domain", "operationId": "create_domain_api_v2_domain_claims_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26133,6 +28064,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26243,6 +28184,17 @@ ], "summary": "Register Finish", "operationId": "register_finish_auth_webauthn_register_finish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26251,6 +28203,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26262,6 +28224,17 @@ ], "summary": "Login Begin", "operationId": "login_begin_auth_webauthn_login_begin_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26270,6 +28243,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26281,6 +28264,17 @@ ], "summary": "Login Finish", "operationId": "login_finish_auth_webauthn_login_finish_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26289,6 +28283,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26395,6 +28399,17 @@ ], "summary": "Upsert Policy", "operationId": "upsert_policy_api_v2_agent_policies_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26403,6 +28418,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26444,6 +28469,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26490,6 +28526,17 @@ ], "summary": "Create Teamspace", "operationId": "create_teamspace_api_v2_wiki_teamspaces_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26498,6 +28545,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } } @@ -26652,6 +28709,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26779,6 +28847,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -26957,6 +29036,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -27035,6 +29125,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -27266,6 +29367,17 @@ "summary": "Preview Blocks", "description": "Render v7.3 blocks to HTML (same renderer used by the export pipeline).", "operationId": "preview_blocks_api_v2_wiki_blocks_preview_post", + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "title": "Body", + "default": {} + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -27274,6 +29386,16 @@ "schema": {} } } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } } } }