From 07904f05e5080b824b149b32a27ec19fa463b805 Mon Sep 17 00:00:00 2001 From: Bruno Charest Date: Thu, 1 Oct 2026 11:19:58 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20A21=20phase=202a=20=E2=80=94=20api=5Fv2?= =?UTF-8?q?=20:=20body=20JSON=20en=20param=C3=A8tre,=2036=20routes=20hors?= =?UTF-8?q?=20loop=20(v7.9.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Les 36 routes d'api_v2 dont le SEUL `await` était `body = await request.json()` (bloc try/except uniforme) → paramètre FastAPI `body: dict = Body(default={})` + conversion en `def` → threadpool : toute leur séquence SQLite quitte l'event loop. - Équivalences vérifiées avant engament (probe FastAPI) : · corps absent → `{}` (identique à l'ancien try/except) · JSON invalide → 422 (avant : avalé comme `{}` — 422 est plus juste) · zéro `body[...]=` / setdefault / update dans api_v2 → défaut partagé jamais muté - verify_webhook_signature (signature multi-ligne) traitée à la main. - Piège courant évité : première version du script supprimait 5 lignes au lieu de 4 (slice m-1:m+4) → fichier restauré depuis git, slice corrigée, 0 ligne perdue (diff logique +39/-183). api_v2 : 96/115 routes hors loop (60 phase 1 + 36 ici) ; 19 async restantes (fire_event, request.form, gitea/webhooks) = phase 2b. suite **1037/1037** (242 s) · `ruff check app tests` OK · docs à jour --- CHANGELOG.md | 24 +++ ROADMAP.md | 4 +- VERSION | 2 +- WORKLOAD.md | 2 +- app/main.py | 2 +- app/routers/api_v2.py | 222 +++++------------------ docs/openapi-v2.json | 398 +++++++++++++++++++++++++++++++++++++++++- 7 files changed, 465 insertions(+), 189 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index bb5fea4..1e515ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,29 @@ # Changelog - FlowDeck +## v7.9.0 (2026-10-01) — Audit : A21 phase 2a (api_v2 hors loop) + +### Changed + +- **A21 (phase 2a)** — dans `api_v2`, les 36 routes dont le **seul** `await` + était `body = await request.json()` passent à un paramètre FastAPI + `body: dict = Body(default={})` (parsing async fait par FastAPI avant + l'appel) puis sont converties en `def` → threadpool. Équivalences vérifiées + avant-engagement : corps absent → `{}` (identique au `try/except` d'avant), + JSON invalide → **422** (avant : traité silencieusement comme `{}`), + zéro `body[...] = ` dans le fichier (le défaut partagé n'est jamais muté) +- `api_v2` : **96/115 routes hors event loop** (60 en phase 1 + 36 ici) ; + il ne reste que **19 routes async** dans ce router (`fire_event`, + `request.form`, appels gitea/webhooks — phase 2b) +- Bug de transformation évité en cours de route : première version du script + supprimait 5 lignes au lieu de 4 (`slice` fermant d'un cran trop loin) — + fichier restauré depuis git puis script corrigé, 0 ligne perdue (diff + logique : +39/-183 = 36 signatures + import, 4 lignes de try/except × 35) + +### Tests + +- Suite complète **1037/1037** (242 s) ; ciblée sur `test_public_api_v2` + + `test_v65` + audit : 62/62 verts avant la passe complète + ## v7.8.0 (2026-10-01) — Audit : A21 phase 1 (SQLite hors event loop) ### Changed diff --git a/ROADMAP.md b/ROADMAP.md index 967d4a0..8860aa7 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). **Reste (phase 2)** : les **311 routes qui ont de vrais `await`** (`request.json()`, `fire_event`, httpx) → enrouler le bloc DB dans `await anyio.to_thread.run_sync(lambda: ...)` (pas de wrapper pour l'instant : rien ne l'appellerait encore). Effort : **M→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**. **Reste (phase 2b)** : les **311-36 = 275 routes** avec de vrais `await` (`fire_event`, `request.form`, httpx…) → enrouler les blocs DB dans `await anyio.to_thread.run_sync(lambda: ...)` (pas de wrapper pour l'instant : rien ne l'appellerait encore) — dans api_v2 il n'en reste que **19**. Effort : **M→L**. - [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. +→ **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. diff --git a/VERSION b/VERSION index 09a6d30..4bc5d61 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -7.8.0 +7.9.0 diff --git a/WORKLOAD.md b/WORKLOAD.md index 1c49d5a..9c4a560 100644 --- a/WORKLOAD.md +++ b/WORKLOAD.md @@ -1,6 +1,6 @@ # WORKLOAD — FlowDeck Notion Clone -> **Début**: 2026-07-08 | **Version**: v7.8.0 (audit — A21 phase 1 : 352 routes hors loop) | **Statut**: EN COURS 🔄 +> **Début**: 2026-07-08 | **Version**: v7.9.0 (audit — A21 phase 2a : api_v2 à 96/115 hors loop) | **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/main.py b/app/main.py index facddf4..b9d33ad 100644 --- a/app/main.py +++ b/app/main.py @@ -185,7 +185,7 @@ async def lifespan(_app: FastAPI): app = FastAPI( title="FlowDeck", - version="7.8.0", + version="7.9.0", docs_url="/docs", redoc_url="/redoc", lifespan=lifespan, diff --git a/app/routers/api_v2.py b/app/routers/api_v2.py index 36162c0..83ed12d 100644 --- a/app/routers/api_v2.py +++ b/app/routers/api_v2.py @@ -12,7 +12,7 @@ import logging import secrets from datetime import datetime -from fastapi import APIRouter, Header, HTTPException, Request +from fastapi import APIRouter, Body, Header, HTTPException, Request from fastapi.responses import JSONResponse, Response from app.db import get_conn @@ -48,13 +48,9 @@ def _v2_rate_check(request: Request, user: dict) -> None: # ── Tokens ──────────────────────────────────────────────────────────────── @router.post("/tokens") -async def create_token(request: Request, authorization: str | None = Header(default=None)): +def create_token(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "API token").strip()[:100] scopes = validate_scopes_input(body.get("scopes") or "read,write") expires_at = body.get("expires_at") @@ -166,13 +162,9 @@ def get_me(request: Request, authorization: str | None = Header(default=None)): return d @router.patch("/users/me") -async def patch_me(request: Request, authorization: str | None = Header(default=None)): +def patch_me(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} allowed = {"full_name", "email", "avatar_color", "notification_prefs", "sidebar_config", "timezone"} updates = {} for k in allowed: @@ -232,16 +224,12 @@ def list_workspaces(request: Request, authorization: str | None = Header(default return {"workspaces": out, "total": total, "limit": limit, "offset": offset} @router.post("/workspaces") -async def create_workspace(request: Request, authorization: str | None = Header(default=None)): +def create_workspace(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) idem = check_idempotency(request, user["id"]) if idem: return JSONResponse(content=idem["data"], status_code=idem["status"]) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") @@ -282,13 +270,9 @@ def get_workspace(workspace_id: int, request: Request, authorization: str | None return d @router.patch("/workspaces/{workspace_id}") -async def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_workspace(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM workspaces WHERE id=?", (workspace_id,)).fetchone() if not row: @@ -336,13 +320,9 @@ def list_workspace_members(workspace_id: int, request: Request, authorization: s return {"members": [row_to_dict(r) for r in rows]} @router.post("/workspaces/{workspace_id}/members") -async def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None)): +def invite_member(workspace_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} target_id = body.get("user_id") or body.get("uid") email = (body.get("email") or "").strip() role = (body.get("role") or "editor").strip().lower() @@ -374,13 +354,9 @@ async def invite_member(workspace_id: int, request: Request, authorization: str return {"workspace_id": workspace_id, "user_id": uid, "role": role, "status": "added"} @router.patch("/workspaces/{workspace_id}/members/{uid}") -async def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None)): +def update_member_role(workspace_id: int, uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} role = (body.get("role") or "").strip().lower() if role not in ("owner", "admin", "editor", "viewer", "commenter"): raise HTTPException(400, "Invalid role") @@ -451,16 +427,12 @@ def list_collections_v2(request: Request, authorization: str | None = Header(def return JSONResponse(content=resp, headers=paginate_headers(total)) @router.post("/collections") -async def create_collection_v2(request: Request, authorization: str | None = Header(default=None)): +def create_collection_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) idem = check_idempotency(request, user["id"]) if idem: return JSONResponse(content=idem["data"], status_code=idem["status"]) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") @@ -507,13 +479,9 @@ def get_collection_v2(collection_id: int, request: Request, authorization: str | return d @router.patch("/collections/{collection_id}") -async def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_collection_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() if not row: @@ -545,13 +513,9 @@ def delete_collection_v2(collection_id: int, request: Request, authorization: st return {"id": collection_id, "status": "deleted"} @router.post("/collections/{collection_id}/linked") -async def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_linked_db(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() or f"Linked DB {collection_id}" with get_conn() as conn: src = conn.execute("SELECT * FROM collections WHERE id=?", (collection_id,)).fetchone() @@ -607,13 +571,9 @@ def list_sources(collection_id: int, request: Request, authorization: str | None return {"sources": [dict(r) for r in rows]} @router.post("/collections/{collection_id}/sources") -async def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def add_source(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} src_id = body.get("source_collection_id") or body.get("source_id") if not src_id: raise HTTPException(400, "source_collection_id required") @@ -862,13 +822,9 @@ async def restore_page_v2(page_id: int, request: Request, authorization: str | N raise HTTPException(404, "Page not found or not deleted") @router.post("/pages/{page_id}/move") -async def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): +def move_page_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not row: @@ -891,13 +847,9 @@ def list_sub_items_v2(page_id: int, request: Request, authorization: str | None return {"sub_items": [row_to_dict(r) for r in rows]} @router.post("/pages/{page_id}/sub-items") -async def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_sub_item_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: parent = conn.execute("SELECT collection_id FROM collection_pages WHERE id=?", (page_id,)).fetchone() if not parent: @@ -920,13 +872,9 @@ def list_dependencies_v2(page_id: int, request: Request, authorization: str | No return {"dependencies": [dict(r) for r in rows]} @router.post("/pages/{page_id}/dependencies") -async def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): +def add_dependency_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} dep_id = body.get("dependency_id") or body.get("depends_on") dtype = body.get("dependency_type") or "blocks" if not dep_id: @@ -967,13 +915,9 @@ def list_properties_v2(collection_id: int, request: Request, authorization: str return {"properties": [row_to_dict(r) for r in rows]} @router.post("/collections/{collection_id}/properties") -async def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_property_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name is required") @@ -992,13 +936,9 @@ async def create_property_v2(collection_id: int, request: Request, authorization return {"id": pid, "name": name, "prop_type": ptype, "status": "created"} @router.patch("/properties/{prop_id}") -async def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_property_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM collection_properties WHERE id=?", (prop_id,)).fetchone() if not row: @@ -1028,13 +968,9 @@ def delete_property_v2(prop_id: int, request: Request, authorization: str | None return {"id": prop_id, "status": "deleted"} @router.post("/properties/{prop_id}/relation") -async def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_relation_v2(prop_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} related_id = body.get("related_collection_id") reverse = (body.get("reverse_name") or "").strip() if not related_id: @@ -1054,13 +990,9 @@ async def create_relation_v2(prop_id: int, request: Request, authorization: str return {"id": prop_id, "status": "updated"} @router.post("/properties/evaluate-formula") -async def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None)): +def evaluate_formula_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} expr = body.get("expression") or body.get("formula") if not expr: raise HTTPException(400, "expression required") @@ -1073,13 +1005,9 @@ async def evaluate_formula_v2(request: Request, authorization: str | None = Head return {"result": res, "expression": expr} @router.post("/properties/compute-rollup") -async def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None)): +def compute_rollup_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} for k in ("collection_id", "relation_property_id", "target_property_id", "page_id"): if k not in body: raise HTTPException(400, f"{k} required") @@ -1126,13 +1054,9 @@ async def create_view_v2(collection_id: int, request: Request, authorization: st return {"id": vid, "name": name, "view_type": vtype, "status": "created"} @router.patch("/views/{view_id}") -async def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() if not row: @@ -1169,13 +1093,9 @@ def delete_view_v2(view_id: int, request: Request, authorization: str | None = H return {"id": view_id, "status": "deleted"} @router.post("/views/{view_id}/save-as") -async def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None)): +def save_as_view_v2(view_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() or "Copy" with get_conn() as conn: row = conn.execute("SELECT * FROM collection_views WHERE id=?", (view_id,)).fetchone() @@ -1196,13 +1116,9 @@ def list_dashboards_v2(collection_id: int, request: Request, authorization: str return {"dashboards": [row_to_dict(r) for r in rows]} @router.post("/collections/{collection_id}/dashboards") -async def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_dashboard_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "Dashboard").strip() layout = body.get("layout") or body.get("layout_json") or {"columns": 1, "widgets": []} with get_conn() as conn: @@ -1212,13 +1128,9 @@ async def create_dashboard_v2(collection_id: int, request: Request, authorizatio return {"id": did, "name": name, "status": "created"} @router.patch("/dashboards/{dashboard_id}") -async def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_dashboard_v2(dashboard_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM collection_dashboards WHERE id=?", (dashboard_id,)).fetchone() if not row: @@ -1390,13 +1302,9 @@ def mark_all_read(request: Request, authorization: str | None = Header(default=N return {"status": "all read"} @router.patch("/users/me/preferences") -async def patch_prefs(request: Request, authorization: str | None = Header(default=None)): +def patch_prefs(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT notification_prefs FROM users WHERE id=?", (user["id"],)).fetchone() try: @@ -1467,13 +1375,9 @@ def list_tags_v2(request: Request, authorization: str | None = Header(default=No return {"tags": [dict(r) for r in rows]} @router.post("/tags") -async def create_tag_v2(request: Request, authorization: str | None = Header(default=None)): +def create_tag_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "").strip() if not name: raise HTTPException(400, "name required") @@ -1488,13 +1392,9 @@ async def create_tag_v2(request: Request, authorization: str | None = Header(def return {"id": tid, "name": name, "color": color, "status": "created"} @router.patch("/tags/{tag_id}") -async def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_tag_v2(tag_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM tags WHERE id=? AND user_id=?", (tag_id, user["id"])).fetchone() if not row: @@ -1515,13 +1415,9 @@ def delete_tag_v2(tag_id: int, request: Request, authorization: str | None = Hea return {"id": tag_id, "status": "deleted"} @router.post("/pages/{page_id}/tags") -async def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): +def attach_tag_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} tag_id = body.get("tag_id") if not tag_id: raise HTTPException(400, "tag_id required") @@ -1593,13 +1489,9 @@ async def create_share_v2(page_id: int, request: Request, authorization: str | N return {"id": nid, "page_id": page_id, "status": "shared"} @router.patch("/shares/{share_id}") -async def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_share_v2(share_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} perm = (body.get("permission") or "").strip().lower() if perm not in ("view", "comment", "edit"): raise HTTPException(400, "Invalid permission") @@ -1661,13 +1553,9 @@ def list_history_v2(page_id: int, request: Request, authorization: str | None = return {"history": [row_to_dict(r) for r in rows], "versions": [row_to_dict(r) for r in vrows]} @router.post("/pages/{page_id}/history/restore") -async def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None)): +def restore_history_v2(page_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} hid = body.get("history_id") or body.get("id") or body.get("version_id") if not hid: raise HTTPException(400, "history_id required") @@ -1760,13 +1648,9 @@ def delete_sprint_v2(sprint_id: int, request: Request, authorization: str | None return {"id": sprint_id, "status": "deleted"} @router.post("/sprints/{sprint_id}/assign") -async def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None)): +def assign_sprint_v2(sprint_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} pid = body.get("page_id") if not pid: raise HTTPException(400, "page_id required") @@ -1824,13 +1708,9 @@ def list_templates_v2(collection_id: int, request: Request, authorization: str | return {"templates": [row_to_dict(r) for r in rows]} @router.post("/collections/{collection_id}/templates") -async def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None)): +def create_template_v2(collection_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} name = (body.get("name") or "Template").strip() pv = json.dumps(body.get("property_values") or body.get("property_values_json") or {}) cj = json.dumps(body.get("content") or body.get("content_json") or []) @@ -1841,13 +1721,9 @@ async def create_template_v2(collection_id: int, request: Request, authorization return {"id": tid, "name": name, "status": "created"} @router.patch("/templates/{template_id}") -async def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM page_templates WHERE id=?", (template_id,)).fetchone() if not row: @@ -1892,13 +1768,9 @@ def list_db_templates_v2(request: Request, authorization: str | None = Header(de return {"templates": [row_to_dict(r) for r in rows]} @router.post("/templates/database/{template_id}/apply") -async def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None)): +def apply_db_template_v2(template_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: tpl = conn.execute("SELECT * FROM database_templates WHERE id=?", (template_id,)).fetchone() if not tpl: @@ -2065,15 +1937,11 @@ def admin_list_users_v2(request: Request, limit: int = 30, offset: int = 0, auth return {"users": [row_to_dict(r) for r in rows], "total": total, "limit": limit, "offset": offset} @router.patch("/admin/users/{uid}") -async def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None)): +def admin_patch_user_v2(uid: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = get_bearer_user(request, authorization) _v2_rate_check(request, user) if not user.get("is_admin") and not has_scope(user.get("_token_scopes"), "admin"): raise HTTPException(403, "Admin scope required") - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: if not conn.execute("SELECT id FROM users WHERE id=?", (uid,)).fetchone(): raise HTTPException(404, "User not found") @@ -2144,13 +2012,9 @@ def list_webhooks_v2(request: Request, authorization: str | None = Header(defaul ) @router.post("/webhooks") -async def create_webhook_v2(request: Request, authorization: str | None = Header(default=None)): +def create_webhook_v2(request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} from app.services.webhook_outbound import EVENTS, _event_matches url = (body.get("url") or "").strip() event = (body.get("event") or "page.created").strip() @@ -2172,13 +2036,9 @@ async def create_webhook_v2(request: Request, authorization: str | None = Header "signature_header": "X-FlowDeck-Signature (HMAC-SHA256, sha256=)" if secret else None} @router.patch("/webhooks/{webhook_id}") -async def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None)): +def patch_webhook_v2(webhook_id: int, request: Request, authorization: str | None = Header(default=None), body: dict = Body(default={})): user = require_scope("write")(request, authorization) _v2_rate_check(request, user) - try: - body = await request.json() - except Exception: - body = {} with get_conn() as conn: row = conn.execute("SELECT * FROM webhook_subscriptions WHERE id=?", (webhook_id,)).fetchone() if not row: @@ -2274,19 +2134,15 @@ async def retry_webhook_deliveries(webhook_id: int, request: Request, @router.post("/webhooks/verify-signature") -async def verify_webhook_signature(request: Request, - authorization: str | None = Header(default=None)): +def verify_webhook_signature(request: Request, + authorization: str | None = Header(default=None), + body: dict = Body(default={})): """Verify a webhook signature (for debugging/testing).""" user = get_bearer_user(request, authorization) _v2_rate_check(request, user) from app.services.webhook_outbound import verify_signature - try: - body = await request.json() - except Exception: - body = {} - secret = body.get("secret", "") payload = body.get("payload", "{}") signature = body.get("signature", "") diff --git a/docs/openapi-v2.json b/docs/openapi-v2.json index e704602..76aa6d9 100644 --- a/docs/openapi-v2.json +++ b/docs/openapi-v2.json @@ -2,7 +2,7 @@ "openapi": "3.1.0", "info": { "title": "FlowDeck", - "version": "7.8.0" + "version": "7.9.0" }, "paths": { "/auth/register": { @@ -16725,6 +16725,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -16974,6 +16985,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17123,6 +17145,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17233,6 +17266,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17397,6 +17441,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17462,6 +17517,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17617,6 +17683,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17727,6 +17804,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -17837,6 +17925,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18003,6 +18102,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18454,6 +18564,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18564,6 +18685,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18674,6 +18806,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18849,6 +18992,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -18905,6 +19059,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19015,6 +19180,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19062,6 +19238,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19109,6 +19296,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19275,6 +19473,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19385,6 +19594,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19495,6 +19715,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -19551,6 +19782,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20125,6 +20367,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20365,6 +20618,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20421,6 +20685,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20531,6 +20806,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -20809,6 +21095,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -21085,6 +21382,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -21361,6 +21669,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -21592,6 +21911,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -21648,6 +21978,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -21861,6 +22202,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -22367,6 +22719,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -22618,6 +22981,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -22674,6 +23048,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response", @@ -22961,6 +23346,17 @@ } } ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "type": "object", + "default": {}, + "title": "Body" + } + } + } + }, "responses": { "200": { "description": "Successful Response",