Compare commits

...
Author SHA1 Message Date
bruno ce2ac08dd3 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : test pytest chantier 5
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 1s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 13:51:31 -04:00
bruno b64efb8674 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : tests node 14
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 35m26s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:26 -04:00
bruno 2841847188 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : styles alertes vendanges
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:22 -04:00
bruno 8b84306f61 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : module alertes vendange ui/alertes
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:18 -04:00
bruno ba995ebe1c Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : css barre vendangee commons
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:13 -04:00
bruno 2b1f7808fd Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : barre vendangee commons
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:09 -04:00
bruno 8827b3dec8 Chantier 5 (fusion #69/#71) : alertes en onglets + onglet Console Spark : onglet agent
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:51:04 -04:00
bruno c2df8d8c00 Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:02:01 -04:00
bruno a930c8192e Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:01:56 -04:00
bruno 021d0a93f3 Bump 7.84.2 (programme Fusion #68/#69/#70)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 13:01:53 -04:00
bruno 40571049d6 Merge pull request 'Recherche partout (#68) : page de résultats unifiée + draco et 401 sur /api/search' (#31) from recherche-partout-68-rebase into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:55:32 -04:00
bruno 6eb7b78534 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:54:59 -04:00
bruno 31a84539f2 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 35m13s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:56 -04:00
bruno 7250ebc78f Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:53 -04:00
bruno 167e93e295 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:49 -04:00
bruno 30edf2f0c0 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m50s
2026-10-28 12:54:45 -04:00
bruno 67718d1d3c Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:41 -04:00
bruno 1dd148532b Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:37 -04:00
bruno bd9ecf69e1 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:54:34 -04:00
bruno 4b1df2d2b9 Recherche partout (#68, rebase sur main post-#70 et post-#69)
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m52s
2026-10-28 12:54:30 -04:00
bruno fe389d46bc Merge pull request 'Fusion #69 : Alertes unifiées (panneau + pastille, agrégation à la demande)' (#29) from alertes-unifiees-69 into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 3h8m46s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:40:49 -04:00
bruno eef46891de Merge pull request 'fusion #70 : retirer le bouton trois points, Console Spark au menu marque' (#28) from fusion-70-barre-console-spark into main
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:40:04 -04:00
bruno 7f9eb080b1 Fusion #69 : Alertes unifiees (tests/draco-alertes.test.js)
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:11:15 -04:00
bruno 9abd6c9d4d Fusion #69 : Alertes unifiees (tests/test_draco_alertes.py)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:10 -04:00
bruno fbe9151ab7 Fusion #69 : Alertes unifiees (static/css/draco-alertes.css)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:05 -04:00
bruno ab63000f5d Fusion #69 : Alertes unifiees (static/js/draco-alertes.js)
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m55s
2026-10-28 12:11:00 -04:00
bruno 52b62b48b8 Fusion #69 : Alertes unifiees (app/middleware/security.py)
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 16m42s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:10:55 -04:00
bruno acaae92e3a Fusion #69 : Alertes unifiees (app/main.py)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:10:51 -04:00
bruno 1065ffb017 Fusion #69 : Alertes unifiees (app/templates/_draco_bar.html)
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 1m57s
2026-10-28 12:10:47 -04:00
bruno 36c97e1553 fusion #70 : vendanger CSS sans menu trois points
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:02:21 -04:00
bruno c356a56648 fusion #70 : vendanger barre sans trois points, Console Spark, fermeture/focus corriges
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:02:17 -04:00
bruno c2a222fc3b fusion #70 : retirer le bouton trois points et la branche menu morte
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:02:14 -04:00
bruno e4be8578ff Bump 7.84.1 (CHANGELOG.md)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:49 -04:00
bruno e733f3309d Bump 7.84.1 (app/main.py)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:47 -04:00
bruno 371908ed1d Bump 7.84.1 (VERSION)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:44 -04:00
bruno da205feec1 Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, menu •••, avatar compte)' (#27) from barre-apps-v0.1.6-flowdeck into main
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 2m8s
2026-10-28 10:34:18 -04:00
bruno c582856051 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 10:31:45 -04:00
bruno ecafeecda0 barre-apps v0.1.6 : JS vendange (derivation -dev, menus)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:31:41 -04:00
bruno 7a43ca00ad barre-apps v0.1.6 : fragment (avatar bouton compte, menu par defaut demande, nonce CSP inchange)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:31:36 -04:00
bruno e5e98ed08f Merge pull request '[Coffre Secrets] Connecteurs par reference ObsiGate, isolation workspace (Phase 4)' (#26) from feat/coffre-reference-obsigate into main
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 00:15:17 -04:00
bruno 62b536c865 coffre ObsiGate Phase 4 (issue #22) : docs/coffre-reference-flowdeck.md
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Successful in 11m19s
FlowDeck CI / docker (push) Failing after 2m5s
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-27 23:48:35 -04:00
bruno a3431b18c9 coffre ObsiGate Phase 4 (issue #22) : tests/test_coffre_reference_flowdeck.py
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:31 -04:00
bruno 45940c26bb coffre ObsiGate Phase 4 (issue #22) : .env.example
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:26 -04:00
bruno f827e9fc7d coffre ObsiGate Phase 4 (issue #22) : app/routers/agent.py
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:20 -04:00
bruno cd0835116d coffre ObsiGate Phase 4 (issue #22) : app/config.py
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 1m56s
2026-10-27 23:48:13 -04:00
bruno b751c81120 coffre ObsiGate Phase 4 (issue #22) : app/migrations.py
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:09 -04:00
bruno 30387c4ed4 coffre ObsiGate Phase 4 (issue #22) : app/services/mcp_client.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:03 -04:00
bruno 4f13068c85 coffre ObsiGate Phase 4 (issue #22) : app/services/connectors.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:58 -04:00
bruno 417c0a23d7 coffre ObsiGate Phase 4 (issue #22) : app/services/coffre_reference.py
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Failing after 3h10m16s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:54 -04:00
Spark 913d6137b1 feat(fusion): format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), contrat panneau (ACC-4.4) — 7.84.0
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-26 16:19:16 -04:00
Spark eff459ddd7 feat(draco): version du service d'agents visible dans le panneau Spark, mode sidebar (7.83.1) — fusion #64
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / docker (push) Canceled after 0s
FlowDeck CI / test (push) Canceled after 2h58m44s
2026-10-26 16:47:37 +00:00
Spark 19b27698f2 feat(fusion): serveur MCP complété (ACC-5.1) + pont MCP ObsiGate (ACC-2.2) — 7.83.0
FlowDeck CI / test (push) Failing after 3h11m23s
FlowDeck CI / lint (push) Failing after 5s
FlowDeck CI / docker (push) Skipped
ACC-5.1 (#24) — app/routers/mcp_server.py : catalogue §7.2 complété
(list_projects, get_project, read_page tronqué proprement,
list_project_tasks), search_pages en plein texte, liens profonds +
kind sur chaque résultat, scopes du jeton appliqués (écriture
refusée -32002 sans scope write) et journalisation §7.4 de chaque
tools/call dans api_audit_log (clés d'arguments seulement).

ACC-2.2 (#20) — pont MCP Flowdeck → ObsiGate, côté client :
- mcp_client : serveurs à session (capture/réémission de
  Mcp-Session-Id, handshake avant tools/call), décodage SSE
  multi-messages par id de réponse, classification des outils à la
  découverte (readOnlyHint sinon heuristique ; lecture par défaut).
- preset connecteur « obsigate » (URL dérivée dev/prod), kinds MCP
  factorisés (connectors.MCP_KINDS), option dans le panneau agent.
- garde-fou n° 2 : permission_manager.is_write_tool couvre les
  outils MCP écrivants → portes politique d'approbation
  (agent_engine) et rôles (assert_can) ; McpTool bloque tout
  apply_* distant (patron §7.5).

Tests : test_fusion_mcp_server 18, nouveau test_fusion_pont_obsigate
7 (faux serveur ObsiGate à session stricte via le vrai _rpc).
Preuve réelle T0-T5 : attend la clé ObsiGate dédiée (1 vault) et
Q-A/Q-B côté Bruno.
2026-10-26 12:38:04 -04:00
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
55 changed files with 6207 additions and 190 deletions
+7
View File
@@ -28,6 +28,13 @@ OAUTH_REDIRECT_URI=
# par release (passé par environment:/env_file: au conteneur).
# Laisser vide = défaut officiel indiqué en commentaire.
DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue ObsiGate #22) ──
# OBSIGATE_COFFRE_URL vide = dérivée de DRACO_SSO_URL (même hôte).
# OBSIGATE_CONSUMER_TOKEN = jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois) — distinct du jeton MCP de notes et de la session SSO.
# Ne jamais le copier dans un log, un rapport ou une réponse API.
OBSIGATE_COFFRE_URL=
OBSIGATE_CONSUMER_TOKEN=
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
+205
View File
@@ -1,5 +1,210 @@
## v7.84.2 (2026-10-28) — Programme Fusion : menu barre, alertes unifiées, recherche partout
- **Barre (#70)** : bouton « ••• » retiré (menu par défaut supprimé), « Console Spark » ajoutée au menu de la marque, fermeture/focus corrigés.
- **Alertes unifiées (#69)** : panneau actives puis historique, par application, avec pastille ; agrégation à la demande, pas une fédération temps réel ; CSP/CORS bornées à `/api/notifications`.
- **Rechercher partout (#68)** : page `/recherche` unifiée par application, liens profonds dans l'environnement courant (dérivation `-dev` respectée), draco + 401 sur `/api/search` (corrige la recherche sans périmètre sans session) ; la palette de la barre ouvre désormais `/recherche`.
# Changelog - FlowDeck
## v7.84.1 (2026-10-28) — Barre d'apps v0.1.6 vendangée (dérivation -dev, menu •••, compte)
- **Barre d'apps** : vendange de `dracodev-commons` v0.1.6 — dérivation `-dev` par l'hôte courant, menu « ••• » par défaut, menu compte fonctionnel, spark sans dérivation inventée.
## v7.84.0 (2026-10-26) — Fusion : format commun des skills (ACC-4.5), pilote réunion → ObsiGate (ACC-6.2), panneau commun (ACC-4.4)
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
contrat figé ACC-0.3 de `dracodev-commons`). Lecture et validation
d'un `SKILL.md` au format commun (frontmatter YAML : `name`,
`description`-déclencheur, `version`, `apps`, `domaines`, `outils`,
`ecritures` obligatoire dès qu'un outil modifie ; corps en trois
sections ordonnées), conversion vers les champs Flowdeck
(installable via `skill_gallery.upsert_skill`) et conversion
inverse depuis un preset de la galerie. **Preuve du format** :
trois skills de la galerie convertis dans `skills-communs/` —
`compte-rendu-reunion`, `rapport-hebdo`, `resume-document`.
- **ACC-6.2 — pilote d'écriture approuvée** (`app/services/meeting_archive.py`).
Nouvelle route `POST /api/v2/meetings/transcripts/{id}/archiver-obsigate` :
prépare l'archive du résumé de réunion (chemin `Réunions/<date> - <titre>.md`,
contenu Markdown avec lien profond vers la note) et la dépose dans
la **file d'approbations unifiée** (`agent_approvals`, outil
`archiver_reunion_obsigate`) — rien n'est écrit avant approbation.
À la décision favorable (route gouvernance), `propose_write_file`
part vers ObsiGate par le pont MCP (ACC-2.2) et la distribution est
journalisée (nouveau canal `obsigate` de `meeting_distributions`).
Sans connecteur ObsiGate configuré, l'exécution échoue avec un
message explicite. Pilote : usage réel conditionnel au chantier
Meetings (décision A/B) et à la clé ObsiGate dédiée au pont.
- **ACC-4.4 — panneau de l'agent commun** : intégré depuis la 7.81.0
(panneau v3, conversation commune du service d'agents), complété
par le mode console (7.82.x) et la version du service en sidebar
(7.83.1). Cette version fige l'intégration par des tests de
contrat (`tests/test_fusion_panneau_commun.py`) : assets chargés,
configuration injectée par l'hôte (aucune URL publique codée en
dur), conversation du service — et automatisations locales
conservées (décision C5).
## v7.83.1 (2026-10-26) — Panneau Spark : version du service d'agents visible en mode sidebar
- **Panneau Spark** (`static/js/draco-panneau.js`) : le pied du panneau
affiche désormais la ligne discrète et permanente « Service d'agents
vX », comme la console pleine page. La version est lue **en direct**
dans la santé du service (`GET /healthz` de l'origine que le panneau
connaît déjà), à l'ouverture du panneau, avec cache de session —
jamais une valeur codée en dur ; service injoignable = rien
d'affiché, aucune erreur visible. Carte fusion #64.
## v7.83.0 (2026-10-26) — Fusion : serveur MCP complété (ACC-5.1) et pont MCP vers ObsiGate (ACC-2.2)
- **ACC-5.1 — serveur MCP Flowdeck complété** (`app/routers/mcp_server.py`,
spec d'accès centralisé §7.2/§7.4). Le catalogue de lecture gagne
`list_projects`, `get_project` (métadonnées + schéma + titres seuls),
`read_page` (page classique ou ligne de collection, contenu tronqué
proprement à 20 000 caractères) et `list_project_tasks` (tâches d'une
base projet, tous assignés). `search_pages` devient une vraie
recherche **plein texte** (titre + contenu des pages, titre +
propriétés des lignes de collection), et chaque résultat porte son
**lien profond** (`url`) et son `kind` — contrat figé : un projet
Flowdeck est une collection. §7.4 tenu : les **scopes du jeton**
sont appliqués (un jeton `read` se voit refuser les outils
d'écriture, erreur -32002) et **chaque appel `tools/call` est
journalisé** dans `api_audit_log` (outil, jeton, statut — jamais les
valeurs d'arguments). `list_meetings`/`get_meeting` restent hors
catalogue tant que la décision Meetings A/B n'est pas tranchée.
- **ACC-2.2 — pont MCP Flowdeck → ObsiGate, côté client**
(`app/services/mcp_client.py`, plan « étape 1 : lectures seules »).
Le client MCP gère désormais les serveurs **à session** (Streamable
HTTP d'ObsiGate) : capture et réémission de l'en-tête
`Mcp-Session-Id`, handshake complet avant `tools/call`, et décodage
SSE **multi-messages** (le message de réponse est sélectionné, pas
le premier `data:`). Nouveau preset de connecteur **« obsigate »**
(URL dérivée de la configuration dev/prod, clé dédiée au pont créée
dans ObsiGate → Configurations → Clés API & MCP).
- **Garde-fou n° 2 du plan du pont** : les outils MCP sont **classés**
à la découverte (annotation `readOnlyHint` du serveur, sinon
heuristique de nom ; lecture par défaut). Un outil MCP écrivant
(`propose_*`, `create_*`…) passe désormais par les mêmes portes que
les outils internes — politique d'approbation (`agent_policies`) et
rôles (`PermissionManager`, via le nouvel `is_write_tool`) — là où
il passait avant pour une lecture. Et un outil distant **`apply_*`
est bloqué côté client** : une écriture croisée ne s'exécute jamais
par un simple appel d'outil ; elle suit le patron §7.5 (propose →
approbation dans l'application propriétaire).
- **Preuve réelle T0–T5** : le code est testé contre un faux serveur
ObsiGate à session stricte (`tests/test_fusion_pont_obsigate.py`,
7 tests ; `tests/test_fusion_mcp_server.py` porté à 18 tests). La
démonstration contre l'ObsiGate réel attend encore deux gestes de
Bruno : créer la clé API ObsiGate dédiée au pont, **limitée à un
seul vault**, et trancher **Q-A/Q-B** (fusion-trois-apps #1).
## 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
+1 -1
View File
@@ -1 +1 @@
7.82.1
7.84.2
+7
View File
@@ -158,6 +158,13 @@ class Settings(BaseSettings):
# KANBANTE_URL=https://kanbante-dev.dracodev.net
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue #22) ──
# URL de distribution : vide = dérivée de draco_sso_url (même hôte).
# Jeton : le jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois), distinct du jeton MCP de notes et de la session SSO.
# Jamais en clair dans un log, un rapport ou une réponse API.
obsigate_coffre_url: str = ""
obsigate_consumer_token: str = ""
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
tiktak_url: str = "https://tiktak.dracodev.net"
kanbante_url: str = "https://kanbante.dracodev.net"
+20 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.82.1"
# version="7.84.2"
# 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()
@@ -239,6 +239,18 @@ app.add_middleware(RateLimitMiddleware)
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Fusion #69 : le panneau Alertes unifiées lit /api/notifications
# depuis les hôtes soeurs, avec credentials — leurs origines doivent
# donc figurer dans allow_origins (résolution draco_urls : explicite
# > hôte de la requête > dérivation > défaut officiel ; hors requête,
# la configuration seule). Lecture seule (GET) ; les POST restent
# soumis au CSRFMiddleware.
from app.services import draco_urls as _draco_urls69
_toutes69 = _draco_urls69.toutes()
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
_draco_urls69.origine(_toutes69[k])
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""})
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
@@ -250,6 +262,13 @@ app.add_middleware(
allow_credentials=True,
)
# Recherche partout (#68) : CORS bornée pour GET /api/search — ajoutée
# après CORSMiddleware donc exécutée avant lui (dernier ajouté = le
# plus externe) : les preflights OPTIONS sont répondus ici, avant
# tout refus d'origine. La politique CORS globale est inchangée.
from app.middleware.recherche_cors import RechercheCorsMiddleware
app.add_middleware(RechercheCorsMiddleware)
app.include_router(auth.router)
app.include_router(sso_router)
app.include_router(dashboard.router)
+190
View File
@@ -0,0 +1,190 @@
"""CORS bornée pour la recherche fédérée (fusion-trois-apps #68).
La page « Rechercher partout » (``/recherche``) appelle, depuis le
navigateur, ``GET /api/search`` sur chaque application sœur. Sans
en-têtes CORS, le navigateur refuse la lecture des réponses
cross-origin ; sans borne, on ouvrirait la lecture à tout le web.
Ce module n'autorise donc que :
- le chemin ``/api/search`` (jamais les autres routes, jamais les
écritures) ;
- les origines sœurs : celles résolues par
``app.services.draco_urls.toutes()`` (explicite > hôte > dérivation
> défaut officiel), **plus** les origines prod ou ``-dev``
officielles correspondant à l'hôte de la requête — ce second
ensemble est nécessaire car ce middleware s'exécute avant le
middleware CSP qui pose le contexte d'hôte de la requête pour
``draco_urls`` ; sans lui, une page servie par ``flowdeck-dev``
verrait ses sœurs ``-dev`` refusées quand la configuration seule
ne les déclare pas ;
- avec credentials (le cookie SSO ``draco_session``, accepté par
``/api/search`` depuis cette livraison, ou la session native).
Jamais ``*``. Le middleware est un ASGI pur : il répond lui-même
aux preflights OPTIONS (qui ne portent jamais de cookies) et ajoute
les en-têtes aux réponses de recherche, sans toucher aux autres
routes ni à la politique CORS globale de l'application.
"""
from __future__ import annotations
from urllib.parse import urlparse
SEARCH_PATH = "/api/search"
PROD_ORIGINS = frozenset({
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
})
DEV_ORIGINS = frozenset({
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
})
# Clés de draco_urls.toutes() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
def is_dev_host(hostname: str | None) -> bool:
"""Même règle que la barre v0.1.6 (voir static/js/recherche.js)."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 4 and first.endswith("-dev")
def is_prod_host(hostname: str | None) -> bool:
"""Hôte dracodev.net qui n'est ni ``-dev`` ni un piège de suffixe."""
if not hostname:
return False
h = hostname.lower().split(":")[0]
if not h.endswith(".dracodev.net"):
return False
first = h.split(".")[0]
return len(first) > 0 and not first.endswith("-dev")
def origin_of(url: str | None) -> str | None:
"""Origine (schéma + hôte) d'une URL, sans chemin ni ``/`` final."""
if not url:
return None
parsed = urlparse(url.strip())
if parsed.scheme not in ("http", "https") or not parsed.hostname:
return None
host = parsed.hostname.lower()
port = f":{parsed.port}" if parsed.port else ""
return f"{parsed.scheme}://{host}{port}"
def sister_origins(resolved: dict | None = None, request_host: str | None = None) -> frozenset:
"""Origines autorisées pour la lecture cross-origin de /api/search.
``resolved`` : dictionnaire rendu par ``draco_urls.toutes()``.
Sans argument, le résolveur est appelé (import paresseux) ; en cas
d'échec, repli sur prod + dev officiels (premier parti uniquement).
``request_host`` ajoute l'ensemble officiel de l'environnement de
la requête (voir le docstring du module).
"""
origins = set()
if resolved is None:
try:
from app.services import draco_urls
resolved = draco_urls.toutes()
except Exception:
resolved = {}
for key in _SOEUR_KEYS:
origin = origin_of((resolved or {}).get(key))
if origin:
origins.add(origin)
if is_dev_host(request_host):
origins |= DEV_ORIGINS
elif is_prod_host(request_host):
origins |= PROD_ORIGINS
if not origins:
return PROD_ORIGINS | DEV_ORIGINS
return frozenset(origins)
def cors_headers_for(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes CORS d'une réponse de recherche, ou None si refusée."""
if not origin:
return None
if origin.strip() not in sister_origins(resolved, request_host):
return None
return {
"Access-Control-Allow-Origin": origin.strip(),
"Access-Control-Allow-Credentials": "true",
"Vary": "Origin",
}
def preflight_headers(
origin: str | None, resolved: dict | None = None, request_host: str | None = None
) -> dict | None:
"""En-têtes d'une réponse de preflight OPTIONS pour /api/search."""
headers = cors_headers_for(origin, resolved, request_host)
if headers is None:
return None
headers.update({
"Access-Control-Allow-Methods": "GET, OPTIONS",
"Access-Control-Allow-Headers": "Accept, Content-Type, Authorization",
"Access-Control-Max-Age": "600",
})
return headers
class RechercheCorsMiddleware:
"""ASGI pur : CORS bornée pour ``GET /api/search`` (voir module)."""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http":
await self.app(scope, receive, send)
return
path = scope.get("path", "")
headers_in = {
k.decode("latin-1").lower(): v.decode("latin-1")
for k, v in scope.get("headers", [])
}
origin = headers_in.get("origin", "")
request_host = (
headers_in.get("x-forwarded-host", "") or headers_in.get("host", "")
).split(",")[0].strip()
if path == SEARCH_PATH and scope.get("method") == "OPTIONS":
preflight = preflight_headers(origin, request_host=request_host)
if preflight is not None:
headers_out = [(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in preflight.items()]
headers_out.append((b"content-length", b"0"))
await send({"type": "http.response.start", "status": 204,
"headers": headers_out})
await send({"type": "http.response.body", "body": b""})
return
if path != SEARCH_PATH:
await self.app(scope, receive, send)
return
async def send_with_cors(message):
if message["type"] == "http.response.start":
cors = cors_headers_for(origin, request_host=request_host)
if cors is not None:
message["headers"] = list(message.get("headers", [])) + [
(k.lower().encode("latin-1"), v.encode("latin-1"))
for k, v in cors.items()
]
await send(message)
await self.app(scope, receive, send_with_cors)
+25 -6
View File
@@ -134,11 +134,12 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# service d'agents commun (premier parti, même programme) —
# 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
# l'origine résolue par la config (DRACO_AGENT_URL ; défaut =
# nom officiel, un dev déclare son agent-dev dans le .env).
# l'origine résolue par draco_urls (DRACO_AGENT_URL explicite,
# 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
# encadrée en iframe par le panneau (surimpression v3).
"connect-src 'self' {agent} ws://{host} wss://{host}; "
"connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; "
"media-src 'self' blob:; "
"frame-src 'self' {agent}; "
"object-src 'none'; "
@@ -152,6 +153,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# tâche créée maintenant → le contexte (donc le nonce) y est copié,
# exactement ce que les templates liront via `csp_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)
# Only set CSP on HTML responses
content_type = response.headers.get("content-type", "")
@@ -160,12 +172,19 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
# on retire quand même tout caractère hors base URL par sécurité.
host = re.sub(r"[^0-9A-Za-z.\-:\[\]]", "",
request.headers.get("host", ""))
from app.services import draco_urls
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
draco_urls.origine(draco_urls.agent_url()))
# Fusion #69 : le panneau Alertes unifiées lit les API
# notifications des applications soeurs depuis cet hôte ;
# leurs origines rejoignent connect-src (même résolution
# draco_urls que les liens de la barre et frame-src).
_toutes69 = draco_urls.toutes()
sisters = " ".join(sorted({
draco_urls.origine(_toutes69[k])
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""}))
response.headers[self.CSP_HEADER] = self.CSP_VALUE.format(
nonce=nonce, host=host, agent=agent
nonce=nonce, host=host, agent=agent, sisters=sisters
)
return response
+28
View File
@@ -2076,3 +2076,31 @@ def _migration_draco_links(conn: sqlite3.Connection) -> None:
)
"""
)
@register(52, "coffre ObsiGate (Phase 4) : workspace_id + coffre_reference sur agent_connectors")
def _migration_coffre_reference(conn: sqlite3.Connection) -> None:
"""Migration 52 — références ObsiGate pour les connecteurs (issue #22).
* ``workspace_id`` : le connecteur appartient à un workspace (NULL =
connecteur historique global, conservé tel quel — migration
opportuniste, pas de big-bang).
* ``coffre_reference`` : ``coffre:<slug>/<nom>`` (métadonnée, pas une
valeur) ; vide = jeton chiffré local historique.
Idempotente (gardée par ``columns()``), comme les migrations 32/34.
"""
cols = columns(conn, "agent_connectors")
if "workspace_id" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)"
)
if "coffre_reference" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN coffre_reference TEXT NOT NULL DEFAULT ''"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_connectors_workspace "
"ON agent_connectors(workspace_id)"
)
+15 -4
View File
@@ -673,6 +673,8 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"),
workspace_id=body.get("workspace_id"),
coffre_reference=body.get("coffre_reference"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -682,11 +684,17 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
_current_user_id(request)
try:
kwargs = {}
if "workspace_id" in body:
kwargs["workspace_id"] = body.get("workspace_id")
if "coffre_reference" in body:
kwargs["coffre_reference"] = body.get("coffre_reference")
row = connectors.update_connector(
connector_id,
name=body.get("name"),
enabled=body.get("enabled"),
secret=body.get("secret"),
**kwargs,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -711,7 +719,8 @@ async def probe_connectors_route(request: Request, body: dict = Body(default={})
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target, user_id)
return await connectors.probe(target, user_id,
workspace_id=body.get("workspace_id"))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
@@ -838,6 +847,8 @@ def list_mentions(request: Request, q: str = ""):
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.
"""
# Les scans de pages excluent les pages-supports de templates
# (search_excluded=1 — cf. library.NO_HOLDER) : jamais mentionnables.
query = (q or "").strip()[:60]
like = f"%{query}%"
limit = 8
@@ -873,7 +884,7 @@ def list_mentions(request: Request, q: str = ""):
if ws:
sql_ws = (
"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 ?"
)
files = [
@@ -922,7 +933,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name "
"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 ?",
(ws, query, like, limit)
).fetchall():
@@ -939,7 +950,7 @@ def list_mentions(request: Request, q: str = ""):
for r in conn.execute(
"SELECT p.id, p.title, w.name AS ws_name FROM pages p "
"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)
).fetchall():
docs.append({"type": "document", "icon": "📄", "id": r["id"],
+7 -5
View File
@@ -33,12 +33,14 @@ def get_redirect_uri(request: Request) -> str:
return f"{scheme}://{host}/auth/callback"
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 =
URL publique de cette instance (FLOWDECK_URL / OAUTH_REDIRECT_URI /
APP_BASE_URL, défaut : nom officiel) + ``/auth/draco``. Aucun nom
public figé : un dev déclare ses noms en ``-dev`` dans le .env.
Émettrice = ``obsigate_url()`` (DRACO_SSO_URL explicite, sinon
l'environnement de la requête — v7.82.4) ; retour = URL publique
de cette instance résolue de la même façon (FLOWDECK_URL /
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
+1
View File
@@ -107,6 +107,7 @@ def _recent_sidebar_pages(uid: int, limit: int = 8) -> list:
FROM pages p
LEFT JOIN workspaces w ON w.id = p.workspace_id
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 REPLACE(COALESCE(p.content,''), ' ', '')
NOT LIKE '%"type":"meeting"%'
+2 -1
View File
@@ -222,7 +222,8 @@ async def list_workspace_projects(request: Request):
builtin = []
with get_conn() as conn:
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()
# A23 : une seule agrégation GROUP BY au lieu d'un COUNT par ligne.
counts = {}
+9
View File
@@ -87,4 +87,13 @@ def decide_approval(approval_id: int, request: Request, body: dict = Body(defaul
raise HTTPException(404, "Pending approval not found")
audit_log(user, "agent.approval.decide", "agent_approval", approval_id,
out["status"], request)
# Pilote ACC-6.2 : une demande d'archivage de réunion approuvée
# s'exécute ici (proposition d'écriture vers ObsiGate) ; le
# résultat accompagne la réponse, sans changer le contrat de la
# file pour les autres outils.
from app.services import meeting_archive
archive = meeting_archive.traiter_decision_sync(out)
if archive is not None:
return {**out, "archive": archive}
return out
+14 -7
View File
@@ -210,6 +210,12 @@ BASE_SELECT = (
"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).
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 "
"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) "
f"AND {MEETING_BLOCK_LIKE}"
f"AND {NO_HOLDER} AND {MEETING_BLOCK_LIKE}"
)
params: list = [uid]
query, params = _apply_source_filter(query, params, source_type)
@@ -275,7 +281,7 @@ def library_recents(
source_type = "all"
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 = []
# 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, "
"p.parent_id, p.share_mode, p.parent_section, 1 as favorited "
"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]
if tree:
@@ -379,7 +386,7 @@ def library_shared(
query = (
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:
query += " AND p.parent_id IS NULL"
@@ -410,7 +417,7 @@ def library_published(
uid = _get_user_id(request)
# 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 = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -435,7 +442,7 @@ def library_private(
source_type = "all"
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 = []
if tree:
query += " AND p.parent_id IS NULL"
@@ -501,7 +508,7 @@ def library_workspace(
source_type = "all"
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 = []
if tree:
+312 -11
View File
@@ -18,6 +18,21 @@ Doctrine du programme : Flowdeck
référence les tâches, il ne les copie pas — aucune tâche TikTak ne
transite par ici. Écritures limitées à la création/mise à jour de
pages et à la création de collections ; aucune suppression.
**ACC-5.1 (architecture v2.0, §7.2)** — le catalogue de lecture cible
est couvert : ``list_projects`` / ``get_project`` (un « projet » du
contrat de liens profonds est une collection : ``/collections/<id>``),
``search_pages`` (plein texte : titres **et** contenus, pages
classiques et lignes de collections), ``read_page`` (contenu tronqué
proprement) et ``list_project_tasks`` (collections ``is_task``). Les
outils réunions restent absents (décision Meetings A/B ouverte).
Chaque résultat d'objet porte son **URL profonde** (forme figée par
``contrats/liens-profonds.md``), comme le §7.2 l'exige.
**§7.4** — chaque ``tools/call`` est journalisé dans
``api_audit_log`` (jeton, outil, horodatage) et la **portée du
jeton** est appliquée : sans le scope ``write``, les trois outils
d'écriture sont refusés (« jeton en lecture seule »).
"""
from __future__ import annotations
@@ -74,6 +89,41 @@ OUTILS = [
{"name": "list_repos",
"description": "Liste le registre des dépôts suivis par Flowdeck (builtin, Gitea, GitHub).",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "list_projects",
"description": "Liste les PROJETS Flowdeck du périmètre du jeton "
"(catalogue §7.2) : au sens du contrat de liens "
"profonds, un projet est une collection — id, titre, "
"état (is_task, nombre de lignes) et URL profonde "
"/collections/<id>.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "get_project",
"description": "Détail d'un projet (collection) SANS le contenu "
"complet de ses pages : métadonnées, schéma des "
"propriétés et titres de ses lignes, URL profonde.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"}},
"required": ["id"]}},
{"name": "read_page",
"description": "Lit le contenu d'une page (§7.2), tronqué "
"proprement à 20 000 caractères (drapeau "
"« truncated »). « kind » départage les deux "
"familles d'identifiants : « page » (page "
"classique d'un workspace, URL /pages/<id>) ou "
"« collection_page » (ligne d'une collection, "
"contenu porté par sa page liée). Sans « kind », "
"la page classique est essayée d'abord.",
"inputSchema": {"type": "object",
"properties": {"id": {"type": "integer"},
"kind": {"type": "string",
"enum": ["page", "collection_page"]}},
"required": ["id"]}},
{"name": "list_project_tasks",
"description": "Liste les tâches des projets « base de tâches » "
"(collections is_task) du périmètre du jeton : "
"titre, statut, échéance, assignés, URL profonde "
"du projet. « project_id » restreint à un projet.",
"inputSchema": {"type": "object",
"properties": {"project_id": {"type": "integer"}}}},
{"name": "create_page",
"description": "Crée une page dans une collection Flowdeck.",
"inputSchema": {"type": "object",
@@ -99,6 +149,38 @@ OUTILS = [
"required": ["name"]}},
]
#: Outils d'écriture — refusés aux jetons sans le scope ``write`` (§7.4).
OUTILS_ECRITURE = {"create_page", "update_page", "create_collection"}
#: Borne de lecture d'un contenu de page (§7.2 : « tronqué proprement »).
CONTENU_MAX = 20000
def _url_projet(collection_id: int) -> str:
"""URL profonde d'un projet (contrat figé : /collections/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/collections/{collection_id}"
def _url_page(page_id: int) -> str:
"""URL profonde d'une page classique (contrat figé : /pages/<id>)."""
from app.services import draco_urls
return f"{draco_urls.flowdeck_url()}/pages/{page_id}"
def _tronquer(texte: str) -> tuple[str, bool]:
"""Troncature propre : jamais au milieu d'un mot si on peut l'éviter."""
texte = texte or ""
if len(texte) <= CONTENU_MAX:
return texte, False
coupe = texte[:CONTENU_MAX]
dernier_espace = coupe.rfind(" ", CONTENU_MAX - 200)
if dernier_espace > 0:
coupe = coupe[:dernier_espace]
return coupe, True
def _props(brut):
"""property_values_json → dict quand c'est du JSON, sinon inchangé."""
@@ -112,8 +194,10 @@ def _props(brut):
def _page_resume(row) -> dict:
return {"id": row["id"], "collection_id": row["collection_id"],
"kind": "collection_page",
"title": row["title"], "icon": row["icon"],
"properties": _props(row["property_values_json"]),
"url": _url_projet(row["collection_id"]),
"updated_at": row["updated_at"]}
@@ -169,6 +253,7 @@ def _appel_outil(nom: str, args: dict, user_id: int):
return [{"id": r["id"], "name": r["name"],
"description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"url": _url_projet(r["id"]),
"page_count": r["page_count"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
@@ -182,7 +267,9 @@ def _appel_outil(nom: str, args: dict, user_id: int):
"ORDER BY position, id", (coll["id"],)).fetchall()
return {"collection": {"id": coll["id"], "name": coll["name"],
"description": coll["description"],
"icon": coll["icon"]},
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"])},
"pages": [_page_resume(p) for p in pages]}
if nom == "get_page":
p = conn.execute("SELECT * FROM collection_pages WHERE id = ?",
@@ -195,19 +282,181 @@ def _appel_outil(nom: str, args: dict, user_id: int):
res[cle] = p[cle]
return res
if nom == "search_pages":
q = f"%{str(args.get('query', '')).strip()}%"
if q == "%%":
# §7.2 : recherche plein texte — titres ET contenus, pages
# classiques comme lignes de collections, URL profonde et
# famille (« kind ») sur chaque résultat pour que read_page
# puisse être appelé sans ambiguïté d'identifiant.
terme = str(args.get("query", "")).strip()
if not terme:
return []
q = f"%{terme}%"
resultats: list[dict] = []
rows = conn.execute(
"SELECT * FROM collection_pages WHERE title LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q,)).fetchall()
return [_page_resume(p) for p in rows
if collection_visible(p["collection_id"])]
"SELECT * FROM collection_pages "
"WHERE title LIKE ? OR property_values_json LIKE ? "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in rows:
if collection_visible(p["collection_id"]):
resultats.append(_page_resume(p))
pages = conn.execute(
"SELECT id, title, workspace_id, updated_at FROM pages "
"WHERE deleted_at IS NULL AND (title LIKE ? OR content LIKE ?) "
"ORDER BY updated_at DESC LIMIT 25", (q, q)).fetchall()
for p in pages:
if p["workspace_id"] is None or p["workspace_id"] in accessible:
resultats.append({
"id": p["id"], "kind": "page", "title": p["title"],
"workspace_id": p["workspace_id"],
"url": _url_page(p["id"]),
"updated_at": p["updated_at"]})
return resultats[:50]
if nom == "list_repos":
rows = conn.execute(
"SELECT id, name, proj_type, owner, language, description "
"FROM projects ORDER BY proj_type, name").fetchall()
return [dict(r) for r in rows]
if nom == "list_projects":
# §7.2 : un « projet » du contrat de liens profonds est une
# collection. Même périmètre et même forme que
# list_collections, vocabulaire du catalogue cible.
rows = conn.execute(
f"SELECT c.*, (SELECT COUNT(*) FROM collection_pages p "
f"WHERE p.collection_id = c.id) AS page_count, "
f"{eff_sql} AS eff_ws "
"FROM collections c ORDER BY c.name").fetchall()
return [{"id": r["id"], "title": r["name"],
"description": r["description"], "icon": r["icon"],
"is_task": bool(r["is_task"]) if "is_task" in r.keys() else False,
"page_count": r["page_count"],
"url": _url_projet(r["id"]),
"updated_at": r["updated_at"]}
for r in rows
if r["eff_ws"] is None or r["eff_ws"] in accessible]
if nom == "get_project":
coll = conn.execute("SELECT * FROM collections WHERE id = ?",
(int(args.get("id", 0)),)).fetchone()
if coll is None or not collection_visible(coll["id"]):
return {"erreur": "projet introuvable"}
schema = _props(coll["schema_json"]) \
if coll["schema_json"] else []
titres = conn.execute(
"SELECT id, title FROM collection_pages "
"WHERE collection_id = ? ORDER BY position, id LIMIT 300",
(coll["id"],)).fetchall()
return {"project": {"id": coll["id"], "title": coll["name"],
"description": coll["description"],
"icon": coll["icon"],
"is_task": bool(coll["is_task"]) if "is_task" in coll.keys() else False,
"url": _url_projet(coll["id"]),
"updated_at": coll["updated_at"]},
"schema": schema,
"pages": [{"id": t["id"], "title": t["title"]}
for t in titres]}
if nom == "read_page":
try:
pid = int(args.get("id"))
except (TypeError, ValueError):
return {"erreur": "id requis (entier)"}
kind = str(args.get("kind") or "")
if kind in ("page", ""):
page = conn.execute(
"SELECT * FROM pages WHERE id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if page is not None and (
page["workspace_id"] is None
or page["workspace_id"] in accessible):
contenu, tronque = _tronquer(page["content"] or "")
return {"id": page["id"], "kind": "page",
"title": page["title"],
"workspace_id": page["workspace_id"],
"content": contenu, "truncated": tronque,
"url": _url_page(page["id"]),
"updated_at": page["updated_at"]}
if kind == "page":
return {"erreur": "page introuvable"}
if kind in ("collection_page", ""):
p = conn.execute(
"SELECT * FROM collection_pages WHERE id = ?",
(pid,)).fetchone()
if p is None or not collection_visible(p["collection_id"]):
return {"erreur": "page introuvable"}
res = _page_resume(p)
liee = conn.execute(
"SELECT content FROM pages "
"WHERE collection_row_id = ? AND deleted_at IS NULL",
(pid,)).fetchone()
if liee is not None:
contenu, tronque = _tronquer(liee["content"] or "")
res["content"] = contenu
res["truncated"] = tronque
return res
return {"erreur": f"kind inconnu : {kind}"}
if nom == "list_project_tasks":
from app.services.task_databases import collect_tasks, is_done
projet_id = args.get("project_id")
if projet_id is not None:
try:
projet_id = int(projet_id)
except (TypeError, ValueError):
return {"erreur": "project_id doit être un entier"}
cible = conn.execute(
"SELECT id, is_task FROM collections WHERE id = ?",
(projet_id,)).fetchone()
if cible is None or not collection_visible(projet_id):
return {"erreur": "projet introuvable"}
if "is_task" in cible.keys() and not cible["is_task"]:
return {"erreur": "ce projet n'est pas une base de tâches"}
taches = collect_tasks(conn, user_id, mine_only=False)
sortie = []
for t in taches:
if projet_id is not None and t["collection_id"] != projet_id:
continue
sortie.append({
"id": t["id"], "title": t["title"],
"status": t["status"], "done": t["done"],
"due": t["due"], "assignees": t["assignees"],
"project_id": t["collection_id"],
"project_name": t["collection_name"],
"url": _url_projet(t["collection_id"])})
if projet_id is not None and not sortie:
# Base de tâches non « configurée » (mapping My Tasks
# incomplet) : collect_tasks la saute par conception,
# mais le serveur MCP répond quand même — lignes brutes,
# statut lu sur la propriété mappée ou nommée Statut.
coll = conn.execute(
"SELECT * FROM collections WHERE id = ?",
(projet_id,)).fetchone()
prop_statut = None
if "task_status_prop" in coll.keys() and coll["task_status_prop"]:
prop_statut = coll["task_status_prop"]
else:
pr = conn.execute(
"SELECT id FROM collection_properties "
"WHERE collection_id = ? AND LOWER(name) IN "
"('statut', 'status', 'état', 'etat') "
"ORDER BY position LIMIT 1", (projet_id,)).fetchone()
if pr is not None:
prop_statut = pr["id"]
lignes = conn.execute(
"SELECT * FROM collection_pages WHERE collection_id = ? "
"ORDER BY position, id", (projet_id,)).fetchall()
for ligne in lignes:
props = _props(ligne["property_values_json"])
statut = ""
if isinstance(props, dict) and prop_statut is not None:
v = props.get(prop_statut, props.get(str(prop_statut), ""))
if isinstance(v, list):
v = v[0] if v else ""
statut = str(v or "")
sortie.append({
"id": ligne["id"], "title": ligne["title"],
"status": statut, "done": is_done(statut),
"due": "", "assignees": [],
"project_id": projet_id,
"project_name": coll["name"],
"url": _url_projet(projet_id)})
return sortie
if nom == "create_page":
try:
collection_id = int(args.get("collection_id"))
@@ -297,7 +546,8 @@ def _appel_outil(nom: str, args: dict, user_id: int):
emettre("flowdeck.collection.created", f"collection:{new_id}", name,
url=url_collection(new_id), actor="agent:mcp")
return {"id": coll["id"], "name": coll["name"],
"description": coll["description"], "icon": coll["icon"]}
"description": coll["description"], "icon": coll["icon"],
"url": _url_projet(coll["id"])}
return {"erreur": f"outil inconnu : {nom}"}
@@ -310,11 +560,45 @@ def _version() -> str:
return "dev"
def _scopes_jeton(token_id: int) -> str:
"""Scopes du jeton MCP (colonne ``api_tokens.scopes``, défaut hérité
``read,write`` posé par la migration v6.3.0)."""
try:
with get_conn() as conn:
row = conn.execute("SELECT scopes FROM api_tokens WHERE id = ?",
(token_id,)).fetchone()
if row is not None and "scopes" in row.keys() and row["scopes"]:
return str(row["scopes"])
except Exception: # noqa: BLE001 — base ancienne sans la colonne
pass
return "read,write"
def _journaliser(proprietaire: dict, outil: str, arguments: dict,
request: Request, statut: str) -> None:
"""Journal d'audit §7.4 : qui (jeton), quel outil, quand — les
valeurs d'arguments ne sont pas journalisées, seules leurs clés."""
try:
from app.services.api_v2_helpers import audit_log
cles = ",".join(sorted(str(k) for k in (arguments or {}).keys()))
audit_log({"id": proprietaire["user_id"],
"_token_id": proprietaire["id"]},
f"mcp:{outil}", "mcp_tool", outil,
f"statut={statut}; jeton={proprietaire.get('name', '')}; args=[{cles}]",
request)
except Exception: # noqa: BLE001 — l'audit ne casse jamais l'appel
pass
@router.get("/mcp")
def mcp_info():
noms = [t["name"] for t in OUTILS]
return {"name": "flowdeck", "version": _version(),
"transport": "streamable-http (JSON-RPC 2.0 over POST)",
"read_only": False, "tools": [t["name"] for t in OUTILS]}
"read_only": False, "tools": noms,
"read_tools": [n for n in noms if n not in OUTILS_ECRITURE],
"write_tools": [n for n in noms if n in OUTILS_ECRITURE]}
@router.post("/mcp")
@@ -349,8 +633,25 @@ async def mcp(request: Request):
return {"jsonrpc": "2.0", "id": rid, "result": {"tools": OUTILS}}
if methode == "tools/call":
params = data.get("params") or {}
res = _appel_outil(params.get("name", ""), params.get("arguments") or {},
proprietaire["user_id"])
outil = params.get("name", "")
arguments = params.get("arguments") or {}
# §7.4 : la portée du jeton fait la loi — un jeton « read »
# (ex. clé dédiée à un pont de lecture) ne peut pas écrire.
if outil in OUTILS_ECRITURE:
from app.services.api_v2_helpers import has_scope
if not has_scope(_scopes_jeton(proprietaire["id"]), "write"):
_journaliser(proprietaire, outil, arguments, request,
"refuse_portee")
return {"jsonrpc": "2.0", "id": rid,
"error": {"code": -32002,
"message": "Jeton en lecture seule : "
"l'outil d'écriture "
f"« {outil} » exige le scope write"}}
res = _appel_outil(outil, arguments, proprietaire["user_id"])
_journaliser(proprietaire, outil, arguments, request,
"erreur" if isinstance(res, dict) and "erreur" in res
else "ok")
return {"jsonrpc": "2.0", "id": rid,
"result": {"content": [{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}}
+22
View File
@@ -396,6 +396,28 @@ def get_transcript_row(transcript_id: int, request: Request):
raise HTTPException(404, str(exc)) from None
@router.post("/api/v2/meetings/transcripts/{transcript_id}/archiver-obsigate",
status_code=201)
def archiver_obsigate(transcript_id: int, request: Request):
"""Pilote ACC-6.2 : dépose l'archivage du résumé dans ObsiGate
dans la file d'approbations unifiée (rien n'est écrit avant
l'approbation ; l'exécution part de la décision, voir
governance.decide_approval)."""
user = _auth_user(request, require_write=True)
from app.services import meeting_archive
try:
approval = meeting_archive.demander_approbation(transcript_id, user["id"])
except ValueError as exc:
msg = str(exc)
raise HTTPException(404 if "not found" in msg else 400, msg) from None
audit_log(user, "meeting.archive.request", "transcript", transcript_id,
f"approval #{approval['id']}", request)
archive = meeting_archive.construire_archive(meet.get_transcript(transcript_id))
return {"approval": approval, "titre": archive["titre"],
"chemin": archive["chemin"]}
@router.post("/api/v2/meetings/transcripts/{transcript_id}/consents")
def post_consent(transcript_id: int, request: Request, body: dict = Body(default={})):
user = _auth_user(request, require_write=True)
+43 -1
View File
@@ -2,10 +2,19 @@
``GET /api/search?q=`` backs the Ctrl+K command palette. Returns matching
editor pages and databases scoped to the current user's accessible workspaces.
Recherche partout (fusion-trois-apps #68) : la même route sert la page
unifiée (``GET /recherche``, coquille dont les résultats sont chargés
côté client) et accepte, en plus de la session native, le jeton SSO
``draco_session`` (validateur local ``app.services.draco``). Sans
session ni jeton valide, 401 — avant cette livraison, l'absence de
session donnait une recherche SANS périmètre (``user_id=None`` → ``1=1``
dans ``_scope_where``) : corrigé ici.
"""
from __future__ import annotations
from fastapi import APIRouter, Query, Request
from fastapi import APIRouter, HTTPException, Query, Request
from fastapi.responses import HTMLResponse
from app.auth.session import SessionManager
from app.services.search import search as search_service
@@ -17,6 +26,18 @@ router = APIRouter(tags=["search"])
def search(request: Request, q: str = Query(default="")):
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
user_id = user.get("id") if user and user.get("id") else None
if user_id is None:
# Repli SSO draco_session : un jeton valide désigne l'utilisateur
# lié, ou l'unique utilisateur actif (cas mono-utilisateur).
try:
from app.services import draco as draco_service
draco_user = draco_service.user_for_draco(request)
user_id = draco_user.get("id") if draco_user else None
except Exception:
user_id = None
if user_id is None:
raise HTTPException(status_code=401, detail="Authentication required")
data = search_service(q, user_id=user_id)
return {
@@ -25,3 +46,24 @@ def search(request: Request, q: str = Query(default="")):
"collections": data["collections"],
"total": len(data["pages"]) + len(data["collections"]),
}
@router.get("/recherche", response_class=HTMLResponse)
def recherche_page(request: Request, q: str = Query(default="")):
"""Page de résultats unifiée « Rechercher partout » (fusion #68).
Coquille seule : les résultats sont chargés côté client par
static/js/recherche.js (API de recherche de chaque app, en
parallèle, états par application). La palette Ctrl+K native et
``GET /api/search`` restent inchangés par ailleurs.
"""
from app.services import draco_urls
from app.templating import ENV
user = SessionManager.decode_session(request.cookies.get("flowdeck_session", ""))
bases = draco_urls.toutes()
template = ENV.get_template("recherche.html")
return HTMLResponse(template.render(
request=request, q=q.strip(), user=user,
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak", "kanbante")},
))
+2 -2
View File
@@ -22,7 +22,7 @@ from app.services import agent_memory
from app.services.agent_policies import check_tool, get_policy
from app.services.context_builder import ContextBuilder
from app.services.llm_client import LLMClient
from app.services.permission_manager import WRITE_TOOLS, PermissionManager
from app.services.permission_manager import PermissionManager, is_write_tool
from app.services.tool_registry import ToolRegistry
logger = logging.getLogger(__name__)
@@ -248,7 +248,7 @@ class AgentEngine:
# v7.2.0 — workspace tool scope + human approval gate, checked
# *before* permissions so a scoped-out tool never reaches ACLs.
gov = check_tool(self.user_id, self.workspace_id, tool,
is_write=tool in WRITE_TOOLS,
is_write=is_write_tool(tool),
conversation_id=conversation_id)
if not gov.get("allowed"):
detail = gov.get("reason") or "Refusé par la politique agent"
+206
View File
@@ -0,0 +1,206 @@
"""Références ObsiGate pour les connecteurs Flowdeck (Phase 4, issue ObsiGate #22).
Un connecteur d'un workspace ne stocke plus un jeton en clair ni même un
jeton chiffré local : il stocke une **référence** ``coffre:<slug>/<nom>``,
toujours liée à un ``workspace_id``. À l'appel, le serveur Flowdeck appelle
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
Flowdeck, utilise la valeur **en mémoire uniquement**, puis l'oublie :
* aucune valeur n'est persistée (ni en base, ni en fichier, ni en cache) ;
* aucune valeur n'est journalisée (les logs ne portent que le statut) ;
* si ObsiGate est indisponible, l'appel échoue proprement — il n'y a
**aucun repli** sur une valeur en cache, puisqu'il n'y en a pas.
Format validé ici, aligné sur ObsiGate (``backend/coffre_store.py``) :
``slug`` et ``nom`` en minuscules, 3 à 64 / 3 à 128 caractères,
``[a-z0-9_-]`` après le premier caractère. La référence elle-même n'est
pas un secret (c'est une métadonnée, comme un chemin) : seule la valeur
distribuée l'est, et elle ne transite que dans la réponse d'ObsiGate.
Isolation workspace (critère central) : l'octroi ObsiGate est accordé au
**consommateur Flowdeck** (un jeton par application), pas à un workspace.
L'isolation par workspace est donc appliquée **côté Flowdeck, avant tout
appel** — voir ``connectors.resolve_secret`` : un connecteur dont le
``workspace_id`` diffère du workspace demandeur est refusé explicitement
et le distributeur n'est jamais appelé. Une isolation appliquée côté
ObsiGate exigerait un consommateur par workspace (reste à faire, voir
``docs/coffre-reference-flowdeck.md``).
"""
from __future__ import annotations
import json
import logging
import re
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
#: Contexte envoyé à ObsiGate : le workspace Flowdeck demandeur, pour que
#: la distribution soit traçable côté ObsiGate (journal des distributions).
CONTEXTE_TYPE = "flowdeck_workspace"
#: Regex ObsiGate, recopiées à l'identique (slug 3–64, nom 3–128).
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
#: Délai maximal d'une distribution (secondes) — un coffre injoignable ne
#: doit pas bloquer indéfiniment un appel de connecteur.
TIMEOUT_S = 10
class CoffreReferenceInvalide(ValueError):
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
class CoffreWorkspaceMismatch(ValueError):
"""Le connecteur appartient à un autre workspace que le demandeur."""
class CoffreAccesRefuse(ValueError):
"""ObsiGate a refusé la distribution (octroi absent, révoqué, expiré…).
``detail`` reprend le code ObsiGate (``octroi_revoque``, …) sans
jamais contenir la valeur du secret.
"""
def __init__(self, message: str, detail: str = "") -> None:
super().__init__(message)
self.detail = detail
class CoffreIndisponible(ValueError):
"""ObsiGate est injoignable ou en erreur technique (pas de repli)."""
def parse_reference(reference: str) -> tuple[str, str]:
"""Valide ``coffre:<slug>/<nom>`` et renvoie ``(slug, nom)`` normalisés.
La casse est ramenée en minuscules, comme côté ObsiGate
(``valider_slug`` / ``valider_nom_secret``).
"""
value = (reference or "").strip().lower()
if not value.startswith("coffre:") or "/" not in value[7:]:
raise CoffreReferenceInvalide(
"référence invalide : format attendu coffre:<slug>/<nom>"
)
slug, nom = value[7:].split("/", 1)
if not SLUG_RE.match(slug):
raise CoffreReferenceInvalide(
"slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
if not NOM_SECRET_RE.match(nom):
raise CoffreReferenceInvalide(
"nom de secret invalide : 3 à 128 caractères, minuscules, "
"chiffres, - ou _"
)
return slug, nom
def is_reference(value: str | None) -> bool:
"""Vrai si la valeur ressemble à une référence (sans lever d'erreur)."""
try:
parse_reference(value or "")
except CoffreReferenceInvalide:
return False
return True
def _config() -> tuple[str, str]:
"""(base ObsiGate, jeton consommateur) depuis la configuration.
L'URL explicite ``obsigate_coffre_url`` prime ; sinon on dérive de
``draco_sso_url`` (ObsiGate, émettrice SSO — même hôte). Le jeton est
le jeton **consommateur** Flowdeck enregistré dans ObsiGate, distinct
du jeton MCP de notes et de la session SSO.
"""
from app.config import settings
base = (getattr(settings, "obsigate_coffre_url", "") or "").strip()
if not base:
base = (settings.draco_sso_url or "").strip()
token = (getattr(settings, "obsigate_consumer_token", "") or "").strip()
return base.rstrip("/"), token
def distribuer(reference: str, workspace_id: int, *, base_url: str | None = None,
consumer_token: str | None = None, request=None) -> str:
"""Distribue une valeur ObsiGate **en mémoire** pour un workspace.
``request`` est le point d'injection des tests : un appelable
``(url, payload, headers) -> (status, body_dict)`` qui remplace
l'appel HTTP réel (faux endpoint de distribution, clairement
identifié comme tel dans les tests). Sans lui, l'appel HTTP réel
est effectué via la bibliothèque standard (aucune dépendance).
Ne conserve rien : la valeur renvoyée doit être utilisée puis
oubliée par l'appelant, jamais écrite ni mise en cache.
"""
slug, nom = parse_reference(reference)
if not workspace_id or int(workspace_id) <= 0:
raise ValueError("workspace_id est requis pour distribuer une référence")
workspace_id = int(workspace_id)
base, token = _config()
if base_url is not None:
base = base_url.rstrip("/")
if consumer_token is not None:
token = consumer_token
if not base:
raise CoffreIndisponible("URL ObsiGate non configurée (obsigate_coffre_url)")
if not token:
raise CoffreIndisponible(
"jeton consommateur ObsiGate non configuré (obsigate_consumer_token)"
)
url = f"{base}/api/coffres/distribuer"
payload = {
"reference": f"coffre:{slug}/{nom}",
"contexte_type": CONTEXTE_TYPE,
"contexte_id": str(workspace_id),
}
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
try:
if request is not None:
status, body = request(url, payload, headers)
else:
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
status, body = resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read() or b"{}")
except (ValueError, TypeError):
body = {}
status = exc.code
except CoffreAccesRefuse:
raise
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
# ObsiGate injoignable : échec propre, SANS repli sur un cache.
logger.warning("Distribution ObsiGate injoignable (statut seul journalisé)")
raise CoffreIndisponible(
"ObsiGate est indisponible : distribution impossible, "
"aucune valeur en cache n'est utilisée"
) from exc
if status == 200:
valeur = (body or {}).get("valeur") or ""
if not valeur:
raise CoffreIndisponible("ObsiGate a répondu sans valeur")
return valeur
detail = str((body or {}).get("detail") or "")
if status in (401, 403):
if detail in ("octroi_revoque", "consommateur_revoque"):
message = ("accès révoqué, à ré-accorder dans ObsiGate "
"(l'octroi de cette référence a été révoqué)")
elif detail == "octroi_expire":
message = "accès expiré, à ré-accorder dans ObsiGate"
else:
message = "accès refusé par ObsiGate (octroi absent ou invalide)"
raise CoffreAccesRefuse(message, detail=detail)
if status == 410:
raise CoffreAccesRefuse("secret expiré côté ObsiGate", detail="secret_expire")
if status in (400,):
raise CoffreReferenceInvalide(f"référence rejetée par ObsiGate ({detail})")
raise CoffreIndisponible(f"ObsiGate a répondu HTTP {status} (distribution impossible)")
+261 -41
View File
@@ -1,5 +1,15 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
Références ObsiGate (Phase 4, issue ObsiGate #22) : un connecteur personnel
peut pointer vers ``coffre:<slug>/<nom>`` au lieu d'un jeton chiffré local.
La référence est **toujours liée à un workspace_id** (voir
``app/services/coffre_reference.py``) ; à l'appel, la valeur est distribuée
en mémoire par le serveur puis oubliée, jamais persistée ni mise en cache.
Les secrets déjà chiffrés localement restent valides (migration
opportuniste, connecteur par connecteur — voir
``docs/coffre-reference-flowdeck.md``).
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
@@ -18,13 +28,19 @@ import logging
from app.config import settings
from app.db import get_conn
from app.services import coffre_reference as coffre_module
from app.services import oauth_connectors
from app.services.coffre_reference import CoffreWorkspaceMismatch
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
logger = logging.getLogger(__name__)
NATIVE_KINDS = ("gitea", "github", "web", "google", "ms365")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak")
CUSTOM_KINDS = ("custom", "discord", "telegram", "mcp", "tiktak", "obsigate")
#: Kinds personnels qui parlent MCP (probe = handshake + tools/list,
#: outils dynamiques ``mcp_<serveur>_*``) — générique + les deux presets
#: de fédération du programme (TikTak ACC-2.1, ObsiGate ACC-2.2).
MCP_KINDS = ("mcp", "tiktak", "obsigate")
AUTH_SCHEMES = ("bearer", "bot", "none")
# Presets proposés par le formulaire du menu + (le jeton reste toujours saisi
# par l'utilisateur — jamais codé en dur ici).
@@ -45,6 +61,16 @@ def _presets() -> dict:
# v7.82.0 : URL dérivée de la config (TIKTAK_URL ; défaut officiel).
"tiktak": {"url": f"{draco_urls.tiktak_url()}/mcp", "auth": "bearer",
"hint": "TikTak → Réglages → Intégrations → clé API tck_…"},
# Fusion ACC-2.2 — pont MCP vers ObsiGate (plan « étape 1 :
# lectures seules », tests T0–T5) : serveur MCP d'ObsiGate sur
# /mcp (Streamable HTTP à session — le client gère le
# Mcp-Session-Id). La clé se crée dans ObsiGate →
# Configurations → Clés API & MCP, dédiée au pont et limitée
# à UN SEUL vault au départ : c'est le jeton qui fait la
# portée (garde-fou n° 1 du plan), pas ce preset.
"obsigate": {"url": f"{draco_urls.obsigate_url()}/mcp", "auth": "bearer",
"hint": "ObsiGate → Configurations → Clés API & MCP → "
"clé dédiée au pont, limitée à un seul vault"},
}
@@ -104,18 +130,62 @@ def native_state(kind: str, user_id: int | None = None) -> tuple[str, str]:
return ("ok", f"recherche web via {settings.web_search_provider or 'duckduckgo'}")
#: Colonnes lues pour un connecteur personnel (les deux colonnes coffre
#: sont ajoutées par la migration 52 ; les bases antérieures les ignorent
#: via les requêtes tolérantes de `_select_cols` — voir ci-dessous).
_CONNECTOR_COLS = ("id", "name", "url", "secret_encrypted", "enabled", "status",
"detail", "kind", "auth", "tools_json", "workspace_id",
"coffre_reference")
def _select_cols(conn) -> str:
"""Liste SELECT tolérante : les colonnes coffre n'existent qu'après
la migration 52 (bases créées avant, ou tests à schéma figé)."""
try:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
except Exception: # noqa: BLE001 — repli : colonnes historiques seules
cols = set(_CONNECTOR_COLS[:10])
wanted = [c for c in _CONNECTOR_COLS if c in cols]
return ", ".join(wanted) if wanted else ", ".join(_CONNECTOR_COLS[:10])
def _row(r) -> dict:
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais)."""
"""Ligne `agent_connectors` → dict API (le jeton n'y figure jamais).
``coffre_reference`` est une métadonnée (pas une valeur) : elle peut
figurer dans la réponse. ``secret_source`` indique la provenance :
``coffre`` (distribué à l'appel), ``chiffre_local`` (Fernet historique)
ou ``aucun``.
"""
try:
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
except (ValueError, TypeError):
tools = []
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
workspace_id = r["workspace_id"]
except (KeyError, IndexError):
workspace_id = None
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
if reference:
source = "coffre"
elif stored:
source = "chiffre_local"
else:
source = "aucun"
return {
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
"status": r["status"], "detail": r["detail"] or "",
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
"tools_count": len(tools),
"has_secret": bool(stored or reference), "oauth": False,
"tools_count": len(tools), "workspace_id": workspace_id,
"coffre_reference": reference, "secret_source": source,
}
@@ -132,28 +202,47 @@ def list_connectors(user_id: int | None = None) -> list[dict]:
"has_secret": False, "oauth": kind in OAUTH_KINDS,
})
with get_conn() as conn:
cols = _select_cols(conn)
rows = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
f"SELECT {cols} FROM agent_connectors ORDER BY id"
).fetchall()
return out + [_row(r) for r in rows]
def get(connector_id: int) -> dict | None:
with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute(
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
f"SELECT {cols} FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
return _row(r) if r else None
def _workspace_exists(workspace_id: int) -> bool:
with get_conn() as conn:
row = conn.execute(
"SELECT id FROM workspaces WHERE id=?", (workspace_id,)
).fetchone()
return row is not None
# ── CRUD (personnels) ────────────────────────────────────────────────────────
def create_connector(name: str, url: str, secret: str = "", *,
kind: str = "custom", auth: str = "bearer") -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
kind: str = "custom", auth: str = "bearer",
workspace_id: int | None = None,
coffre_reference: str | None = None) -> dict:
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet).
Variante coffre (Phase 4) : si ``coffre_reference`` est fourni, le
champ jeton **devient** ce champ référence — aucun jeton n'est stocké
(``secret_encrypted`` reste vide) et un ``workspace_id`` existant est
**obligatoire** (octroi toujours lié à un workspace). ``secret`` et
``coffre_reference`` sont exclusifs. Un connecteur sans référence peut
aussi recevoir un ``workspace_id`` (première étape d'une migration
opportuniste : rattacher d'abord, basculer ensuite).
"""
from app.services.importers.url_fetch import _validate_url
name = (name or "").strip()
@@ -163,22 +252,60 @@ def create_connector(name: str, url: str, secret: str = "", *,
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
if auth not in AUTH_SCHEMES:
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(AUTH_SCHEMES)})")
reference = ""
if coffre_reference is not None and str(coffre_reference).strip():
reference = coffre_module.parse_reference(str(coffre_reference))
reference = f"coffre:{reference[0]}/{reference[1]}"
if (secret or "").strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
if workspace_id is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if workspace_id is not None:
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
url = (url or "").strip()
if "{secret}" in url and not (secret or "").strip():
if "{secret}" in url and not (secret or "").strip() and not reference:
raise ValueError("clé requise : l'URL contient {secret}")
url = _validate_url(url) # lève ValueError si hôte interne
with get_conn() as conn:
cols = {r[1] for r in conn.execute("PRAGMA table_info(agent_connectors)")}
extra_cols, extra_vals = "", ()
if "workspace_id" in cols:
extra_cols += ", workspace_id"
extra_vals += (workspace_id,)
if "coffre_reference" in cols:
extra_cols += ", coffre_reference"
extra_vals += (reference,)
cur = conn.execute(
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})",
(name, url, encrypt_secret(secret or "") if not reference else "",
"unknown", "", kind, auth, *extra_vals),
)
conn.commit()
cid = cur.lastrowid
return get(cid)
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None) -> dict | None:
_UNSET = object()
def update_connector(connector_id: int, *, name=None, enabled=None, secret=None,
workspace_id=_UNSET, coffre_reference=_UNSET) -> dict | None:
"""Met à jour un connecteur personnel.
Règles coffre (Phase 4) :
* poser une référence exige un workspace (celui du connecteur, ou
celui passé dans le même appel) ; la référence et un jeton local
sont exclusifs dans le même appel ;
* un ``workspace_id`` déjà posé est **immuable** : on ne transfère pas
un octroi d'un workspace à l'autre en éditant le connecteur ;
* effacer la référence (``coffre_reference=""``) réactive l'éventuel
jeton local dormant, sans jamais le réécrire.
"""
row = get(connector_id)
if row is None:
return None
@@ -192,7 +319,40 @@ def update_connector(connector_id: int, *, name=None, enabled=None, secret=None)
if enabled is not None:
sets.append("enabled=?")
params.append(1 if enabled else 0)
effective_workspace = row.get("workspace_id")
if workspace_id is not _UNSET:
if workspace_id is None:
raise ValueError("workspace_id ne peut pas être effacé")
workspace_id = int(workspace_id)
if workspace_id <= 0 or not _workspace_exists(workspace_id):
raise ValueError(f"workspace introuvable: {workspace_id}")
if row.get("workspace_id") is not None and row["workspace_id"] != workspace_id:
raise ValueError("workspace_id immuable : un connecteur ne change "
"pas de workspace (pas de transfert d'octroi)")
effective_workspace = workspace_id
sets.append("workspace_id=?")
params.append(workspace_id)
reference_cleared = False
if coffre_reference is not _UNSET:
if coffre_reference is None or not str(coffre_reference).strip():
reference_cleared = True
sets.append("coffre_reference=?")
params.append("")
else:
parsed = coffre_module.parse_reference(str(coffre_reference))
if effective_workspace is None:
raise ValueError("coffre_reference exige un workspace_id "
"(octroi toujours lié à un workspace)")
if secret is not None and str(secret).strip():
raise ValueError("secret et coffre_reference sont exclusifs "
"(le jeton devient la référence)")
sets.append("coffre_reference=?")
params.append(f"coffre:{parsed[0]}/{parsed[1]}")
if secret is not None:
if row.get("coffre_reference") and not reference_cleared:
raise ValueError("ce connecteur pointe vers une référence ObsiGate : "
"effacez d'abord coffre_reference pour revenir "
"à un jeton local")
sets.append("secret_encrypted=?")
params.append(encrypt_secret(str(secret)))
if sets:
@@ -212,22 +372,72 @@ def delete_connector(connector_id: int) -> bool:
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
def _headers(connector_id: int | None = None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
def resolve_secret(connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> str:
"""Résout le jeton d'un connecteur **en mémoire**, jamais persisté.
* Connecteur à référence : le ``workspace_id`` demandé doit être
celui du connecteur, sinon ``CoffreWorkspaceMismatch`` explicite
**avant tout appel** à ObsiGate (le distributeur n'est pas appelé).
La valeur distribuée est renvoyée à l'appelant, qui l'utilise puis
l'oublie — aucune mise en cache ici.
* Connecteur historique : déchiffrement Fernet local (migration
opportuniste : ces connecteurs restent valides tels quels).
* Sans jeton ni référence : chaîne vide.
"""
# Lecture directe en base : le dict API de get() n'expose jamais le
# jeton (chiffré ou non), c'est ici seulement qu'il est résolu.
with get_conn() as conn:
cols = _select_cols(conn)
r = conn.execute(
f"SELECT {cols} FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r is None:
raise ValueError(f"Connecteur introuvable: {connector_id}")
try:
reference = r["coffre_reference"] or ""
except (KeyError, IndexError):
reference = ""
try:
owner = r["workspace_id"]
except (KeyError, IndexError):
owner = None
if reference:
if workspace_id is None:
raise ValueError("workspace_id est requis pour un connecteur "
"à référence ObsiGate")
if owner is None or int(owner) != int(workspace_id):
raise CoffreWorkspaceMismatch(
f"workspace {workspace_id} ne peut pas utiliser ce connecteur "
f"(il appartient au workspace {owner}) : octroi d'un autre "
"workspace refusé"
)
return coffre_module.distribuer(
reference, int(workspace_id), request=distribute)
try:
stored = r["secret_encrypted"]
except (KeyError, IndexError):
stored = ""
return decrypt_secret(stored or "")
def _headers(connector_id: int | None = None, *, workspace_id: int | None = None,
distribute=None) -> dict:
"""En-têtes d'appel : la clé n'est lue (et déchiffrée/distribuée) qu'ici.
``auth`` = bearer (défaut) | bot (Discord, jeton préfixé) | none (jeton ailleurs).
Pour un connecteur à référence, ``workspace_id`` est obligatoire et
l'isolation est vérifiée par ``resolve_secret`` avant distribution.
"""
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
secret, auth = "", "bearer"
if connector_id:
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
if r:
secret = decrypt_secret(r["secret_encrypted"] or "")
auth = r["auth"] or "bearer"
row = get(connector_id)
if row:
auth = row["auth"] or "bearer"
secret = resolve_secret(connector_id, workspace_id=workspace_id,
distribute=distribute)
if secret and auth == "bot":
headers["Authorization"] = f"Bot {secret}"
elif secret and auth != "none":
@@ -236,16 +446,13 @@ def _headers(connector_id: int | None = None) -> dict:
return headers
def _with_secret(url: str, connector_id: int) -> str:
def _with_secret(url: str, connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> str:
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
if "{secret}" not in url:
return url
with get_conn() as conn:
r = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(connector_id,),
).fetchone()
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
secret = resolve_secret(connector_id, workspace_id=workspace_id,
distribute=distribute)
if not secret:
raise ValueError("clé manquante pour une URL contenant {secret}")
return url.replace("{secret}", secret)
@@ -289,13 +496,20 @@ def _resolve(connector: str) -> tuple[str, str | int]:
raise ValueError(f"Connecteur inconnu: {token}")
async def probe(connector: str, user_id: int | None = None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
async def probe(connector: str, user_id: int | None = None, *,
workspace_id: int | None = None, distribute=None) -> dict:
"""Teste un connecteur et **persiste** le résultat (personnel uniquement).
``workspace_id`` est transmis aux chemins à référence ObsiGate ; un
connecteur à référence testé sans workspace échoue proprement
(``error``), jamais avec une valeur d'un autre workspace.
"""
kind, ref = _resolve(connector)
try:
if kind in ("mcp", "tiktak"):
if kind in MCP_KINDS:
from app.services import mcp_client
tools = await mcp_client.initialize_and_list(int(ref))
tools = await mcp_client.initialize_and_list(
int(ref), workspace_id=workspace_id, distribute=distribute)
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
elif kind in OAUTH_KINDS:
res = await oauth_connectors.api_get(kind, user_id,
@@ -312,8 +526,10 @@ async def probe(connector: str, user_id: int | None = None) -> dict:
status, detail = "ok", native_state("web")[1]
else:
row = get(int(ref))
url = _with_secret(row["url"], int(ref))
code, _ = await _get(url, _headers(int(ref)))
url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
distribute=distribute)
code, _ = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
logger.info("Connector probe failed (%s): %s", connector, exc)
@@ -337,13 +553,15 @@ def _gh_headers() -> dict:
async def connector_fetch(connector: str, path: str = "", query: str = "",
user_id: int | None = None) -> dict:
user_id: int | None = None, *,
workspace_id: int | None = None,
distribute=None) -> dict:
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
kind, ref = _resolve(connector)
path = (path or "").strip()
if kind in OAUTH_KINDS:
return await oauth_connectors.api_get(kind, user_id, path)
if kind in ("mcp", "tiktak"):
if kind in MCP_KINDS:
from app.services import mcp_client
return {"status": "ok", "text": mcp_client.tools_text(int(ref))}
if kind == "gitea":
@@ -367,10 +585,12 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
return {"status": "error", "text": "Connecteur supprimé"}
if not row["enabled"]:
return {"status": "error", "text": "Connecteur désactivé"}
url = _with_secret(row["url"], int(ref)).rstrip("/")
url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
distribute=distribute).rstrip("/")
if path:
url += "/" + path.lstrip("/")
code, text = await _get(url, _headers(int(ref)))
code, text = await _get(url, _headers(int(ref), workspace_id=workspace_id,
distribute=distribute))
if code >= 400:
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
return {"status": "ok", "text": text[:FETCH_LIMIT]}
+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
comportement) : variable d'environnement > ``app.config.settings`` >
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
import os
import re
from contextvars import ContextVar
from urllib.parse import urlparse
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 = {
"obsigate": "https://og.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]
# 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:
"""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:
"""Service d'agents (Spark) — base sans chemin."""
return _resoudre("DRACO_AGENT_URL", "draco_agent_url", "agent")
"""Service d'agents (Spark) — base sans chemin.
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:
return _resoudre("TIKTAK_URL", "tiktak_url", "tiktak")
return _resoudre_soeur("TIKTAK_URL", "tiktak_url", "tiktak")
def kanbante_url() -> str:
return _resoudre("KANBANTE_URL", "kanbante_url", "kanbante")
return _resoudre_soeur("KANBANTE_URL", "kanbante_url", "kanbante")
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
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", ""))
if env:
@@ -95,9 +301,19 @@ def flowdeck_url() -> str:
p = urlparse(candidate)
if p.scheme and 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 "")
if base and "localhost" not in base and "127.0.0.1" not in base:
return base
derivee = _url_derivee("flowdeck")
if derivee:
return derivee
return OFFICIELLES["flowdeck"]
@@ -129,7 +345,12 @@ def hote(url: 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 {
"obsigate": obsigate_url(),
"agent": agent_url(),
+157 -35
View File
@@ -6,10 +6,30 @@ JSON-RPC 2.0 : ``initialize`` → ``notifications/initialized`` → ``tools/list
``ToolRegistry`` sous des noms ``mcp_<serveur>_<outil>`` — outils dynamiques,
sans état en mémoire.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; on lit soit la
réponse JSON directe, soit la première ligne ``data:`` d'une
``text/event-stream`` — pas de client SSE à l'état (les méthodes utilisées
répondent en JSON chez la grande majorité des serveurs).
**Sessions (ACC-2.2, pont ObsiGate — test T0 du plan d'étape 1).**
Certains serveurs Streamable HTTP (ObsiGate) sont **à session** : ils
renvoient un en-tête ``Mcp-Session-Id`` à ``initialize`` et refusent
tout appel qui ne le réémet pas. Le client capture donc cet en-tête à
chaque réponse et le réémet sur les appels suivants de la même
opération ; ``call_tool`` fait désormais le handshake complet avant
``tools/call`` (les serveurs sans état comme TikTak l'acceptent sans
frais). Réponses **JSON ou SSE** : dans un flux ``text/event-stream``
qui porte plusieurs messages (notifications + réponse), c'est le
message **de réponse** (id correspondant, sinon porteur de
``result``/``error``) qui est retenu — pas le premier ``data:`` venu.
**Classification des outils (garde-fou n° 2 du plan du pont).** Chaque
outil découvert est classé au moment du cache : **lecture par défaut** ;
écrivant si le serveur le déclare (annotation ``readOnlyHint: false``)
ou si son nom porte un verbe d'écriture (``create_``, ``propose_``,
``apply_``…). La classe est consommée par les portes de gouvernance
(``agent_policies.check_tool`` et ``PermissionManager`` via
``permission_manager.is_write_tool``) : un outil MCP écrivant suit le
même régime d'approbation et de rôles que les outils internes.
ponytail : **1 seul seam** ``_rpc()`` (monkeypatché en test) ; pas de
client SSE à l'état (les méthodes utilisées répondent en JSON ou en
événements ponctuels chez les serveurs du programme).
"""
from __future__ import annotations
@@ -19,7 +39,7 @@ import logging
import re
from app.db import get_conn
from app.services.connectors import _headers, _with_secret, get
from app.services.connectors import MCP_KINDS, _headers, _with_secret, get
logger = logging.getLogger(__name__)
@@ -27,6 +47,36 @@ logger = logging.getLogger(__name__)
PROTOCOL_VERSION = "2024-11-05"
CLIENT_INFO = {"name": "FlowDeck", "version": "7"}
#: Verbes d'écriture reconnus en tête d'un nom d'outil MCP quand le
#: serveur ne déclare pas d'annotation ``readOnlyHint``. ``propose_`` /
#: ``apply_`` sont le patron d'écriture approuvée d'ObsiGate : les deux
#: sont classés écrivants (voir aussi le blocage d'``apply_`` dans
#: ``tool_registry.McpTool``).
_PREFIXES_ECRITURE = (
"create_", "update_", "delete_", "add_", "remove_", "set_", "move_",
"write_", "edit_", "rename_", "propose_", "apply_", "archive_",
"unarchive_", "send_", "publish_", "close_", "sync_", "trigger_",
"import_",
)
#: Derniers ``Mcp-Session-Id`` capturés, par URL de serveur. Mémoire de
#: processus volontairement éphémère : une session perdue se rouvre par
#: un handshake, jamais par une persistance.
_sessions: dict[str, str] = {}
def classer_outil(nom_original: str, annotations: dict | None = None) -> bool:
"""True si l'outil MCP est **écrivant**.
L'annotation du serveur fait foi quand elle existe
(``readOnlyHint``) ; sinon, heuristique de nom. Lecture par défaut —
c'est la règle du plan du pont : un outil ne devient écrivant que
s'il est déclaré comme tel.
"""
if isinstance(annotations, dict) and "readOnlyHint" in annotations:
return not bool(annotations["readOnlyHint"])
return (nom_original or "").lower().startswith(_PREFIXES_ECRITURE)
def tool_name(server_name: str, tool_name: str) -> str:
"""Nom LLM stable : ``mcp_<serveur>_<outil>`` (slug minuscules/underscores)."""
@@ -36,24 +86,66 @@ def tool_name(server_name: str, tool_name: str) -> str:
return re.sub(r"_{2,}", "_", joined)
def parse_body(content_type: str, body: str, status: int) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événement ``data:`` d'un flux SSE)."""
def parse_body(content_type: str, body: str, status: int,
expected_id=None) -> dict:
"""Corps MCP → dict JSON (JSON direct ou événements ``data:`` SSE).
Dans un flux SSE multi-messages, le message retenu est celui dont
l'``id`` correspond à la requête (``expected_id``), sinon le
premier porteur de ``result``/``error``, sinon le premier message.
"""
text = body or ""
data = None
if "text/event-stream" in (content_type or ""):
candidats: list = []
for line in text.splitlines():
if line.startswith("data:"):
text = line[5:].strip()
break
try:
data = json.loads(text)
except ValueError as exc:
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
try:
candidats.append(json.loads(line[5:].strip()))
except ValueError:
continue
if expected_id is not None:
data = next((c for c in candidats
if isinstance(c, dict) and c.get("id") == expected_id),
None)
if data is None:
data = next((c for c in candidats
if isinstance(c, dict)
and ("result" in c or "error" in c)), None)
if data is None and candidats:
data = candidats[0]
if data is None:
raise ValueError(f"Réponse MCP illisible (HTTP {status})")
else:
try:
data = json.loads(text)
except ValueError as exc:
raise ValueError(f"Réponse MCP illisible (HTTP {status})") from exc
if isinstance(data, dict) and data.get("error"):
err = data["error"] if isinstance(data["error"], dict) else {}
raise ValueError(f"MCP {err.get('code', '?')} : {err.get('message', 'erreur')}")
return data if isinstance(data, dict) else {}
def _capturer_session(url: str, headers) -> None:
"""Mémorise le ``Mcp-Session-Id`` d'une réponse, s'il est présent."""
try:
sid = headers.get("mcp-session-id") if headers else None
except AttributeError:
sid = None
if sid:
_sessions[url] = sid
def _session_headers(url: str, headers: dict | None) -> dict:
"""En-têtes d'appel + session connue pour cette URL (si capturée)."""
out = dict(headers or {})
sid = _sessions.get(url)
if sid and not any(k.lower() == "mcp-session-id" for k in out):
out["Mcp-Session-Id"] = sid
return out
async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
"""POST JSON-RPC gardé SSRF → réponse décodée. Point d'injection des tests."""
from app.services.http_client import shared_client
@@ -65,8 +157,9 @@ async def _rpc(url: str, payload: dict, headers: dict | None = None) -> dict:
**(headers or {})}
async with shared_client(timeout=15, headers=hdrs) as client:
resp = await client.post(safe, json=payload)
_capturer_session(url, resp.headers)
return parse_body(resp.headers.get("content-type", ""), resp.text or "",
resp.status_code)
resp.status_code, expected_id=payload.get("id"))
async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
@@ -77,16 +170,37 @@ async def _notify(url: str, payload: dict, headers: dict | None = None) -> None:
logger.debug("MCP notification ignorée: %s", exc)
def _server(connector_id: int) -> dict:
async def _handshake(url: str, headers: dict | None) -> dict:
"""``initialize`` + ``notifications/initialized`` → en-têtes de session.
Obligatoire avant tout ``tools/call`` contre un serveur à session
(ObsiGate) ; sans effet de bord contre un serveur sans état. Les
en-têtes retournés portent le ``Mcp-Session-Id`` capturé pendant
``initialize`` quand le serveur en a émis un.
"""
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, headers)
session = _session_headers(url, headers)
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
session)
return session
def _server(connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> dict:
row = get(connector_id)
if row is None:
raise ValueError("Serveur MCP introuvable")
if row["kind"] not in ("mcp", "tiktak"):
if row["kind"] not in MCP_KINDS:
raise ValueError("Ce connecteur n'est pas un serveur MCP")
if not row["enabled"]:
raise ValueError("Serveur MCP désactivé")
return {"id": row["id"], "name": row["name"], "url": row["url"],
"headers": _headers(connector_id)}
"headers": _headers(connector_id, workspace_id=workspace_id,
distribute=distribute)}
def _normalize(server: dict, raw: list) -> list[dict]:
@@ -102,6 +216,7 @@ def _normalize(server: dict, raw: list) -> list[dict]:
"original": str(t["name"]),
"description": str(t.get("description") or "Outil MCP"),
"parameters": params,
"write": classer_outil(str(t["name"]), t.get("annotations")),
})
return out
@@ -113,19 +228,19 @@ def _save_tools(connector_id: int, tools: list[dict]) -> None:
conn.commit()
async def initialize_and_list(connector_id: int) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
await _rpc(url, {
"jsonrpc": "2.0", "id": 1, "method": "initialize",
"params": {"protocolVersion": PROTOCOL_VERSION, "capabilities": {},
"clientInfo": CLIENT_INFO},
}, srv["headers"])
await _notify(url, {"jsonrpc": "2.0", "method": "notifications/initialized"},
srv["headers"])
async def initialize_and_list(connector_id: int, *, workspace_id: int | None = None,
distribute=None) -> list[dict]:
"""Handshake + ``tools/list`` → met à jour le cache de la base.
Pour un serveur à référence ObsiGate, ``workspace_id`` est obligatoire
(isolation vérifiée avant distribution, voir ``connectors``).
"""
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
distribute=distribute)
session = await _handshake(url, srv["headers"])
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
"params": {}}, srv["headers"])
"params": {}}, session)
result = data.get("result") if isinstance(data.get("result"), dict) else {}
tools = _normalize(srv, result.get("tools") or [])
_save_tools(connector_id, tools)
@@ -137,7 +252,7 @@ def cached_tools() -> list[dict]:
with get_conn() as conn:
rows = conn.execute(
"SELECT id, tools_json FROM agent_connectors "
"WHERE kind IN ('mcp', 'tiktak') AND enabled=1"
"WHERE kind IN ('mcp', 'tiktak', 'obsigate') AND enabled=1"
).fetchall()
out: list[dict] = []
for r in rows:
@@ -169,14 +284,21 @@ def _content_text(result: dict) -> str:
return "\n".join(p for p in parts if p) or json.dumps(result, ensure_ascii=False)[:4000]
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.)."""
srv = _server(connector_id)
url = _with_secret(srv["url"], connector_id)
async def call_tool(connector_id: int, tool: str, arguments: dict | None = None, *,
workspace_id: int | None = None, distribute=None) -> dict:
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
Le handshake (avec session éventuelle) précède systématiquement
l'appel : c'est la condition T0 des serveurs à session.
"""
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
distribute=distribute)
session = await _handshake(url, srv["headers"])
data = await _rpc(url, {
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
"params": {"name": tool, "arguments": arguments or {}},
}, srv["headers"])
}, session)
result = data.get("result") if isinstance(data.get("result"), dict) else {}
text = _content_text(result)[:20000]
return {"status": "error" if result.get("isError") else "ok", "text": text}
+166
View File
@@ -0,0 +1,166 @@
"""Pilote ACC-6.2 — archiver un résumé de réunion Flowdeck dans
ObsiGate, **via la file d'approbations unifiée**.
Chaîne du pilote (patron d'écriture approuvée §7.5) :
1. ``demander_approbation`` — depuis la note de réunion (transcript
avec résumé généré), on prépare l'archive (titre, chemin cible
dans le vault, contenu Markdown) et on dépose une demande dans
``agent_approvals`` (outil ``archiver_reunion_obsigate``) : rien
n'est écrit tant que Bruno n'a pas approuvé.
2. À l'approbation (route gouvernance ``…/agent-approvals/{id}/decide``
qui appelle ``traiter_decision``), ``executer`` envoie le contenu à
ObsiGate par le pont MCP (ACC-2.2) en ``propose_write_file`` —
jamais en écriture directe : ObsiGate garde son propre brouillon,
l'application propriétaire tranche l'application finale.
3. La distribution est journalisée dans ``meeting_distributions``
(canal ``obsigate``), comme les autres canaux de diffusion.
Prérequis réels (gestes de Bruno, inchangés) : connecteur
« obsigate » configuré et sondé dans Flowdeck (clé ObsiGate dédiée
au pont, limitée à un vault) — sans lui, l'exécution échoue avec un
message explicite, la demande reste tracée. Le pilote est
**conditionnel au chantier Meetings** (décision A/B de Bruno) pour
son usage réel ; le code n'en dépend pas : il archive le résumé
d'un transcript existant.
"""
from __future__ import annotations
import asyncio
import json
import logging
import re
from app.db import get_conn
from app.services import draco_urls, meetings
logger = logging.getLogger(__name__)
#: Nom d'« outil » porté par les demandes d'approbation du pilote
#: dans la file unifiée (`agent_approvals`).
OUTIL = "archiver_reunion_obsigate"
#: Dossier cible dans le vault ObsiGate.
DOSSIER = "Réunions"
def _assainir(nom: str) -> str:
nom = re.sub(r"[\\/:*?\"<>|]+", " ", nom or "")
nom = re.sub(r"\s+", " ", nom).strip().strip(".")
return nom[:80] or "Réunion sans titre"
def construire_archive(tr: dict) -> dict:
"""Transcript (avec résumé) → {titre, chemin, contenu} de l'archive."""
titre = (tr.get("generated_title") or "").strip() or "Réunion"
date = str(tr.get("created_at") or "")[:10]
chemin = f"{DOSSIER}/{date + ' - ' if date else ''}{_assainir(titre)}.md"
lien = f"{draco_urls.flowdeck_url()}/pages/{tr['page_id']}"
contenu = (
f"# {titre}\n\n"
f"> Résumé de réunion archivé depuis Flowdeck "
f"({date or 'date inconnue'}) — note source : {lien}\n\n"
f"{(tr.get('summary') or '').strip()}\n"
)
return {"titre": titre, "chemin": chemin, "contenu": contenu}
def demander_approbation(transcript_id: int, user_id: int) -> dict:
"""Dépose la demande d'archivage dans la file d'approbations.
Lève ValueError si le transcript n'existe pas, si son résumé est
vide (rien à archiver) ou si une demande identique est déjà en
attente (pas de doublon dans la file).
"""
tr = meetings.get_transcript(transcript_id)
if not (tr.get("summary") or "").strip():
raise ValueError("Résumé vide : générer le résumé avant d'archiver")
archive = construire_archive(tr)
args = {"transcript_id": transcript_id, "chemin": archive["chemin"],
"titre": archive["titre"]}
with get_conn() as conn:
en_attente = conn.execute(
"SELECT id, args_json FROM agent_approvals "
"WHERE tool=? AND status='pending'", (OUTIL,)).fetchall()
for row in en_attente:
try:
if json.loads(row["args_json"] or "{}").get("transcript_id") == transcript_id:
raise ValueError(
"Une demande d'archivage est déjà en attente "
f"(approbation #{row['id']})")
except json.JSONDecodeError:
continue
cur = conn.execute(
"INSERT INTO agent_approvals "
"(conversation_id, tool, args_json, status, requester_id) "
"VALUES (0, ?, ?, 'pending', ?)",
(OUTIL, json.dumps(args, ensure_ascii=False), user_id))
conn.commit()
row = conn.execute("SELECT * FROM agent_approvals WHERE id=?",
(cur.lastrowid,)).fetchone()
return dict(row)
def _connecteur_obsigate() -> dict | None:
"""Premier connecteur ObsiGate activé (preset ACC-2.2), ou None."""
from app.services import connectors
for row in connectors.list_connectors():
if row.get("kind") == "obsigate" and row.get("enabled"):
return row
return None
async def executer(approval: dict) -> dict:
"""Exécute un archivage **approuvé** : propose_write_file via le
pont MCP. Retourne {status: ok|erreur, detail} — ne lève pas."""
from app.services import mcp_client
try:
args = json.loads(approval.get("args_json") or "{}")
except json.JSONDecodeError:
args = {}
transcript_id = args.get("transcript_id")
conn = _connecteur_obsigate()
if conn is None:
return {"status": "erreur",
"detail": "Connecteur ObsiGate non configuré dans Flowdeck "
"(Panneau agent → Connecteurs → ObsiGate) : "
"archivage impossible."}
try:
tr = meetings.get_transcript(int(transcript_id))
archive = construire_archive(tr)
res = await mcp_client.call_tool(
conn["id"], "propose_write_file",
{"path": archive["chemin"], "content": archive["contenu"]})
except Exception as exc: # noqa: BLE001 — l'échec est journalisé, pas levé
logger.warning("Archivage réunion ObsiGate échoué: %s", exc)
return {"status": "erreur", "detail": str(exc)[:300]}
if res.get("status") != "ok":
return {"status": "erreur",
"detail": (res.get("text") or "ObsiGate a refusé la proposition")[:300]}
meetings.record_distribution(
int(transcript_id), channel="obsigate",
actor_id=approval.get("approver_id"),
recipients=archive["chemin"], status="propose")
return {"status": "ok",
"detail": f"Proposition d'écriture envoyée à ObsiGate : "
f"{archive['chemin']} (brouillon à appliquer dans ObsiGate)"}
def executer_sync(approval: dict) -> dict:
"""Variante synchrone pour les routes non async (threadpool)."""
return asyncio.run(executer(approval))
def traiter_decision_sync(approval: dict) -> dict | None:
"""Après décision dans la file unifiée : si la demande est du
pilote et approuvée, exécuter l'archivage. Sinon None (autre
outil) — la route gouvernance est synchrone (threadpool)."""
if not approval or approval.get("tool") != OUTIL:
return None
if approval.get("status") != "approved":
return {"status": "rejete",
"detail": "Demande rejetée : rien n'a été envoyé à ObsiGate."}
return executer_sync(approval)
+2 -2
View File
@@ -717,8 +717,8 @@ async def summarize_transcript(transcript_id: int, user_id: int | None = None,
def record_distribution(transcript_id: int, *, channel: str,
actor_id: int | None = None,
recipients: str = "", status: str = "created") -> dict:
if channel not in ("copy_link", "email", "slack"):
raise ValueError("channel must be copy_link|email|slack")
if channel not in ("copy_link", "email", "slack", "obsigate"):
raise ValueError("channel must be copy_link|email|slack|obsigate")
get_transcript(transcript_id)
with get_conn() as conn:
cur = conn.execute(
+23 -1
View File
@@ -55,6 +55,28 @@ DESTRUCTIVE_TOOLS = {
}
def is_write_tool(tool: str) -> bool:
"""L'outil est-il écrivant ? Outils internes (``WRITE_TOOLS``) et
outils **MCP dynamiques** classés écrivants à la découverte
(``mcp_client.classer_outil`` — garde-fou n° 2 du pont ObsiGate,
ACC-2.2 : avant, un ``mcp_*`` passait toujours pour une lecture,
donc un ``apply_*`` distant échappait aux deux portes).
"""
if tool in WRITE_TOOLS:
return True
if tool.startswith("mcp_"):
try:
from app.services import mcp_client
for entry in mcp_client.cached_tools():
if entry.get("name") == tool:
return bool(entry.get("write"))
except Exception: # noqa: BLE001 — la classification ne casse rien
logger.debug("is_write_tool: classification MCP indisponible",
exc_info=True)
return False
class PermissionManager:
"""Resolves workspace role and gates agent + granular ACL checks."""
@@ -186,7 +208,7 @@ class PermissionManager:
- destructive tools: admin/owner, or requires confirm approval mode.
"""
role = self.role_in_workspace(workspace_id)
if tool in WRITE_TOOLS and role not in WRITE_ROLES:
if is_write_tool(tool) and role not in WRITE_ROLES:
raise HTTPException(
status_code=403,
detail=f"Agent tool '{tool}' requires editor+ role (user is '{role}')",
+218
View File
@@ -0,0 +1,218 @@
"""Format de skill commun (Fusion ACC-0.3) — lecture, validation,
conversion (ACC-4.5).
Le contrat figé vit dans `dracodev-commons/contrats/skill-format.md` :
un dossier par skill, un `SKILL.md` à frontmatter YAML (`name`,
`description` = déclencheur, `version`, `apps`, `domaines`, `outils`,
section `ecritures` obligatoire dès qu'il y a modification) et un
corps en trois sections ordonnées — **Quand l'utiliser**,
**Procédure**, **Garde-fous**.
Ce module est la part Flowdeck de la preuve du format (ACC-4.5) :
- ``analyser_skill_md`` : SKILL.md → structure (métadonnées + sections) ;
- ``valider`` : contrôle du contrat, liste d'erreurs lisibles ;
- ``vers_champs_flowdeck`` : conversion vers les champs d'un skill
Flowdeck (`agent_skills` / galerie) — les outils `flowdeck.*`
deviennent les `allowed_tools`, la Procédure devient le
`prompt_template` (précédée du déclencheur et suivie des garde-fous) ;
- ``depuis_preset_flowdeck`` : conversion inverse d'un preset de la
galerie vers un SKILL.md au format commun (point de départ d'une
conversion relue par un humain).
Les skills convertis de la galerie vivent dans `skills-communs/` à la
racine du dépôt ; ils sont installables tels quels via
``skill_gallery.upsert_skill(vers_champs_flowdeck(...))``.
"""
from __future__ import annotations
import re
import yaml
#: Sections du corps, dans l'ordre imposé par le contrat.
SECTIONS = ("Quand l'utiliser", "Procédure", "Garde-fous")
#: Valeurs admises pour l'approbation d'une écriture.
APPROBATIONS = ("toujours", "selon-portee", "jamais")
#: Champs de frontmatter obligatoires (contrat § « En-tête »).
CHAMPS_REQUIS = ("name", "description", "version", "apps", "domaines")
_KEBAB = re.compile(r"^[a-z0-9]+(-[a-z0-9]+)*$")
_TITRE_SECTION = re.compile(r"^#{1,3}\s+(.+?)\s*$", re.MULTILINE)
def analyser_skill_md(texte: str) -> dict:
"""SKILL.md → {meta, sections, corps}. Lève ValueError si le
frontmatter est absent ou illisible."""
brut = (texte or "").replace("\r\n", "\n")
if not brut.startswith("---\n"):
raise ValueError("SKILL.md doit commencer par un frontmatter YAML (---)")
fin = brut.find("\n---", 4)
if fin < 0:
raise ValueError("Frontmatter YAML non fermé (---)")
try:
meta = yaml.safe_load(brut[4:fin]) or {}
except yaml.YAMLError as exc:
raise ValueError(f"Frontmatter YAML illisible : {exc}") from exc
if not isinstance(meta, dict):
raise ValueError("Frontmatter YAML : un objet est attendu")
corps = brut[fin + 4:].lstrip("\n")
sections: dict[str, str] = {}
marques = [(m.start(), m.group(1).strip()) for m in _TITRE_SECTION.finditer(corps)]
for i, (pos, titre) in enumerate(marques):
if titre in SECTIONS:
debut = corps.find("\n", pos) + 1
fin_s = marques[i + 1][0] if i + 1 < len(marques) else len(corps)
sections[titre] = corps[debut:fin_s].strip()
return {"meta": meta, "sections": sections, "corps": corps}
def _outils_flowdeck(skill: dict) -> list[str]:
"""Noms d'outils Flowdeck déclarés (préfixe `flowdeck.` retiré),
lectures (`outils`) puis écritures (`ecritures`), sans doublons."""
meta = skill["meta"]
noms: list[str] = []
for entree in meta.get("outils") or []:
nom = str(entree).strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
for entree in meta.get("ecritures") or []:
if isinstance(entree, dict):
nom = str(entree.get("action") or "").strip()
if nom.startswith("flowdeck."):
noms.append(nom[len("flowdeck."):])
return list(dict.fromkeys(noms))
def valider(skill: dict) -> list[str]:
"""Contrôle le contrat ACC-0.3 → liste d'erreurs (vide = conforme)."""
erreurs: list[str] = []
meta = skill.get("meta") or {}
for champ in CHAMPS_REQUIS:
valeur = meta.get(champ)
if valeur is None or (isinstance(valeur, str) and not valeur.strip()) \
or (isinstance(valeur, list) and not valeur):
erreurs.append(f"Champ obligatoire manquant ou vide : {champ}")
nom = str(meta.get("name") or "")
if nom and not _KEBAB.match(nom):
erreurs.append(f"name doit être en kebab-case : {nom!r}")
for champ in ("apps", "domaines"):
valeur = meta.get(champ)
if valeur is not None and (not isinstance(valeur, list)
or not all(isinstance(v, str) for v in valeur)):
erreurs.append(f"{champ} doit être une liste de chaînes")
outils = meta.get("outils") or []
if not isinstance(outils, list) or not all(isinstance(o, str) for o in outils):
erreurs.append("outils doit être une liste de noms (app.outil)")
ecritures = meta.get("ecritures") or []
if not isinstance(ecritures, list):
erreurs.append("ecritures doit être une liste d'objets {action, approbation}")
ecritures = []
for entree in ecritures:
if not isinstance(entree, dict) or not str(entree.get("action") or "").strip():
erreurs.append("Chaque écriture doit déclarer une action")
continue
approbation = str(entree.get("approbation") or "selon-portee")
if approbation not in APPROBATIONS:
erreurs.append(
f"approbation inconnue pour {entree['action']} : {approbation!r} "
f"(attendu : {', '.join(APPROBATIONS)})")
# Règle du contrat : « ecritures » obligatoire dès que le skill
# modifie quelque chose — pour la part Flowdeck, un outil déclaré
# qui est un outil d'écriture interne impose la section.
if not ecritures and "flowdeck" in (meta.get("apps") or []):
from app.services.permission_manager import DESTRUCTIVE_TOOLS, WRITE_TOOLS
modifiants = [o for o in _outils_flowdeck(skill)
if o in WRITE_TOOLS or o in DESTRUCTIVE_TOOLS]
if modifiants:
erreurs.append(
"Section ecritures obligatoire : le skill utilise des outils "
f"d'écriture Flowdeck ({', '.join(modifiants)})")
sections = skill.get("sections") or {}
for titre in SECTIONS:
if not sections.get(titre):
erreurs.append(f"Section de corps manquante ou vide : « {titre} »")
return erreurs
def vers_champs_flowdeck(skill: dict) -> dict:
"""Skill au format commun → champs Flowdeck (`agent_skills`).
Le `prompt_template` assemble le déclencheur, la procédure et les
garde-fous ; les écritures déclarées restent visibles dans le
prompt (le skill ne s'exécute jamais sans la politique d'agent).
"""
erreurs = valider(skill)
if erreurs:
raise ValueError("Skill non conforme au format commun : " + " ; ".join(erreurs))
meta = skill["meta"]
sections = skill["sections"]
morceaux = [
"Quand l'utiliser\n" + sections["Quand l'utiliser"],
"Procédure\n" + sections["Procédure"],
"Garde-fous\n" + sections["Garde-fous"],
]
ecritures = meta.get("ecritures") or []
if ecritures:
lignes = "\n".join(
f"- {e['action']} : approbation "
f"{e.get('approbation') or 'selon-portee'}"
for e in ecritures)
morceaux.append(f"Écritures déclarées (format commun)\n{lignes}")
return {
"name": str(meta["name"]),
"description": str(meta["description"]).strip(),
"prompt_template": "\n\n".join(morceaux),
"allowed_tools": _outils_flowdeck(skill),
}
def _slug(nom: str) -> str:
import unicodedata
plat = unicodedata.normalize("NFD", nom.lower())
plat = "".join(c for c in plat if unicodedata.category(c) != "Mn")
slug = re.sub(r"[^a-z0-9]+", "-", plat).strip("-")
return slug or "skill"
def depuis_preset_flowdeck(preset: dict, *, domaines: list[str] | None = None) -> str:
"""Preset de la galerie Flowdeck → SKILL.md au format commun.
Point de départ d'une conversion (ACC-4.5) : les outils
d'écriture du preset deviennent la section `ecritures`
(approbation `selon-portee`, le régime par défaut du contrat) ;
le texte est à relire par un humain avant diffusion.
"""
from app.services.permission_manager import WRITE_TOOLS
outils = [str(o) for o in preset.get("allowed_tools") or []]
lectures = [o for o in outils if o not in WRITE_TOOLS]
ecritures = [o for o in outils if o in WRITE_TOOLS]
meta: dict = {
"name": _slug(str(preset.get("name") or "skill")),
"description": str(preset.get("description") or "").strip(),
"version": "1.0.0",
"apps": ["flowdeck"],
"domaines": domaines or ["pro"],
"outils": [f"flowdeck.{o}" for o in lectures],
}
if ecritures:
meta["ecritures"] = [
{"action": f"flowdeck.{o}", "approbation": "selon-portee"}
for o in ecritures
]
front = yaml.safe_dump(meta, allow_unicode=True, sort_keys=False).strip()
corps = (
f"## Quand l'utiliser\n\n{preset.get('description') or ''}\n\n"
f"## Procédure\n\n{preset.get('prompt_template') or ''}\n\n"
"## Garde-fous\n\n"
"- N'agir que dans les domaines déclarés ; un besoin croisé "
"entre domaines est signalé, jamais contourné.\n"
"- Ne jamais inventer un contenu absent des sources lues.\n"
)
return f"---\n{front}\n---\n\n{corps}"
+15 -1
View File
@@ -1230,9 +1230,23 @@ class McpTool(Tool):
async def execute(self, args, *, user_id=None) -> ToolResult:
from app.services import mcp_client
# Garde-fou du pont (ACC-2.2, patron §7.5) : un outil distant
# « apply_* » EXÉCUTE une écriture croisée — il n'est jamais
# utilisable par un simple appel d'outil depuis un agent
# Flowdeck. L'écriture passe par « propose_* » (classé
# écrivant : rôles + approbation) puis par l'approbation dans
# l'application propriétaire de la donnée.
original = self.entry.get("original") or ""
if original.lower().startswith("apply_"):
return ToolResult(
status="error", tool=self.name, target_type="mcp",
message=(f"Outil « {original} » bloqué côté client : une "
"écriture croisée ne s'exécute pas par un appel "
"d'outil — propose_* puis approbation dans "
"l'application propriétaire (patron §7.5)."))
try:
res = await mcp_client.call_tool(self.entry["connector_id"],
self.entry.get("original") or "", args)
original, args)
except ValueError as exc:
return ToolResult(status="error", tool=self.name, message=str(exc))
except Exception as exc: # noqa: BLE001 — réseau : erreur outil, pas run
+19 -10
View File
@@ -1,11 +1,15 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
(data-app="agent", draco_urls().agent), après Kanbante ; l'entrée
« Console Spark » du menu de la marque est conservée.
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
<header class="draco-bar" data-current="flowdeck">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
@@ -31,6 +35,10 @@
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
<a class="draco-apptab" data-app="agent" href="{{ draco_urls().agent }}/">
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
@@ -56,17 +64,17 @@
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script nonce="{{ csp_nonce() }}">
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le hamburger natif, les alertes ouvrent la cloche
native de la page ; la recherche fédérée et Spark arrivent avec
les phases suivantes du programme Fusion. */
le rail pilote le hamburger natif, les alertes ouvrent le
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
pas une fédération temps réel ; la cloche native reste le
repli si le module ne charge pas). */
window.DRACO_BAR_CONFIG = {
current: "flowdeck",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
@@ -82,12 +90,12 @@
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
if (act === "alertes") {
var b = document.querySelector(".fd-notif-bell button");
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); }
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
return;
}
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); return; }
if (act === "menu") { dire("Menu commun : arrive avec la fédération (programme Fusion)."); return; }
if (act === "palette") { location.href = "/recherche"; return; }
/* Correctif fusion #70 : action "menu" supprimée avec le bouton trois points (console agent dans le menu de la marque). Nonce CSP du script inchangé. */
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -101,3 +109,4 @@
}
};
</script>
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
+2 -1
View File
@@ -452,6 +452,7 @@ body.fd-ap-resizing *{cursor:col-resize!important}
<option value="telegram">Telegram (bot)</option>
<option value="mcp">Serveur MCP (streamable HTTP)</option>
<option value="tiktak">TikTak — tâches (MCP, Fusion)</option>
<option value="obsigate">ObsiGate — notes (MCP, Fusion, pont lecture)</option>
</select>
<label for="fd-plus-cn-name">Nom</label>
<input id="fd-plus-cn-name" type="text" x-model="connectorForm.name" placeholder="ex. Base de connaissances" @keydown.enter.prevent="connectorCreate()">
@@ -533,5 +534,5 @@ body.fd-ap-resizing *{cursor:col-resize!important}
</div>
</div>
<script nonce="{{ csp_nonce() }}">window.FLOWDECK_TIKTAK_MCP_URL = {{ (draco_urls().tiktak + "/mcp") | tojson }};</script>
<script nonce="{{ csp_nonce() }}">window.FLOWDECK_TIKTAK_MCP_URL = {{ (draco_urls().tiktak + "/mcp") | tojson }};window.FLOWDECK_OBSIGATE_MCP_URL = {{ (draco_urls().obsigate + "/mcp") | tojson }};</script>
<script data-cfasync="false" nonce="{{ csp_nonce() }}" src="/static/js/agent_panel_2.js?v={{ asset_version }}"></script>
+36
View File
@@ -0,0 +1,36 @@
{% extends "base.html" %}
{% block title_prefix %}Rechercher partout{% endblock %}
{% block content %}
<link rel="stylesheet" href="/static/css/recherche.css">
<main class="rp-page" id="recherche" data-host="flowdeck"
data-base-obsigate="{{ bases.obsigate }}"
data-base-flowdeck="{{ bases.flowdeck }}"
data-base-tiktak="{{ bases.tiktak }}"
data-base-kanbante="{{ bases.kanbante }}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value="{{ q }}"
placeholder="Rechercher partout…" autocomplete="off" autofocus>
</div>
<section class="rp-section" data-rp-section="obsigate">
<div class="rp-head"><h2>ObsiGate — notes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="flowdeck">
<div class="rp-head"><h2>Flowdeck — pages</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="tiktak">
<div class="rp-head"><h2>TikTak — tâches</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="kanbante">
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
</main>
{% endblock %}
{% block scripts %}
<script src="/static/js/recherche.js"></script>
{% endblock %}
+117
View File
@@ -0,0 +1,117 @@
# Flowdeck — Connecteurs à référence ObsiGate (Phase 4, issue ObsiGate #22)
## Principe
Un connecteur personnel d'un workspace peut pointer vers une référence
ObsiGate au lieu d'un jeton :
```
coffre:<slug>/<nom> ex. coffre:flowdeck/gitea-lecture
```
* Le champ jeton du connecteur **devient** le champ référence : aucun
jeton n'est stocké pour ce connecteur (`secret_encrypted` reste vide).
* L'octroi est **toujours lié à un `workspace_id`** : créer un connecteur
à référence sans workspace est refusé (HTTP 400 / `ValueError`).
* À l'appel, le **serveur** Flowdeck appelle
`POST /api/coffres/distribuer` d'ObsiGate avec le jeton consommateur
Flowdeck et un contexte `flowdeck_workspace / <workspace_id>`, utilise
la valeur **en mémoire**, puis l'oublie. La valeur n'est jamais
persistée, jamais mise en cache, jamais journalisée, jamais renvoyée
à une réponse API ni au LLM.
## Configuration (serveur Flowdeck)
| Variable | Défaut | Rôle |
| --- | --- | --- |
| `OBSIGATE_COFFRE_URL` | dérivée de `DRACO_SSO_URL` | Base ObsiGate pour `/api/coffres/distribuer` |
| `OBSIGATE_CONSUMER_TOKEN` | (vide = distribution désactivée) | Jeton **consommateur** Flowdeck enregistré dans ObsiGate |
Le jeton consommateur est distinct du jeton MCP de notes et de la
session SSO. Il ne figure dans aucun log, rapport ou réponse.
## Isolation workspace — ce qui est garanti, et sa limite honnête
**Garanti côté Flowdeck, avant tout appel** (`connectors.resolve_secret`) :
* un connecteur à référence ne se résout que pour son propre
`workspace_id` ;
* workspace A tentant d'utiliser le connecteur (donc la référence /
l'octroi) du workspace B reçoit un refus explicite
(`CoffreWorkspaceMismatch` : « … ne peut pas utiliser ce connecteur
(il appartient au workspace …) : octroi d'un autre workspace refusé »)
et **le distributeur n'est pas appelé** ;
* un `workspace_id` déjà posé sur un connecteur est immuable : on ne
transfère pas un octroi en éditant le connecteur ;
* un connecteur à référence testé/appelé sans `workspace_id` échoue
proprement, jamais avec la valeur d'un autre workspace.
**Limite à connaître** : côté ObsiGate, l'octroi est accordé au
consommateur **Flowdeck** (un jeton par application), pas à un workspace
— le `contexte_id` transmis sert à la traçabilité, pas à un contrôle
d'accès côté ObsiGate. L'isolation par workspace repose donc sur le
contrôle Flowdeck ci-dessus. Une isolation appliquée côté ObsiGate
exigerait un consommateur par workspace : c'est un reste à faire
décidé côté ObsiGate (enregistrement de consommateurs par workspace),
pas un prérequis pour utiliser ce mécanisme.
## Échecs propres, sans repli
| Situation | Comportement Flowdeck |
| --- | --- |
| ObsiGate injoignable / timeout / HTTP 5xx | `CoffreIndisponible` — l'appel échoue, **aucune** valeur en cache n'est utilisée (il n'y en a pas) |
| Octroi révoqué / consommateur révoqué (403) | `CoffreAccesRefuse` — message « accès révoqué, à ré-accorder dans ObsiGate » |
| Octroi expiré (403) / secret expiré (410) | `CoffreAccesRefuse` avec le code ObsiGate, sans la valeur |
| Référence mal formée | `CoffreReferenceInvalide` (format `coffre:<slug>/<nom>`, slug 3–64, nom 3–128, minuscules, chiffres, `-`/`_`, aligné sur ObsiGate) |
## Secrets déjà chiffrés : pas de big-bang
Les connecteurs historiques (`secret_encrypted` Fernet) restent valides
tels quels. La migration est **opportuniste, connecteur par
connecteur** :
1. (optionnel) rattacher d'abord le connecteur à son workspace
(`PATCH /api/agent/connectors/{id}` avec `workspace_id`) ;
2. puis basculer vers la référence (`PATCH …` avec
`coffre_reference: "coffre:<slug>/<nom>"`) — le jeton local dormant
n'est pas effacé automatiquement, il est simplement ignoré tant que
la référence est posée ; effacer la référence (`""`) le réactive ;
3. vérifier par un probe avec `workspace_id`, puis supprimer le jeton
local si besoin (rotation côté ObsiGate).
## Chemin de migration par connecteur
| Connecteur actuel | État dans cette branche | Chemin |
| --- | --- | --- |
| Personnels (`custom`, `discord`, `telegram`, `mcp`, `tiktak`, `obsigate`) | **Implémenté** : `workspace_id` + `coffre_reference`, résolution en mémoire, isolation testée | Basculer connecteur par connecteur (ci-dessus) |
| Natifs Gitea / GitHub | **Inchangés** : jetons globaux d'instance (`GITEA_TOKEN`, `GITHUB_TOKEN` en `.env`) | Créer un connecteur personnel workspace-scopé à référence vers le même service et l'utiliser à la place du natif, quand le besoin workspace par workspace se présente ; les natifs ne sont pas supprimés (pas de big-bang) |
| LLM global (`llm_config` id=1) et clés par utilisateur (`user_llm_keys`) | **Inchangés** : clés en clair en base (globale / par utilisateur, pas par workspace) | Travail ultérieur dédié : ajouter une colonne référence sur `user_llm_keys` + résolution par workspace, avec le même patron (distribution en mémoire, workspace obligatoire) ; hors périmètre de cette branche |
| OAuth Google / M365 (`connector_tokens`) | **Inchangés** : jetons OAuth chiffrés par (kind, utilisateur) | Hors périmètre : le flux OAuth ne se prête pas à une référence coffre sans repenser le refresh ; à traiter séparément si besoin |
## API
* `POST /api/agent/connectors` accepte `workspace_id` et
`coffre_reference` (référence sans workspace → 400).
* `PATCH /api/agent/connectors/{id}` accepte `workspace_id`
(premier rattachement seulement) et `coffre_reference` (`""` =
effacer la référence).
* `POST /api/agent/connectors/probe` accepte `workspace_id`.
* Réponses : `workspace_id`, `coffre_reference`, `secret_source`
(`coffre` | `chiffre_local` | `aucun`) — jamais de valeur.
* `GET /api/agent/connectors` liste les connecteurs avec ces champs.
## Ce qui reste à faire (validation réelle)
* Valider contre une vraie instance ObsiGate : créer le consommateur
Flowdeck + les octrois, puis un probe réel avec `workspace_id`
(les tests ici utilisent un faux endpoint de distribution).
* Threader `workspace_id` depuis l'agent : `AgentEngine.workspace_id`
→ `connector_fetch` (outil LLM) et `McpTool.call_tool` — aujourd'hui
ces chemins appellent sans workspace et échouent proprement pour les
connecteurs à référence (refus, pas de fuite) ; les connecteurs
historiques ne sont pas affectés.
* Le cas échéant, consommateur ObsiGate par workspace (voir limite
ci-dessus) et bascule des natifs Gitea/GitHub et des clés LLM
(tableau ci-dessus).
* Interface : sélecteur de référence dans le formulaire connecteur
(aujourd'hui, champ `coffre_reference` via l'API seulement).
@@ -0,0 +1,46 @@
---
name: compte-rendu-reunion
description: >
Transforme une note brute de réunion en compte rendu structuré
(décisions et actions) dans Flowdeck. À utiliser quand Bruno demande
un compte rendu, un CR ou une synthèse décisionnelle à partir d'une
note de réunion.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno fournit une note de réunion brute — ou demande de la
retrouver dans le workspace — et veut un compte rendu propre :
décisions prises, actions avec responsable et échéance, points
ouverts. Ne pas l'utiliser pour un simple résumé informatif sans
décisions (voir `resume-document`).
## Procédure
1. Retrouver la note de réunion si elle n'est pas fournie
(search_workspace), puis la lire intégralement (read_document).
2. Créer un document « CR — <titre> » (create_document).
3. Y écrire (write_blocks) la structure : Contexte · Décisions
prises · Actions (avec responsable et échéance) · Points ouverts.
4. Ne garder que ce qui est dans la note ; lister explicitement les
points manquants plutôt que de les combler.
## Garde-fous
- Aucune décision ni action inventée : tout vient de la note lue.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture du document suit la politique d'approbation de l'agent
(section `ecritures`) : le contenu exact est présenté avant
exécution quand la portée l'exige.
+46
View File
@@ -0,0 +1,46 @@
---
name: rapport-hebdo
description: >
Agrège les pages et documents modifiés de la semaine dans Flowdeck
et rédige un rapport structuré de l'équipe. À utiliser quand Bruno
demande le rapport hebdomadaire, un bilan de semaine ou l'état
d'avancement de l'équipe.
version: 1.0.0
apps: [flowdeck]
domaines: [pro]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno demande le rapport de la semaine : ce qui a avancé, ce
qui bloque, ce qui a été décidé, et le plan de la semaine suivante.
Ne pas l'utiliser pour une seule réunion (voir
`compte-rendu-reunion`) ni pour un document unique (voir
`resume-document`).
## Procédure
1. Repérer les pages et documents modifiés cette semaine
(search_workspace).
2. Lire les plus significatifs (read_document) et en extraire
avancées, blocages et décisions.
3. Créer un document « Rapport hebdo — <date> » (create_document) et
y écrire (write_blocks) la structure : Résumé · Faits marquants ·
Blocages · Plan de la semaine prochaine.
## Garde-fous
- Chaque affirmation s'appuie sur un document lu, jamais sur une
supposition ; une section sans élément le dit explicitement.
- Domaine professionnel uniquement (domaine déclaré) : ne pas
mélanger des contenus famille ou perso au rapport d'équipe.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+45
View File
@@ -0,0 +1,45 @@
---
name: resume-document
description: >
Résume un document long de Flowdeck en une page : points clés,
chiffres marquants, décisions et questions ouvertes. À utiliser
quand Bruno demande de résumer, condenser ou synthétiser un
document précis.
version: 1.0.0
apps: [flowdeck]
domaines: [pro, famille, perso]
outils:
- flowdeck.search_workspace
- flowdeck.read_document
ecritures:
- action: flowdeck.create_document
approbation: selon-portee
- action: flowdeck.write_blocks
approbation: selon-portee
---
## Quand l'utiliser
Quand Bruno désigne un document — ou demande de le retrouver — et
veut l'essentiel en une page. Ne pas l'utiliser pour agréger
plusieurs documents sur une période (voir `rapport-hebdo`) ni pour
produire un compte rendu décisionnel de réunion (voir
`compte-rendu-reunion`).
## Procédure
1. Retrouver le document s'il n'est pas fourni (search_workspace),
puis le lire intégralement (read_document).
2. Créer un document « Résumé — <titre> » (create_document).
3. Y écrire (write_blocks) : 5 points clés, chiffres marquants,
décisions/engagements, questions restées ouvertes. Maximum une
page, phrases courtes.
## Garde-fous
- Aucune reformulation qui change le sens ; en cas de doute,
citer le passage plutôt que l'interpréter.
- N'agir que dans les domaines déclarés ; un besoin croisé entre
domaines est signalé, jamais contourné.
- L'écriture suit la politique d'approbation de l'agent (section
`ecritures`).
+58
View File
@@ -0,0 +1,58 @@
/* Alertes unifiées dracodev — Fusion #69. Styles neutres, variables
* d'hôte avec replis sombres (les 4 hôtes sont à dominante sombre). */
.draco-alertes-overlay {
position: fixed; inset: 0; z-index: 30000;
display: none; align-items: flex-start; justify-content: center;
padding: 8vh 16px 16px;
background: rgb(0 0 0 / .55); overflow-y: auto;
}
.draco-alertes-box {
width: 720px; max-width: 100%;
background: var(--bg-primary, #1f1f1f);
color: var(--text, #eceee9);
border: 1px solid var(--border, #333);
border-radius: 14px; box-shadow: 0 16px 48px rgb(0 0 0 / .5);
padding: 16px 18px 18px;
}
.draco-alertes-head { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
.draco-alertes-head h2 { margin: 0; font-size: 18px; }
.draco-alertes-close {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 8px; padding: 4px 10px; cursor: pointer;
}
.draco-alertes-note { font-size: 12.5px; color: var(--text-dim, #999); margin: 8px 0 14px; }
.draco-alertes-group { font-size: 15px; margin: 18px 0 6px; }
.draco-alertes-section h3 { font-size: 14px; margin: 12px 0 6px; }
.draco-alertes-item {
border: 1px solid var(--border, #2a2a2a); border-radius: 10px;
padding: 9px 12px; margin: 6px 0;
}
.draco-alertes-item.is-active { border-color: #e8762c; }
.draco-alertes-title { font-weight: 600; color: inherit; text-decoration: none; }
.draco-alertes-title:hover { text-decoration: underline; }
.draco-alertes-detail {
font-size: 13px; color: var(--text-dim, #bbb); margin-top: 2px;
display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden;
}
.draco-alertes-meta { font-size: 11.5px; color: var(--text-tertiary, #777); margin-top: 4px; }
.draco-alertes-empty { font-size: 13px; color: var(--text-dim, #999); padding: 6px 0; }
.draco-alertes-empty a { color: inherit; }
/* Chantier 5 — onglets + filtre (mêmes variables d'hôte, mêmes replis). */
.draco-alertes-tabs { display: flex; flex-wrap: wrap; gap: 6px; margin: 2px 0 12px; }
.draco-alertes-tab {
border: 1px solid var(--border, #333); background: transparent;
color: inherit; border-radius: 999px; padding: 5px 11px;
cursor: pointer; font-size: 13px; line-height: 1.3;
}
.draco-alertes-tab[aria-selected="true"] { background: #e8762c; border-color: #e8762c; color: #fff; }
.draco-alertes-tab:focus-visible { outline: 2px solid #e8762c; outline-offset: 2px; }
.draco-alertes-filtre { display: flex; gap: 8px; align-items: center; margin: 0 0 4px; }
.draco-alertes-filtre label { font-size: 13px; color: var(--text-dim, #999); }
.draco-alertes-filtre input {
flex: 1; min-width: 0; background: transparent; color: inherit;
border: 1px solid var(--border, #333); border-radius: 8px; padding: 6px 10px;
font-size: 13px;
}
.draco-alertes-compte { font-size: 12.5px; color: var(--text-dim, #999); margin: 4px 0 10px; min-height: 1em; }
.draco-alertes-compte:empty { display: none; }
.draco-alertes-item.is-error { border-color: #c2437e; }
+40 -6
View File
@@ -2,7 +2,11 @@
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée) ;
v0.1.6 : menu compte (construit par le JS, mêmes jetons que le
menu des applications) ; l'avatar est un vrai <button>.
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
défaut supprimés ; le menu de la marque gagne « Console Spark ».
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -32,6 +36,7 @@
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
@@ -42,6 +47,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menu compte (v0.1.6) — même boîte que le menu des apps, ancré à
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
@@ -122,8 +150,10 @@
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
display: grid; place-items: center; flex: none;
background: #2f6fed; color: #fff; font: inherit; font-weight: 700;
cursor: pointer;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
@@ -147,8 +177,8 @@
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
avatar. Le rail, la recherche et les domaines disparaissent :
chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
@@ -162,5 +192,9 @@
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
/* Chantier 5 : sur la console elle-même (data-current="agent",
programme console #71), c'est l'onglet Console Spark qui reste
visible en mobile — les 4 hôtes gardent leur propre courant. */
.draco-bar[data-current="agent"] .draco-apptab[data-app="agent"] { display: flex; }
}
+35
View File
@@ -0,0 +1,35 @@
/* Recherche partout (fusion #68) — classes préfixées .rp- pour éviter
* toute collision avec les styles de l'hôte. Couleurs neutres héritées
* autant que possible : la page reste lisible en thème clair et sombre. */
.rp-page { max-width: 880px; margin: 0 auto; padding: 24px 16px 64px; }
.rp-page h1 { font-size: 1.4rem; margin: 0 0 4px; }
.rp-hint { opacity: .7; margin: 0 0 16px; font-size: .9rem; }
.rp-form { display: flex; gap: 8px; margin-bottom: 24px; }
.rp-input {
flex: 1; font: inherit; font-size: 1.05rem; padding: 10px 12px;
border: 1px solid #888; border-radius: 8px; background: inherit; color: inherit;
}
.rp-input:focus { outline: 2px solid #3b82f6; outline-offset: 1px; }
.rp-section { margin-bottom: 28px; }
.rp-head {
display: flex; align-items: baseline; gap: 10px;
border-bottom: 1px solid #888; padding-bottom: 6px; margin-bottom: 8px;
}
.rp-head h2 { font-size: 1.05rem; margin: 0; }
.rp-count {
font-size: .8rem; font-weight: 700; background: #3b82f6; color: #fff;
border-radius: 999px; padding: 1px 8px; min-width: 1.2em; text-align: center;
}
.rp-count:empty { display: none; }
.rp-all { margin-left: auto; font-size: .85rem; white-space: nowrap; }
.rp-state { opacity: .75; margin: 8px 0; }
.rp-error { opacity: 1; }
.rp-item {
display: block; padding: 8px 10px; border-radius: 8px;
text-decoration: none; color: inherit;
}
.rp-item:hover, .rp-item:focus { background: rgba(127, 127, 127, .15); outline: none; }
.rp-item:focus-visible { outline: 2px solid #3b82f6; }
.rp-title { display: block; font-weight: 600; }
.rp-context { display: block; font-size: .8rem; opacity: .65; }
.rp-snippet { display: block; font-size: .88rem; opacity: .85; margin-top: 2px; }
+3 -1
View File
@@ -1425,7 +1425,9 @@
hint:'BotFather → Bot Token ({secret} complète l\u2019URL)'},
mcp: {url:'', auth:'bearer', hint:'URL du endpoint MCP (streamable HTTP)'},
tiktak: {url:(window.FLOWDECK_TIKTAK_MCP_URL || 'https://tiktak.dracodev.net/mcp'), auth:'bearer', name:'TikTak',
hint:'TikTak → Réglages → Intégrations → clé API tck_…'}
hint:'TikTak → Réglages → Intégrations → clé API tck_…'},
obsigate:{url:(window.FLOWDECK_OBSIGATE_MCP_URL || 'https://og.dracodev.net/mcp'), auth:'bearer', name:'ObsiGate',
hint:'ObsiGate → Configurations → Clés API & MCP → clé dédiée au pont, limitée à un seul vault'}
};
var p = presets[f.kind] || presets.custom;
f.url = p.url; f.auth = p.auth; f.hint = p.hint;
+749
View File
@@ -0,0 +1,749 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2) + chantier 5
* (onglets + composant partagé + onglet Console Spark).
*
* SOURCE CANONIQUE : Projets/dracodev-commons, ui/alertes/. Les copies
* dans les 4 hôtes (TikTak, Kanbante, Flowdeck, ObsiGate) sont des
* vendoring byte-identiques de ce fichier — ne pas les modifier sur
* place, toute évolution passe par commons puis re-vendoring.
*
* Agrégation À LA DEMANDE, pas une fédération temps réel : à chaque
* ouverture du panneau (et au chargement, pour la pastille), le module
* interroge les API de notifications réellement exposées par chaque
* application, avec les identifiants du navigateur (credentials:
* "include"). Aucune alerte n'est inventée : une source injoignable
* ou non authentifiée affiche son état réel par section (une section
* en échec n'empêche jamais de consulter les autres), jamais du vide
* déguisé. En attendant le correctif CORS des allowlists (autre
* chantier), une section inter-origines peut afficher cet état
* d'erreur : c'est un état d'erreur transitoire, pas un comportement
* normal — aucun état « Injoignable » systématique n'est présenté
* comme normal.
*
* Sources réelles (audit #69, 2026-10-28, inchangé) :
* - Flowdeck : GET {base}/api/notifications?limit=50
* -> { notifications: [{id, ntype, title, message, url, is_read,
* created_at}], unread } ; active = is_read = 0 ; lien = base + url.
* - TikTak : GET {base}/api/notifications
* -> [{id, kind, title, body, task_id, is_read, created_at}] ;
* active = is_read = 0 ; lien = base + "/?task=" + task_id, sinon
* base + "/notifications".
* - Kanbante : GET {base}/api/events?limit=100
* -> { events: [{repo_full_name, issue_number, event_type,
* from_column, to_column, created_at}] } ; AUCUN état lu/non lu
* n'existe dans Kanbante, donc tout y est historique et rien n'y
* est compté comme actif ; lien = base + "/board/" + repo.
* - ObsiGate : AUCUNE notification entrante consultable. Ses
* /api/notify/* sont des canaux d'envoi sortants (admin, secrets
* masqués), pas un journal lisible par l'utilisateur. L'onglet
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
* - Console Spark : AUCUN endpoint d'alertes console n'est publié
* sur agent-commun main au moment de ce chantier (autre chantier
* en cours). L'onglet Console Spark affiche donc un état vide
* HONNÊTE (« source indisponible », lien vers la console) et ne
* fabrique aucune alerte. Branchement prévu, sans deviner d'URL :
* poser window.DRACO_ALERTES_CONSOLE_URL (ou
* DRACO_BAR_CONFIG.consoleAlertsUrl) vers l'endpoint livré par
* l'autre chantier ; forme attendue : tableau, ou
* {alerts:[...]} / {items:[...]}, chaque alerte portant title,
* detail|message|context, date|created_at, active (booléen) ou
* status/state (pause budget, accès requis, en attente, fermée…),
* url|link. Les missions en pause budget / accès requis et les
* approbations en attente y apparaîtront avec leurs états réels.
*
* Résolution des bases (même ordre que barre-apps v0.1.6) :
* 1. window.DRACO_BAR.urls déjà résolue par la barre, sinon
* window.DRACO_BAR_CONFIG.urls (config explicite de l'hôte) ;
* 2. dérivation -dev par l'hôte courant (hôte -dev dracodev.net) ;
* 3. href rendu de l'onglet .draco-apptab[data-app] (l'onglet
* « Console Spark » ajouté par le chantier 5 porte data-app
* "agent", donc la base agent suit le même ordre) ;
* 4. défaut officiel (production).
* ObsiGate n'a pas de .draco-apptab dans tous les cas : l'étape 3 y
* lit aussi window.DRACO_URLS (injecté par son backend, clé "agent"
* incluse).
* Aucun nom en -dev n'est un défaut : la dérivée -dev ne s'applique
* que sur un hôte -dev, exactement comme la barre.
*
* Présentation (identique partout, imposée par ce seul fichier) :
* champs par alerte = application, titre, date, état, lien profond
* (environnement -dev respecté via la résolution ci-dessus) ;
* ordre = date décroissante dans chaque onglet (sans date en
* dernier) ; format de date = toLocaleString("fr-FR"), « sans date »
* si absente ou illisible ; libellés d'état affichés = « Active »,
* « Lue », ou le libellé d'événement Kanbante (ex. « Fermée »),
* ou l'état réel console quand la source existe. Le type brut
* Kanbante (« moved »…) n'est plus affiché : il dupliquait le
* libellé d'état en anglais.
* Onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark, Historique — dans cet ordre. Tout et chaque onglet
* par application ne montrent QUE les alertes actives ; Historique
* ne montre QUE les alertes fermées (lues / événements Kanbante /
* console fermées). Le filtre texte (titre + contexte) s'applique
* APRÈS le périmètre de l'onglet courant : il ne mélange jamais
* actifs et historique. L'onglet courant est dans l'URL
* (#alertes=<onglet>, partageable, retour arrière via pushState).
*
* Le fichier ne touche à AUCUN fichier ui/barre-apps.* : il consomme
* seulement l'action "alertes" relayée par l'hôte et DRACO_BAR.setCounts
* pour la pastille (repli : mise à jour directe du badge).
* Insertion des données : textContent uniquement, jamais innerHTML
* avec une valeur venue du serveur.
*/
(function (global) {
"use strict";
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/"
};
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante", console: "Console Spark"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var ALL_APPS = ["obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var TABS = ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"];
var TAB_LABELS = {
tout: "Tout", obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante",
console: "Console Spark", historique: "Historique"
};
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
var CONSOLE_ACTIVE_STATUSES = [
"active", "pending", "pause", "pause_budget", "acces_requis",
"en_attente", "en attente", "requise", "awaiting"
];
var CONSOLE_CLOSED_STATUSES = [
"closed", "resolved", "resolue", "lue", "terminee", "done", "fermee"
];
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function joinUrl(base, path) {
var b = withSlash(base);
if (!path) return b;
if (/^https?:\/\//.test(path)) return path;
return b + String(path).replace(/^\//, "");
}
function parseDate(s) {
if (!s) return null;
var t = new Date(String(s).indexOf("T") === -1 && String(s).indexOf("Z") === -1
? String(s).replace(" ", "T") + "Z" : s);
return isNaN(t.getTime()) ? null : t;
}
function fmtDate(s) {
var t = parseDate(s);
if (!t) return "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
function capitalize(s) {
s = String(s || "");
return s ? s.charAt(0).toUpperCase() + s.slice(1) : s;
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls, consoleUrl }. */
function resolveUrls(env) {
env = env || {};
var hostname = env.hostname;
if (hostname === undefined) {
try { hostname = (global.location && global.location.hostname) || ""; } catch (e) { hostname = ""; }
}
var dev = isDevHost(hostname);
var urls = {};
ALL_APPS.forEach(function (key) {
var app = key === "console" ? "agent" : key;
var out = key === "console" ? "agent" : key;
var u = cleanUrl(env.barUrls && (env.barUrls[key] || env.barUrls[app]))
|| cleanUrl(env.configUrls && (env.configUrls[key] || env.configUrls[app]));
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && (env.tabHrefs[key] || env.tabHrefs[app])) href = env.tabHrefs[key] || env.tabHrefs[app];
else if (typeof document !== "undefined" && document.querySelector) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
href = tab ? tab.getAttribute("href") : null;
}
u = cleanUrl(href);
}
if (!u && env.dracoUrls && (env.dracoUrls[key] || env.dracoUrls[app])) u = cleanUrl(env.dracoUrls[key] || env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && (global.DRACO_URLS[key] || global.DRACO_URLS[app])) u = cleanUrl(global.DRACO_URLS[key] || global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[out] = withSlash(u);
if (key === "console") urls.console = withSlash(u);
});
return urls;
}
function browserEnv() {
var cfg = global.DRACO_BAR_CONFIG || {};
var bar = global.DRACO_BAR || {};
var tabs = {};
try {
if (typeof document !== "undefined" && document.querySelectorAll) {
var nodes = document.querySelectorAll(".draco-apptab[data-app]");
for (var i = 0; i < nodes.length; i++) {
tabs[nodes[i].getAttribute("data-app")] = nodes[i].getAttribute("href");
}
}
} catch (e) { /* rien */ }
return {
barUrls: bar.urls || null, configUrls: cfg.urls || null,
tabHrefs: tabs, dracoUrls: global.DRACO_URLS || null,
consoleUrl: null
};
}
/* URL de la source console, si l'hôte l'a configurée. AUCUNE URL
* n'est devinée : sans configuration, la source est indisponible
* (état vide honnête, voir renderCurrent). */
function consoleSourceUrl(env) {
env = env || {};
if (env.consoleUrl) return env.consoleUrl;
try {
if (global.DRACO_ALERTES_CONSOLE_URL) return global.DRACO_ALERTES_CONSOLE_URL;
var cfg = global.DRACO_BAR_CONFIG || {};
if (cfg.consoleAlertsUrl) return cfg.consoleAlertsUrl;
} catch (e) { /* rien */ }
return null;
}
function normalizeFlowdeck(base, payload) {
var list = (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "flowdeck", id: n.id, title: n.title || "(sans titre)",
detail: n.message || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.ntype || "", url: joinUrl(base, n.url || "")
};
});
}
function normalizeTiktak(base, payload) {
var list = Array.isArray(payload) ? payload : (payload && payload.notifications) || [];
return list.map(function (n) {
return {
app: "tiktak", id: n.id, title: n.title || "(sans titre)",
detail: n.body || "", date: n.created_at || "",
active: !n.is_read, state: n.is_read ? "lue" : "active",
kind: n.kind || "",
url: n.task_id ? withSlash(base) + "?task=" + n.task_id : joinUrl(base, "notifications")
};
});
}
function normalizeKanbante(base, payload) {
var list = (payload && payload.events) || [];
return list.map(function (e) {
var label = EVENT_LABELS[e.event_type] || e.event_type || "événement";
var detail = (e.from_column ? e.from_column + " → " : "") + (e.to_column || "");
return {
app: "kanbante", id: e.id,
title: (e.repo_full_name || "?") + " #" + e.issue_number + " — " + label,
detail: detail, date: e.created_at || "",
active: false, state: label, kind: e.event_type || "",
url: joinUrl(base, "board/" + (e.repo_full_name || ""))
};
});
}
function normalizeConsole(base, payload) {
var list = Array.isArray(payload) ? payload
: (payload && (payload.alerts || payload.items)) || [];
return list.map(function (n) {
var status = String(n.status || "").toLowerCase().replace(/_/g, " ").trim();
var stateRaw = String(n.state || status || "");
var active;
if (typeof n.active === "boolean") active = n.active;
else if (status && CONSOLE_ACTIVE_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = true;
else if (status && CONSOLE_CLOSED_STATUSES.indexOf(status.replace(/ /g, "_")) !== -1) active = false;
else if (/pause|acc[eè]s|attente/.test(stateRaw.toLowerCase())) active = true;
else if (/ferm|résol|resol|termin/.test(stateRaw.toLowerCase())) active = false;
else active = true; /* source réelle, statut inconnu : on l'expose en active */
return {
app: "console", id: n.id, title: n.title || "(sans titre)",
detail: n.detail || n.message || n.context || "", date: n.date || n.created_at || "",
active: active, state: stateRaw || (active ? "en attente" : "fermée"),
kind: n.kind || n.type || "",
url: n.url || n.link ? joinUrl(base, n.url || n.link) : withSlash(base)
};
});
}
/* Libellé d'état AFFICHÉ, uniforme partout (les valeurs brutes de
* normalize* sont inchangées, pour compatibilité des tests #69). */
function displayState(a) {
if (a.active) return "Active";
if (a.app === "kanbante") return capitalize(a.state);
if (a.app === "console") return capitalize(a.state);
return "Lue";
}
function metaLine(a) {
var date = fmtDate(a.date) || "sans date";
/* Kanbante : le type brut (« moved ») dupliquait le libellé
* d'état ; il n'est plus affiché. Les autres apps gardent leur
* type quand il apporte une information. */
if (a.app === "kanbante") return APP_LABELS[a.app] + " · " + date + " · " + displayState(a);
var kind = a.kind ? " · " + a.kind : "";
return APP_LABELS[a.app] + kind + " · " + date + " · " + displayState(a);
}
/* Actives d'abord, puis date décroissante (sans date en dernier).
* Dans un onglet, le périmètre est déjà homogène (que des actives
* ou que des fermées) : l'ordre effectif est donc la date décroissante. */
function sortAlerts(list) {
return list.slice().sort(function (a, b) {
if (a.active !== b.active) return a.active ? -1 : 1;
var da = parseDate(a.date), db = parseDate(b.date);
if (da && db) return db - da;
if (da) return -1;
if (db) return 1;
return 0;
});
}
function countActive(list) {
var n = 0;
list.forEach(function (a) { if (a.active) n++; });
return n;
}
function allItems(results) {
var all = [];
ALL_APPS.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
return all;
}
/* Périmètre d'un onglet. Retour : { items, errors, consoleMissing }.
* Tout et onglets par application = actives seulement ; Historique =
* fermées seulement. Les erreurs de section sont remontées dans
* chaque onglet concerné (jamais silencieusement avalées). */
function scopeAlerts(all, tab, results) {
results = results || {};
var items = [], errors = [];
function collect(app, pred) {
if (results[app] && results[app].error) errors.push({ app: app, error: results[app].error });
all.forEach(function (a) { if (a.app === app && pred(a)) items.push(a); });
}
if (tab === "historique") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return !a.active; }); });
} else if (tab === "tout") {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
} else if (ALL_APPS.indexOf(tab) !== -1) {
collect(tab, function (a) { return a.active; });
} else {
ALL_APPS.forEach(function (app) { collect(app, function (a) { return a.active; }); });
}
return {
items: sortAlerts(items), errors: errors,
consoleMissing: !!(results.console && results.console.sourceMissing)
};
}
function countByTab(all, results) {
var counts = {};
TABS.forEach(function (tab) { counts[tab] = scopeAlerts(all, tab, results).items.length; });
return counts;
}
/* Filtre texte, appliqué APRÈS le périmètre de l'onglet : titre ou
* contexte, insensible à la casse, sans jamais mélanger actifs et
* historique (le périmètre est déjà fixé par scopeAlerts). */
function filterAlerts(list, q) {
q = String(q || "").trim().toLowerCase();
if (!q) return list.slice();
return list.filter(function (a) {
return (String(a.title || "").toLowerCase().indexOf(q) !== -1)
|| (String(a.detail || "").toLowerCase().indexOf(q) !== -1);
});
}
/* Onglet depuis le hash d'URL : "#alertes=historique" -> "historique",
* "#alertes" -> "tout", autre -> null. */
function parseAlertTab(hash) {
if (!hash) return null;
var h = String(hash).replace(/^#/, "");
if (h === "alertes") return "tout";
var m = /^alertes=([a-z]+)$/.exec(h);
if (m && TABS.indexOf(m[1]) !== -1) return m[1];
return null;
}
function hashForTab(tab) {
if (TABS.indexOf(tab) === -1) tab = "tout";
return "#alertes=" + tab;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, tabsEl = null, filterEl = null,
countEl = null, badgeTimer = null;
var currentTab = "tout", currentData = null, currentFilter = "";
var panelPushed = false, closingViaBack = false, suppressHash = false;
function el(tag, cls, text) {
var d = document.createElement(tag);
if (cls) d.className = cls;
if (text !== undefined && text !== null) d.textContent = text;
return d;
}
function ensurePanel() {
if (panel) return panel;
var ov = el("div", "draco-alertes-overlay");
ov.setAttribute("role", "dialog");
ov.setAttribute("aria-label", "Alertes unifiées");
var box = el("div", "draco-alertes-box");
var head = el("div", "draco-alertes-head");
head.appendChild(el("h2", "", "Alertes unifiées"));
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", function () { closePanel(); });
head.appendChild(close);
box.appendChild(head);
box.appendChild(el("p", "draco-alertes-note",
"Agrégation à la demande, pas une fédération temps réel : " +
"les alertes sont relues dans chaque application à l'ouverture. " +
"Une section en échec n'empêche pas de consulter les autres."));
tabsEl = el("div", "draco-alertes-tabs");
tabsEl.setAttribute("role", "tablist");
tabsEl.setAttribute("aria-label", "Alertes par application");
tabsEl.addEventListener("keydown", onTabsKeydown);
box.appendChild(tabsEl);
var filtre = el("div", "draco-alertes-filtre");
var lbl = el("label", "", "Filtrer");
lbl.setAttribute("for", "draco-alertes-filtre-input");
filtre.appendChild(lbl);
filterEl = el("input", "");
filterEl.type = "search";
filterEl.id = "draco-alertes-filtre-input";
filterEl.setAttribute("placeholder", "Filtrer par titre ou contexte, dans cet onglet…");
filterEl.setAttribute("aria-label", "Filtrer les alertes de l'onglet courant");
filterEl.addEventListener("input", function () {
currentFilter = filterEl.value;
renderCurrent();
});
filtre.appendChild(filterEl);
box.appendChild(filtre);
countEl = el("p", "draco-alertes-compte", "");
box.appendChild(countEl);
listEl = el("div", "draco-alertes-list");
listEl.setAttribute("role", "tabpanel");
box.appendChild(listEl);
ov.appendChild(box);
ov.addEventListener("click", function (ev) { if (ev.target === ov) closePanel(); });
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && panel && panel.style.display !== "none") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() {
if (!panel) return;
panel.style.display = "none";
if (panelPushed) {
panelPushed = false;
closingViaBack = true;
try { history.back(); } catch (e) { closingViaBack = false; }
return;
}
try {
var h = parseAlertTab(window.location.hash);
if (h) {
suppressHash = true;
history.replaceState(null, "", window.location.pathname + window.location.search);
suppressHash = false;
}
} catch (e) { /* rien */ }
}
function onHashChange() {
if (suppressHash) return;
if (closingViaBack) { closingViaBack = false; return; }
var tab = parseAlertTab(window.location.hash);
if (tab) {
if (!panel || panel.style.display === "none") openPanel(tab);
else setTab(tab, false);
} else if (panel && panel.style.display !== "none") {
panelPushed = false;
panel.style.display = "none";
}
}
function onTabsKeydown(ev) {
var keys = ["ArrowLeft", "ArrowRight", "Home", "End"];
if (keys.indexOf(ev.key) === -1) return;
var btns = tabsEl.querySelectorAll(".draco-alertes-tab");
var idx = TABS.indexOf(currentTab);
if (ev.key === "ArrowLeft") idx = (idx + TABS.length - 1) % TABS.length;
else if (ev.key === "ArrowRight") idx = (idx + 1) % TABS.length;
else if (ev.key === "Home") idx = 0;
else if (ev.key === "End") idx = TABS.length - 1;
ev.preventDefault();
setTab(TABS[idx], true);
if (btns[idx] && btns[idx].focus) btns[idx].focus();
}
function renderTabs() {
if (!tabsEl || !currentData) return;
tabsEl.textContent = "";
var all = allItems(currentData.results);
var counts = countByTab(all, currentData.results);
TABS.forEach(function (tab) {
var b = el("button", "draco-alertes-tab",
TAB_LABELS[tab] + " (" + counts[tab] + ")");
b.type = "button";
b.setAttribute("role", "tab");
b.setAttribute("aria-selected", tab === currentTab ? "true" : "false");
b.tabIndex = tab === currentTab ? 0 : -1;
b.addEventListener("click", function () { setTab(tab, true); });
tabsEl.appendChild(b);
});
}
function renderRow(a) {
var row = el("div", "draco-alertes-item" + (a.active ? " is-active" : ""));
var main = el("div", "draco-alertes-main");
var link = el("a", "draco-alertes-title", a.title);
link.href = a.url;
main.appendChild(link);
if (a.detail) main.appendChild(el("div", "draco-alertes-detail", a.detail));
main.appendChild(el("div", "draco-alertes-meta", metaLine(a)));
row.appendChild(main);
return row;
}
function renderErrorRow(app, msg) {
var row = el("div", "draco-alertes-item is-error");
var main = el("div", "draco-alertes-main");
main.appendChild(el("div", "draco-alertes-title", APP_LABELS[app] + " — section en échec"));
main.appendChild(el("div", "draco-alertes-detail", msg));
main.appendChild(el("div", "draco-alertes-meta",
APP_LABELS[app] + " · section en échec — les autres sections restent consultables"));
row.appendChild(main);
return row;
}
function renderCurrent() {
if (!listEl || !currentData) return;
renderTabs();
var scope = scopeAlerts(allItems(currentData.results), currentTab, currentData.results);
var filtered = filterAlerts(scope.items, currentFilter);
var q = String(currentFilter || "").trim();
if (countEl) {
countEl.textContent = q
? filtered.length + " résultat(s) sur " + scope.items.length + " dans cet onglet — filtre « " + q + " »"
: "";
}
listEl.textContent = "";
/* Onglet Console sans source : état vide honnête, jamais d'alertes
* inventées, avec le lien profond vers la console. */
if (currentTab === "console" && scope.consoleMissing) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "Console Spark (0)"));
sec.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte console — source indisponible : l'endpoint " +
"d'alertes console (agent-commun, autre chantier) n'est pas " +
"encore publié. Les missions en pause budget / accès requis " +
"et les approbations en attente apparaîtront ici, avec leurs " +
"états réels, dès sa livraison."));
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir la Console Spark");
a.href = currentData.urls.agent || currentData.urls.console || "#";
link.appendChild(a);
sec.appendChild(link);
listEl.appendChild(sec);
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
return;
}
if (currentTab === "obsigate" && !scope.items.length && !scope.errors.length) {
var secOg = el("section", "draco-alertes-section");
secOg.appendChild(el("h3", "", "ObsiGate (0)"));
secOg.appendChild(el("p", "draco-alertes-empty",
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
var linkOg = el("div", "draco-alertes-empty");
var aOg = el("a", "", "Ouvrir ObsiGate");
aOg.href = currentData.urls.obsigate;
linkOg.appendChild(aOg);
secOg.appendChild(linkOg);
listEl.appendChild(secOg);
return;
}
scope.errors.forEach(function (e) { listEl.appendChild(renderErrorRow(e.app, e.error)); });
if (!scope.items.length && !scope.errors.length) {
var empty = currentTab === "historique"
? "Aucune alerte fermée dans les applications joignables."
: currentTab === "tout"
? "Aucune alerte active dans les applications joignables."
: "Aucune alerte active pour " + TAB_LABELS[currentTab] + ".";
if (currentTab === "console") empty = "Aucune alerte console.";
listEl.appendChild(el("p", "draco-alertes-empty", empty));
return;
}
if (q && !filtered.length) {
listEl.appendChild(el("p", "draco-alertes-empty",
"Aucune alerte dans cet onglet pour ce filtre."));
return;
}
filtered.forEach(function (a) { listEl.appendChild(renderRow(a)); });
}
function setTab(tab, push) {
if (TABS.indexOf(tab) === -1) tab = "tout";
currentTab = tab;
renderCurrent();
if (push) {
try {
history.pushState(null, "", hashForTab(tab));
panelPushed = true;
} catch (e) { /* URL partageable : repli silencieux */ }
}
}
function fetchJson(url, timeoutMs) {
var ctrl = null, timer = null;
try {
if (typeof AbortController !== "undefined") {
ctrl = new AbortController();
timer = setTimeout(function () { ctrl.abort(); }, timeoutMs || 8000);
}
} catch (e) { ctrl = null; }
var opts = { credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
return fetch(url, opts).then(function (r) {
if (timer) clearTimeout(timer);
if (!r.ok) {
var err = new Error("HTTP " + r.status);
err.status = r.status;
throw err;
}
return r.json();
}).catch(function (e) {
if (timer) clearTimeout(timer);
throw e;
});
}
function loadAll() {
var urls = resolveUrls(browserEnv());
var consoleUrl = consoleSourceUrl(browserEnv());
var jobs = {
flowdeck: fetchJson(urls.flowdeck + "api/notifications?limit=50")
.then(function (p) { return { items: normalizeFlowdeck(urls.flowdeck, p) }; }),
tiktak: fetchJson(urls.tiktak + "api/notifications")
.then(function (p) { return { items: normalizeTiktak(urls.tiktak, p) }; }),
kanbante: fetchJson(urls.kanbante + "api/events?limit=100")
.then(function (p) { return { items: normalizeKanbante(urls.kanbante, p) }; })
};
if (consoleUrl) {
var agentBase = urls.agent || urls.console;
jobs.console = fetchJson(consoleUrl)
.then(function (p) { return { items: normalizeConsole(agentBase, p) }; });
}
var keys = Object.keys(jobs);
return Promise.all(keys.map(function (k) {
return jobs[k].then(function (v) { return [k, v]; }).catch(function (e) {
var msg = (e && e.status === 401)
? "Connexion requise sur " + APP_LABELS[k] + " : ouvrez sa page native pour vous y connecter, puis rouvrez ce panneau."
: "Application injoignable depuis cet hôte (" + (e && e.message ? e.message : "erreur réseau") + ").";
return [k, { items: [], error: msg }];
});
})).then(function (pairs) {
var results = { obsigate: { items: [] } };
pairs.forEach(function (kv) { results[kv[0]] = kv[1]; });
if (!consoleUrl) results.console = { items: [], sourceMissing: true };
return { urls: urls, results: results };
});
}
function setBadge(n) {
try {
if (global.DRACO_BAR && typeof global.DRACO_BAR.setCounts === "function") {
global.DRACO_BAR.setCounts({ alertes: n });
return;
}
} catch (e) { /* repli ci-dessous */ }
try {
document.querySelectorAll('.draco-badge[data-badge="alertes"]').forEach(function (b) {
b.textContent = n;
b.hidden = !(n > 0);
});
} catch (e) { /* rien */ }
}
function refreshBadge() {
return loadAll().then(function (r) {
var n = countActive(allItems(r.results));
setBadge(n);
return n;
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel(initialTab) {
ensurePanel();
panel.style.display = "flex";
var tab = initialTab || parseAlertTab(window.location.hash) || "tout";
currentTab = tab;
currentFilter = "";
if (filterEl) filterEl.value = "";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
if (tabsEl) tabsEl.textContent = "";
loadAll().then(function (r) {
currentData = r;
/* Ouverture par le bouton (pas de hash) : l'onglet entre dans
* l'URL pour être partageable ; ouverture par lien partagé : le
* hash est déjà le bon, on ne pousse pas d'entrée en double. */
var fromHash = parseAlertTab(window.location.hash);
if (!fromHash) {
try { history.pushState(null, "", hashForTab(tab)); panelPushed = true; } catch (e) { /* rien */ }
}
renderCurrent();
setBadge(countActive(allItems(r.results)));
}).catch(function () {
currentData = { urls: resolveUrls(browserEnv()), results: { obsigate: { items: [] }, console: { items: [], sourceMissing: true } } };
renderCurrent();
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
normalizeConsole: normalizeConsole, consoleSourceUrl: consoleSourceUrl,
sortAlerts: sortAlerts, countActive: countActive,
filterAlerts: filterAlerts, scopeAlerts: scopeAlerts,
countByTab: countByTab, parseAlertTab: parseAlertTab,
hashForTab: hashForTab, displayState: displayState,
metaLine: metaLine, fmtDate: fmtDate,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS,
TABS: TABS, TAB_LABELS: TAB_LABELS, APP_LABELS: APP_LABELS
}
};
global.DRACO_ALERTES = api;
if (typeof module !== "undefined" && module.exports) module.exports = api._test;
/* Pastille au chargement (après les scripts defer de la barre) ;
* un lien partagé #alertes=<onglet> ouvre directement le panneau
* sur cet onglet. */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
var tab = parseAlertTab(window.location.hash);
if (tab) setTimeout(function () { openPanel(tab); }, 350);
});
window.addEventListener("hashchange", onHashChange);
}
})(typeof window !== "undefined" ? window : globalThis);
+33
View File
@@ -46,6 +46,7 @@
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
var versionEl = null, versionConnue = "", versionMaj = 0;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
@@ -274,6 +275,7 @@
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
' <div class="draco-panneau-version" style="margin-top:6px;font-size:11.5px;opacity:.6" hidden></div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
@@ -282,6 +284,7 @@
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
versionEl = root.querySelector(".draco-panneau-version");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
@@ -336,6 +339,35 @@
titreEl.title = titreEl.textContent;
}
/* ── Version du service : lue en direct dans la santé de
* l'agent (GET /healthz → champ « version », la même source que
* la console) à l'origine que le panneau connaît déjà. Cache de
* session (5 min), rafraîchie à l'ouverture du panneau, au même
* rythme que la vérification d'identité. Jamais de valeur codée
* en dur : source injoignable ou réponse illisible = la ligne
* reste masquée — pas de fausse version, pas d'erreur visible. */
function afficherVersion() {
if (!versionEl) return;
if (versionConnue) {
versionEl.textContent = "Service d'agents v" + versionConnue;
versionEl.hidden = false;
}
}
function chargerVersion() {
if (!API || !versionEl) return;
if (versionConnue && Date.now() - versionMaj < 300000) { afficherVersion(); return; }
fetch(API + "/healthz")
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (d && typeof d.version === "string" && /^[0-9]+\.[0-9]+\.[0-9]+/.test(d.version)) {
versionConnue = d.version;
versionMaj = Date.now();
afficherVersion();
}
})
.catch(function () { /* service injoignable : rien d'affiché */ });
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
@@ -812,6 +844,7 @@
root.classList.add("open");
appliquerTheme();
verifierIdentite();
chargerVersion();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
+514
View File
@@ -0,0 +1,514 @@
/* Recherche partout — page de résultats unifiée (fusion-trois-apps #68).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
*
* Contrat d'API (audité, rien d'inventé) :
* - ObsiGate : GET {base}/api/search?q=&vault=all&limit=8
* -> { results: [{ vault, path, title, snippet }] }
* Lien profond vérifié : {base}#file={vault}:{path} (Viewer.openFile).
* - Flowdeck : GET {base}/api/search?q=
* -> { pages: [{ title, excerpt, subtitle, url: "/pages/<id>" }],
* collections: [{ title, subtitle, url: "/db/<id>" }] }
* - TikTak : GET {base}/api/search?q=
* -> { tasks: [{ id, title, description, status, due_at }], ... }
* Lien profond vérifié : {base}search?q=<q>&task=<id> (page /search).
* - Kanbante : GET {base}/api/search?q= (NOUVEAU, cette livraison)
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
*
* Résolution des bases sœurs (même ordre que la barre v0.1.6) :
* 1. data-base-* rendus par l'hôte (son propre résolveur : explicite >
* hôte de la requête > dérivation > défaut officiel) ;
* 2. window.DRACO_BAR.urls (valeur déjà résolue par la barre) ;
* 3. dérivation -dev si l'hôte courant est un hôte -dev dracodev.net ;
* 4. href des onglets .draco-apptab rendus ;
* 5. défauts officiels prod. Aucune URL inventée (pas de spark-dev).
*
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via cette
* livraison). Chaque sœur n'autorise en CORS que les origines sœurs
* (GET sur l'endpoint de recherche uniquement, pas de "*").
* Une sœur injoignable / non autorisée ne casse pas les autres :
* sa section affiche l'erreur + un lien vers sa recherche native.
*/
(function (global) {
"use strict";
/* globalThis : le fichier est chargé comme script classique dans les
* navigateurs, requis en CJS par les tests TikTak/Kanbante/Flowdeck,
* et importé en ESM par le test ObsiGate (frontend/js est "type":
* "module") — où `this` de niveau supérieur vaut undefined. */
var PROD = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
};
var APPS = [
{ key: "obsigate", label: "ObsiGate" },
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
qa: "QA",
review: "Relecture",
todo: "À faire",
done: "Terminé",
};
var TIMEOUT_MS = 8000;
var DEBOUNCE_MS = 250;
var PER_APP_LIMIT = 8;
function esc(s) {
return String(s == null ? "" : s)
.replace(/&/g, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
function cleanUrl(u) {
if (!u) return null;
u = String(u).trim();
if (!u || u.indexOf("{{") !== -1) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) === "/" ? u : u + "/";
}
/* Même règle que la barre v0.1.6 : première étiquette en -dev ET
* domaine dracodev.net, sinon rien (un hôte piège ne déclenche rien). */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* opts : { host, explicit:{}, barUrls:{}, tabHrefs:{} } */
function resolveBases(opts) {
opts = opts || {};
var dev = isDevHost(opts.host);
var out = {};
APPS.forEach(function (app) {
var key = app.key;
var u = cleanUrl(opts.explicit && opts.explicit[key]);
if (!u) u = cleanUrl(opts.barUrls && opts.barUrls[key]);
if (!u && dev) u = DEV[key];
if (!u) u = cleanUrl(opts.tabHrefs && opts.tabHrefs[key]);
if (!u) u = PROD[key];
out[key] = withSlash(u);
});
return out;
}
function searchPath(app, q) {
if (app === "obsigate") {
return "api/search?q=" + encodeURIComponent(q) + "&vault=all&limit=" + PER_APP_LIMIT;
}
return "api/search?q=" + encodeURIComponent(q);
}
function snippetOf(s, n) {
s = String(s == null ? "" : s).replace(/\s+/g, " ").trim();
return s.length > (n || 160) ? s.slice(0, n || 160) + "…" : s;
}
/* Normalise la réponse d'une app en [{ title, snippet, context, url }].
* base : base résolue de l'app (avec / final). q : requête (liens TikTak). */
function normalize(app, data, base, q) {
data = data || {};
if (app === "obsigate") {
return (data.results || []).slice(0, PER_APP_LIMIT).map(function (r) {
return {
title: r.title || r.path || "Sans titre",
snippet: snippetOf(r.snippet || ""),
context: (r.vault || "") + (r.path ? " · " + r.path : ""),
url:
base +
"#file=" +
encodeURIComponent(r.vault || "") +
":" +
encodeURIComponent(r.path || ""),
};
});
}
if (app === "flowdeck") {
var items = (data.pages || []).concat(data.collections || []);
return items.slice(0, PER_APP_LIMIT).map(function (r) {
var rel = String(r.url || "").replace(/^\//, "");
return {
title: r.title || "Sans titre",
snippet: snippetOf(r.excerpt || ""),
context: r.subtitle || (r.type === "collection" ? "Base" : "Page"),
url: base + rel,
};
});
}
if (app === "tiktak") {
return (data.tasks || []).slice(0, PER_APP_LIMIT).map(function (t) {
var ctx = t.status === "done" ? "Terminée" : "Active";
if (t.due_at) ctx += " · échéance " + String(t.due_at).slice(0, 10);
return {
title: t.title || "Sans titre",
snippet: snippetOf(t.description || ""),
context: "Tâche · " + ctx,
url: base + "search?q=" + encodeURIComponent(q || "") + "&task=" + t.id,
};
});
}
if (app === "kanbante") {
var rows = [];
(data.groups || []).forEach(function (g) {
(g.rows || []).forEach(function (row) {
rows.push({ group: g.full_name, row: row });
});
});
return rows.slice(0, PER_APP_LIMIT).map(function (x) {
var issue = x.row.issue || {};
var col = COLUMN_LABELS[x.row.column] || x.row.column || "";
return {
title: "#" + issue.number + " " + (issue.title || "Sans titre"),
snippet: snippetOf(issue.body || ""),
context: x.group + (col ? " · " + col : ""),
url: base + "board/" + x.group,
};
});
}
return [];
}
/* Recherche native de l'app (repli quand la section est en erreur). */
function nativeSearchUrl(app, base, q) {
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
}
function appLabel(key) {
for (var i = 0; i < APPS.length; i++) {
if (APPS[i].key === key) return APPS[i].label;
}
return key;
}
var api = {
esc: esc,
isDevHost: isDevHost,
resolveBases: resolveBases,
searchPath: searchPath,
normalize: normalize,
nativeSearchUrl: nativeSearchUrl,
PROD: PROD,
DEV: DEV,
};
if (typeof module !== "undefined" && module.exports) module.exports = api;
global.RecherchePartout = api;
/* ---- Partie DOM : ne s'exécute que dans un navigateur. ---- */
if (typeof document === "undefined") return;
function fetchSection(app, url, onDone) {
var ctrl = null;
var timer = null;
try {
ctrl = new AbortController();
timer = setTimeout(function () {
ctrl.abort();
}, TIMEOUT_MS);
} catch (e) {
ctrl = null;
}
var opts = { headers: { Accept: "application/json" }, credentials: "include" };
if (ctrl) opts.signal = ctrl.signal;
fetch(url, opts)
.then(function (resp) {
if (!resp.ok) {
var err = new Error("HTTP " + resp.status);
err.status = resp.status;
throw err;
}
return resp.json();
})
.then(function (data) {
if (timer) clearTimeout(timer);
onDone(null, data);
})
.catch(function (err) {
if (timer) clearTimeout(timer);
onDone(err, null);
});
}
function renderSection(section, state) {
/* state : { status: "loading"|"empty"|"error"|"ok", items, message, q, base, app } */
var body = section.querySelector(".rp-body");
var count = section.querySelector(".rp-count");
var toutVoir = section.querySelector(".rp-all");
toutVoir.href = nativeSearchUrl(state.app, state.base, state.q);
toutVoir.textContent = "Tout voir";
toutVoir.title = nativeSearchLabel(state.app);
if (state.status === "loading") {
count.textContent = "";
body.innerHTML = '<p class="rp-state">Recherche…</p>';
return;
}
if (state.status === "error") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state rp-error">' +
esc(state.message) +
' — <a href="' +
esc(nativeSearchUrl(state.app, state.base, state.q)) +
'">' +
esc(nativeSearchLabel(state.app)) +
"</a></p>";
return;
}
if (state.status === "empty") {
count.textContent = "";
body.innerHTML =
'<p class="rp-state">Aucun résultat pour « ' + esc(state.q) + " ».</p>";
return;
}
count.textContent = String(state.items.length);
body.innerHTML = state.items
.map(function (item) {
return (
'<a class="rp-item" data-rp-link href="' +
esc(item.url) +
'">' +
'<span class="rp-title">' +
esc(item.title) +
"</span>" +
(item.context
? '<span class="rp-context">' + esc(item.context) + "</span>"
: "") +
(item.snippet
? '<span class="rp-snippet">' + esc(item.snippet) + "</span>"
: "") +
"</a>"
);
})
.join("");
}
function init() {
var root = document.getElementById("recherche");
if (!root) return;
var input = document.getElementById("rp-q");
if (!input) return;
var hostApp = root.getAttribute("data-host") || "";
var explicit = {};
APPS.forEach(function (app) {
var v = root.getAttribute("data-base-" + app.key);
if (v) explicit[app.key] = v;
});
var barUrls = {};
try {
if (global.DRACO_BAR && global.DRACO_BAR.urls) barUrls = global.DRACO_BAR.urls;
} catch (e) {}
var tabHrefs = {};
try {
document.querySelectorAll(".draco-apptab[data-app]").forEach(function (tab) {
var href = tab.getAttribute("href");
if (href) tabHrefs[tab.getAttribute("data-app")] = href;
});
} catch (e) {}
var bases = resolveBases({
host: global.location.hostname,
explicit: explicit,
barUrls: barUrls,
tabHrefs: tabHrefs,
});
var sections = {};
APPS.forEach(function (app) {
var el = document.querySelector('[data-rp-section="' + app.key + '"]');
if (el) sections[app.key] = el;
});
var currentQ = "";
var runId = 0;
var debounceTimer = null;
function runSearch(q) {
currentQ = q;
var myRun = ++runId;
if (!q) {
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
sec.querySelector(".rp-body").innerHTML =
'<p class="rp-state">Tapez une recherche ci-dessus.</p>';
sec.querySelector(".rp-count").textContent = "";
sec.querySelector(".rp-all").href = nativeSearchUrl(app.key, bases[app.key], "");
});
return;
}
APPS.forEach(function (app) {
var sec = sections[app.key];
if (!sec) return;
renderSection(sec, { status: "loading", app: app.key, base: bases[app.key], q: q });
var url;
if (app.key === hostApp) {
/* Même origine : pas de CORS, pas de CSP cross-origin. */
url = "/" + searchPath(app.key, q);
} else {
url = bases[app.key] + searchPath(app.key, q);
}
fetchSection(app.key, url, function (err, data) {
if (myRun !== runId) return;
if (err) {
var msg;
if (err.name === "AbortError") msg = "Délai dépassé (8 s)";
else if (err.status === 401 || err.status === 403)
msg = "Connexion requise sur " + app.label;
else if (err.status) msg = "Indisponible (HTTP " + err.status + ")";
else msg = "Injoignable depuis cet hôte (CORS ou réseau)";
renderSection(sec, {
status: "error",
message: msg,
app: app.key,
base: bases[app.key],
q: q,
});
return;
}
var items = normalize(app.key, data, bases[app.key], q);
renderSection(sec, {
status: items.length ? "ok" : "empty",
items: items,
app: app.key,
base: bases[app.key],
q: q,
});
});
});
}
function scheduleSearch(q, immediate) {
if (debounceTimer) clearTimeout(debounceTimer);
if (immediate) {
runSearch(q);
return;
}
debounceTimer = setTimeout(function () {
runSearch(q);
}, DEBOUNCE_MS);
}
function syncUrl(q) {
try {
var url = new URL(global.location.href);
if (q) url.searchParams.set("q", q);
else url.searchParams.delete("q");
global.history.replaceState(null, "", url.toString());
} catch (e) {}
}
input.addEventListener("input", function () {
var q = input.value.trim();
syncUrl(q);
scheduleSearch(q, false);
});
input.addEventListener("keydown", function (e) {
if (e.key === "Enter") {
var first = document.querySelector("[data-rp-link]");
if (first) {
e.preventDefault();
first.click();
}
} else if (e.key === "Escape") {
input.value = "";
syncUrl("");
scheduleSearch("", true);
input.focus();
} else if (e.key === "ArrowDown" || e.key === "ArrowUp") {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
});
document.addEventListener(
"keydown",
function (e) {
var target = e.target;
var typing =
target &&
(target.tagName === "INPUT" ||
target.tagName === "TEXTAREA" ||
target.isContentEditable);
if ((e.key === "/" && !typing) || ((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "k")) {
e.preventDefault();
e.stopPropagation();
input.focus();
input.select();
return;
}
if (!typing && (e.key === "ArrowDown" || e.key === "ArrowUp")) {
moveFocus(e.key === "ArrowDown" ? 1 : -1, e);
}
},
true
);
function moveFocus(delta, e) {
var links = Array.prototype.slice.call(document.querySelectorAll("[data-rp-link]"));
if (!links.length) return;
var active = document.activeElement;
var idx = links.indexOf(active);
var next;
if (idx === -1) next = delta > 0 ? 0 : links.length - 1;
else next = (idx + delta + links.length) % links.length;
e.preventDefault();
links[next].focus();
}
var params = new URLSearchParams(global.location.search);
var initialQ = (params.get("q") || "").trim();
if (initialQ) {
input.value = initialQ;
runSearch(initialQ);
} else {
runSearch("");
}
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})(
typeof window !== "undefined"
? window
: typeof globalThis !== "undefined"
? globalThis
: this
);
+460 -42
View File
@@ -1,5 +1,14 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
* v0.1.3 : menu des applications ouvert par la marque ✦).
* v0.1.3 : menu des applications ouvert par la marque ✦ ;
* v0.1.5 : noms publics par configuration — aucun nom public d'un
* environnement particulier n'est codé en dur, voir « URL » ci-dessous ;
* v0.1.6 (prochaine publication — VERSION non bumpée dans cette PR,
* la publication est séparée) : dérivation -dev par l'hôte courant,
* menu trois points par défaut et menu compte fonctionnels, voir ci-dessous ;
* correctif fusion #70 (SANS bump de version, publication séparée) :
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
* « Menu des applications », « Focus » et « Écouteurs globaux »).
*
* Aucun gestionnaire inline : un seul écouteur délégué sur le document
* (patron validé par le prototype — les onclick du HTML généré sont
@@ -10,19 +19,205 @@
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* user: "Bruno", // nom affiché (v0.1.6) ;
* // ou { display_name: "Bruno", username: "bruno" }
* urls: { // bases des applications
* obsigate: "https://og.dracodev.net/",
* flowdeck: "https://flowdeck.dracodev.net/",
* tiktak: "https://tiktak.dracodev.net/",
* kanbante: "https://kanbante.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
*
* URL des applications (contrat dev/prod, v0.1.5 puis v0.1.6) —
* résolution, dans l'ordre STRICT, pour chaque app :
* 1. DRACO_BAR_CONFIG.urls.<app>, injectée par l'app hôte (rendu
* serveur ou client) depuis SA configuration d'environnement —
* explicite, donc prioritaire, même sur un hôte -dev ;
* 2. (v0.1.6) SI l'hôte courant est un hôte -dev dracodev.net
* (première étiquette se terminant par -dev : tiktak-dev,
* kanbante-dev, og-dev, flowdeck-dev, agent-dev…), l'URL sœur
* dérivée avec -dev (voir DEV_URLS). C'est le correctif du bug
* constaté sur tiktak-dev / kanbante-dev, où les liens suivaient
* les noms SANS -dev faute de configuration explicite ;
* 3. le href déjà rendu dans le fragment HTML, c.-à-d. le jeton
* {{URL_<APP>}} remplacé côté serveur par l'app hôte ;
* 4. le défaut officiel ci-dessous (production, noms SANS suffixe).
* Un jeton {{URL_…}} non remplacé n'est jamais une URL : il est ignoré
* partout (config injectée comme href rendu) et ne peut pas être exposé.
* Cas spark (v0.1.6) : AUCUNE dérivation inventée — aucun hôte
* spark-dev connu ; sur un hôte -dev, spark retombe sur l'étape 3
* (href rendu) puis 4 (défaut prod), sauf config explicite (étape 1).
* La clé spark ne correspond à aucun onglet de la barre : elle est
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
* rendu » comme les autres onglets, en plus d'alimenter le menu de
* la marque et DRACO_BAR.urls.
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
* ouvre le menu des applications, construit depuis les onglets, plus
* (depuis le correctif #70) une entrée « Console Spark » vers la
* console de l'agent commun (URL agent résolue, voir ci-dessous) ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu compte : voir ci-dessous.
* L'action « menu » n'est PLUS émise nulle part : le bouton trois points
* a été supprimé (#70) ; les branches onAction("menu") des hôtes
* (TikTak/Kanbante/Flowdeck : retour false pour le menu par défaut ;
* ObsiGate : clic #header-menu-btn) sont donc mortes et ont été
* retirées des gabarits hôtes — le menu natif d'ObsiGate
* (#header-menu-btn) lui-même n'est pas touché.
*
* Retrait du bouton trois points (correctif fusion #70) — le bouton et
* son menu par défaut (console agent + applications) sont supprimés
* du fragment, du JS et du CSS. La console agent reste joignable par
* l'entrée « Console Spark » du menu de la marque (ci-dessous) et
* par le bouton Spark (panneau), donc aucun accès n'est perdu.
* Menu des applications (v0.1.3, étendu #70) — construit depuis les
* onglets présents dans la barre, puis une entrée « Console Spark »
* (logo ✦, rôle « Agent commun ») APRÈS Kanbante, vers l'URL agent
* résolue : 1) config explicite DRACO_BAR_CONFIG.urls.agent,
* 2) dérivation -dev agent-dev sur hôte -dev, 3) href rendu de
* l'onglet « Console Spark » (chantier 5, data-app="agent"),
* 4) défaut officiel prod https://agent.dracodev.net/.
* AUCUN hôte spark-dev n'est inventé :
* la clé spark, elle, suit toujours son propre contrat (explicite >
* href rendu > défaut prod) et ne sert qu'au panneau Spark.
* Focus (correctif #70) — ouverture au clavier : Entrée sur la marque
* (clic natif du lien) ; Espace sur la marque (keydown dédié, car un
* lien ne s'active pas à l'Espace) : ouvre le menu et met le focus
* sur son premier item. Fermeture : Échap (focus rendu au déclencheur
* qui avait ouvert le menu : la marque pour le menu apps, l'avatar
* pour le menu compte), nouvel appui sur le déclencheur (le focus y
* est déjà), choix d'un item, ou clic extérieur — ce dernier cas ne
* déplace VOLONTAIREMENT pas le focus (l'utilisateur vient de cliquer
* ailleurs : recherche, tâche… ; lui voler le focus casserait son
* geste). aria-expanded est tenu à jour sur chaque déclencheur.
* Écouteurs globaux (correctif #70, bug tiktak-dev) — les écouteurs
* clic/Échap de la barre sont posés via EventTarget.prototype (voir
* addGlobalListener), PAS via document.addEventListener. Cause
* prouvée sur TikTak (app/static/js/app.js, navigation #89) : app.js
* remplace document/window.addEventListener par un registre
* (__tcDocWin) et chaque navigation douce appelle tcInit() puis
* __tcTeardown(), qui SUPPRIME tous les écouteurs du registre — y
* compris ceux de draco-barre.js (script defer, enregistré après le
* tcInit() initial). La barre survit à la navigation (hors .layout
* remplacé) mais ses gestionnaires sont morts : un menu ouvert avant
* la navigation reste ouvert (Échap et clic extérieur sans effet) et
* la marque, lien href="/", est interceptée par l'intercepteur SPA
* brut de TikTak (__tcDocAdd, hors registre, donc survivant) comme
* une navigation interne au lieu de basculer le menu. Les écouteurs
* posés sur le prototype ne figurent pas dans le registre et ne sont
* donc jamais supprimés par le teardown ; en complément, le gabarit
* TikTak exclut désormais .draco-bar de l'intercepteur SPA, pour que
* la marque ne déclenche plus de navigation douce. Les autres hôtes
* (SSR, rechargement complet) ne sont pas concernés par le teardown
* mais bénéficient du même code ; repli : si EventTarget.prototype
* est indisponible (mini-DOM de tests), document.addEventListener
* classique est utilisé.
* Menu compte (v0.1.6) — l'avatar, auparavant un <span> mort affichant
* un « B » codé en dur dans le fragment, est un vrai <button>
* (data-act="compte"). Son initiale vient de cfg.user ou, à défaut, de
* la première lettre du title {{UTILISATEUR}} remplacé ; sans nom connu,
* le neutre ✦ est conservé (jamais de lettre codée en dur). Le clic
* ouvre un petit menu affichant le nom de l'utilisateur ; les items
* Profil / Déconnexion sont relayés à l'hôte via onAction ou, à défaut,
* via l'événement draco:bar (détail { act: "profil" | "deconnexion" }),
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
/* Défauts OFFICIELS (production) — les seuls noms publics présents
* dans ce fichier, documentés comme défauts (contrat dev/prod). */
var DEFAULT_URLS = {
obsigate: "https://og.dracodev.net/",
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
* est lui-même un hôte -dev dracodev.net (voir isDevHost). spark est
* volontairement absent : aucun hôte spark-dev connu, aucune URL
* inventée ; spark retombe sur href rendu puis défaut prod. */
var DEV_URLS = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
* se terminant par -dev ET domaine dracodev.net (un suffixe -dev sur
* un autre domaine, ou un hôte piège du type x-dev.dracodev.net.evil,
* ne déclenche rien). Insensible à la casse. */
function isDevHost(hostname) {
if (!hostname) return false;
var h = String(hostname).toLowerCase();
if (h.slice(-13) !== ".dracodev.net") return false;
var first = h.split(".")[0];
return first.length > 4 && first.slice(-4) === "-dev";
}
/* Un jeton {{…}} non remplacé n'est pas une URL : l'ignorer. */
function cleanUrl(u) {
if (!u || typeof u !== "string") return null;
if (u.indexOf("{{") !== -1) return null;
if (!u.replace(/\s/g, "")) return null;
return u;
}
function withSlash(u) {
return u.charAt(u.length - 1) !== "/" ? u + "/" : u;
}
function resolveUrls() {
var host = "";
try { host = (window.location && window.location.hostname) || ""; } catch (e) { host = ""; }
var dev = isDevHost(host);
var urls = {};
Object.keys(DEFAULT_URLS).forEach(function (app) {
/* 1. Config explicite de l'hôte — prioritaire, même sur -dev. */
var u = cleanUrl(cfg.urls && cfg.urls[app]);
/* 2. Dérivation -dev par l'hôte courant (spark : pas de dérivée). */
if (!u && dev) u = DEV_URLS[app] || null;
/* 3. href rendu dans le fragment (jeton remplacé côté serveur). */
if (!u) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var href = tab ? tab.getAttribute("href") : null;
u = cleanUrl(href);
}
/* 4. Défaut officiel (production). */
if (!u) u = DEFAULT_URLS[app];
urls[app] = withSlash(u);
});
return urls;
}
function applyUrls(urls) {
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app && urls[t.dataset.app]) t.setAttribute("href", urls[t.dataset.app]);
});
}
var resolvedUrls = resolveUrls();
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
@@ -43,26 +238,110 @@
});
}
/* ── Menu des applications (v0.1.3) ──────────────────────────────
* Construit depuis les onglets présents dans la barre : mêmes
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
* choix d'une application (la navigation suit son cours). */
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
* ✦ du fragment est conservé — jamais de lettre codée en dur. */
function userName() {
var u = cfg.user;
if (typeof u === "string" && u.replace(/\s/g, "")) return u.trim();
if (u && typeof u === "object") {
var n = u.display_name || u.name || u.username;
if (n && String(n).replace(/\s/g, "")) return String(n).trim();
}
return null;
}
function titleName() {
var av = document.querySelector(".draco-avatar");
var t = av ? av.getAttribute("title") : null;
if (t && t.indexOf("{{") === -1 && t.replace(/\s/g, "")) return t.trim();
return null;
}
function displayName() { return userName() || titleName(); }
function syncAvatar() {
var av = document.querySelector(".draco-avatar");
if (!av) return;
/* Compatibilité avec un fragment vendangé antérieur (avatar <span>
* sans data-act) : le rendre déclencheur du menu compte. */
if (!av.getAttribute("data-act")) av.setAttribute("data-act", "compte");
if (!av.getAttribute("aria-haspopup")) av.setAttribute("aria-haspopup", "menu");
var name = displayName();
if (name) {
av.textContent = name.charAt(0).toUpperCase();
av.setAttribute("title", name);
av.setAttribute("aria-label", "Compte : " + name);
}
}
/* ── Menus déroulants (apps, compte) ───────────────────────────────
* Un seul menu ouvert à la fois ; fermeture par clic extérieur,
* Échap, ou choix d'un item. aria-expanded tenu à jour sur chaque
* déclencheur. Les href des menus sont (re)synchronisés avec
* resolvedUrls à chaque ouverture, pour que setUrls et la dérivation
* -dev s'y reflètent même si le menu a été construit avant. */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, compteMenu = null;
/* Déclencheur du menu ouvert en dernier (marque ou avatar) : Échap
* lui rend le focus à la fermeture (correctif #70). */
var lastOpener = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
function setExpanded(btn, open) {
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
}
function refreshMenuHrefs(menu) {
if (!menu) return;
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
setExpanded(btn, open);
}
function closeAllPopups(returnFocus) {
var anyOpen = (appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden);
if (appsMenu) togglePopup(appsMenu, brand, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
/* Échap seulement : le clic extérieur ne déplace pas le focus
* (voir l'en-tête, « Focus »). */
if (returnFocus && anyOpen && lastOpener && typeof lastOpener.focus === "function") lastOpener.focus();
}
function tabLabel(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var fort = tab ? tab.querySelector(".draco-apptab-txt strong") : null;
return fort ? fort.textContent : (APP_LABELS[app] || app);
}
function tabRole(app) {
var tab = document.querySelector('.draco-apptab[data-app="' + app + '"]');
var petit = tab ? tab.querySelector(".draco-apptab-txt small") : null;
return petit ? petit.textContent : "";
}
/* Menu des applications (v0.1.3) — construit depuis les onglets
* présents dans la barre : mêmes liens, mêmes icônes, mêmes badges.
* Ouvert par la marque ✦ (data-act="apps"), fermé par un clic
* extérieur, Échap, ou le choix d'une application (la navigation
* suit son cours). */
function buildAppsMenu() {
if (!bar || !brand || appsMenu) return;
appsMenu = document.createElement("div");
appsMenu.className = "draco-apps-menu";
appsMenu.hidden = true;
appsMenu.setAttribute("role", "menu");
appsMenu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
/* Chantier 5 : l'onglet agent (« Console Spark ») n'est PAS
* repris ici — l'entrée dédiée ci-dessous le représente déjà
* (après Kanbante, avec séparateur) ; sans ce saut, le menu
* listerait « Console Spark » deux fois. */
if (t.dataset.app === "agent") return;
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.href = resolvedUrls[t.dataset.app] || t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
@@ -84,44 +363,183 @@
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
appsMenu.appendChild(a);
});
bar.appendChild(menu);
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
* de l'agent commun (URL agent résolue — explicite > -dev >
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
appsMenu.appendChild(sep);
var sparkItem = document.createElement("a");
sparkItem.className = "draco-apps-item";
sparkItem.href = resolvedUrls.agent || "#";
sparkItem.dataset.app = "agent";
sparkItem.setAttribute("role", "menuitem");
var sparkIco = document.createElement("span");
sparkIco.className = "draco-apptab-ico";
sparkIco.setAttribute("style", "--app:#c2437e");
sparkIco.textContent = "✦";
sparkItem.appendChild(sparkIco);
var sparkTxt = document.createElement("span");
sparkTxt.className = "draco-apps-item-txt";
var sparkNom = document.createElement("span");
sparkNom.className = "draco-apps-item-nom";
sparkNom.textContent = "Console Spark";
sparkTxt.appendChild(sparkNom);
var sparkRole = document.createElement("span");
sparkRole.className = "draco-apps-item-role";
sparkRole.textContent = "Agent commun";
sparkTxt.appendChild(sparkRole);
sparkItem.appendChild(sparkTxt);
appsMenu.appendChild(sparkItem);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
if (!appsMenu.hidden) lastOpener = brand;
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
compteMenu.className = "draco-compte-menu";
compteMenu.hidden = true;
compteMenu.setAttribute("role", "menu");
compteMenu.setAttribute("aria-label", "Compte");
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
if (!compteMenu) return;
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
}
function toggleCompteMenu(force) {
buildCompteMenu();
if (!compteMenu) return;
if (force !== false) { syncAvatar(); refreshCompteMenu(); }
togglePopup(compteMenu, avatarBtn, force);
if (!compteMenu.hidden) lastOpener = avatarBtn;
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* Relais d'une action vers l'hôte : onAction si présent, sinon
* événement draco:bar (contrat historique des actions relayées). */
function relay(act, el, ev) {
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
}
/* Écouteurs globaux posés sur le prototype EventTarget quand il
* existe, pour survivre au registre/teardown d'un hôte SPA (TikTak,
* voir l'en-tête) ; sinon document.addEventListener classique. */
function addGlobalListener(type, fn) {
try {
if (typeof EventTarget !== "undefined" && EventTarget.prototype &&
EventTarget.prototype.addEventListener) {
EventTarget.prototype.addEventListener.call(document, type, fn, false);
return;
}
} catch (e) { /* repli ci-dessous */ }
document.addEventListener(type, fn);
}
addGlobalListener("click", function (ev) {
/* Items du menu compte : relais + fermeture, jamais de navigation. */
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
if (compteItem) {
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
toggleCompteMenu(false);
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
if (el && el.dataset.act === "apps") {
ev.preventDefault();
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
toggleCompteMenu();
return;
}
/* Clic extérieur : ferme tout menu ouvert (clic dans un menu ou
* sur son déclencheur déjà traité ci-dessus). */
var inPopup = ev.target.closest ? (ev.target.closest(".draco-apps-menu") ||
ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
relay(act, el, ev);
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
addGlobalListener("keydown", function (ev) {
if (ev.key === "Escape") { closeAllPopups(true); return; }
/* Espace sur la marque : un lien ne s'active pas à l'Espace, donc
* ouverture explicite + focus sur le premier item du menu (#70).
* Entrée : clic natif du lien, déjà géré par le gestionnaire clic
* ci-dessus (pas de double bascule ici). */
if ((ev.key === " " || ev.key === "Spacebar") && ev.target && ev.target.closest) {
var brandEl = ev.target.closest('[data-act="apps"]');
if (brandEl) {
if (ev.preventDefault) ev.preventDefault();
toggleAppsMenu();
if (appsMenu && !appsMenu.hidden) {
var first = appsMenu.querySelector(".draco-apps-item");
if (first && typeof first.focus === "function") first.focus();
}
}
}
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
/* API publique : l'app met les compteurs à jour sans recharger.
* setUrls(urls) fusionne de nouvelles bases (même contrat que
* DRACO_BAR_CONFIG.urls — donc prioritaires sur la dérivation -dev)
* et les ré-applique aux onglets et aux menus ; DRACO_BAR.urls expose
* les bases résolues (défauts inclus), pour le panneau Spark et tout
* autre consommateur de l'hôte. */
window.DRACO_BAR = {
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
urls: resolvedUrls,
setUrls: function (urls) {
cfg.urls = Object.assign({}, cfg.urls, urls || {});
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
}
};
buildMenu();
applyUrls(resolvedUrls);
buildAppsMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+236
View File
@@ -0,0 +1,236 @@
// Tests unitaires (node:test) du module partagé draco-alertes.js.
// Exécutés dans la sandbox : node --test draco-alertes.test.js
// Copie vendangée byte-identique de Projets/dracodev-commons
// ui/alertes/ (chantier 5) — seul le require change selon l'hôte.
// Hôte : Flowdeck
// Aucune donnée inventée : les payloads reprennent la forme exacte
// des API réelles (Flowdeck /api/notifications, TikTak
// /api/notifications, Kanbante /api/events). Le payload console
// n'est qu'un EXEMPLE DE FORMAT (voir README commons) : aucun
// endpoint console n'est publié sur agent-commun main au moment
// du chantier.
const test = require("node:test");
const assert = require("node:assert/strict");
const t = require("../static/js/draco-alertes.js");
function sampleResults() {
return {
flowdeck: { items: t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention budget", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel lu", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
}) },
tiktak: { items: t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel tâche", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage lu", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]) },
kanbante: { items: t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
}) },
obsigate: { items: [] },
console: { items: [], sourceMissing: true },
};
}
function allOf(results) {
const all = [];
for (const app of ["obsigate", "flowdeck", "tiktak", "kanbante", "console"]) {
(results[app] && results[app].items || []).forEach((a) => all.push(a));
}
return all;
}
test("ordre des onglets : Tout, apps, Console Spark, Historique", () => {
assert.deepEqual(t.TABS, ["tout", "obsigate", "flowdeck", "tiktak", "kanbante", "console", "historique"]);
assert.equal(t.TAB_LABELS.console, "Console Spark");
});
test("resolveUrls: config explicite > hote -dev > href > defaut prod, agent inclus", () => {
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
assert.equal(prod.tiktak, "https://tiktak.dracodev.net/");
assert.equal(prod.agent, "https://agent.dracodev.net/");
const dev = t.resolveUrls({ hostname: "tiktak-dev.dracodev.net", tabHrefs: {} });
assert.equal(dev.flowdeck, "https://flowdeck-dev.dracodev.net/");
assert.equal(dev.obsigate, "https://og-dev.dracodev.net/");
assert.equal(dev.agent, "https://agent-dev.dracodev.net/");
const explicit = t.resolveUrls({
hostname: "tiktak-dev.dracodev.net",
configUrls: { flowdeck: "https://flowdeck.dracodev.net/" },
});
assert.equal(explicit.flowdeck, "https://flowdeck.dracodev.net/");
const href = t.resolveUrls({
hostname: "example.org",
tabHrefs: { kanbante: "https://kanbante-dev.dracodev.net/", agent: "https://agent.dracodev.net/" },
});
assert.equal(href.kanbante, "https://kanbante-dev.dracodev.net/");
assert.equal(href.agent, "https://agent.dracodev.net/");
const token = t.resolveUrls({ hostname: "x", tabHrefs: { tiktak: "{{URL_TIKTAK}}" } });
assert.equal(token.tiktak, "https://tiktak.dracodev.net/");
assert.equal(t.isDevHost("tiktak-dev.dracodev.net.evil.com"), false);
assert.equal(t.isDevHost("x-dev.dracodev.net"), true);
});
test("normalizeFlowdeck: non lues actives, lien = base + url", () => {
const items = t.normalizeFlowdeck("https://flowdeck.dracodev.net/", {
notifications: [
{ id: 1, ntype: "mention", title: "Mention", message: "voir page", url: "/collections/3", is_read: 0, created_at: "2026-10-28T10:00:00" },
{ id: 2, ntype: "reminder", title: "Rappel", message: "", url: "", is_read: 1, created_at: "2026-10-27T09:00:00" },
], unread: 1,
});
assert.equal(items.length, 2);
assert.equal(items[0].active, true);
assert.equal(items[0].state, "active");
assert.equal(items[0].url, "https://flowdeck.dracodev.net/collections/3");
assert.equal(items[1].active, false);
assert.equal(items[1].url, "https://flowdeck.dracodev.net/");
});
test("normalizeTiktak: lien tache, sinon page notifications", () => {
const items = t.normalizeTiktak("https://tiktak.dracodev.net/", [
{ id: 7, kind: "reminder", title: "Rappel", body: "tache", task_id: 42, is_read: 0, created_at: "2026-10-28T08:00:00" },
{ id: 8, kind: "share", title: "Partage", body: "", task_id: null, is_read: 1, created_at: "2026-10-26T08:00:00" },
]);
assert.equal(items[0].url, "https://tiktak.dracodev.net/?task=42");
assert.equal(items[1].url, "https://tiktak.dracodev.net/notifications");
assert.equal(items[0].active, true);
});
test("normalizeKanbante: jamais actif (pas d'etat lu/non lu), lien board", () => {
const items = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [
{ id: 9, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "commented", from_column: "", to_column: "", created_at: "2026-10-28T11:50:00" },
{ id: 10, repo_full_name: "Projets/fusion-trois-apps", issue_number: 69, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" },
],
});
assert.equal(items.length, 2);
assert.ok(items.every((i) => i.active === false));
assert.equal(items[0].url, "https://kanbante.dracodev.net/board/Projets/fusion-trois-apps");
assert.match(items[0].title, /#69/);
});
test("sortAlerts + countActive: actives d'abord, date decroissante", () => {
const list = [
{ app: "tiktak", active: false, date: "2026-10-28T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-26T12:00:00" },
{ app: "flowdeck", active: true, date: "2026-10-28T12:00:00" },
{ app: "kanbante", active: false, date: "" },
];
const sorted = t.sortAlerts(list);
assert.equal(sorted[0].date, "2026-10-28T12:00:00");
assert.equal(sorted[0].active, true);
assert.equal(sorted[1].date, "2026-10-26T12:00:00");
assert.equal(sorted[3].date, "");
assert.equal(t.countActive(list), 2);
});
test("scopeAlerts: Tout et onglets apps = actives seulement ; Historique = fermees seulement", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r);
assert.equal(tout.items.length, 2);
assert.ok(tout.items.every((a) => a.active));
assert.equal(tout.items[0].date, "2026-10-28T10:00:00"); // date decroissante
const fd = t.scopeAlerts(all, "flowdeck", r);
assert.equal(fd.items.length, 1);
assert.equal(fd.items[0].app, "flowdeck");
const hist = t.scopeAlerts(all, "historique", r);
assert.equal(hist.items.length, 4); // 1 FD lue + 1 TK lue + 2 KB
assert.ok(hist.items.every((a) => !a.active));
const og = t.scopeAlerts(all, "obsigate", r);
assert.equal(og.items.length, 0);
const cs = t.scopeAlerts(all, "console", r);
assert.equal(cs.items.length, 0);
assert.equal(cs.consoleMissing, true);
});
test("scopeAlerts: une section en echec remonte aussi dans Historique (pas avalee)", () => {
const r = sampleResults();
r.flowdeck = { items: [], error: "Application injoignable depuis cet hôte (TypeError)." };
const hist = t.scopeAlerts(allOf(r), "historique", r);
assert.equal(hist.errors.length, 1);
assert.equal(hist.errors[0].app, "flowdeck");
const tout = t.scopeAlerts(allOf(r), "tout", r);
assert.equal(tout.errors.length, 1);
});
test("countByTab: compteurs par onglet sur l'echantillon", () => {
const r = sampleResults();
const c = t.countByTab(allOf(r), r);
assert.deepEqual(c, {
tout: 2, obsigate: 0, flowdeck: 1, tiktak: 1,
kanbante: 0, console: 0, historique: 4,
});
});
test("filterAlerts: titre + contexte, insensible a la casse, sans melanger actifs/historique", () => {
const r = sampleResults();
const all = allOf(r);
const tout = t.scopeAlerts(all, "tout", r).items;
assert.equal(t.filterAlerts(tout, "budget").length, 1);
assert.equal(t.filterAlerts(tout, "BUDGET").length, 1);
assert.equal(t.filterAlerts(tout, "voir page").length, 1); // contexte (detail)
assert.equal(t.filterAlerts(tout, "tache").length, 1);
// « Rappel lu » est une alerte fermee : absente de Tout meme filtree.
assert.equal(t.filterAlerts(tout, "Rappel lu").length, 0);
const hist = t.scopeAlerts(all, "historique", r).items;
assert.equal(t.filterAlerts(hist, "Rappel lu").length, 1);
assert.equal(t.filterAlerts(tout, "").length, tout.length);
});
test("presentation identique: meta Kanbante sans type brut anglais ; date illisible = sans date", () => {
const kb = t.normalizeKanbante("https://kanbante.dracodev.net/", {
events: [{ id: 1, repo_full_name: "R", issue_number: 1, event_type: "moved", from_column: "todo", to_column: "review", created_at: "2026-10-28T12:00:00" }],
})[0];
assert.ok(!t.metaLine(kb).includes("moved"));
assert.match(t.metaLine(kb), /Kanbante · .* · Déplacée/);
assert.equal(t.displayState({ active: true }), "Active");
assert.equal(t.displayState({ active: false, app: "flowdeck" }), "Lue");
const sansDate = { app: "tiktak", active: true, title: "x", detail: "", date: "nimporte-quoi", kind: "", url: "https://x/" };
assert.match(t.metaLine(sansDate), /sans date/);
});
test("hash URL: parse + round-trip, onglet inconnu rejete", () => {
assert.equal(t.parseAlertTab("#alertes=historique"), "historique");
assert.equal(t.parseAlertTab("#alertes=console"), "console");
assert.equal(t.parseAlertTab("#alertes"), "tout");
assert.equal(t.parseAlertTab("#autre"), null);
assert.equal(t.parseAlertTab("#alertes=nimporte"), null);
assert.equal(t.parseAlertTab(""), null);
for (const tab of t.TABS) assert.equal(t.parseAlertTab(t.hashForTab(tab)), tab);
assert.equal(t.hashForTab("nimporte"), "#alertes=tout");
});
test("normalizeConsole (FORMAT EXEMPLE): statuts reels -> actif/ferme, lien console par defaut", () => {
const items = t.normalizeConsole("https://agent.dracodev.net/", {
alerts: [
{ id: "m1", title: "Mission en pause budget", detail: "budget épuisé", status: "pause_budget", created_at: "2026-10-28T09:00:00" },
{ id: "m2", title: "Accès requis", detail: "jeton manquant", status: "acces_requis", created_at: "2026-10-28T09:30:00" },
{ id: "a1", title: "Approbation en attente", status: "en_attente", url: "/approbations", created_at: "2026-10-28T10:00:00" },
{ id: "m3", title: "Mission terminée", status: "terminee", created_at: "2026-10-27T10:00:00" },
{ id: "m4", title: "Sans statut", created_at: "2026-10-27T11:00:00" },
],
});
assert.equal(items.length, 5);
assert.equal(items[0].active, true);
assert.equal(items[1].active, true);
assert.equal(items[2].active, true);
assert.equal(items[2].url, "https://agent.dracodev.net/approbations");
assert.equal(items[3].active, false);
assert.equal(items[4].active, true); // statut inconnu : expose en active, jamais masque
assert.equal(items[0].url, "https://agent.dracodev.net/");
const r = sampleResults();
r.console = { items };
const c = t.countByTab(allOf(r), r);
assert.equal(c.console, 4);
assert.equal(c.tout, 6);
assert.equal(c.historique, 5);
});
test("consoleSourceUrl: null sans configuration (aucune URL devineee)", () => {
assert.equal(t.consoleSourceUrl({}), null);
assert.equal(t.consoleSourceUrl({ consoleUrl: "https://agent-dev.dracodev.net/api/alertes" }),
"https://agent-dev.dracodev.net/api/alertes");
});
+68
View File
@@ -0,0 +1,68 @@
/* Tests Node sans navigateur de recherche.js (logique pure).
* Usage : node recherche.test.js — sortie 0 si tout passe. */
"use strict";
const assert = require("node:assert/strict");
const rp = require("../static/js/recherche.js");
let n = 0;
function ok(cond, label) {
assert.ok(cond, label);
n += 1;
}
/* esc */
ok(rp.esc('<a href="x">&') === "&lt;a href=&quot;x&quot;&gt;&amp;", "esc échappe HTML");
/* isDevHost : même règle que la barre v0.1.6 */
ok(rp.isDevHost("tiktak-dev.dracodev.net") === true, "dev reconnu");
ok(rp.isDevHost("tiktak.dracodev.net") === false, "prod non dev");
ok(rp.isDevHost("x-dev.dracodev.net.evil") === false, "hôte piège rejeté");
ok(rp.isDevHost("dev.dracodev.net") === false, "étiquette trop courte rejetée");
ok(rp.isDevHost("TIKTAK-DEV.DRACODEV.NET") === true, "insensible à la casse");
/* resolveBases : ordre explicite > barre > dev > href > prod */
let b = rp.resolveBases({
host: "tiktak-dev.dracodev.net",
explicit: { obsigate: "https://og-perso.example/" },
barUrls: { flowdeck: "https://flowdeck-barre.example/" },
tabHrefs: { kanbante: "https://kanbante-href.example/" },
});
ok(b.obsigate === "https://og-perso.example/", "explicite prioritaire");
ok(b.flowdeck === "https://flowdeck-barre.example/", "barre avant dev");
ok(b.tiktak === "https://tiktak-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
b = rp.resolveBases({ host: "tiktak.dracodev.net", tabHrefs: { kanbante: "https://kanbante-href.example/" } });
ok(b.kanbante === "https://kanbante-href.example/", "href en prod");
ok(b.obsigate === "https://og.dracodev.net/", "défaut prod");
b = rp.resolveBases({});
ok(b.flowdeck === "https://flowdeck.dracodev.net/", "défaut prod sans hôte");
/* searchPath */
ok(rp.searchPath("obsigate", "café & thé") === "api/search?q=caf%C3%A9%20%26%20th%C3%A9&vault=all&limit=8", "path ObsiGate");
ok(rp.searchPath("tiktak", "a b") === "api/search?q=a%20b", "path TikTak");
/* normalize ObsiGate */
let items = rp.normalize("obsigate", { results: [{ vault: "home-bruno", path: "Notes/Idée.md", title: "Idée", snippet: "extrait" }] }, "https://og-dev.dracodev.net/", "idée");
ok(items.length === 1 && items[0].url === "https://og-dev.dracodev.net/#file=home-bruno:Notes%2FId%C3%A9e.md", "lien profond ObsiGate");
ok(items[0].context === "home-bruno · Notes/Idée.md", "contexte ObsiGate");
/* normalize Flowdeck */
items = rp.normalize("flowdeck", { pages: [{ title: "P", excerpt: "ex", subtitle: "Espace", url: "/pages/42" }], collections: [{ title: "B", subtitle: "Database · Espace", url: "/db/7" }] }, "https://flowdeck-dev.dracodev.net/", "p");
ok(items.length === 2 && items[0].url === "https://flowdeck-dev.dracodev.net/pages/42", "lien page Flowdeck");
ok(items[1].url === "https://flowdeck-dev.dracodev.net/db/7", "lien base Flowdeck");
/* normalize TikTak */
items = rp.normalize("tiktak", { tasks: [{ id: 9, title: "Faire X", description: "d", status: "active", due_at: "2026-10-30 09:00:00" }] }, "https://tiktak-dev.dracodev.net/", "faire");
ok(items[0].url === "https://tiktak-dev.dracodev.net/search?q=faire&task=9", "lien profond TikTak");
ok(items[0].context === "Tâche · Active · échéance 2026-10-30", "contexte TikTak");
/* normalize Kanbante */
items = rp.normalize("kanbante", { groups: [{ full_name: "Projets/site", rows: [{ issue: { number: 12, title: "Bug", body: "corps" }, column: "in_progress" }] }] }, "https://kanbante-dev.dracodev.net/", "bug");
ok(items[0].title === "#12 Bug" && items[0].url === "https://kanbante-dev.dracodev.net/board/Projets/site", "lien tableau Kanbante");
ok(items[0].context === "Projets/site · En cours", "contexte Kanbante");
/* nativeSearchUrl */
ok(rp.nativeSearchUrl("tiktak", "https://tiktak-dev.dracodev.net/", "q") === "https://tiktak-dev.dracodev.net/search?q=q", "repli TikTak");
ok(rp.nativeSearchUrl("obsigate", "https://og-dev.dracodev.net/", "q") === "https://og-dev.dracodev.net/", "repli ObsiGate = accueil (palette)");
console.log("recherche.test.js : " + n + " assertions OK");
+9
View File
@@ -24,6 +24,7 @@ import contextlib
import ipaddress
import json
import socket
import sys
import httpx
import pytest
@@ -108,6 +109,14 @@ def mock_http(handler):
from app.services.importers import url_fetch
if getattr(url_fetch, "shared_client", None) is build:
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):
+282
View File
@@ -0,0 +1,282 @@
"""Phase 4 (issue ObsiGate #22) — connecteurs Flowdeck à référence ObsiGate.
Couverture : format de référence, création liée à un workspace, isolation
(A ne distribue pas avec l'octroi de B — le faux distributeur n'est même
pas appelé), distribution en mémoire sans persistance ni cache, octroi
révoqué, ObsiGate indisponible (aucun repli), connecteurs historiques
chiffrés toujours valides, routes API.
Le distributeur est un FAUX endpoint (appelable injecté), clairement
identifié comme tel : aucun test ne parle à une vraie instance ObsiGate.
Les valeurs de test sont fictives (``TEST-VALUE-XXXX``).
"""
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import coffre_reference, connectors
from app.services.coffre_reference import (
CoffreAccesRefuse,
CoffreIndisponible,
CoffreReferenceInvalide,
CoffreWorkspaceMismatch,
)
PUBLIC_URL = "https://example.com/api"
REFERENCE = "coffre:flowdeck/gitea-lecture"
FAKE_VALUE = "TEST-VALUE-XXXX"
@pytest.fixture(autouse=True)
def _faux_jeton_consommateur():
"""Le distributeur exige un jeton consommateur : valeur fictive de test.
Sans elle, ``distribuer`` refuserait avant même d'appeler le faux
endpoint (comportement voulu en production).
"""
import app.config
app.config.settings.obsigate_consumer_token = "TEST-CONSUMER-TOKEN-XXXX"
yield
def _workspaces():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) "
"VALUES (1, 'tester', 'Tester', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier A', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier B', 1)"
)
conn.commit()
rows = conn.execute(
"SELECT id FROM workspaces ORDER BY id"
).fetchall()
return int(rows[0]["id"]), int(rows[1]["id"])
class _FakeDistributeur:
"""Faux endpoint ObsiGate : compte les appels, ne persiste rien."""
def __init__(self, behaviour="ok"):
self.behaviour = behaviour
self.calls = []
def __call__(self, url, payload, headers):
self.calls.append((url, dict(payload)))
assert url.endswith("/api/coffres/distribuer"), url
assert headers.get("Authorization", "").startswith("Bearer "), headers
if self.behaviour == "ok":
return 200, {"reference": payload["reference"], "valeur": FAKE_VALUE,
"version": 1}
if self.behaviour == "revoque":
return 403, {"detail": "octroi_revoque"}
if self.behaviour == "indisponible":
raise ConnectionError("faux ObsiGate injoignable")
raise AssertionError(self.behaviour)
def _create_coffre(client, ws, **kw):
body = {"name": "Conn coffre", "url": PUBLIC_URL,
"workspace_id": ws, "coffre_reference": REFERENCE, **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Format ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad", [
"gitea-lecture", "coffre:", "coffre:slug-seul", "coffre:AB/mauvais",
"coffre:a/b", "coffre:ok/mauvais nom", "coffre:ok/",
])
def test_reference_invalide_rejetee(bad):
with pytest.raises(CoffreReferenceInvalide):
coffre_reference.parse_reference(bad)
def test_reference_normalisee_minuscules():
assert coffre_reference.parse_reference("Coffre:FlowDeck/Gitea-Lecture") == (
"flowdeck", "gitea-lecture")
# ── Création liée au workspace ─────────────────────────────────────────────
def test_creation_coffre_exige_workspace(client):
r = client.post("/api/agent/connectors",
json={"name": "Sans WS", "url": PUBLIC_URL,
"coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
assert "workspace" in r.text.lower()
def test_creation_coffre_refuse_secret_et_reference(client):
ws_a, _ = _workspaces()
r = client.post("/api/agent/connectors",
json={"name": "Ambigu", "url": PUBLIC_URL, "secret": "x",
"workspace_id": ws_a, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
def test_creation_coffre_ne_stocke_aucun_jeton(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
assert row["coffre_reference"] == REFERENCE
assert row["workspace_id"] == ws_a
assert row["secret_source"] == "coffre"
assert row["has_secret"] is True
assert FAKE_VALUE not in str(row)
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted, coffre_reference FROM agent_connectors "
"WHERE id=?", (row["id"],)
).fetchone()
assert stored["secret_encrypted"] == "" # aucun jeton stocké
assert stored["coffre_reference"] == REFERENCE # métadonnée seulement
def test_creation_workspace_introuvable(client):
r = client.post("/api/agent/connectors",
json={"name": "WS ?", "url": PUBLIC_URL,
"workspace_id": 999999, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
# ── Résolution + isolation (critère central) ───────────────────────────────
def test_resolve_bon_workspace_distribue_en_memoire(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
valeur = connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert valeur == FAKE_VALUE
assert len(fake.calls) == 1
_, payload = fake.calls[0]
assert payload["reference"] == REFERENCE
assert payload["contexte_type"] == "flowdeck_workspace"
assert payload["contexte_id"] == str(ws_a)
# Rien n'a été persisté ni mis en cache par la distribution.
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(row["id"],)
).fetchone()
assert stored["secret_encrypted"] == ""
assert FAKE_VALUE not in str(connectors.get(row["id"]))
def test_isolation_workspace_a_ne_distribue_pas_octroi_de_b(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_b) # octroi du workspace B
fake = _FakeDistributeur()
with pytest.raises(CoffreWorkspaceMismatch) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "ne peut pas utiliser" in str(exc.value)
assert fake.calls == [] # distributeur jamais appelé
def test_resolve_sans_workspace_refuse(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
with pytest.raises(ValueError, match="workspace_id"):
connectors.resolve_secret(row["id"], distribute=fake)
assert fake.calls == []
def test_headers_bon_workspace_mauvais_workspace(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
headers = connectors._headers(row["id"], workspace_id=ws_a,
distribute=fake)
assert headers["Authorization"] == f"Bearer {FAKE_VALUE}"
with pytest.raises(CoffreWorkspaceMismatch):
connectors._headers(row["id"], workspace_id=ws_b, distribute=fake)
# ── Échecs propres ─────────────────────────────────────────────────────────
def test_octroi_revoque_message_explicite_sans_repli(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="revoque")
with pytest.raises(CoffreAccesRefuse) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "révoqué" in str(exc.value)
assert "ré-accorder" in str(exc.value)
assert exc.value.detail == "octroi_revoque"
def test_obsigate_indisponible_sans_valeur_en_cache(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="indisponible")
for _ in range(2): # deux appels : aucun ne doit renvoyer une valeur
with pytest.raises(CoffreIndisponible):
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert len(fake.calls) == 2 # chaque appel retente, rien en cache
# ── Migration opportuniste ─────────────────────────────────────────────────
def test_connecteur_historique_chiffre_toujours_valide(client):
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc"}
row = client.post("/api/agent/connectors", json=body).json()
assert row["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(row["id"]) == "sk-live-abc"
def test_bascule_patch_vers_reference_puis_retour(client):
ws_a, ws_b = _workspaces()
row = client.post("/api/agent/connectors",
json={"name": "Migrable", "url": PUBLIC_URL,
"secret": "sk-live-abc"}).json()
cid = row["id"]
# 1. rattacher au workspace, 2. basculer vers la référence.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_a})
assert r.status_code == 200, r.text
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": REFERENCE})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "coffre"
# Le workspace est immuable : pas de transfert d'octroi vers B.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_b})
assert r.status_code == 400, r.text
# Effacer la référence réactive le jeton local dormant.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": ""})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(cid) == "sk-live-abc"
def test_probe_coffre_sans_workspace_echoue_proprement(client, monkeypatch):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
async def ok_get(url, headers=None):
return 200, "{}"
monkeypatch.setattr(connectors, "_get", ok_get)
r = client.post("/api/agent/connectors/probe",
json={"connector": str(row["id"])})
assert r.status_code == 200
assert r.json()["status"] == "error" # refus, pas de fuite inter-workspace
def test_routes_exigent_session(client):
anon_csrf(client)
assert client.post("/api/agent/connectors",
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
+84
View File
@@ -0,0 +1,84 @@
"""Fusion #69 — Alertes unifiées (chantier 2 + chantier 5, Flowdeck).
Source réelle de Flowdeck : GET /api/notifications (non lues =
actives, `url` relative pour le lien profond), déjà exposée et
inchangée ici. Couverts : placeholder Alertes disparu, module servi,
CSP connect-src avec les origines soeurs, CORS (origines soeurs
ajoutées à allow_origins ; tiers sans en-tête).
"""
SOEURS = ("https://og.dracodev.net", "https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net", "https://kanbante.dracodev.net")
def test_barre_alertes_sans_placeholder_et_module_servi(client):
# Les pages applicatives authentifiées ne sont pas atteignables
# telles quelles dans la sandbox (l'écran de connexion est servi
# pour "/" comme pour "/dashboard") : on vérifie donc le gabarit
# livré lui-même, plus les fichiers statiques servis.
from pathlib import Path
gabarit = Path("app/templates/_draco_bar.html").read_text()
assert "Alertes unifiées : arrive avec la fédération" not in gabarit
assert "draco-alertes.js" in gabarit
assert "draco-alertes.css" in gabarit
assert "DRACO_ALERTES.open()" in gabarit
js = client.get("/static/js/draco-alertes.js")
assert js.status_code == 200
assert "DRACO_ALERTES" in js.text
assert "api/notifications" in js.text
css = client.get("/static/css/draco-alertes.css")
assert css.status_code == 200
assert "draco-alertes-overlay" in css.text
def test_csp_connect_src_contient_les_soeurs(client):
# La CSP est posée sur toute réponse HTML, y compris l'écran de
# connexion servi ici : elle prouve les origines soeurs en
# connect-src pour les pages de l'hôte.
r = client.get("/")
csp = r.headers.get("content-security-policy", "")
assert "connect-src" in csp
for origine in SOEURS:
assert origine in csp, csp
def test_notifications_api_et_cors_soeur(client):
r = client.get("/api/notifications")
assert r.status_code == 200
body = r.json()
assert isinstance(body["notifications"], list)
assert "unread" in body
r2 = client.options("/api/notifications",
headers={"Origin": "https://tiktak.dracodev.net",
"Access-Control-Request-Method": "GET"})
assert r2.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
assert r2.headers.get("access-control-allow-credentials") == "true"
r3 = client.get("/api/notifications",
headers={"Origin": "https://tiktak.dracodev.net"})
assert r3.headers.get("access-control-allow-origin") == "https://tiktak.dracodev.net"
r4 = client.options("/api/notifications",
headers={"Origin": "https://evil.example",
"Access-Control-Request-Method": "GET"})
assert "access-control-allow-origin" not in r4.headers
def test_chantier5_onglet_console_spark_et_alertes_onglets(client):
"""Chantier 5 : onglet Console Spark en dernier dans la barre
(gabarit lu, pages authentifiées inatteignables en sandbox comme
pour le test #69), et vue Alertes en onglets (module + styles)."""
from pathlib import Path
gabarit = Path("app/templates/_draco_bar.html").read_text()
nav = gabarit.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
assert nav.index('data-app="kanbante"') < nav.index('data-app="agent"')
assert "Console Spark" in nav
js = client.get("/static/js/draco-alertes.js")
assert js.status_code == 200
assert "draco-alertes-tab" in js.text
assert "historique" in js.text
assert "source indisponible" in js.text
css = client.get("/static/css/draco-alertes.css")
assert css.status_code == 200
assert "draco-alertes-tab" in css.text
assert "draco-alertes-filtre" in css.text
+248
View File
@@ -110,6 +110,72 @@ def test_preset_tiktak_depuis_config(monkeypatch):
"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):
from app.routers.auth import _sso_login_url
@@ -122,3 +188,185 @@ def test_bouton_sso_login_depuis_config(monkeypatch):
assert _sso_login_url() == (
"https://og-dev.dracodev.net/sso/authorize?return_to="
"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
+78
View File
@@ -0,0 +1,78 @@
"""Fusion ACC-4.5 — conversion d'Agents & Skills Flowdeck au format
commun (contrat ACC-0.3, `dracodev-commons/contrats/skill-format.md`).
Preuve du format : trois presets de la galerie Flowdeck convertis en
`skills-communs/<nom>/SKILL.md` (compte-rendu-reunion, rapport-hebdo,
resume-document). Ils doivent être conformes au contrat, se convertir
en champs Flowdeck et s'installer dans un workspace via le chemin
existant (`skill_gallery.upsert_skill`).
"""
from pathlib import Path
import pytest
from app.services import skill_format, skill_gallery
RACINE = Path(__file__).resolve().parent.parent
CONVERTIS = ["compte-rendu-reunion", "rapport-hebdo", "resume-document"]
def _lire(nom: str) -> str:
return (RACINE / "skills-communs" / nom / "SKILL.md").read_text(encoding="utf-8")
def test_trois_skills_convertis_conformes():
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
assert skill_format.valider(skill) == [], (nom, skill_format.valider(skill))
assert skill["meta"]["name"] == nom
assert skill["meta"]["apps"] == ["flowdeck"]
assert skill["meta"]["version"] == "1.0.0"
# Toute écriture est déclarée, avec approbation explicite.
actions = {e["action"] for e in skill["meta"]["ecritures"]}
assert actions == {"flowdeck.create_document", "flowdeck.write_blocks"}
def test_conversion_vers_flowdeck_et_installation(client):
for nom in CONVERTIS:
skill = skill_format.analyser_skill_md(_lire(nom))
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == nom
assert set(champs["allowed_tools"]) == {
"search_workspace", "read_document", "create_document", "write_blocks"}
assert "Procédure" in champs["prompt_template"]
assert "Garde-fous" in champs["prompt_template"]
row, cree = skill_gallery.upsert_skill(
champs, workspace_id=None, created_by=1)
assert cree is True and row["name"] == nom
def test_validation_rejette_les_ecarts():
incomplet = skill_format.analyser_skill_md(
"---\nname: mauvais nom\napps: [flowdeck]\n---\n\n## Procédure\n\nx\n")
erreurs = skill_format.valider(incomplet)
assert any("kebab" in e for e in erreurs)
assert any("description" in e for e in erreurs)
assert any("Quand l'utiliser" in e for e in erreurs)
with pytest.raises(ValueError):
skill_format.vers_champs_flowdeck(incomplet)
def test_ecritures_obligatoires_si_outil_ecrivant():
texte = (
"---\nname: supprime-tout\ndescription: x\nversion: 1.0.0\n"
"apps: [flowdeck]\ndomaines: [pro]\noutils: [flowdeck.delete_page]\n---\n\n"
"## Quand l'utiliser\n\nx\n\n## Procédure\n\nx\n\n## Garde-fous\n\nx\n")
erreurs = skill_format.valider(skill_format.analyser_skill_md(texte))
assert any("ecritures obligatoire" in e for e in erreurs)
def test_aller_retour_depuis_un_preset():
preset = skill_gallery.get_gallery("compte-rendu-reunion")
texte = skill_format.depuis_preset_flowdeck(preset, domaines=["pro"])
skill = skill_format.analyser_skill_md(texte)
assert skill_format.valider(skill) == []
champs = skill_format.vers_champs_flowdeck(skill)
assert champs["name"] == "compte-rendu-de-reunion"
assert "create_document" in champs["allowed_tools"]
+167 -3
View File
@@ -70,8 +70,9 @@ def test_mcp_handshake_et_outils(client, jeton):
noms = [t["name"] for t in r.json()["result"]["tools"]]
assert noms == ["list_workspaces", "get_workspace",
"list_collections", "get_collection", "get_page",
"search_pages", "list_repos", "create_page",
"update_page", "create_collection"]
"search_pages", "list_repos", "list_projects",
"get_project", "read_page", "list_project_tasks",
"create_page", "update_page", "create_collection"]
def test_mcp_workspaces(client, jeton):
@@ -107,7 +108,13 @@ def test_mcp_collections_et_pages(client, jeton, donnees):
assert titres == ["Page de cadrage", "Revue hebdo"]
assert detail["pages"][0]["properties"] == {"statut": "en cours"}
res = _appel(client, jeton, 3, "search_pages", {"query": "cadrage"})
assert [p["title"] for p in res] == ["Page de cadrage"]
# ACC-5.1 : la recherche est plein texte et couvre aussi les pages
# classiques — on vérifie la ligne de collection, sa famille et son
# URL profonde (§7.2 : l'URL est systématique).
lignes = [p for p in res if p["kind"] == "collection_page"]
assert [p["title"] for p in lignes] == ["Page de cadrage"]
assert all(p["url"].endswith(f"/collections/{donnees}") for p in lignes)
assert all("url" in p and "kind" in p for p in res)
res = _appel(client, jeton, 4, "get_collection", {"id": 99999})
assert "introuvable" in res["erreur"]
@@ -237,3 +244,160 @@ def test_mcp_info_get(client, jeton):
assert "create_page" in infos["tools"]
assert "update_page" in infos["tools"]
assert "create_collection" in infos["tools"]
# ACC-5.1 : le catalogue distingue lectures et écritures.
assert "list_projects" in infos["read_tools"]
assert "read_page" in infos["read_tools"]
assert infos["write_tools"] == ["create_page", "update_page",
"create_collection"]
# ── ACC-5.1 : catalogue de lecture §7.2 ────────────────────────────────────
@pytest.fixture()
def projet_taches(client):
"""Un workspace + une base de tâches configurée (mapping complet)."""
with get_conn() as conn:
conn.execute("INSERT OR IGNORE INTO users (id, login) VALUES (1, 'tester')")
cur = conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier MCP', 1)")
wid = cur.lastrowid
cur = conn.execute(
"INSERT INTO collections (name, description, icon, workspace_id, is_task) "
"VALUES ('Suivi projet', 'Base de tâches', '🗂️', ?, 1)", (wid,))
cid = cur.lastrowid
props = {}
for nom, typ, opts in (
("Assigné à", "person", "[]"),
("Statut", "status", json.dumps(
[{"name": "À faire"}, {"name": "Terminé"}])),
("Échéance", "date", "[]")):
cur = conn.execute(
"INSERT INTO collection_properties "
"(collection_id, name, prop_type, options_json) VALUES (?, ?, ?, ?)",
(cid, nom, typ, opts))
props[nom] = cur.lastrowid
conn.execute(
"UPDATE collections SET task_assignee_prop=?, task_status_prop=?, "
"task_due_prop=? WHERE id=?",
(props["Assigné à"], props["Statut"], props["Échéance"], cid))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, "
"property_values_json) VALUES (?, 'Relire le pont MCP', 'file', 1, ?)",
(cid, json.dumps({str(props["Statut"]): "À faire",
str(props["Échéance"]): "2026-10-30"})))
conn.execute(
"INSERT INTO collection_pages (collection_id, title, icon, position, "
"property_values_json) VALUES (?, 'Brancher ObsiGate', 'file', 2, ?)",
(cid, json.dumps({str(props["Statut"]): "Terminé"})))
conn.commit()
return {"workspace_id": wid, "collection_id": cid, "props": props}
def test_mcp_projets_catalogue(client, jeton, donnees, projet_taches):
projets = _appel(client, jeton, 90, "list_projects")
alpha = [p for p in projets if p["title"] == "Projet Alpha"]
assert alpha and alpha[0]["id"] == donnees
assert alpha[0]["url"].endswith(f"/collections/{donnees}")
assert alpha[0]["page_count"] == 2
suivi = [p for p in projets if p["title"] == "Suivi projet"]
assert suivi and suivi[0]["is_task"] is True
detail = _appel(client, jeton, 91, "get_project", {"id": donnees})
assert detail["project"]["title"] == "Projet Alpha"
assert detail["project"]["url"].endswith(f"/collections/{donnees}")
# Détail sans contenu complet : titres seulement.
assert [p["title"] for p in detail["pages"]] == ["Page de cadrage",
"Revue hebdo"]
assert all(set(p) == {"id", "title"} for p in detail["pages"])
res = _appel(client, jeton, 92, "get_project", {"id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_list_project_tasks(client, jeton, projet_taches):
cid = projet_taches["collection_id"]
taches = _appel(client, jeton, 93, "list_project_tasks",
{"project_id": cid})
assert [t["title"] for t in taches] == ["Relire le pont MCP",
"Brancher ObsiGate"]
assert taches[0]["status"] == "À faire" and taches[0]["done"] is False
assert taches[0]["due"] == "2026-10-30"
assert taches[1]["done"] is True
assert taches[0]["url"].endswith(f"/collections/{cid}")
toutes = _appel(client, jeton, 94, "list_project_tasks")
assert {t["title"] for t in toutes} >= {"Relire le pont MCP"}
res = _appel(client, jeton, 95, "list_project_tasks",
{"project_id": 99999})
assert "introuvable" in res["erreur"]
def test_mcp_read_page_classique_et_troncature(client, jeton, projet_taches):
long_contenu = "mot " * 6000 # 24 000 caractères > borne 20 000
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO pages (workspace, workspace_id, title, content, "
"parent_section) VALUES ('Atelier MCP', ?, 'Grande note', ?, 'Private')",
(projet_taches["workspace_id"], long_contenu))
pid = cur.lastrowid
conn.commit()
res = _appel(client, jeton, 96, "read_page", {"id": pid})
assert res["kind"] == "page" and res["title"] == "Grande note"
assert res["truncated"] is True and len(res["content"]) <= 20000
assert res["url"].endswith(f"/pages/{pid}")
res = _appel(client, jeton, 97, "read_page", {"id": 99999, "kind": "page"})
assert "introuvable" in res["erreur"]
def test_mcp_read_page_ligne_de_collection(client, jeton, donnees):
with get_conn() as conn:
ligne = conn.execute(
"SELECT id FROM collection_pages WHERE collection_id=? AND "
"title='Page de cadrage'", (donnees,)).fetchone()["id"]
conn.execute(
"INSERT INTO pages (workspace, title, content, collection_row_id) "
"VALUES ('', 'contenu', 'Le contenu porté de la ligne', ?)",
(ligne,))
conn.commit()
res = _appel(client, jeton, 98, "read_page",
{"id": ligne, "kind": "collection_page"})
assert res["kind"] == "collection_page"
assert res["content"] == "Le contenu porté de la ligne"
assert res["truncated"] is False
assert res["properties"] == {"statut": "en cours"}
def test_mcp_jeton_lecture_seule_refuse_les_ecritures(client, jeton, donnees):
"""§7.4 : un jeton au scope « read » (pont de lecture) ne peut pas
écrire via MCP, mais il peut toujours lire."""
lecture = "fd_token_lecture_seule"
with get_conn() as conn:
conn.execute(
"INSERT INTO api_tokens (user_id, name, token_hash, token_prefix, "
"revoked, scopes) VALUES (1, 'pont-lecture', ?, ?, 0, 'read')",
(hashlib.sha256(lecture.encode()).hexdigest(), lecture[:12]))
conn.commit()
cols = _appel(client, lecture, 100, "list_projects")
assert any(c["title"] == "Projet Alpha" for c in cols)
r = _rpc(client, lecture, 101, "tools/call",
{"name": "create_page",
"arguments": {"collection_id": donnees, "title": "Interdite"}})
assert r.status_code == 200
erreur = r.json()["error"]
assert erreur["code"] == -32002 and "lecture seule" in erreur["message"]
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) FROM collection_pages WHERE title='Interdite'"
).fetchone()[0]
assert n == 0
def test_mcp_journalisation_des_appels(client, jeton, donnees):
"""§7.4 : chaque tools/call laisse une trace dans api_audit_log."""
_appel(client, jeton, 110, "list_projects")
with get_conn() as conn:
rows = conn.execute(
"SELECT action, user_id, token_id, detail FROM api_audit_log "
"WHERE action='mcp:list_projects'").fetchall()
assert rows, "aucune ligne d'audit pour l'appel MCP"
assert rows[-1]["user_id"] == 1 and rows[-1]["token_id"] is not None
assert "statut=ok" in rows[-1]["detail"]
+57
View File
@@ -0,0 +1,57 @@
"""Fusion ACC-4.4 — panneau de l'agent commun intégré à Flowdeck.
Le panneau commun (composant partagé des quatre apps, contrat
d'injection `dracodev-commons`) est intégré depuis la 7.81.0
(panneau v3), complété par le mode console (7.82.x) et la version du
service affichée en mode sidebar (7.83.1, fusion #64). Ces tests
figent le contrat d'intégration côté Flowdeck :
- le gabarit de base charge les assets du panneau et injecte la
configuration (URL du service d'agents résolue par configuration,
jamais un nom public codé en dur dans le JS) ;
- le panneau parle au service d'agents (conversation **commune** :
même discussion perpétuelle que dans les autres apps) ;
- les automatisations locales de Flowdeck restent locales (C5) :
le panneau commun ne remplace pas le moteur d'agent local.
"""
from pathlib import Path
RACINE = Path(__file__).resolve().parent.parent
def test_gabarit_charge_le_panneau_et_injecte_la_config():
base = (RACINE / "app/templates/base.html").read_text(encoding="utf-8")
assert "/static/css/draco-panneau.css" in base
assert "/static/js/draco-panneau.js" in base
assert "window.DRACO_PANNEAU_CONFIG" in base
assert "draco_agent_url()" in base
def test_panneau_parle_au_service_agent_conversation_commune():
js = (RACINE / "static/js/draco-panneau.js").read_text(encoding="utf-8")
# Même conversation continue que dans les autres apps : le
# panneau s'appuie sur l'API du service (contrat commons).
assert "/api/v1/me" in js
assert "/api/v1/chat" in js
assert "/api/v1/conversations" in js
# La configuration vient de l'hôte ; le seul repli est le nom
# officiel — aucun nom public d'app en dur dans le composant.
assert "DRACO_PANNEAU_CONFIG" in js
for nom in ("flowdeck-dev", "og-dev", "tiktak-dev", "kanbante-dev",
"agent-dev"):
assert f"://{nom}." not in js # aucune URL publique -dev codée en dur
def test_assets_panneau_servis(client):
for chemin in ("/static/js/draco-panneau.js", "/static/css/draco-panneau.css"):
r = client.get(chemin)
assert r.status_code == 200, chemin
def test_automatisations_locales_conservées(client):
# C5 : l'agent local de Flowdeck (automatisations, gouvernance)
# reste en place à côté du panneau commun.
r = client.get("/api/v2/agent-approvals", params={"status": "pending"})
assert r.status_code == 200
assert "approvals" in r.json()
@@ -0,0 +1,191 @@
"""Fusion ACC-6.2 — pilote d'écriture approuvée : archiver un résumé
de réunion Flowdeck dans ObsiGate via la file d'approbations unifiée.
Chaîne testée : demande déposée dans `agent_approvals` (outil
`archiver_reunion_obsigate`) → décision dans la file (route
gouvernance) → si approuvée, `propose_write_file` part vers ObsiGate
par le pont MCP (ACC-2.2) et la distribution est journalisée (canal
`obsigate`). Rejet = rien n'est envoyé. Sans connecteur ObsiGate,
l'exécution échoue proprement avec un message explicite.
La preuve réelle reste conditionnelle (chantier Meetings A/B +
clé ObsiGate dédiée de Bruno) ; ici le serveur MCP est simulé.
"""
import asyncio
import json
import pytest
from app.auth.session import SessionManager
from app.db import get_conn
from app.services import mcp_client, meeting_archive, meetings
from app.services.importers import url_fetch
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
monkeypatch.setattr(mcp_client, "_sessions", {})
@pytest.fixture
def admin_cookies():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin)"
" VALUES (1, 'tester', 'Tester', 1)")
conn.commit()
session = SessionManager.create_session(
{"id": 1, "login": "tester", "is_admin": 1})
return {"flowdeck_session": session}
def _transcript_avec_resume():
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Réunion hebdo', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id, summary)"
" VALUES (?, ?)",
(pid, "## Décisions\n\n- Lancer le pilote d'archivage.\n"
"- Revoir le pont MCP la semaine prochaine.")).lastrowid
conn.commit()
meetings._update(tid, generated_title="Hebdo produit", status="done")
return tid
def _creer_connecteur_obsigate(client):
r = client.post("/api/agent/connectors", json={
"name": "ObsiGate", "kind": "obsigate",
"url": "https://og.dracodev.net/mcp",
"secret": "og_cle_pont_test", "auth": "bearer"})
assert r.status_code == 200, r.text
return r.json()["id"]
def _faux_rpc_obsigate(appels):
async def fake_rpc(url, payload, headers=None):
appels.append(payload.get("method"))
method = payload.get("method")
if method == "initialize":
return {"result": {"protocolVersion": "2025-03-26",
"serverInfo": {"name": "obsigate"}}}
if method == "tools/call":
params = payload.get("params") or {}
appels.append((params.get("name"), params.get("arguments")))
return {"result": {"content": [
{"type": "text", "text": json.dumps({"draft_id": "b-42"})}]}}
return {}
return fake_rpc
def test_demande_approbation_et_gardes(client, admin_cookies):
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 201, r.text
body = r.json()
assert body["titre"] == "Hebdo produit"
assert body["chemin"].startswith("Réunions/")
assert body["chemin"].endswith("Hebdo produit.md")
approval = body["approval"]
assert approval["tool"] == "archiver_reunion_obsigate"
assert approval["status"] == "pending"
assert json.loads(approval["args_json"])["transcript_id"] == tid
# Doublon refusé tant que la demande est en attente.
r2 = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r2.status_code == 400
def test_resume_vide_refuse(client, admin_cookies):
with get_conn() as conn:
pid = conn.execute(
"INSERT INTO pages (workspace, title, content, content_format)"
" VALUES ('test', 'Vide', '', 'blocks')").lastrowid
tid = conn.execute(
"INSERT INTO meeting_transcripts (page_id) VALUES (?)",
(pid,)).lastrowid
conn.commit()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
assert r.status_code == 400
assert "Résumé vide" in r.text
def test_approbation_execute_la_proposition(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
r = client.post(f"/api/v2/meetings/transcripts/{tid}/archiver-obsigate",
cookies=admin_cookies)
aid = r.json()["approval"]["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
assert d.status_code == 200, d.text
body = d.json()
assert body["status"] == "approved"
assert body["archive"]["status"] == "ok", body["archive"]
assert "Réunions/" in body["archive"]["detail"]
# propose_write_file a reçu le résumé, jamais un apply direct.
calls = [a for a in appels if isinstance(a, tuple)]
assert len(calls) == 1 and calls[0][0] == "propose_write_file"
assert "Lancer le pilote" in calls[0][1]["content"]
assert calls[0][1]["path"].endswith("Hebdo produit.md")
# Distribution journalisée, canal obsigate.
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM meeting_distributions WHERE transcript_id=?",
(tid,)).fetchall()
assert len(rows) == 1
assert rows[0]["channel"] == "obsigate" and rows[0]["status"] == "propose"
def test_rejet_ne_envoie_rien(client, admin_cookies, monkeypatch):
_creer_connecteur_obsigate(client)
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": False})
assert d.json()["archive"]["status"] == "rejete"
assert not [a for a in appels if isinstance(a, tuple)]
with get_conn() as conn:
n = conn.execute(
"SELECT COUNT(*) AS n FROM meeting_distributions"
" WHERE transcript_id=?", (tid,)).fetchone()["n"]
assert n == 0
def test_sans_connecteur_erreur_explicite(client, admin_cookies):
tid = _transcript_avec_resume()
aid = meeting_archive.demander_approbation(tid, 1)["id"]
d = client.post(f"/api/v2/agent-approvals/{aid}/decide",
cookies=admin_cookies, json={"approve": True})
archive = d.json()["archive"]
assert archive["status"] == "erreur"
assert "Connecteur ObsiGate non configuré" in archive["detail"]
def test_construire_archive_contenu(client):
tr = meetings.get_transcript(_transcript_avec_resume())
archive = meeting_archive.construire_archive(tr)
assert archive["chemin"].startswith("Réunions/")
assert "# Hebdo produit" in archive["contenu"]
assert "Lancer le pilote" in archive["contenu"]
assert "/pages/" in archive["contenu"] # lien profond vers la note
def test_executer_async_direct(client, monkeypatch):
appels: list = []
monkeypatch.setattr(mcp_client, "_rpc", _faux_rpc_obsigate(appels))
tid = _transcript_avec_resume()
approval = meeting_archive.demander_approbation(tid, 1)
# Sans connecteur : erreur propre, pas d'exception.
out = asyncio.run(meeting_archive.executer(approval))
assert out["status"] == "erreur"
+320
View File
@@ -0,0 +1,320 @@
"""Fusion ACC-2.2 — pont MCP Flowdeck → ObsiGate (plan « étape 1 :
lectures seules », tests T0–T5 du plan ``pont-mcp-etape1``).
Ce que le code livre ici (la preuve réelle T0–T5 contre l'ObsiGate de
Bruno exige sa clé dédiée limitée à un vault + ses arbitrages Q-A/Q-B) :
- **T0 (connectivité)** : le client MCP parle à un serveur **à session**
façon ObsiGate — ``initialize`` renvoie ``Mcp-Session-Id``, tout
appel sans cet en-tête est refusé ; le handshake précède aussi
``tools/call``. Réponses SSE multi-messages décodées correctement.
- **Preset « obsigate »** : URL ``<base ObsiGate>/mcp`` dérivée de la
configuration (dev/prod), clé chiffrée jamais renvoyée.
- **Garde-fou n° 2** : les outils MCP sont **classés** à la découverte
(annotations ``readOnlyHint``, sinon heuristique de nom) — les
écrivants (``propose_*``…) passent par les portes existantes
(politique d'approbation + rôles) ; ``apply_*`` est **bloqué** côté
client (patron §7.5 : l'écriture croisée s'approuve dans l'app
propriétaire).
"""
import asyncio
import json
import httpx
import pytest
from app.db import get_conn
from app.services import connectors, http_client, mcp_client
from app.services.importers import url_fetch
from app.services.tool_registry import ToolRegistry
SESSION_ID = "sess-og-test-123"
URL_PONT = "https://og.dracodev.net/mcp"
@pytest.fixture(autouse=True)
def _dns_monde_reel(monkeypatch):
"""Même neutralisation DNS que test_fusion_tiktak (hôtes publics
résolus en 198.18.0.0/15 localement, refusés par la garde SSRF)."""
monkeypatch.setattr(url_fetch, "_validate_url", lambda url: url.strip())
monkeypatch.setattr(mcp_client, "_sessions", {})
# Outils tels qu'ObsiGate les déclare sur son serveur MCP : lectures,
# puis le patron d'écriture propose/apply (spec d'accès centralisé §7.5).
OUTILS_OBSIGATE = [
{"name": "list_vaults",
"description": "Liste les vaults accessibles au jeton.",
"inputSchema": {"type": "object", "properties": {}}},
{"name": "search_fulltext",
"description": "Recherche plein texte dans le vault du jeton.",
"inputSchema": {"type": "object",
"properties": {"query": {"type": "string"}},
"required": ["query"]},
"annotations": {"readOnlyHint": True}},
{"name": "read_file",
"description": "Lit un fichier du vault.",
"inputSchema": {"type": "object",
"properties": {"path": {"type": "string"}},
"required": ["path"]}},
{"name": "propose_write_file",
"description": "Propose une écriture (brouillon à approuver).",
"inputSchema": {"type": "object",
"properties": {"path": {"type": "string"},
"content": {"type": "string"}},
"required": ["path", "content"]},
"annotations": {"readOnlyHint": False}},
{"name": "apply_write_file",
"description": "Applique une écriture approuvée.",
"inputSchema": {"type": "object",
"properties": {"draft_id": {"type": "string"}},
"required": ["draft_id"]}},
]
def _gestionnaire_obsigate(appels):
"""Rejoue le serveur MCP d'ObsiGate : Streamable HTTP **à session
stricte** (tout appel post-initialize sans ``Mcp-Session-Id``
valide → erreur -32000), ``tools/list`` répondu en SSE
multi-messages (une notification précède la réponse)."""
def handler(request: httpx.Request) -> httpx.Response:
body = json.loads(request.content or b"{}")
method = body.get("method")
appels.append((method, dict(request.headers)))
if method == "initialize":
return httpx.Response(
200,
json={"jsonrpc": "2.0", "id": body.get("id"),
"result": {"protocolVersion": "2025-03-26",
"capabilities": {"tools": {}},
"serverInfo": {"name": "obsigate",
"version": "2.78"}}},
headers={"Mcp-Session-Id": SESSION_ID})
if request.headers.get("mcp-session-id") != SESSION_ID:
return httpx.Response(
400,
json={"jsonrpc": "2.0", "id": body.get("id"),
"error": {"code": -32000,
"message": "Mcp-Session-Id manquant ou invalide"}})
if method == "notifications/initialized":
return httpx.Response(202, content=b"")
if method == "tools/list":
sse = (
'event: message\n'
'data: {"jsonrpc": "2.0", "method": "notifications/progress",'
' "params": {"message": "scan des vaults"}}\n\n'
'event: message\n'
f'data: {json.dumps({"jsonrpc": "2.0", "id": body.get("id"), "result": {"tools": OUTILS_OBSIGATE}})}\n\n'
)
return httpx.Response(200, content=sse.encode(),
headers={"Content-Type": "text/event-stream"})
if method == "tools/call":
nom = (body.get("params") or {}).get("name")
if nom == "search_fulltext":
res = [{"path": "Notes/Pont MCP.md", "vault": "home-bruno",
"extraits": ["le pont MCP relie Flowdeck à ObsiGate"]}]
elif nom == "read_file":
res = {"path": "Notes/Pont MCP.md",
"content": "Contenu du fichier Pont MCP."}
elif nom == "list_vaults":
res = [{"name": "home-bruno"}]
else:
res = {"ok": True}
return httpx.Response(
200, json={"jsonrpc": "2.0", "id": body.get("id"),
"result": {"content": [
{"type": "text",
"text": json.dumps(res, ensure_ascii=False)}]}})
return httpx.Response(200, json={"jsonrpc": "2.0",
"id": body.get("id"), "result": {}})
return handler
@pytest.fixture
def faux_obsigate(monkeypatch):
"""Branche le vrai ``_rpc`` du client sur le faux serveur (transport
httpx simulé) : sessions, en-têtes et SSE sont exercés pour de vrai."""
appels: list = []
transport = httpx.MockTransport(_gestionnaire_obsigate(appels))
vraie_classe = http_client.shared_client
class FauxShared:
def __init__(self, **kwargs):
kwargs["transport"] = transport
self._inner = vraie_classe(**kwargs)
async def __aenter__(self):
return await self._inner.__aenter__()
async def __aexit__(self, *exc):
return await self._inner.__aexit__(*exc)
monkeypatch.setattr(http_client, "shared_client", FauxShared)
return appels
def _creer_obsigate(client, **kw):
body = {"name": "ObsiGate", "kind": "obsigate", "url": URL_PONT,
"secret": "og_cle_pont_test", "auth": "bearer", **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
def _sonder(client):
row = _creer_obsigate(client)
res = asyncio.run(connectors.probe(str(row["id"])))
assert res["status"] == "ok", res
return row, res
# ── Preset ───────────────────────────────────────────────────────────────
def test_preset_obsigate_et_secret_chiffre(client):
row = _creer_obsigate(client)
assert row["kind"] == "obsigate" and row["auth"] == "bearer"
assert row["has_secret"] is True
assert "og_cle_pont_test" not in json.dumps(row) # jamais renvoyée
# URL par défaut = défaut officiel ; en dev, draco_urls la dérive
# en og-dev (résolution par configuration, chantier dev/prod).
assert connectors.PRESETS["obsigate"]["url"] == "https://og.dracodev.net/mcp"
hdrs = connectors._headers(row["id"])
assert hdrs["Authorization"] == "Bearer og_cle_pont_test"
# ── T0 : session + handshake + SSE ───────────────────────────────────────
def test_t0_session_handshake_et_cache(client, faux_obsigate):
row, res = _sonder(client)
assert "5 outil(s)" in res["detail"]
methodes = [m for m, _ in faux_obsigate]
assert methodes[:3] == ["initialize", "notifications/initialized", "tools/list"]
# L'appel tools/list porte la session émise à initialize — sans
# elle, le faux serveur refuse (session stricte) et le probe échoue.
entetes_list = [h for m, h in faux_obsigate if m == "tools/list"][0]
assert entetes_list.get("mcp-session-id") == SESSION_ID
assert entetes_list.get("authorization") == "Bearer og_cle_pont_test"
with get_conn() as conn:
brut = conn.execute(
"SELECT tools_json FROM agent_connectors WHERE id=?",
(row["id"],)).fetchone()["tools_json"]
outils = {t["original"]: t for t in json.loads(brut)}
assert set(outils) == {"list_vaults", "search_fulltext", "read_file",
"propose_write_file", "apply_write_file"}
# Classification à la découverte (garde-fou n° 2).
assert outils["search_fulltext"]["write"] is False # annotation readOnlyHint
assert outils["read_file"]["write"] is False # défaut = lecture
assert outils["propose_write_file"]["write"] is True # annotation + préfixe
assert outils["apply_write_file"]["write"] is True # préfixe apply_
def test_t1_appel_outil_lecture_avec_session(client, faux_obsigate):
_sonder(client)
reg = ToolRegistry()
noms = {t["name"] for t in reg.schema()}
assert "mcp_obsigate_search_fulltext" in noms
assert "mcp_obsigate_read_file" in noms
avant = len(faux_obsigate)
res = asyncio.run(reg.execute("mcp_obsigate_search_fulltext",
{"query": "pont MCP"}))
assert res.status == "success", res
assert "Pont MCP.md" in res.data["text"]
# call_tool refait le handshake complet, session réémise partout.
sequence = [m for m, _ in faux_obsigate[avant:]]
assert sequence == ["initialize", "notifications/initialized", "tools/call"]
entetes_call = [h for m, h in faux_obsigate if m == "tools/call"][0]
assert entetes_call.get("mcp-session-id") == SESSION_ID
res2 = asyncio.run(reg.execute("mcp_obsigate_read_file",
{"path": "Notes/Pont MCP.md"}))
assert res2.status == "success"
assert "Contenu du fichier Pont MCP." in res2.data["text"]
def test_parse_body_sse_multi_messages():
sse = (
': keep-alive\n'
'event: message\n'
'data: {"jsonrpc": "2.0", "method": "notifications/progress", "params": {}}\n\n'
'event: message\n'
'data: {"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}\n\n'
)
# Sans id attendu : le message porteur de result gagne sur la notification.
assert mcp_client.parse_body("text/event-stream", sse, 200) == {
"jsonrpc": "2.0", "id": 2, "result": {"tools": []}}
# Avec id attendu : c'est lui qui tranche.
assert mcp_client.parse_body("text/event-stream", sse, 200,
expected_id=2)["result"] == {"tools": []}
def test_classer_outil():
assert mcp_client.classer_outil("search_fulltext") is False
assert mcp_client.classer_outil("read_file", {"readOnlyHint": True}) is False
assert mcp_client.classer_outil("propose_write_file") is True
assert mcp_client.classer_outil("apply_write_file") is True
assert mcp_client.classer_outil("create_note") is True
# L'annotation du serveur fait foi, même contre l'heuristique.
assert mcp_client.classer_outil("create_note", {"readOnlyHint": True}) is False
assert mcp_client.classer_outil("get_stats", {"readOnlyHint": False}) is True
# ── Garde-fous : portes de gouvernance + blocage apply_* ────────────────
def test_apply_bloque_cote_client(client, faux_obsigate):
_sonder(client)
reg = ToolRegistry()
res = asyncio.run(reg.execute("mcp_obsigate_apply_write_file",
{"draft_id": "b-1"}))
assert res.status == "error"
assert "bloqué" in res.message
# Aucun tools/call n'a quitté le client pour cet outil.
assert [m for m, _ in faux_obsigate].count("tools/call") == 0
def test_portes_gouvernance_outils_mcp(client, faux_obsigate):
from app.services.agent_policies import check_tool
from app.services.permission_manager import PermissionManager, is_write_tool
row, _res = _sonder(client)
assert row["id"]
assert is_write_tool("mcp_obsigate_propose_write_file") is True
assert is_write_tool("mcp_obsigate_apply_write_file") is True
assert is_write_tool("mcp_obsigate_search_fulltext") is False
assert is_write_tool("create_page") is True # interne, inchangé
assert is_write_tool("search") is False
# Politique globale « approbation requise » : l'écrivant MCP est
# intercepté comme un écrivant interne, la lecture passe.
with get_conn() as conn:
conn.execute(
"INSERT INTO agent_policies (workspace_id, require_approval)"
" VALUES (NULL, 1)")
conn.commit()
gate = check_tool(1, None, "mcp_obsigate_propose_write_file",
is_write=is_write_tool("mcp_obsigate_propose_write_file"))
assert gate["allowed"] is False and gate["approval_id"]
ok = check_tool(1, None, "mcp_obsigate_search_fulltext",
is_write=is_write_tool("mcp_obsigate_search_fulltext"))
assert ok["allowed"] is True
# Rôles : un viewer ne peut pas lancer un outil MCP écrivant.
from fastapi import HTTPException
with get_conn() as conn:
admin = conn.execute("SELECT id FROM users ORDER BY id LIMIT 1").fetchone()
conn.execute("INSERT INTO users (login, is_admin) VALUES ('lecteur_pont', 0)")
lecteur = conn.execute(
"SELECT id FROM users WHERE login='lecteur_pont'").fetchone()
conn.execute("INSERT INTO workspaces (name, owner_id) VALUES ('ws-pont', ?)",
(admin["id"],))
ws = conn.execute("SELECT id FROM workspaces WHERE name='ws-pont'").fetchone()
conn.execute(
"INSERT INTO workspace_members (workspace_id, user_id, role)"
" VALUES (?,?, 'viewer')", (ws["id"], lecteur["id"]))
conn.commit()
pm = PermissionManager(lecteur["id"])
with pytest.raises(HTTPException):
pm.assert_can("mcp_obsigate_propose_write_file", {}, ws["id"])
pm.assert_can("mcp_obsigate_search_fulltext", {}, ws["id"])
+73
View File
@@ -0,0 +1,73 @@
"""Tests de la recherche fédérée Flowdeck (fusion #68).
Fonctions pures de app/middleware/recherche_cors.py uniquement :
aucune base, aucun serveur.
"""
from app.middleware.recherche_cors import (
cors_headers_for,
is_dev_host,
is_prod_host,
origin_of,
preflight_headers,
sister_origins,
)
RESOLVED_DEV = {
"obsigate": "https://og-dev.dracodev.net/",
"flowdeck": "https://flowdeck-dev.dracodev.net",
"tiktak": "https://tiktak-dev.dracodev.net/",
"kanbante": "https://kanbante-dev.dracodev.net/",
"agent": "https://agent-dev.dracodev.net/",
"gitea": "https://git.dracodev.net/",
}
def test_is_dev_host():
assert is_dev_host("flowdeck-dev.dracodev.net") is True
assert is_dev_host("flowdeck.dracodev.net") is False
assert is_dev_host("x-dev.dracodev.net.evil") is False
assert is_dev_host("flowdeck-dev.dracodev.net:8443") is True
assert is_prod_host("flowdeck.dracodev.net") is True
assert is_prod_host("flowdeck-dev.dracodev.net") is False
def test_origin_of():
assert origin_of("https://og-dev.dracodev.net/") == "https://og-dev.dracodev.net"
assert origin_of("{{URL_OBSIGATE}}") is None
def test_sister_origins_dev():
assert sister_origins(RESOLVED_DEV) == {
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
}
def test_sister_origins_ajoute_env_de_la_requete():
# Configuration muette (hors requête) + hôte dev : les -dev passent.
assert "https://og-dev.dracodev.net" in sister_origins(
{}, request_host="flowdeck-dev.dracodev.net"
)
assert "https://og.dracodev.net" in sister_origins(
{}, request_host="flowdeck.dracodev.net"
)
assert "https://og-dev.dracodev.net" not in sister_origins(
{}, request_host="flowdeck.dracodev.net"
)
def test_cors_headers():
headers = cors_headers_for("https://og-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Origin"] == "https://og-dev.dracodev.net"
assert headers["Access-Control-Allow-Credentials"] == "true"
assert cors_headers_for("https://evil.example", RESOLVED_DEV) is None
assert cors_headers_for("https://og.dracodev.net", RESOLVED_DEV) is None
def test_preflight_headers():
headers = preflight_headers("https://kanbante-dev.dracodev.net", RESOLVED_DEV)
assert headers["Access-Control-Allow-Methods"] == "GET, OPTIONS"
assert preflight_headers("https://evil.example", RESOLVED_DEV) is None
+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 "wiki/pages" in src and "wiki/titles" in src # picker + label resolution
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
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