Compare commits
57
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ce2ac08dd3 | ||
|
|
b64efb8674 | ||
|
|
2841847188 | ||
|
|
8b84306f61 | ||
|
|
ba995ebe1c | ||
|
|
2b1f7808fd | ||
|
|
8827b3dec8 | ||
|
|
c2df8d8c00 | ||
|
|
a930c8192e | ||
|
|
021d0a93f3 | ||
|
|
40571049d6 | ||
|
|
6eb7b78534 | ||
|
|
31a84539f2 | ||
|
|
7250ebc78f | ||
|
|
167e93e295 | ||
|
|
30edf2f0c0 | ||
|
|
67718d1d3c | ||
|
|
1dd148532b | ||
|
|
bd9ecf69e1 | ||
|
|
4b1df2d2b9 | ||
|
|
fe389d46bc | ||
|
|
eef46891de | ||
|
|
7f9eb080b1 | ||
|
|
9abd6c9d4d | ||
|
|
fbe9151ab7 | ||
|
|
ab63000f5d | ||
|
|
52b62b48b8 | ||
|
|
acaae92e3a | ||
|
|
1065ffb017 | ||
|
|
36c97e1553 | ||
|
|
c356a56648 | ||
|
|
c2a222fc3b | ||
|
|
e4be8578ff | ||
|
|
e733f3309d | ||
|
|
371908ed1d | ||
|
|
da205feec1 | ||
|
|
c582856051 | ||
|
|
ecafeecda0 | ||
|
|
7a43ca00ad | ||
|
|
e5e98ed08f | ||
|
|
62b536c865 | ||
|
|
a3431b18c9 | ||
|
|
45940c26bb | ||
|
|
f827e9fc7d | ||
|
|
cd0835116d | ||
|
|
b751c81120 | ||
|
|
30387c4ed4 | ||
|
|
4f13068c85 | ||
|
|
417c0a23d7 | ||
|
|
913d6137b1 | ||
|
|
eff459ddd7 | ||
|
|
19b27698f2 | ||
|
|
fd79a49d63 | ||
|
|
283e226adf | ||
|
|
53ba3d1124 | ||
|
|
8ce3b4b421 | ||
|
|
193fbd14ce |
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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"%'
|
||||
|
||||
@@ -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 = {}
|
||||
|
||||
@@ -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
@@ -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
@@ -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)}]}}
|
||||
|
||||
@@ -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
@@ -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")},
|
||||
))
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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}
|
||||
|
||||
@@ -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)
|
||||
@@ -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(
|
||||
|
||||
@@ -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}')",
|
||||
|
||||
@@ -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}"
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
@@ -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.
|
||||
@@ -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`).
|
||||
@@ -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`).
|
||||
@@ -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; }
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
@@ -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; }
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
@@ -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"); }
|
||||
|
||||
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.replace(/"/g, """);
|
||||
}
|
||||
|
||||
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
|
||||
);
|
||||
Vendored
+460
-42
@@ -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);
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
@@ -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">&') === "<a href="x">&", "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");
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"
|
||||
@@ -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"])
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user