Compare commits

..
7 Commits
Author SHA1 Message Date
Spark fd79a49d63 fix(draco): résolution dev/prod par l'hôte de la requête (7.82.4)
FlowDeck CI / test (push) Failing after 3h13m27s
FlowDeck CI / lint (push) Successful in 1m47s
FlowDeck CI / docker (push) Skipped
Le mode console du panneau restait une page morte sur flowdeck-dev :
CSP frame-src/connect-src et DRACO_PANNEAU_CONFIG.agent_url pointaient
l'agent prod (pas encore installée). Deux trous de la 7.82.3 :
agent_url() sans aucune dérivation, et aucune résolution lisant
l'hôte de la requête.

Pour tout ce qui est rendu à une requête (barre/DRACO_URLS, iframe
du panneau, CSP, boutons et redirections SSO) : explicite >
hôte public de la requête (motif fermé
^(og|flowdeck|tiktak|kanbante|agent)(SUFFIXE).dracodev.net$,
suffixe appliqué aux sœurs et à l'agent) > dérivation par les
adresses de l'agent connues en config (élargie : agent_url() se
dérive de l'URL d'événements explicite) > défaut officiel.
L'hôte est posé par le middleware CSP dans un ContextVar (patron
du nonce CSP) : iframe et CSP de la même réponse consomment la
même origine résolue. Contextes sans requête inchangés sinon.

Tests : +12 dans test_draco_urls_devprod.py (25/25) — Host dev
sans aucun env → tout en -dev ; Host prod → officiels ; Host LAN
→ repli config/défauts ; explicite gagne sur l'hôte ; motif fermé.
Suite complète : 4 failed / 26 errors identiques sur arbre propre
(familles préexistantes MCP/realtime/my_tasks/gallery/sync).
2026-10-26 03:04:50 +00:00
Spark 283e226adf fix(draco): URLs sœurs dérivées de l'environnement de l'agent (7.82.3)
FlowDeck CI / test (push) Successful in 29m39s
FlowDeck CI / lint (push) Successful in 2m18s
FlowDeck CI / docker (push) Successful in 1m41s
Résolution uniforme des quatre sœurs (ObsiGate, Flowdeck, TikTak,
Kanbante) dans app/services/draco_urls.py : explicite prioritaire
(contrat inchangé), sinon dérivation depuis l'hôte de l'agent connu
via AGENT_EVENEMENTS_URL (agent<SUFFIXE>.dracodev.net →
<slug><SUFFIXE>.dracodev.net), sinon défaut officiel. Hors motif
(autre domaine, LAN, IP) : aucune dérivation. Gitea inchangé.
Tests +4 dans test_draco_urls_devprod.py.
2026-10-26 00:46:21 +00:00
Spark 53ba3d1124 chore(release): 7.82.2 — CI test entièrement verte (pages-supports exclues des listes)
FlowDeck CI / docker (push) Successful in 1m43s
FlowDeck CI / lint (push) Successful in 1m49s
FlowDeck CI / test (push) Successful in 32m21s
VERSION 7.82.1 -> 7.82.2, littéral de synchronisation version="7.82.2" dans app/main.py, entrée CHANGELOG v7.82.2.
2026-10-25 22:38:53 +00:00
Spark 8ce3b4b421 test: réparer la fuite mock_http → og_fetcher et le marqueur page-templates
- mock_http (test_agent_web_tools) : og_fetcher, importé paresseusement
  par url_fetch pendant la fenêtre patchée, figeait la factory mockée
  'build' comme shared_client ; son handler répondait ensuite aux
  fetch_og_metadata des tests suivants du même worker xdist (flake
  test_v55 : « Titre de la page »). La restauration du finally couvre
  désormais og_fetcher, comme elle couvrait déjà url_fetch.
- test_editor_front_end_wired : depuis la refonte templates (fc85481),
  le câblage page-templates vit dans page_editor_scripts.js (le picker
  de base.html passe par le registre unifié /api/templates) ; le
  marqueur est vérifié dans le fichier qui le porte, à force égale.
2026-10-25 22:38:53 +00:00
Spark 193fbd14ce fix(listes): exclure les pages-supports de templates des listes utilisateur
Les pages-supports créées par TemplateService._create_holder (une par
template, search_excluded=1, « masquées, jamais listées ») n'étaient
exclues d'aucune liste : semées à l'init avec updated_at = now (99 sur
une base fraîche), elles occupaient toutes les places des listes
triées par récence (LIMIT 50/20/8) devant les vraies pages — cause
racine de 7 échecs CI (library recents/private/workspace, sidebar
recents, projets builtin, mentions agent), dont l'instabilité du test
provenance (égalité de seconde avec le seed).

Filtre COALESCE(search_excluded, 0) = 0 ajouté : onglets bibliothèque
(meetings, recents, favorites, shared, published, private, workspace),
sidebar Recents, projets builtin, scans de pages des mentions agent.
2026-10-25 22:38:53 +00:00
Spark 3c8fc3070a chore(release): 7.82.1 — CI verte (lint + test de version)
FlowDeck CI / test (push) Failing after 33m52s
FlowDeck CI / lint (push) Successful in 1m51s
FlowDeck CI / docker (push) Skipped
VERSION 7.82.0 -> 7.82.1, littéral de synchronisation version="7.82.1" dans app/main.py, entrée CHANGELOG v7.82.1.
2026-10-25 19:26:59 +00:00
Spark 49164ce057 fix(ci): corriger les 16 erreurs ruff et resynchroniser le littéral de version
Lint (run 2304, job 7058, ruff check app tests) : E402+I001 import mcp_server remonté en tête de app/main.py ; B904 x8 dans api_v2/templates_io.py (raise ... from None, from exc pour ValueError) ; UP017 datetime.UTC dans agent_evenements.py ; F841 x3 dans fd_templates.py (void/uid morts, import inutilisé retirés en chaîne) ; F401+F841 dans tests/test_fd_templates.py (import json inutilisé, n_presets en appel nu).

Version : le test test_pwa_offline cherche le littéral version="7.82.0" dans app/main.py — patron historique des bumps (scripts/bump_version.py le cherche aussi), rompu en 7.80.2 quand l'argument FastAPI est passé à _lire_version(). Littéral reposé en commentaire de synchronisation explicite, version runtime inchangée (lecture dynamique VERSION).
2026-10-25 19:26:09 +00:00
17 changed files with 688 additions and 50 deletions
+135
View File
@@ -1,5 +1,140 @@
# Changelog - FlowDeck # Changelog - FlowDeck
## v7.82.4 (2026-10-25) — Résolution dev/prod par l'hôte de la requête : le mode console du panneau revit en dev
- **Bug corrigé (dev)** : sur flowdeck-dev, le mode console du
panneau Spark restait une page morte — prouvé en direct avant
correctif : la 7.82.3 tournait bien (`GET /api/health` →
`7.82.3`), mais sa CSP servait `frame-src 'self'
https://agent.dracodev.net` (et `connect-src` idem), pointant
l'agent **prod, pas encore installée**, et la source de l'iframe
console (`DRACO_PANNEAU_CONFIG.agent_url`, rendue par
`base.html`) valait la même origine morte. Deux trous laissés par
la v7.82.3 : (1) `agent_url()` n'avait **aucune dérivation** —
même avec `AGENT_EVENEMENTS_URL` en `-dev`, l'agent lui-même
retombait sur le nom officiel ; (2) **aucune** résolution ne
lisait l'hôte de la requête — un `.env` dev sans
`DRACO_AGENT_URL` ni `AGENT_EVENEMENTS_URL` retombait
entièrement sur la prod.
- **Cure** (`app/services/draco_urls.py`, `app/middleware/security.py`) :
pour tout ce qui est **rendu à une requête** (liens de la barre /
`DRACO_URLS` servi, source de l'iframe du panneau, origine agent
des CSP `frame-src`/`connect-src`, boutons et redirections SSO —
émettrice via `DRACO_SSO_URL` résolu, callback =
`OAUTH_REDIRECT_URI` dont l'origine est sa propre origine
résolue), l'ordre de résolution devient : 1) variable explicite
de l'installation, prioritaire et inchangée ; 2) **hôte public de
la requête** (posé par le middleware CSP dans un `ContextVar`,
même patron que le nonce CSP ; `X-Forwarded-Host` sinon `Host`) —
motif fermé `^(og|flowdeck|tiktak|kanbante|agent)(<SUFFIXE>)\.dracodev\.net$`,
suffixe optionnel (`-dev`, vide en prod) appliqué à toutes les
sœurs **et à l'agent** ; jamais de suffixe inventé depuis un hôte
arbitraire, hors motif (LAN, IP) on passe à la suite ; 3)
dérivation par les adresses de l'agent connues en configuration,
**élargie** (URL d'événements **ou** base du service — notamment
`agent_url()` se dérive désormais de l'URL d'événements
explicite) — aussi la résolution des contextes **sans requête**,
inchangée sinon ; 4) défaut officiel. L'iframe du panneau et la
CSP de la même réponse consomment la **même** origine résolue :
les deux lisent `agent_url()` pendant la même requête.
- Tests : `tests/test_draco_urls_devprod.py` +12 (25/25 au total) —
requête `Host: flowdeck-dev.dracodev.net` et **aucune variable
d'environnement** → barre, iframe, CSP et SSO rendus entièrement
en `-dev` ; Host prod → noms officiels ; Host LAN → repli
config/défauts ; explicite gagne sur l'hôte ; motif fermé
(hôtes arbitraires → aucune dérivation) ; agent dérivé du seul
`AGENT_EVENEMENTS_URL` ; contextes sans requête inchangés.
Suite complète relue : **4 failed / 26 errors, identiques sur
arbre propre** (1307 passed en propre ; familles préexistantes
MCP/realtime/my_tasks/gallery/sync, sans rapport avec ce
correctif) — 1319 passed avec le correctif. Ruff vert.
## v7.82.3 (2026-10-25) — URLs sœurs dérivées de l'environnement de l'agent
- **Bug corrigé (dev)** : les URL sœurs servies par `draco_urls()`
(menu inter-apps, SSO, preset TikTak, références croisées)
restaient aux noms **prod** quand le `.env` ne déclarait pas les
quatre URL une à une — seul l'agent était en `-dev`, via
`AGENT_EVENEMENTS_URL`. Le menu inter-apps de la dev envoyait
donc vers la prod.
- **Dérivation d'environnement** (`app/services/draco_urls.py`,
résolution uniforme des quatre sœurs — ObsiGate, Flowdeck,
TikTak, Kanbante) : 1) URL explicite prioritaire, contrat
inchangé (`DRACO_SSO_URL`, `TIKTAK_URL`, `KANBANTE_URL`,
`FLOWDECK_URL`/`OAUTH_REDIRECT_URI`…) ; 2) sinon, si l'hôte de
l'agent tel que l'app le connaît déjà (`AGENT_EVENEMENTS_URL`,
via `agent_evenements_url()`) est de la forme
`agent<SUFFIXE>.dracodev.net` (SUFFIXE éventuellement vide,
typiquement `-dev`), l'URL sœur est dérivée :
`https://<slug><SUFFIXE>.dracodev.net` (slugs `og`, `flowdeck`,
`tiktak`, `kanbante`) ; hôte hors motif (autre domaine, LAN,
IP) = **aucune dérivation**, jamais un lien deviné ; 3) sinon
défaut officiel, comme avant. L'URL **Gitea ne change pas** ;
les gabarits et le JS de la barre, qui consomment `draco_urls()`,
sont inchangés.
- Tests : `tests/test_draco_urls_devprod.py` +4 — seul
`AGENT_EVENEMENTS_URL=…agent-dev…` posé → sœurs en `-dev` ;
explicite gagne sur la dérivation ; hôtes d'agent hors motif
→ défauts officiels ; agent officiel (suffixe vide) → sœurs
officielles. Non-régression « aucun env » déjà couverte.
## v7.82.2 (2026-10-25) — CI test entièrement verte : pages-supports de templates exclues des listes
Cause racine commune à 7 des 8 échecs restants : la refonte templates
(`fc85481`, 2026-10-10) a introduit les **pages-supports** — une page
interne par template système (99 sur une base fraîche), créée par
`TemplateService._create_holder` avec `search_excluded=1` et la consigne
« masquée, jamais listée ». Aucune liste ne respectait cette consigne :
datées `updated_at = now`, elles occupaient toutes les places des
listes triées par récence (LIMIT 50/20/8), devant les vraies pages.
- **Bibliothèque** (`app/routers/library.py`) : filtre `NO_HOLDER`
(`COALESCE(p.search_excluded, 0) = 0`) ajouté aux onglets meetings,
recents, favorites, shared, published, private et workspace.
- **Sidebar Recents** (`dashboard/_common.py::_recent_sidebar_pages`)
et **projets builtin** (`dashboard/workspace.py`) : même exclusion —
les pages-supports n'y sont plus ni des « fichiers récents » ni des
« projets ».
- **Mentions agent** (`routers/agent.py::list_mentions`) : les trois
scans de pages excluent les pages-supports, jamais mentionnables.
- **Flake `test_v55` (og parser)** : `mock_http`
(`tests/test_agent_web_tools.py`) répare désormais aussi la référence
`shared_client` figée par `og_fetcher` quand il est importé pendant
la fenêtre patchée (même classe de fuite que celle déjà traitée pour
`url_fetch`) — le handler d'un test `fetch_url` répondait sinon aux
fetchs OG des tests suivants du même worker xdist.
- **`test_editor_front_end_wired`** : depuis `fc85481`, le picker de
`base.html` passe par le registre unifié (`/api/templates`) et le
câblage `page-templates` vit dans l'éditeur
(`static/js/page_editor_scripts.js`) ; le marqueur est vérifié dans
le fichier qui le porte. Aucune assertion supprimée ni affaiblie.
## v7.82.1 (2026-10-25) — CI : lint ruff vert et littéral de version resynchronisé
- **Lint (CI run 2304, job 7058)** : les 16 erreurs `ruff check
app tests` sont corrigées dans le code, sans toucher à la
configuration ruff ni ajouter de `noqa` : import du routeur MCP
remonté en tête de `app/main.py` (E402/I001) ; `raise … from
None` / `from exc` sur les 8 `raise` dans des `except` de
`app/routers/api_v2/templates_io.py` (B904) ; `datetime.UTC`
dans `app/services/agent_evenements.py` (UP017) ; variables
mortes `void`/`uid` et import devenu inutilisé retirés de
`app/services/fd_templates.py` (F841) ; import `json`
inutilisé et variable `n_presets` morte retirés de
`tests/test_fd_templates.py` (F401/F841).
- **Test de version** : `test_pwa_offline` cherche le littéral
`version="…"` dans `app/main.py` — patron historique des bumps
(`scripts/bump_version.py` le cherche aussi), rompu en 7.80.2
quand l'argument FastAPI est passé à la lecture dynamique de
`VERSION`. Le littéral est reposé en commentaire de
synchronisation explicite (`version="7.82.1"`), à mettre à
jour à chaque bump ; la version runtime reste lue
dynamiquement depuis `VERSION` par `_lire_version()`.
- Aucun autre comportement modifié. Huit échecs de tests
pré-existants (antérieurs à la 7.82.0, prouvés par comparaison
des runs ciblés sur `eb4c42b` et `f844940`) restent ouverts et
ne sont pas corrigés par cette version.
## v7.82.0 (2026-10-25) — Noms publics dracodev par configuration (dev/prod) ## v7.82.0 (2026-10-25) — Noms publics dracodev par configuration (dev/prod)
- **Aucun nom public d'une autre application n'est plus codé en dur** - **Aucun nom public d'une autre application n'est plus codé en dur**
+1 -1
View File
@@ -1 +1 @@
7.82.0 7.82.4
+8 -2
View File
@@ -51,6 +51,7 @@ from app.routers.github_routes import router as github_router
from app.routers.governance import router as governance_router from app.routers.governance import router as governance_router
from app.routers.imports import page_router as import_page_router from app.routers.imports import page_router as import_page_router
from app.routers.imports import router as imports_router from app.routers.imports import router as imports_router
from app.routers.mcp_server import router as mcp_server_router
from app.routers.meetings import router as meetings_router from app.routers.meetings import router as meetings_router
from app.routers.notifications import router as notifications_router from app.routers.notifications import router as notifications_router
from app.routers.permissions import router as permissions_router from app.routers.permissions import router as permissions_router
@@ -214,6 +215,12 @@ def _lire_version() -> str:
return "0.0.0" return "0.0.0"
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.82.4"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
# (correctif 7.80.2) — ce littéral n'est qu'un repère de synchronisation.
app = FastAPI( app = FastAPI(
title="FlowDeck", title="FlowDeck",
version=_lire_version(), version=_lire_version(),
@@ -263,8 +270,7 @@ app.include_router(admin.router)
app.include_router(gitea_router) app.include_router(gitea_router)
app.include_router(github_router) app.include_router(github_router)
app.include_router(public_api.router) app.include_router(public_api.router)
from app.routers import mcp_server as _mcp_server # Fusion — serveur MCP (service d'agents) app.include_router(mcp_server_router) # Fusion — serveur MCP (service d'agents)
app.include_router(_mcp_server.router)
app.include_router(sharing.router) app.include_router(sharing.router)
app.include_router(sidebar_config.router) app.include_router(sidebar_config.router)
app.include_router(export.router) app.include_router(export.router)
+14 -4
View File
@@ -134,8 +134,9 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# service d'agents commun (premier parti, même programme) — # service d'agents commun (premier parti, même programme) —
# seul hôte externe nommé, rien d'autre ne s'ouvre. # seul hôte externe nommé, rien d'autre ne s'ouvre.
# v7.82.0 (dev/prod) : cet hôte n'est plus figé — {agent} est # v7.82.0 (dev/prod) : cet hôte n'est plus figé — {agent} est
# l'origine résolue par la config (DRACO_AGENT_URL ; défaut = # l'origine résolue par draco_urls (DRACO_AGENT_URL explicite,
# nom officiel, un dev déclare son agent-dev dans le .env). # sinon hôte de la requête — v7.82.4 —, sinon dérivation par
# l'agent connu en config ; défaut = nom officiel).
# Même origine pour frame-src : la console du service est # Même origine pour frame-src : la console du service est
# encadrée en iframe par le panneau (surimpression v3). # encadrée en iframe par le panneau (surimpression v3).
"connect-src 'self' {agent} ws://{host} wss://{host}; " "connect-src 'self' {agent} ws://{host} wss://{host}; "
@@ -152,6 +153,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# tâche créée maintenant → le contexte (donc le nonce) y est copié, # tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_nonce()`. # exactement ce que les templates liront via `csp_nonce()`.
CSP_NONCE.set(nonce) CSP_NONCE.set(nonce)
# v7.82.4 : même patron pour l'hôte public de la requête —
# `draco_urls` le lit pour résoudre l'environnement (dev/prod)
# de tout ce qui est rendu à cette requête (barre, iframe du
# panneau, SSO), et la CSP ci-dessous consomme la MÊME origine
# agent résolue. X-Forwarded-Host prime, comme
# `get_redirect_uri()` (app/routers/auth.py).
from app.services import draco_urls
draco_urls.definir_hote_requete(
request.headers.get("x-forwarded-host", "")
or request.headers.get("host", ""))
response = await call_next(request) response = await call_next(request)
# Only set CSP on HTML responses # Only set CSP on HTML responses
content_type = response.headers.get("content-type", "") content_type = response.headers.get("content-type", "")
@@ -160,8 +172,6 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# on retire quand même tout caractère hors base URL par sécurité. # on retire quand même tout caractère hors base URL par sécurité.
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "", host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
request.headers.get("host", "")) request.headers.get("host", ""))
from app.services import draco_urls
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "", agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
draco_urls.origine(draco_urls.agent_url())) draco_urls.origine(draco_urls.agent_url()))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format( response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
+5 -3
View File
@@ -838,6 +838,8 @@ def list_mentions(request: Request, q: str = ""):
Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans Les entrées identiques (même type + même titre, ex. plusieurs pages « Sans
titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois. titre ») sont **dédupliquées** pour ne pas afficher le même fichier plusieurs fois.
""" """
# Les scans de pages excluent les pages-supports de templates
# (search_excluded=1 — cf. library.NO_HOLDER) : jamais mentionnables.
query = (q or "").strip()[:60] query = (q or "").strip()[:60]
like = f"%{query}%" like = f"%{query}%"
limit = 8 limit = 8
@@ -873,7 +875,7 @@ def list_mentions(request: Request, q: str = ""):
if ws: if ws:
sql_ws = ( sql_ws = (
"SELECT p.id, p.title FROM pages p " "SELECT p.id, p.title FROM pages p "
"WHERE p.workspace_id=? AND p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.workspace_id=? AND p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?" "ORDER BY p.updated_at DESC LIMIT ?"
) )
files = [ files = [
@@ -922,7 +924,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name " "SELECT p.id, p.title, w.name AS ws_name "
"FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (p.workspace_id IS ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (p.workspace_id IS ? OR p.workspace_id IS NULL) "
"AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?", "AND (? = '' OR p.title LIKE ?) ORDER BY p.updated_at DESC LIMIT ?",
(ws, query, like, limit) (ws, query, like, limit)
).fetchall(): ).fetchall():
@@ -939,7 +941,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute( for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p " "SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"LEFT JOIN workspaces w ON w.id=p.workspace_id " "LEFT JOIN workspaces w ON w.id=p.workspace_id "
"WHERE p.deleted_at IS NULL AND (? = '' OR p.title LIKE ?) " "WHERE p.deleted_at IS NULL AND COALESCE(p.search_excluded, 0) = 0 AND (? = '' OR p.title LIKE ?) "
"ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit) "ORDER BY p.updated_at DESC LIMIT ?", (query, like, limit)
).fetchall(): ).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"], docs.append({"type": "document", "icon": "📄", "id": r["id"],
+8 -8
View File
@@ -235,9 +235,9 @@ def get_fd_template_v2(template_id: str, request: Request, authorization: str |
row = TemplateService.get_template(conn, _v2_actor(user), template_id) row = TemplateService.get_template(conn, _v2_actor(user), template_id)
return _row_to_dto(conn, row) return _row_to_dto(conn, row)
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
@router.post("/fd-templates/{template_id}/instantiate") @router.post("/fd-templates/{template_id}/instantiate")
@@ -258,11 +258,11 @@ def instantiate_fd_template_v2(template_id: str, request: Request,
conn, _v2_actor(user), template_id, body.get("variables") or {}, conn, _v2_actor(user), template_id, body.get("variables") or {},
body.get("context") or {}, idempotency_key=idem, run_source="api") body.get("context") or {}, idempotency_key=idem, run_source="api")
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
except ValueError as exc: except ValueError as exc:
raise HTTPException(400, str(exc)) raise HTTPException(400, str(exc)) from exc
audit_log(user, "template.instantiate", "template", template_id, "") audit_log(user, "template.instantiate", "template", template_id, "")
if idem: if idem:
store_idempotency(idem, user["id"], result, 200) store_idempotency(idem, user["id"], result, 200)
@@ -281,9 +281,9 @@ def export_fd_template_v2(template_id: str, request: Request, authorization: str
dto = _row_to_dto(conn, row) dto = _row_to_dto(conn, row)
blocks, _w = _read_holder_blocks(conn, row) blocks, _w = _read_holder_blocks(conn, row)
except LookupError: except LookupError:
raise HTTPException(404, "Template not found") raise HTTPException(404, "Template not found") from None
except PermissionError: except PermissionError:
raise HTTPException(403, "Forbidden") raise HTTPException(403, "Forbidden") from None
manifest = { manifest = {
"format": "flowdeck-template", "version": 1, "kind": dto["kind"], "format": "flowdeck-template", "version": 1, "kind": dto["kind"],
"name": dto["name"], "description": dto["description"], "icon": dto["icon"], "name": dto["name"], "description": dto["description"], "icon": dto["icon"],
@@ -308,7 +308,7 @@ def import_fd_template_v2(request: Request, authorization: str | None = Header(d
try: try:
version = int(body.get("version") or 0) version = int(body.get("version") or 0)
except (TypeError, ValueError): except (TypeError, ValueError):
raise HTTPException(400, "version invalide") raise HTTPException(400, "version invalide") from None
if version > 1: if version > 1:
raise HTTPException(400, "version de manifeste non supportée") raise HTTPException(400, "version de manifeste non supportée")
kind = body.get("kind") or "page" kind = body.get("kind") or "page"
+7 -5
View File
@@ -33,12 +33,14 @@ def get_redirect_uri(request: Request) -> str:
return f"{scheme}://{host}/auth/callback" return f"{scheme}://{host}/auth/callback"
def _sso_login_url() -> str: def _sso_login_url() -> str:
"""URL du bouton « Login with ObsiGate (SSO) », résolue par la config. """URL du bouton « Login with ObsiGate (SSO) ».
Émettrice = DRACO_SSO_URL (défaut : nom officiel) ; retour = Émettrice = ``obsigate_url()`` (DRACO_SSO_URL explicite, sinon
URL publique de cette instance (FLOWDECK_URL / OAUTH_REDIRECT_URI / l'environnement de la requête — v7.82.4) ; retour = URL publique
APP_BASE_URL, défaut : nom officiel) + ``/auth/draco``. Aucun nom de cette instance résolue de la même façon (FLOWDECK_URL /
public figé : un dev déclare ses noms en ``-dev`` dans le .env. OAUTH_REDIRECT_URI explicites, sinon hôte de la requête, le
callback = sa propre origine résolue) + ``/auth/draco``. Aucun
nom public figé.
""" """
from urllib.parse import quote from urllib.parse import quote
+1
View File
@@ -107,6 +107,7 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
FROM pages p FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id LEFT JOIN workspaces w ON w.id = p.workspace_id
WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash' WHERE p.deleted_at IS NULL AND p.parent_section != 'Trash'
AND COALESCE(p.search_excluded, 0) = 0
AND (w.owner_id = ? OR p.workspace_id IS NULL) AND (w.owner_id = ? OR p.workspace_id IS NULL)
AND REPLACE(COALESCE(p.content,''), ' ', '') AND REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%' NOT LIKE '%"type":"meeting"%'
+2 -1
View File
@@ -222,7 +222,8 @@ async def list_workspace_projects(request: Request):
builtin = [] builtin = []
with get_conn() as conn: with get_conn() as conn:
rows = conn.execute( rows = conn.execute(
"SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL ORDER BY updated_at DESC LIMIT 20" "SELECT id, title FROM pages WHERE parent_id IS NULL AND collection_row_id IS NULL "
"AND COALESCE(search_excluded, 0) = 0 ORDER BY updated_at DESC LIMIT 20"
).fetchall() ).fetchall()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne. # A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {} counts = {}
+14 -7
View File
@@ -210,6 +210,12 @@ BASE_SELECT = (
"p.parent_id, p.share_mode, p.parent_section, p.page_icon" "p.parent_id, p.share_mode, p.parent_section, p.page_icon"
) )
# Pages-supports de templates (fd_templates._create_holder) : pages internes
# marquées search_excluded=1 — « masquées, jamais listées ». Elles ne doivent
# apparaître dans AUCUN onglet de la bibliothèque (une base fraîche en sème 99,
# qui noieraient sinon recents/private/workspace sous leur updated_at = now).
NO_HOLDER = "COALESCE(p.search_excluded, 0) = 0"
# Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python). # Lignes contenant un bloc AI Meeting Notes (formats JSON navigateur/Python).
MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'" MEETING_BLOCK_LIKE = "REPLACE(COALESCE(p.content,''), ' ', '') LIKE '%\"type\":\"meeting\"%'"
@@ -240,7 +246,7 @@ def library_meetings(
" ORDER BY mt.id DESC LIMIT 1) AS meeting_summary " " ORDER BY mt.id DESC LIMIT 1) AS meeting_summary "
"FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id " "FROM pages p LEFT JOIN workspaces w ON w.id = p.workspace_id "
"WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) " "WHERE p.deleted_at IS NULL AND (w.owner_id = ? OR p.workspace_id IS NULL) "
f"AND {MEETING_BLOCK_LIKE}" f"AND {NO_HOLDER} AND {MEETING_BLOCK_LIKE}"
) )
params: list = [uid] params: list = [uid]
query, params = _apply_source_filter(query, params, source_type) query, params = _apply_source_filter(query, params, source_type)
@@ -275,7 +281,7 @@ def library_recents(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
# Hierarchical view: show only root-level pages at the top # Hierarchical view: show only root-level pages at the top
@@ -312,7 +318,8 @@ def library_favorites(
"SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, " "SELECT p.id, p.title, p.workspace, p.updated_at, p.content_format, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited " "p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"FROM favorites f JOIN pages p ON p.id = f.page_id " "FROM favorites f JOIN pages p ON p.id = f.page_id "
"WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" "WHERE f.user_id = ? AND p.parent_section != 'Trash' AND p.deleted_at IS NULL "
f"AND {NO_HOLDER}"
) )
params: list = [uid] params: list = [uid]
if tree: if tree:
@@ -379,7 +386,7 @@ def library_shared(
query = ( query = (
f"{BASE_SELECT} FROM pages p " f"{BASE_SELECT} FROM pages p "
f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" f"WHERE ({' OR '.join(conds)}) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
) )
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -410,7 +417,7 @@ def library_published(
uid = _get_user_id(request) uid = _get_user_id(request)
# v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin). # v7.69.6 : les deux drapeaux (lignes historiques issues d'un seul chemin).
query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL" query = f"{BASE_SELECT} FROM pages p WHERE (p.is_published = 1 OR p.published = 1) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -435,7 +442,7 @@ def library_private(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE p.parent_section = 'Private' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree:
query += " AND p.parent_id IS NULL" query += " AND p.parent_id IS NULL"
@@ -501,7 +508,7 @@ def library_workspace(
source_type = "all" source_type = "all"
uid = _get_user_id(request) uid = _get_user_id(request)
query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_MEETING_BLOCK}" query = f"{BASE_SELECT} FROM pages p WHERE (p.workspace != '' OR p.workspace_id IS NOT NULL) AND p.parent_section != 'Trash' AND p.deleted_at IS NULL AND {NO_HOLDER} AND {NO_MEETING_BLOCK}"
params: list = [] params: list = []
if tree: if tree:
+2 -2
View File
@@ -31,7 +31,7 @@ import logging
import os import os
import threading import threading
import uuid import uuid
from datetime import datetime, timezone from datetime import UTC, datetime
import httpx import httpx
@@ -86,7 +86,7 @@ def construire_enveloppe(type_: str, objet_ref: str, label: str,
env = { env = {
"id": uuid.uuid4().hex, "id": uuid.uuid4().hex,
"type": type_, "type": type_,
"occurred_at": datetime.now(timezone.utc).isoformat(), "occurred_at": datetime.now(UTC).isoformat(),
"actor": actor or "user", "actor": actor or "user",
"domain": domain or "pro", "domain": domain or "pro",
"source_app": SOURCE_APP, "source_app": SOURCE_APP,
+228 -7
View File
@@ -18,15 +18,77 @@ Priorité de résolution, à chaque appel (les tests et les modules qui
lisaient déjà ``os.environ`` au moment de l'appel gardent ce lisaient déjà ``os.environ`` au moment de l'appel gardent ce
comportement) : variable d'environnement > ``app.config.settings`` > comportement) : variable d'environnement > ``app.config.settings`` >
défaut officiel. Aucun défaut ne contient ``-dev``. défaut officiel. Aucun défaut ne contient ``-dev``.
**Dérivation d'environnement (v7.82.3)** — pour les quatre URL
sœurs (ObsiGate, Flowdeck, TikTak, Kanbante), entre la configuration
explicite et le défaut officiel : si l'origine de l'agent telle que
l'app la connaît déjà (``AGENT_EVENEMENTS_URL``, via
``agent_evenements_url()``) a un hôte de la forme
``agent<SUFFIXE>.dracodev.net`` (SUFFIXE éventuellement vide,
typiquement ``-dev``), l'URL sœur est dérivée :
``https://<slug><SUFFIXE>.dracodev.net`` (slugs : ``og``,
``flowdeck``, ``tiktak``, ``kanbante``). Ainsi une installation dev
qui ne déclare que l'agent pointe ses liens inter-apps en ``-dev``
sans quatre variables de plus. Si l'hôte de l'agent ne correspond
pas au motif (autre domaine, LAN, IP), **aucune dérivation** : on
ne fabrique jamais un lien deviné. L'URL Gitea ne change pas.
**Résolution par l'hôte de la requête (v7.82.4)** — la v7.82.3
laissait deux trous, prouvés en direct sur flowdeck-dev (CSP
``frame-src 'self' https://agent.dracodev.net`` alors que la prod
n'existe pas encore → console du panneau = page morte) :
1. ``agent_url()`` n'avait **aucune** dérivation : même avec
``AGENT_EVENEMENTS_URL`` en ``-dev``, l'agent lui-même retombait
sur le nom officiel ;
2. **aucune** résolution ne lisait l'hôte de la requête : un
``.env`` dev sans ``DRACO_AGENT_URL`` ni ``AGENT_EVENEMENTS_URL``
retombait entièrement sur la prod.
Pour tout ce qui est **rendu à une requête** (liens de la barre /
``DRACO_URLS`` servi, source de l'iframe du panneau, origine agent
des CSP ``frame-src``/``connect-src``, boutons et redirections
SSO), l'ordre de résolution devient :
1. variable explicite de l'installation — prioritaire, inchangée ;
2. **hôte public de la requête** (posé par le middleware CSP dans
``HOTE_REQUETE``, même patron que le nonce CSP) : motif fermé
``^(og|flowdeck|tiktak|kanbante|agent)(<SUFFIXE>)\\.dracodev\\.net$``
(suffixe optionnel, p. ex. ``-dev``, vide en prod) →
l'environnement = ce suffixe, appliqué à toutes les sœurs **et
à l'agent**. Jamais de suffixe inventé depuis un hôte arbitraire ;
hors motif (LAN, IP) → étape suivante ;
3. dérivation par les adresses de l'agent connues en configuration
(URL d'événements **ou** base du service) — aussi la résolution
des contextes **sans requête**, inchangée sinon ;
4. défaut officiel.
L'iframe du panneau et la CSP de la même réponse consomment la
même origine résolue : les deux lisent ``agent_url()`` pendant la
même requête, avec le même ``HOTE_REQUETE``.
""" """
from __future__ import annotations from __future__ import annotations
import os import os
import re
from contextvars import ContextVar
from urllib.parse import urlparse from urllib.parse import urlparse
from app.config import settings from app.config import settings
# Hôte public de la requête en cours (``X-Forwarded-Host`` sinon
# ``Host``), posé par le middleware CSP avant le traitement — même
# patron que ``CSP_NONCE`` : le contexte est copié dans la tâche qui
# rend les templates. Vide hors requête (tâches de fond, CLI, tests
# unitaires) → la résolution retombe sur la configuration.
HOTE_REQUETE: ContextVar[str] = ContextVar("draco_hote_requete", default="")
def definir_hote_requete(hote: str) -> None:
"""Mémorise l'hôte public de la requête (premier de la liste)."""
HOTE_REQUETE.set((hote or "").split(",")[0].strip().lower())
OFFICIELLES = { OFFICIELLES = {
"obsigate": "https://og.dracodev.net", "obsigate": "https://og.dracodev.net",
"agent": "https://agent.dracodev.net", "agent": "https://agent.dracodev.net",
@@ -51,22 +113,165 @@ def _resoudre(env_name: str, settings_attr: str, app: str) -> str:
return OFFICIELLES[app] return OFFICIELLES[app]
# Slugs d'hôte des applications sœurs (dérivation d'environnement).
_SLUGS_SOEURS = {
"obsigate": "og",
"flowdeck": "flowdeck",
"tiktak": "tiktak",
"kanbante": "kanbante",
}
# Hôte de l'agent : agent<SUFFIXE>.dracodev.net (SUFFIXE = ``-dev``…).
_MOTIF_HOTE_AGENT = re.compile(r"^agent(-[a-z0-9-]+)?\.dracodev\.net$")
# Hôte public d'une requête : motif FERMÉ des cinq applications.
# Le suffixe capturé (groupe 2, p. ex. ``-dev``) est l'environnement
# de TOUTE l'installation — jamais un suffixe inventé depuis un
# hôte arbitraire : hors motif (autre domaine, LAN, IP), None.
_MOTIF_HOTE_PUBLIC = re.compile(
r"^(og|flowdeck|tiktak|kanbante|agent)(-[a-z0-9-]+)?\.dracodev\.net$")
# Slugs pour la résolution par l'hôte de la requête (l'agent inclus).
_SLUGS_TOUTES = {**_SLUGS_SOEURS, "agent": "agent"}
def _suffixe_environnement() -> str | None:
"""Suffixe d'environnement porté par l'hôte de l'agent, ou None.
Source : l'origine de l'agent telle que l'app la connaît déjà —
``agent_evenements_url()`` (``AGENT_EVENEMENTS_URL`` explicite,
sinon dérivée de la base de l'agent). Retourne ``""`` pour
l'hôte officiel, ``"-dev"`` pour ``agent-dev.dracodev.net``, et
None si l'hôte ne correspond pas au motif (aucune dérivation).
"""
hote_agent = (urlparse(agent_evenements_url()).hostname or "").lower()
m = _MOTIF_HOTE_AGENT.match(hote_agent)
if not m:
return None
return m.group(1) or ""
def _url_derivee(app: str) -> str:
"""URL sœur dérivée de l'environnement de l'agent ("" si aucune)."""
slug = _SLUGS_SOEURS.get(app)
if not slug:
return ""
suffixe = _suffixe_environnement()
if suffixe is None:
return ""
return f"https://{slug}{suffixe}.dracodev.net"
def _suffixe_requete() -> str | None:
"""Suffixe d'environnement porté par l'hôte de la requête, ou None.
Motif fermé ``_MOTIF_HOTE_PUBLIC`` sur l'hôte public mémorisé
(port retiré) : ``""`` pour un nom officiel, ``"-dev"`` pour un
nom en ``-dev``, None hors motif ou hors requête.
"""
hote_req = HOTE_REQUETE.get().split(":")[0]
if not hote_req:
return None
m = _MOTIF_HOTE_PUBLIC.match(hote_req)
if not m:
return None
return m.group(2) or ""
def _url_depuis_requete(app: str) -> str:
"""URL d'``app`` dérivée de l'hôte de la requête ("" si aucune)."""
slug = _SLUGS_TOUTES.get(app)
if not slug:
return ""
suffixe = _suffixe_requete()
if suffixe is None:
return ""
return f"https://{slug}{suffixe}.dracodev.net"
def _suffixe_evenements_explicite() -> str | None:
"""Suffixe porté par l'URL d'événements **explicite**, ou None.
Lit ``AGENT_EVENEMENTS_URL`` (environnement puis settings) sans
passer par ``agent_evenements_url()`` : pas de récursion avec
``agent_url()``, dont c'est une source de dérivation.
"""
for candidate in (os.environ.get("AGENT_EVENEMENTS_URL", ""),
getattr(settings, "agent_evenements_url", "") or ""):
candidate = _nettoyer(candidate)
if candidate:
hote_agent = (urlparse(candidate).hostname or "").lower()
m = _MOTIF_HOTE_AGENT.match(hote_agent)
return (m.group(1) or "") if m else None
return None
def _resoudre_soeur(env_name: str, settings_attr: str, app: str) -> str:
"""Résolution d'une URL sœur, dans l'ordre du chantier :
explicite > **hôte de la requête** > dérivation par l'agent
connu en configuration > officiel.
La valeur ``settings`` égale au nom officiel est le *défaut* de
``app/config.py``, pas un choix explicite : elle ne doit pas
bloquer les dérivations. Une valeur settings différente (dev,
LAN, domaine propre) reste prioritaire.
"""
env = _nettoyer(os.environ.get(env_name, ""))
if env:
return env
cfg = _nettoyer(getattr(settings, settings_attr, "") or "")
if cfg and cfg != OFFICIELLES[app]:
return cfg
depuis_requete = _url_depuis_requete(app)
if depuis_requete:
return depuis_requete
derivee = _url_derivee(app)
if derivee:
return derivee
if cfg:
return cfg
return OFFICIELLES[app]
def obsigate_url() -> str: def obsigate_url() -> str:
"""ObsiGate — aussi l'émettrice SSO (DRACO_SSO_URL, déjà en service).""" """ObsiGate — aussi l'émettrice SSO (DRACO_SSO_URL, déjà en service)."""
return _resoudre("DRACO_SSO_URL", "draco_sso_url", "obsigate") return _resoudre_soeur("DRACO_SSO_URL", "draco_sso_url", "obsigate")
def agent_url() -> str: def agent_url() -> str:
"""Service d'agents (Spark) — base sans chemin.""" """Service d'agents (Spark) — base sans chemin.
return _resoudre("DRACO_AGENT_URL", "draco_agent_url", "agent")
Même ordre que les sœurs (la v7.82.3 l'avait laissé sans aucune
dérivation — trou n° 1 du mode console mort en dev) : explicite
(``DRACO_AGENT_URL`` / settings non officiel) > hôte de la
requête > dérivation par l'URL d'événements explicite
(``AGENT_EVENEMENTS_URL`` : ``agent-dev…`` → base ``agent-dev…``)
> défaut officiel.
"""
env = _nettoyer(os.environ.get("DRACO_AGENT_URL", ""))
if env:
return env
cfg = _nettoyer(getattr(settings, "draco_agent_url", "") or "")
if cfg and cfg != OFFICIELLES["agent"]:
return cfg
depuis_requete = _url_depuis_requete("agent")
if depuis_requete:
return depuis_requete
suffixe = _suffixe_evenements_explicite()
if suffixe is not None:
return f"https://agent{suffixe}.dracodev.net"
if cfg:
return cfg
return OFFICIELLES["agent"]
def tiktak_url() -> str: def tiktak_url() -> str:
return _resoudre("TIKTAK_URL", "tiktak_url", "tiktak") return _resoudre_soeur("TIKTAK_URL", "tiktak_url", "tiktak")
def kanbante_url() -> str: def kanbante_url() -> str:
return _resoudre("KANBANTE_URL", "kanbante_url", "kanbante") return _resoudre_soeur("KANBANTE_URL", "kanbante_url", "kanbante")
def gitea_url() -> str: def gitea_url() -> str:
@@ -78,7 +283,8 @@ def flowdeck_url() -> str:
FLOWDECK_URL > origine de OAUTH_REDIRECT_URI (déjà déclarée en FLOWDECK_URL > origine de OAUTH_REDIRECT_URI (déjà déclarée en
dev : ``https://flowdeck-dev.dracodev.net/auth/callback``) > dev : ``https://flowdeck-dev.dracodev.net/auth/callback``) >
APP_BASE_URL si elle n'est pas locale > nom officiel. hôte de la requête (motif fermé) > APP_BASE_URL si elle n'est
pas locale > dérivation par l'agent > nom officiel.
""" """
env = _nettoyer(os.environ.get("FLOWDECK_URL", "")) env = _nettoyer(os.environ.get("FLOWDECK_URL", ""))
if env: if env:
@@ -95,9 +301,19 @@ def flowdeck_url() -> str:
p = urlparse(candidate) p = urlparse(candidate)
if p.scheme and p.netloc: if p.scheme and p.netloc:
return f"{p.scheme}://{p.netloc}" return f"{p.scheme}://{p.netloc}"
# Étape 2 du chantier : l'hôte de la requête — c'est l'origine
# que le navigateur utilise déjà pour cette instance, donc
# aussi celle du callback SSO (``/auth/draco``) quand rien
# d'explicite n'est déclaré.
depuis_requete = _url_depuis_requete("flowdeck")
if depuis_requete:
return depuis_requete
base = _nettoyer(getattr(settings, "app_base_url", "") or "") base = _nettoyer(getattr(settings, "app_base_url", "") or "")
if base and "localhost" not in base and "127.0.0.1" not in base: if base and "localhost" not in base and "127.0.0.1" not in base:
return base return base
derivee = _url_derivee("flowdeck")
if derivee:
return derivee
return OFFICIELLES["flowdeck"] return OFFICIELLES["flowdeck"]
@@ -129,7 +345,12 @@ def hote(url: str) -> str:
def toutes() -> dict[str, str]: def toutes() -> dict[str, str]:
"""Toutes les URL résolues — injectées aux templates (hôte = config).""" """Toutes les URL résolues — injectées aux templates.
Résolution à l'appel : dans une requête, l'hôte public de la
requête fait foi après les variables explicites ; hors requête,
la configuration seule (voir le docstring du module).
"""
return { return {
"obsigate": obsigate_url(), "obsigate": obsigate_url(),
"agent": agent_url(), "agent": agent_url(),
-6
View File
@@ -714,7 +714,6 @@ class TemplateService:
cols = _cols(conn, "pages") cols = _cols(conn, "pages")
ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private" ws_name = (_workspace_name(conn, workspace_id) if workspace_id else "") or "Private"
try: try:
uid = _actor_id(actor)
data: dict[str, Any] = {} data: dict[str, Any] = {}
if "workspace" in cols: if "workspace" in cols:
data["workspace"] = ws_name data["workspace"] = ws_name
@@ -740,7 +739,6 @@ class TemplateService:
f"INSERT INTO pages ({keys}) VALUES ({placeholders})", f"INSERT INTO pages ({keys}) VALUES ({placeholders})",
tuple(data.values()), tuple(data.values()),
) )
void = uid # traçabilité future (réservé)
return int(cur.lastrowid) return int(cur.lastrowid)
except Exception: except Exception:
logger.exception("création page-support template") logger.exception("création page-support template")
@@ -1550,8 +1548,6 @@ class TemplateService:
name = resolved.get("__title__") or row["name"] name = resolved.get("__title__") or row["name"]
icon = row["icon"] or "📋" icon = row["icon"] or "📋"
description = row["description"] or "" description = row["description"] or ""
from app.services.db_templates import create_from_template
title = name title = name
uid = _actor_id(actor) uid = _actor_id(actor)
ccols = _cols(conn, "collections") ccols = _cols(conn, "collections")
@@ -1605,7 +1601,6 @@ class TemplateService:
) )
except Exception: except Exception:
logger.exception("seed row template base %s", row["id"]) logger.exception("seed row template base %s", row["id"])
void = create_from_template # compat : le helper historique reste l'API bas niveau
return collection_id return collection_id
@staticmethod @staticmethod
@@ -1614,7 +1609,6 @@ class TemplateService:
*, idempotency_key: str | None, run_source: str, *, idempotency_key: str | None, run_source: str,
) -> dict: ) -> dict:
"""Avant la migration 48 : instancie directement depuis les silos.""" """Avant la migration 48 : instancie directement depuis les silos."""
uid = _actor_id(actor)
if template_id.startswith("legacy_database:"): if template_id.startswith("legacy_database:"):
tid = int(template_id.split(":", 1)[1]) tid = int(template_id.split(":", 1)[1])
legacy = conn.execute( legacy = conn.execute(
+9
View File
@@ -24,6 +24,7 @@ import contextlib
import ipaddress import ipaddress
import json import json
import socket import socket
import sys
import httpx import httpx
import pytest import pytest
@@ -108,6 +109,14 @@ def mock_http(handler):
from app.services.importers import url_fetch from app.services.importers import url_fetch
if getattr(url_fetch, "shared_client", None) is build: if getattr(url_fetch, "shared_client", None) is build:
url_fetch.shared_client = _REAL_SHARED_CLIENT url_fetch.shared_client = _REAL_SHARED_CLIENT
# Même fuite via og_fetcher : url_fetch l'importe paresseusement
# (parse_og) PENDANT la fenêtre, et son `from … import shared_client`
# fige alors `build` — le handler du test courant répondait ensuite
# aux fetch_og_metadata des tests suivants du même worker xdist
# (flake test_v55 : titre « Titre de la page » au lieu du sien).
og_fetcher = sys.modules.get("app.services.og_fetcher")
if og_fetcher is not None and getattr(og_fetcher, "shared_client", None) is build:
og_fetcher.shared_client = _REAL_SHARED_CLIENT
def run(coro): def run(coro):
+248
View File
@@ -110,6 +110,72 @@ def test_preset_tiktak_depuis_config(monkeypatch):
"https://tiktak-dev.dracodev.net/mcp" "https://tiktak-dev.dracodev.net/mcp"
def test_derivation_dev_depuis_agent_evenements_seul(monkeypatch):
# Cas réel de la dev : seul AGENT_EVENEMENTS_URL est posé —
# les quatre URLs sœurs servies suivent en ``-dev``, sans
# déclarer quatre variables de plus. Gitea ne change pas.
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
# Les accesseurs individuels partagent la même résolution.
assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net"
assert draco_urls.flowdeck_url() == "https://flowdeck-dev.dracodev.net"
assert draco_urls.tiktak_url() == "https://tiktak-dev.dracodev.net"
assert draco_urls.kanbante_url() == "https://kanbante-dev.dracodev.net"
def test_derivation_explicite_gagne_sur_derivation(monkeypatch):
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
# Explicites (contrat inchangé) : elles priment sur la dérivation,
# y compris quand elles pointent le nom officiel.
monkeypatch.setenv("DRACO_SSO_URL", "https://og.dracodev.net")
monkeypatch.setenv("TIKTAK_URL", "https://tiktak.example.net")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["tiktak"] == "https://tiktak.example.net"
# Non déclarées : dérivées.
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
def test_derivation_hote_agent_hors_motif_defauts_officiels(monkeypatch):
# Autre domaine, LAN, IP : aucune dérivation, jamais un lien deviné.
for url_agent in (
"https://agent.example.com/api/v1/evenements",
"https://agent-dev.example.net/api/v1/evenements",
"https://monagent-dev.dracodev.net/api/v1/evenements",
"https://agent-dev.dracodev.net.evil.com/api/v1/evenements",
"http://192.168.30.101:8790/api/v1/evenements",
"http://127.0.0.1:8790/api/v1/evenements",
"http://agent-dev.local:8790/api/v1/evenements",
):
monkeypatch.setenv("AGENT_EVENEMENTS_URL", url_agent)
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net", url_agent
assert urls["flowdeck"] == "https://flowdeck.dracodev.net", url_agent
assert urls["tiktak"] == "https://tiktak.dracodev.net", url_agent
assert urls["kanbante"] == "https://kanbante.dracodev.net", url_agent
def test_derivation_agent_officiel_suffixe_vide(monkeypatch):
# Hôte agent officiel : suffixe vide, les sœurs restent officielles.
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL", "https://agent.dracodev.net/api/v1/evenements")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert urls["tiktak"] == "https://tiktak.dracodev.net"
assert urls["kanbante"] == "https://kanbante.dracodev.net"
def test_bouton_sso_login_depuis_config(monkeypatch): def test_bouton_sso_login_depuis_config(monkeypatch):
from app.routers.auth import _sso_login_url from app.routers.auth import _sso_login_url
@@ -122,3 +188,185 @@ def test_bouton_sso_login_depuis_config(monkeypatch):
assert _sso_login_url() == ( assert _sso_login_url() == (
"https://og-dev.dracodev.net/sso/authorize?return_to=" "https://og-dev.dracodev.net/sso/authorize?return_to="
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") "https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco")
# ── v7.82.4 — résolution par l'hôte de la requête ─────────────────────
# Correctif du mode console mort sur flowdeck-dev : l'agent n'avait
# aucune dérivation (v7.82.3) et rien ne lisait l'hôte de la requête.
# Ordre : explicite > hôte public de la requête (motif fermé) >
# dérivation par l'agent connu en configuration > officiel.
@pytest.fixture
def avec_hote():
"""Pose l'hôte de requête comme le middleware CSP le fait."""
jetons = []
def poser(hote):
jetons.append(draco_urls.HOTE_REQUETE.set(hote))
yield poser
for jeton in reversed(jetons):
draco_urls.HOTE_REQUETE.reset(jeton)
def test_agent_url_derivee_depuis_evenements_seul(monkeypatch):
# Trou n° 1 de la v7.82.3 : seul AGENT_EVENEMENTS_URL posé (cas
# réel de la dev) → l'agent lui-même suit en ``-dev``, pas
# seulement les sœurs.
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
assert draco_urls.agent_url() == "https://agent-dev.dracodev.net"
assert draco_urls.agent_evenements_url() == \
"https://agent-dev.dracodev.net/api/v1/evenements"
def test_hote_requete_dev_toutes_urls_sans_env(avec_hote):
# Aucune variable d'environnement (fixture _env_propre) : l'hôte
# public suffit — sœurs ET agent en ``-dev``, Gitea inchangée.
avec_hote("flowdeck-dev.dracodev.net")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["agent"] == "https://agent-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
assert urls["gitea"] == "https://git.dracodev.net"
def test_hote_requete_quelconque_des_cinq_slugs(avec_hote):
# L'environnement est celui de l'installation, pas de l'app
# demandée : un hôte ``agent-dev`` ou ``og-dev`` vaut ``-dev``
# pour toutes, port éventuel ignoré.
for hote in ("agent-dev.dracodev.net", "og-dev.dracodev.net:443",
"tiktak-dev.dracodev.net", "kanbante-dev.dracodev.net"):
avec_hote(hote)
assert draco_urls.agent_url() == "https://agent-dev.dracodev.net", hote
assert draco_urls.obsigate_url() == "https://og-dev.dracodev.net", hote
def test_hote_requete_prod_noms_officiels(avec_hote):
avec_hote("flowdeck.dracodev.net")
urls = draco_urls.toutes()
assert urls["obsigate"] == "https://og.dracodev.net"
assert urls["agent"] == "https://agent.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck.dracodev.net"
assert all("-dev" not in u for u in urls.values())
def test_hote_requete_motif_ferme_aucune_derivation(avec_hote):
# Jamais de suffixe inventé depuis un hôte arbitraire.
for hote in ("evil-dev.dracodev.net",
"flowdeck-dev.dracodev.net.evil.com",
"monflowdeck-dev.dracodev.net",
"flowdeckdev.dracodev.net",
"flowdeck-dev.example.net"):
avec_hote(hote)
urls = draco_urls.toutes()
assert urls["agent"] == "https://agent.dracodev.net", hote
assert urls["obsigate"] == "https://og.dracodev.net", hote
assert urls["flowdeck"] == "https://flowdeck.dracodev.net", hote
def test_hote_requete_lan_repli_configuration(avec_hote, monkeypatch):
# Hors motif (LAN, IP) : repli configuration/défauts, comme
# hors requête — ici la dérivation par AGENT_EVENEMENTS_URL.
avec_hote("192.168.30.101:8080")
urls = draco_urls.toutes()
assert urls["agent"] == "https://agent.dracodev.net"
assert urls["obsigate"] == "https://og.dracodev.net"
monkeypatch.setenv(
"AGENT_EVENEMENTS_URL",
"https://agent-dev.dracodev.net/api/v1/evenements")
urls = draco_urls.toutes()
assert urls["agent"] == "https://agent-dev.dracodev.net"
assert urls["obsigate"] == "https://og-dev.dracodev.net"
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
def test_hote_requete_explicite_gagne_sur_hote(avec_hote, monkeypatch):
avec_hote("flowdeck-dev.dracodev.net")
# Explicites prioritaires, y compris quand elles pointent le
# nom officiel ou un domaine propre.
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net")
monkeypatch.setenv("DRACO_SSO_URL", "https://og.example.net")
urls = draco_urls.toutes()
assert urls["agent"] == "https://agent.dracodev.net"
assert urls["obsigate"] == "https://og.example.net"
# Non déclarées : l'hôte de la requête fait foi.
assert urls["tiktak"] == "https://tiktak-dev.dracodev.net"
assert urls["kanbante"] == "https://kanbante-dev.dracodev.net"
assert urls["flowdeck"] == "https://flowdeck-dev.dracodev.net"
def test_hote_requete_bouton_sso_tout_dev(avec_hote):
from app.routers.auth import _sso_login_url
avec_hote("flowdeck-dev.dracodev.net")
# Émettrice via DRACO_SSO_URL résolu par l'hôte + callback =
# propre origine résolue de l'instance.
assert _sso_login_url() == (
"https://og-dev.dracodev.net/sso/authorize?return_to="
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco")
def test_http_dev_sans_env_tout_en_dev(client):
# Requête complète, aucune variable d'environnement : barre,
# panneau (config de l'iframe console), CSP et SSO en ``-dev``.
r = client.get("/workspaces",
headers={"Host": "flowdeck-dev.dracodev.net"})
assert r.status_code == 200
html = r.text
# Barre inter-apps (les quatre sœurs).
for soeur in ("og-dev", "flowdeck-dev", "tiktak-dev", "kanbante-dev"):
assert f'href="https://{soeur}.dracodev.net/"' in html
# Panneau : la source de l'iframe console vient de cette config.
assert 'agent_url: "https://agent-dev.dracodev.net"' in html
# CSP de la MÊME réponse : même origine agent résolue.
csp = r.headers.get("content-security-policy", "")
assert "frame-src 'self' https://agent-dev.dracodev.net" in csp
assert "connect-src 'self' https://agent-dev.dracodev.net" in csp
# Aucun nom officiel nu ne subsiste dans la réponse.
for officiel in ("https://og.dracodev.net", "https://agent.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://flowdeck.dracodev.net"):
assert officiel not in html.replace("-dev.dracodev.net", ""), officiel
assert officiel not in csp.replace("-dev.dracodev.net", ""), officiel
# Bouton SSO de la page de connexion, même requête type.
r = client.get("/auth/login?provider=local",
headers={"Host": "flowdeck-dev.dracodev.net"})
assert ("https://og-dev.dracodev.net/sso/authorize?return_to="
"https%3A%2F%2Fflowdeck-dev.dracodev.net%2Fauth%2Fdraco") in r.text
def test_http_prod_noms_officiels(client):
r = client.get("/workspaces",
headers={"Host": "flowdeck.dracodev.net"})
assert r.status_code == 200
assert 'agent_url: "https://agent.dracodev.net"' in r.text
assert 'href="https://og.dracodev.net/"' in r.text
csp = r.headers.get("content-security-policy", "")
assert "frame-src 'self' https://agent.dracodev.net" in csp
assert "-dev.dracodev.net" not in r.text
assert "-dev.dracodev.net" not in csp
def test_http_lan_repli_defauts(client):
r = client.get("/workspaces",
headers={"Host": "192.168.30.101:8080"})
assert r.status_code == 200
assert 'agent_url: "https://agent.dracodev.net"' in r.text
csp = r.headers.get("content-security-policy", "")
assert "frame-src 'self' https://agent.dracodev.net" in csp
def test_http_explicite_gagne_sur_hote(client, monkeypatch):
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent.dracodev.net")
r = client.get("/workspaces",
headers={"Host": "flowdeck-dev.dracodev.net"})
assert 'agent_url: "https://agent.dracodev.net"' in r.text
csp = r.headers.get("content-security-policy", "")
assert "frame-src 'self' https://agent.dracodev.net;" in csp
# Le reste suit l'hôte de la requête.
assert 'href="https://og-dev.dracodev.net/"' in r.text
+1 -3
View File
@@ -5,8 +5,6 @@ résolution des variables (aucun {{token}} survivant), dégradation sur
propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée, propriété renommée, défaut exclusif, idempotence, récurrence dédupliquée,
API interne et annulation (undo). API interne et annulation (undo).
""" """
import json
import pytest import pytest
from app.db import get_conn from app.db import get_conn
@@ -53,7 +51,7 @@ def test_backfill_idempotent_and_presets(client, actor):
"SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0] "SELECT COUNT(*) FROM templates WHERE source_kind='legacy_database'").fetchone()[0]
assert reg_db == legacy_db and legacy_db > 0 assert reg_db == legacy_db and legacy_db > 0
# Galerie système : au moins 5 presets utilisables. # Galerie système : au moins 5 presets utilisables.
n_presets = TemplateService.ensure_system_presets(conn) TemplateService.ensure_system_presets(conn)
sys_rows = conn.execute( sys_rows = conn.execute(
"SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0] "SELECT COUNT(*) FROM templates WHERE scope='system' AND deleted_at IS NULL").fetchone()[0]
assert sys_rows >= 5 assert sys_rows >= 5
+5 -1
View File
@@ -396,7 +396,11 @@ def test_editor_front_end_wired(client):
assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader assert "gtTok" in src and "fd-wiki-chip" in src # chip render + reader
assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution assert "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
assert "WM={" in src and "fdpage:" in src # [[ picker module assert "WM={" in src and "fdpage:" in src # [[ picker module
assert "_openTemplatePicker" in base and "page-templates" in base # Depuis la refonte templates (fc85481), le picker de base.html passe par
# le registre unifié (/api/templates) ; le câblage « page-templates » vit
# dans l'éditeur (fetch /board/api/page-templates) — chaque marqueur est
# donc vérifié dans le fichier qui le porte réellement.
assert "_openTemplatePicker" in base and "page-templates" in src
assert "toggleLock" in src and "saveAsTemplate" in src assert "toggleLock" in src and "saveAsTemplate" in src
css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8") css = pathlib.Path("static/css/app.css").read_text(encoding="utf-8")
assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css assert ".fd-wiki-chip" in css and ".fd-lock-banner" in css and ".tpl-picker-modal" in css