Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1a717ede16 | ||
|
|
d3abeb197a | ||
|
|
dee5fb5752 | ||
|
|
9de1166661 | ||
|
|
c0add7b6de | ||
|
|
d29986e51d | ||
|
|
cd8969a3a2 | ||
|
|
e8cbd8e0f3 | ||
|
|
d40044e0c4 | ||
|
|
41865c702a | ||
|
|
56b8ff2072 | ||
|
|
fc5431308c | ||
|
|
7d31af8ec2 | ||
|
|
2e73802f5f | ||
|
|
46b876eea8 | ||
|
|
2431bb6059 | ||
|
|
ac2f0c8cfc | ||
|
|
58ad5d9164 | ||
|
|
91fad4eba3 | ||
|
|
7995dbe9d2 | ||
|
|
0e3e6ec197 | ||
|
|
d6ea617ed7 | ||
|
|
f7c2ea0d34 | ||
|
|
d16666be85 | ||
|
|
fb4ce38403 | ||
|
|
34253c7b3f | ||
|
|
4490a2ed70 | ||
|
|
c9f7754f5f | ||
|
|
43e416a889 | ||
|
|
fefd9b1ad3 | ||
|
|
3b43be3761 | ||
|
|
972d40a75f | ||
|
|
fb942bc871 | ||
|
|
b8ebea1479 | ||
|
|
ee065978b2 | ||
|
|
71c7113a8c | ||
|
|
c0bf0e8523 | ||
|
|
9ae054d1ba | ||
|
|
1e552a55a4 | ||
|
|
fd9df60788 | ||
|
|
984e92b593 | ||
|
|
8ba38a69a4 | ||
|
|
ee477bf2d1 | ||
|
|
ba5f8c8eab | ||
|
|
d4fef9c1bf | ||
|
|
f708fa77e2 | ||
|
|
7711bd3af7 | ||
|
|
b4923493ac | ||
|
|
0ead9fea4a | ||
|
|
8b871ee39f | ||
|
|
171fdf063b | ||
|
|
ea12938e7c | ||
|
|
3f14dc7130 | ||
|
|
71454f94d1 | ||
|
|
3145cea8e1 | ||
|
|
f49eb62172 | ||
|
|
746ab4c0e1 | ||
|
|
cdf943aff5 | ||
|
|
751daa43dd | ||
|
|
3895e79167 | ||
|
|
8dcddfe34f | ||
|
|
b506a447c5 | ||
|
|
9ca831f816 | ||
|
|
c09a0ac51d | ||
|
|
2d3a93511b | ||
|
|
dd979f81a7 | ||
|
|
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 |
@@ -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
|
||||
|
||||
@@ -1,5 +1,90 @@
|
||||
## v7.84.9 (2026-10-29) — Barre commune : marque monochrome, anneau rouge avatar
|
||||
|
||||
- **Barre d'apps v0.1.11 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.11 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons, JS inchangé en v0.1.11) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.11, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
|
||||
- **Marque monochrome** : la marque n'est plus le logo rouge — c'est l'étoile Spark monochrome (`static/img/brand-spark.png`, copie de `ui/barre-apps/brand-spark.png` de commons, étoile blanche sur fond transparent, 26 px), blanche sur barre foncée et noire sur barre claire via le filtre du CSS vendangé. L'onglet agent et son entrée de menu GARDENT le logo rouge.
|
||||
- **Anneau rouge avatar** : le bouton avatar flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px du CSS vendangé, `var(--agent)`).
|
||||
- **Thème** : la barre suit `data-theme` sur `<html>` (choix Flowdeck `fd_theme`, défaut light) — les règles thème de commons s'appliquent telles quelles, aucune surcharge propre n'a été ajoutée.
|
||||
- **Cache** : le bump 7.84.8 → 7.84.9 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
|
||||
## v7.84.8 (2026-10-29) — Barre commune : rouge Spark, avatar flottant, Alertes cloche seule
|
||||
|
||||
- **Barre d'apps v0.1.10 vendangée (dracodev-commons)** : `static/css/draco-barre.css` remplacé verbatim par la v0.1.10 ; `static/js/vendor/draco-barre.js` inchangé (déjà identique à commons entre v0.1.9 et v0.1.10) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.10, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP, `asset_version`) est inchangé.
|
||||
- **Rouge Spark** : `--agent` rose `#c2437e` → rouge `#c00000` (échantillonné dans le logo Spark) ; onglet agent `--app #c00000`, marque = logo Spark (26 px) au lieu du ✦.
|
||||
- **Bouton Spark rose supprimé** de la barre, remplacé par le bouton avatar flottant bas-centre (cercle personnage Spark, `static/img/avatar-spark.png`, `data-act="spark"` → le panneau Spark de l'hôte, comme l'ancien bouton).
|
||||
- **Alertes** : cloche seule, sans libellé — bouton moins large.
|
||||
- **Cache** : le bump 7.84.7 → 7.84.8 change aussi `asset_version`, donc les navigateurs ne garderont pas l'ancienne barre.
|
||||
|
||||
## v7.84.7 (2026-10-29) — Barre commune : les 5 logos dans le menu et la barre
|
||||
|
||||
- **Barre d'apps v0.1.9 vendangée (dracodev-commons)** : `static/js/vendor/draco-barre.js` et `static/css/draco-barre.css` remplacés verbatim par la v0.1.9 (les fichiers vendangés précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale n'est perdue) ; fragment `app/templates/_draco_bar.html` porté à la v0.1.9, le reste de son intégration Flowdeck (URL `draco_urls()`, `onAction`, nonce CSP) est inchangé.
|
||||
- **Icônes** : les 5 tuiles de la barre — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers servis depuis `static/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px) ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (la marque ✦ et le bouton Spark gardent leur ✦).
|
||||
- **Cache** : le bump 7.84.6 → 7.84.7 change aussi `asset_version`, donc les navigateurs ne garderont pas les anciennes icônes.
|
||||
|
||||
## v7.84.6 (2026-10-28) — Recherche console : CORS Flowdeck sans doublons (PR #41)
|
||||
|
||||
- **CORS recherche (PR #41)** : le middleware de recherche remplace désormais les en-têtes CORS existants au lieu de les empiler — le CORSMiddleware global posait déjà Allow-Credentials: true, l'ancien middleware ajoutait les siens par-dessus (« true, true » rejeté par le navigateur) ; l'onglet Flowdeck de la recherche console agent-dev n'est plus injoignable.
|
||||
|
||||
## v7.84.5 (2026-10-28) — Corrections console/recherche : texte parasite, onglets, CORS console
|
||||
|
||||
- **Barre (PR #37)** : suppression du texte parasite « Chantier 5 » rendu au-dessus du header.
|
||||
- **Recherche (PR #38)** : onglets visibles après déploiement (base console explicite + purge cache service worker).
|
||||
- **CORS recherche (PR #40)** : la recherche Flowdeck autorise désormais la console Spark (agent prod+dev) en inter-origines.
|
||||
|
||||
## v7.84.4 (2026-10-28) — Alignement recherche console : onglet Console Spark sur /api/v1/recherche
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte.
|
||||
|
||||
## v7.84.3 (2026-10-28) — Corrections recherche/alertes : CORS prod+dev, recherche en onglets, alertes en onglets
|
||||
|
||||
- **CORS recherche/alertes (#68/#69)** : allowlist d'origines soeurs prod+dev explicites + credentials, jamais `*` — chaque serveur n'autorisait que l'environnement resolu, d'ou les echecs inter-apps constates.
|
||||
- **Recherche partout (#68)** : recherche pleine largeur, reintegree dans le gabarit, focus auto, « Tout voir » restant sur place, onglets Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark.
|
||||
- **Alertes (#69/#71)** : vue Alertes en onglets (Tout/ObsiGate/Flowdeck/TikTak/Kanbante/Console Spark/Historique, actives sauf Historique = fermees), filtre texte, composant partage vendange byte-identique ; bouton Console Spark en 5e onglet de la barre apres Kanbante.
|
||||
|
||||
## 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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
||||
|
||||
|
||||
# Littéral de version synchronisé avec le fichier VERSION :
|
||||
# version="7.83.1"
|
||||
# version="7.84.3"
|
||||
# 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,29 @@ 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")
|
||||
} - {""})
|
||||
# Correctif CORS alertes (fusion #69, 2026-10-28) : allow_origins
|
||||
# est figé à l'import (configuration seule, sans hôte de requête)
|
||||
# — il ne doit donc pas dépendre de l'environnement résolu. Les
|
||||
# origines sœurs prod ET dev sont listées explicitement (module
|
||||
# CORS de la recherche, jamais "*") : c'est ce qui manquait à
|
||||
# /api/notifications lu depuis les autres hôtes.
|
||||
from app.middleware.recherche_cors import DEV_ORIGINS as _DEV_ORIGINS
|
||||
from app.middleware.recherche_cors import PROD_ORIGINS as _PROD_ORIGINS
|
||||
|
||||
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | set(_DEV_ORIGINS)
|
||||
| set(_PROD_ORIGINS))
|
||||
# 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 +273,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,227 @@
|
||||
"""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 : les origines officielles **prod ET dev,
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
|
||||
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
|
||||
ET dev — fédération de la recherche depuis la console,
|
||||
2026-10-28), **plus**
|
||||
celles résolues par ``app.services.draco_urls.toutes()``
|
||||
(explicite > hôte > dérivation > défaut officiel) — jamais ``*``.
|
||||
Correctif 2026-10-28 (fusion #68/#69, matrice constatée par
|
||||
Bruno) : l'ancienne allowlist ne contenait que l'environnement
|
||||
résolu (configuration seule, ce middleware s'exécutant avant le
|
||||
middleware CSP qui pose le contexte d'hôte, plus l'environnement
|
||||
de l'hôte de la requête) — jamais les deux à la fois. Une page
|
||||
servie dans l'autre environnement voyait donc sa section
|
||||
afficher « Injoignable depuis cet hôte (CORS ou réseau) », et
|
||||
c'est Flowdeck qui échouait depuis tous les hôtes ;
|
||||
- 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",
|
||||
})
|
||||
|
||||
AGENT_ORIGINS = frozenset({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-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)
|
||||
# Correctif 2026-10-28 : prod ET dev toujours — ``request_host``
|
||||
# ne restreint plus l'allowlist (il ne servait qu'à ajouter
|
||||
# l'environnement de la requête, désormais inclus d'office).
|
||||
# ``resolved`` n'ajoute que des origines de configuration sans
|
||||
# jamais retirer une origine officielle.
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_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:
|
||||
# Remplacer, pas empiler (défaut 3b, 2026-10-28) :
|
||||
# le CORSMiddleware global de l'application pose
|
||||
# déjà Access-Control-Allow-Credentials: true sur
|
||||
# TOUTES les réponses (comportement Starlette avec
|
||||
# allow_credentials=True, même pour une origine
|
||||
# hors allowlist et hors /api/search) ; empiler les
|
||||
# en-têtes de recherche donnait, sur /api/search
|
||||
# depuis agent-dev, Allow-Credentials en double
|
||||
# (« true, true » ≠ « true » pour le contrôle CORS
|
||||
# du navigateur → fetch rejeté → « injoignable
|
||||
# (CORS ou réseau) ») et, depuis une sœur
|
||||
# (origine déjà dans la allowlist globale),
|
||||
# Allow-Origin en double (rejet certain). Les
|
||||
# hôtes TikTak/Kanbante, sans ce cumul, répondent
|
||||
# avec des en-têtes uniques — d'où l'échec
|
||||
# Flowdeck seul. Une origine non autorisée
|
||||
# traverse sans modification (comportement
|
||||
# antérieur préservé : le seul en-tête global
|
||||
# reste, sans Allow-Origin, donc bloqué).
|
||||
restants = [
|
||||
(k, v) for k, v in message.get("headers", [])
|
||||
if k.decode("latin-1").lower() not in (
|
||||
"access-control-allow-origin",
|
||||
"access-control-allow-credentials",
|
||||
"vary")]
|
||||
message["headers"] = restants + [
|
||||
(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)
|
||||
@@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
# 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'; "
|
||||
@@ -174,8 +174,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
||||
request.headers.get("host", ""))
|
||||
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)"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,29 @@ 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")
|
||||
# Defaut 6 : la base agent est exposee explicitement (data-base-console
|
||||
# dans recherche.html), comme les 3 autres hotes — recherche.js ne doit
|
||||
# plus la deviner (derivation -dev / href d'onglet de barre) pour
|
||||
# l'onglet Console Spark (GET /api/v1/recherche, alignement #36).
|
||||
return HTMLResponse(template.render(
|
||||
request=request, q=q.strip(), user=user,
|
||||
bases={k: bases[k] for k in ("obsigate", "flowdeck", "tiktak",
|
||||
"kanbante", "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)")
|
||||
@@ -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,7 +28,9 @@ 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__)
|
||||
@@ -118,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,
|
||||
}
|
||||
|
||||
|
||||
@@ -146,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()
|
||||
@@ -177,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
|
||||
@@ -206,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:
|
||||
@@ -226,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":
|
||||
@@ -250,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)
|
||||
@@ -303,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_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,
|
||||
@@ -326,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)
|
||||
@@ -351,7 +553,9 @@ 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()
|
||||
@@ -381,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]}
|
||||
|
||||
@@ -189,7 +189,8 @@ async def _handshake(url: str, headers: dict | None) -> dict:
|
||||
return session
|
||||
|
||||
|
||||
def _server(connector_id: int) -> dict:
|
||||
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")
|
||||
@@ -198,7 +199,8 @@ def _server(connector_id: int) -> dict:
|
||||
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]:
|
||||
@@ -226,10 +228,16 @@ 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)
|
||||
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": {}}, session)
|
||||
@@ -276,14 +284,16 @@ 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:
|
||||
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)
|
||||
url = _with_secret(srv["url"], connector_id)
|
||||
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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}"
|
||||
@@ -1,36 +1,63 @@
|
||||
{# 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.11
|
||||
(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). #}
|
||||
par défaut, noms en -dev déclarés dans le .env de l'environnement).
|
||||
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 (menu construit par draco-barre.js depuis les onglets).
|
||||
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
|
||||
fournis, tels quels, servis depuis /static/img/logos/ (copie de
|
||||
ui/barre-apps/logos/ de dracodev-commons) — jamais de glyphe
|
||||
texte, jamais de redessin ; l'entrée « Console Spark » du menu
|
||||
clone l'icône de l'onglet agent (draco-barre.js).
|
||||
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000 ;
|
||||
marque ✦ → logo Spark (26 px) ; bouton « Spark » rose SUPPRIMÉ
|
||||
de la barre, remplacé par le bouton avatar flottant bas-centre
|
||||
(static/img/avatar-spark.png, data-act="spark" → panneau de l'hôte) ;
|
||||
Alertes = cloche seule, sans libellé. JS inchangé depuis v0.1.9.
|
||||
v0.1.11 (2026-10-29) : marque = étoile Spark monochrome
|
||||
(static/img/brand-spark.png, copie de ui/barre-apps/brand-spark.png
|
||||
de commons — blanche sur barre foncée, noire sur barre claire
|
||||
via le filtre du CSS vendangé) ; onglet agent et son entrée de
|
||||
menu GARDENT le logo rouge ; anneau rouge #c00000 autour du
|
||||
bouton avatar flottant (bordure du CSS vendangé). Thème de la
|
||||
barre : data-theme sur <html> (fd_theme, défaut light) — les
|
||||
règles thème de commons s'appliquent, aucune surcharge propre
|
||||
n'est nécessaire. #}
|
||||
<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>
|
||||
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="{{ draco_urls().obsigate }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="{{ draco_urls().flowdeck }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="{{ draco_urls().tiktak }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
|
||||
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="kanbante" href="{{ draco_urls().kanbante }}/">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
|
||||
<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:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></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">
|
||||
@@ -49,24 +76,20 @@
|
||||
</div>
|
||||
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
|
||||
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
|
||||
<span class="draco-alertes-txt">Alertes</span>
|
||||
<span class="draco-badge" data-badge="alertes" hidden>0</span>
|
||||
</button>
|
||||
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
|
||||
<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>
|
||||
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
|
||||
<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 +105,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 +124,4 @@
|
||||
}
|
||||
};
|
||||
</script>
|
||||
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
{% extends "base.html" %}
|
||||
{% block title_prefix %}Rechercher partout{% endblock %}
|
||||
{% block content %}
|
||||
{# Versionnes (?v=asset_version) comme les autres feuilles/scripts de
|
||||
base.html : sans version, le service worker (static/sw.js, cache-first
|
||||
des .js/.css) resservait indefiniment les copies pre-#33 de ces deux
|
||||
fichiers aux navigateurs les ayant deja visites — la page paraissait
|
||||
donc « non corrigee » malgre les PR #33/#36. #}
|
||||
<link rel="stylesheet" href="/static/css/recherche.css?v={{ asset_version }}">
|
||||
{# Console : data-base-console rendu explicitement, comme les 3 autres
|
||||
hotes — la route Flowdeck de /recherche expose la base agent dans
|
||||
son contexte `bases` (defaut 6). recherche.js garde ses replis
|
||||
(derivation -dev, href d'onglet de barre) si l'attribut est vide. #}
|
||||
<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 }}"
|
||||
data-base-console="{{ bases.agent }}">
|
||||
<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. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</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>
|
||||
<nav class="rp-tabs" role="tablist" aria-label="Résultats par application">
|
||||
<button type="button" class="rp-tab active" role="tab" aria-selected="true" data-rp-tab="all">Tout <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="obsigate">ObsiGate <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
|
||||
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
|
||||
</nav>
|
||||
<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>
|
||||
<section class="rp-section" data-rp-section="console">
|
||||
<div class="rp-head"><h2>Console Spark — discussions</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?v={{ asset_version }}"></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,16 @@
|
||||
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 ».
|
||||
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
|
||||
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
|
||||
Alertes icône seule.
|
||||
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
|
||||
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
|
||||
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. */
|
||||
@@ -10,7 +19,7 @@
|
||||
.draco-bar {
|
||||
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
|
||||
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
|
||||
--agent: #c2437e; --danger: #cf4444;
|
||||
--agent: #c00000; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
@@ -32,7 +41,16 @@
|
||||
/* 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-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { line-height: 0; }
|
||||
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
|
||||
fond transparent — noire sur barre claire (filtre), blanche sur
|
||||
barre foncée (filtre annulé par les règles thème ci-dessous). */
|
||||
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.draco-bar .draco-brand-mark img { filter: none; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
@@ -42,6 +60,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;
|
||||
@@ -66,6 +107,13 @@
|
||||
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||
}
|
||||
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
|
||||
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
|
||||
* texte, jamais de redessin. Le fond de la tuile est transparent :
|
||||
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
|
||||
* reste sur le fond de la barre. */
|
||||
.draco-apptab-ico { background: transparent; }
|
||||
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
|
||||
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
|
||||
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
|
||||
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
|
||||
@@ -103,18 +151,23 @@
|
||||
|
||||
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
|
||||
.draco-alertes {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
|
||||
position: relative; display: flex; align-items: center; height: 38px;
|
||||
padding: 0 9px; border-radius: 10px; border: 1px solid var(--line);
|
||||
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
|
||||
}
|
||||
|
||||
/* Spark */
|
||||
.draco-spark {
|
||||
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
|
||||
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
|
||||
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
|
||||
/* Bouton avatar Spark flottant — bas-centre, ouvre le panneau Spark
|
||||
de l'hôte via data-act="spark" (délégation globale existante).
|
||||
La console Spark ne l'inclut pas : son fragment est propre. */
|
||||
.draco-spark-flottant {
|
||||
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
|
||||
z-index: 65; width: 64px; height: 64px; padding: 0; border: 0;
|
||||
border-radius: 50%; background: transparent; cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / .45);
|
||||
}
|
||||
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||
.draco-spark-flottant img { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; box-sizing: border-box; border: 3px solid var(--agent); }
|
||||
.draco-spark-flottant:hover { transform: translateX(-50%) scale(1.05); }
|
||||
.draco-spark-flottant:focus-visible { outline: 2px solid var(--agent); outline-offset: 3px; }
|
||||
|
||||
.draco-icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||
@@ -122,8 +175,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) */
|
||||
@@ -139,7 +194,7 @@
|
||||
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
|
||||
}
|
||||
@media (max-width: 1200px) {
|
||||
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
|
||||
.draco-search-txt, .draco-search kbd { display: none; }
|
||||
.draco-search { width: 38px; padding: 0; justify-content: center; }
|
||||
.draco-bar { grid-template-columns: auto auto 1fr; }
|
||||
.draco-bar-droite { justify-content: flex-end; }
|
||||
@@ -147,8 +202,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 +217,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,90 @@
|
||||
/* 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; }
|
||||
|
||||
/* ---- Onglets (chantier 2+3) : mêmes onglets sur les 4 hôtes. ---- */
|
||||
.rp-tabs {
|
||||
display: flex; gap: 4px; flex-wrap: wrap;
|
||||
border-bottom: 1px solid #888; margin-bottom: 20px;
|
||||
}
|
||||
.rp-tab {
|
||||
background: none; border: none; border-bottom: 2px solid transparent;
|
||||
padding: 8px 10px; margin-bottom: -1px; font: inherit; cursor: pointer;
|
||||
color: inherit; opacity: .7; white-space: nowrap;
|
||||
}
|
||||
.rp-tab:hover { opacity: 1; }
|
||||
.rp-tab.active { opacity: 1; border-bottom-color: #3b82f6; font-weight: 700; }
|
||||
.rp-tab:focus-visible { outline: 2px solid #3b82f6; outline-offset: 2px; }
|
||||
.rp-tab-count {
|
||||
font-size: .8rem; font-weight: 700; background: rgba(127, 127, 127, .25);
|
||||
border-radius: 999px; padding: 1px 7px; margin-left: 2px;
|
||||
}
|
||||
.rp-tab-count:empty { display: none; }
|
||||
.rp-section[hidden] { display: none; }
|
||||
.rp-all[hidden] { display: none; }
|
||||
|
||||
/* ---- TikTak : la page sort de la colonne sidebar (chantier 2+3).
|
||||
* Cause : recherche.html étend base.html sans layout_class, donc
|
||||
* .rp-page (enfant direct de .layout) tombait en auto-placement dans
|
||||
* la 2e colonne de la grille (largeur sidebar). La règle ci-dessous,
|
||||
* gardée par l'ancêtre .layout (TikTak seul), l'étend de la colonne 2
|
||||
* à la fin de la grille — juste à droite du rail d'icônes — sur toute
|
||||
* la largeur disponible, sans toucher aux autres pages (elles ne
|
||||
* portent pas .rp-page). En mobile (≤760 px, point de bascule TikTak),
|
||||
* la grille n'a qu'une colonne : la page la prend entièrement. */
|
||||
.layout > .rp-page {
|
||||
grid-column: 2 / -1; max-width: none; min-width: 0; overflow-y: auto;
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.layout > .rp-page { grid-column: 1 / -1; }
|
||||
}
|
||||
|
||||
/* ---- ObsiGate : navigation statique de la page autonome (chantier
|
||||
* 2+3). La page /recherche est servie hors SPA (route avant le
|
||||
* catch-all) : elle reprend les classes de l'app (style.css :
|
||||
* .app-container, .header, .main-body, .sidebar, .content-wrapper,
|
||||
* .content-area) et cette navigation, calée sur les variables de
|
||||
* l'app, en guise de sidebar. Les vaults dynamiques restent dans
|
||||
* la SPA (voir le commentaire dans recherche.html). */
|
||||
.rp-og-nav { display: flex; flex-direction: column; gap: 4px; padding: 12px; }
|
||||
.rp-og-nav a {
|
||||
display: block; padding: 8px 10px; border-radius: 8px;
|
||||
text-decoration: none; color: inherit;
|
||||
}
|
||||
.rp-og-nav a:hover { background: rgba(127, 127, 127, .15); }
|
||||
.rp-og-nav a[aria-current="page"] {
|
||||
background: rgba(127, 127, 127, .18); font-weight: 700;
|
||||
}
|
||||
.rp-og-note { opacity: .65; font-size: .85rem; padding: 8px 10px; margin: 8px 0 0; }
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
|
||||
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
|
||||
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
|
||||
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
@@ -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);
|
||||
@@ -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,194 @@
|
||||
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 ; son icône CLONE celle de l'onglet agent
|
||||
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
|
||||
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
|
||||
* autres éléments de la barre n'est pas changé ici. */
|
||||
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;
|
||||
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
|
||||
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
|
||||
if (agentIco) {
|
||||
sparkIco = agentIco.cloneNode(true);
|
||||
} else {
|
||||
sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#d21f2c");
|
||||
sparkIco.setAttribute("data-icon", "agent");
|
||||
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);
|
||||
|
||||
@@ -8,7 +8,14 @@
|
||||
═══════════════════════════════════════════════════════════ */
|
||||
'use strict';
|
||||
|
||||
const CACHE_NAME = 'flowdeck-v8'; // v8 : Alpine CSP build (A20 fini)
|
||||
const CACHE_NAME = 'flowdeck-v9'; // v9 : purge des caches pre-#33/#36 —
|
||||
// recherche.js/recherche.css etaient servis cache-first SANS version
|
||||
// (?v= absent dans recherche.html) : les navigateurs ayant visite
|
||||
// Flowdeck avant les onglets gardaient les copies pre-#33 (JS sans
|
||||
// onglets/compteurs/console, CSS sans .rp-tabs) malgre les deplois
|
||||
// 7.84.3/7.84.4. Le bump purge ces caches a l'activation, et les
|
||||
// liens versionnes (?v=asset_version) font des futurs changements
|
||||
// de nouvelles cles de cache.
|
||||
const DATA_CACHE = 'flowdeck-data-v7';
|
||||
|
||||
// App shell (assets versionnés comme référencés dans les templates).
|
||||
|
||||
@@ -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,184 @@
|
||||
/* 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)");
|
||||
|
||||
|
||||
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
|
||||
|
||||
/* parseTab */
|
||||
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
|
||||
ok(rp.parseTab("console") === "console", "onglet console valide");
|
||||
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
|
||||
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
|
||||
|
||||
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
|
||||
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
|
||||
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
|
||||
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
|
||||
|
||||
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
|
||||
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
|
||||
ok(rp.pageItems(douze, false).length === 8, "vue Tout tronquée à 8");
|
||||
ok(rp.pageItems(douze, true).length === 12, "onglet app sans troncature");
|
||||
|
||||
/* tabCounts : "…" en chargement, total sinon, "" en erreur (pas de faux 0) */
|
||||
let c = rp.tabCounts({
|
||||
obsigate: { status: "loading", total: 0 },
|
||||
flowdeck: { status: "ok", total: 3 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.obsigate === "…", "compteur … pendant le chargement");
|
||||
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
|
||||
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
|
||||
ok(c.all === "…", "Tout = … tant qu'une section charge");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "ok", total: 5 },
|
||||
flowdeck: { status: "ok", total: 2 },
|
||||
tiktak: { status: "empty", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "ok", total: 4 },
|
||||
});
|
||||
ok(c.all === "7", "Tout = somme des totaux connus");
|
||||
ok(c.console === "4", "compteur console");
|
||||
c = rp.tabCounts({
|
||||
obsigate: { status: "error", total: 0 },
|
||||
flowdeck: { status: "error", total: 0 },
|
||||
tiktak: { status: "error", total: 0 },
|
||||
kanbante: { status: "error", total: 0 },
|
||||
console: { status: "idle", total: 0 },
|
||||
});
|
||||
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
|
||||
|
||||
/* buildSearchUrl : état onglet partageable */
|
||||
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
|
||||
ok(rp.buildSearchUrl("x", "all") === "?q=x", "URL Tout sans param tab");
|
||||
ok(rp.buildSearchUrl("", "all") === "", "URL vide sans recherche ni onglet");
|
||||
ok(rp.buildSearchUrl("", "console") === "?tab=console", "URL onglet seul");
|
||||
|
||||
/* searchPath complet (onglet) vs tronqué (Tout) */
|
||||
ok(rp.searchPath("obsigate", "café", true) === "api/search?q=caf%C3%A9&vault=all&limit=100", "path ObsiGate complet (100)");
|
||||
ok(rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8", "path ObsiGate Tout (8)");
|
||||
|
||||
/* normalize complet vs tronqué */
|
||||
let taches12 = Array.from({ length: 12 }, (_, i) => ({ id: i + 1, title: "T" + i, status: "active" }));
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t", true).length === 12, "normalize complet");
|
||||
ok(rp.normalize("tiktak", { tasks: taches12 }, "https://tiktak-dev.dracodev.net/", "t").length === 8, "normalize tronqué par défaut");
|
||||
|
||||
/* normalizeConsole : formes tolérées, pas de faux lien, format inconnu signalé */
|
||||
let nc = rp.normalizeConsole({ results: [{ title: "D", url: "https://agent.dracodev.net/console/1", snippet: "s" }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent.dracodev.net/console/1", "console : URL absolue conservée");
|
||||
nc = rp.normalizeConsole({ discussions: [{ name: "Conv", href: "/discussions/9" }] }, "https://agent-dev.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === "https://agent-dev.dracodev.net/discussions/9", "console : URL relative préfixée");
|
||||
nc = rp.normalizeConsole({ items: [{ title: "Sans lien", id: 7 }] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === true && nc.items[0].url === null, "console : sans URL -> pas de faux lien");
|
||||
nc = rp.normalizeConsole({ autre: [] }, "https://agent.dracodev.net/");
|
||||
ok(nc.recognized === false, "console : format inconnu -> non reconnu (indisponible, pas vide)");
|
||||
|
||||
/* resolveConsoleBase : explicite > agent injecté > dev > href barre > prod */
|
||||
ok(rp.resolveConsoleBase({ explicit: "https://agent-perso.example/", agentUrl: "https://agent-autre.example/" }) === "https://agent-perso.example/", "console : explicite prioritaire");
|
||||
ok(rp.resolveConsoleBase({ agentUrl: "https://agent-injecte.example/" }) === "https://agent-injecte.example/", "console : agent injecté par l'hôte");
|
||||
ok(rp.resolveConsoleBase({ host: "tiktak-dev.dracodev.net" }) === "https://agent-dev.dracodev.net/", "console : dérivation -dev");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { console: "https://console-perso.example/" } }) === "https://console-perso.example/", "console : href onglet barre");
|
||||
ok(rp.resolveConsoleBase({}) === "https://agent.dracodev.net/", "console : défaut prod");
|
||||
|
||||
|
||||
/* ---- Alignement console (agent-commun /api/v1/recherche) ---- */
|
||||
ok(rp.CONSOLE_SEARCH_PATH === "api/v1/recherche", "console : chemin reel /api/v1/recherche");
|
||||
ok(rp.CONSOLE_SEARCH_LIMIT === 20, "console : limite 20 (plafond par type)");
|
||||
let rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "discussion", id: "c42", titre: "Conv", extrait: "ex", statut: null, domaine: "pro", section: "conversation", objet: "c42", lien: "/?section=conversation&disc=c42" },
|
||||
{ type: "mission", id: "t1", titre: "Mission", extrait: "ex", statut: "paused_budget", domaine: "pro", section: "missions", objet: "t1", lien: "/?section=missions&objet=t1" },
|
||||
{ type: "approbation", id: "a9", titre: "publier", extrait: "ex", statut: "pending", domaine: null, section: "approbations", objet: "a9", lien: "/?section=approbations&objet=a9" },
|
||||
{ type: "objectif", id: "o3", titre: "Obj", extrait: "ex", statut: "actif", domaine: "pro", section: "objectifs", objet: "o3", lien: "/?section=objectifs&objet=o3" },
|
||||
{ type: "activite", id: "a7", titre: "Act", extrait: "ex", statut: "terminee", domaine: "pro", section: null, objet: "a7", lien: "/?activite=a7" },
|
||||
{ type: "livrable", id: "l1", titre: "Liv", extrait: "ex", statut: "terminee", domaine: "pro", section: "livrables", objet: null, lien: "/?section=livrables" },
|
||||
{ type: "planification", id: "p1", titre: "Plan", extrait: "ex", statut: "active", domaine: "pro", section: "planifie", objet: null, lien: "/?section=planifie" },
|
||||
{ type: "identite", id: "i1", titre: "Id", extrait: "ex", statut: "active", domaine: null, section: "identites", objet: null, lien: "/?section=identites" },
|
||||
] }, "https://agent-dev.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 8, "console : 8 resultats reconnus");
|
||||
ok(rc.items[0].url === "https://agent-dev.dracodev.net/?section=conversation&disc=c42", "console : lien profond discussion");
|
||||
ok(rc.items[1].url === "https://agent-dev.dracodev.net/?section=missions&objet=t1" && rc.items[1].context === "Mission \u00b7 paused_budget \u00b7 pro", "console : lien mission + contexte statut/domaine");
|
||||
ok(rc.items[2].url === "https://agent-dev.dracodev.net/?section=approbations&objet=a9", "console : lien profond approbation");
|
||||
ok(rc.items[3].url === "https://agent-dev.dracodev.net/?section=objectifs&objet=o3", "console : lien profond objectif");
|
||||
ok(rc.items[4].url === "https://agent-dev.dracodev.net/?activite=a7", "console : lien activite sans section");
|
||||
ok(rc.items[5].url === "https://agent-dev.dracodev.net/?section=livrables", "console : livrable = vue seulement");
|
||||
ok(rc.items[6].url === "https://agent-dev.dracodev.net/?section=planifie", "console : planification = vue seulement");
|
||||
ok(rc.items[7].url === "https://agent-dev.dracodev.net/?section=identites", "console : identite = vue seulement");
|
||||
rc = rp.normalizeConsole({ resultats: [
|
||||
{ type: "mission", id: "t9", titre: "Sans lien", extrait: "", statut: null, domaine: null, section: "missions", objet: "t9" },
|
||||
{ type: "activite", id: "a9", titre: "Act sans lien", extrait: "", statut: null, domaine: null, section: null, objet: "a9" },
|
||||
] }, "https://agent.dracodev.net/");
|
||||
ok(rc.items[0].url === "https://agent.dracodev.net/?section=missions&objet=t9", "console : lien reconstruit sans lien servi");
|
||||
ok(rc.items[1].url === "https://agent.dracodev.net/?activite=a9", "console : lien activite reconstruit");
|
||||
rc = rp.normalizeConsole({ resultats: [] }, "https://agent.dracodev.net/");
|
||||
ok(rc.recognized === true && rc.items.length === 0, "console : resultats vide reconnu (vide honnete, pas indisponible)");
|
||||
ok(rp.consoleLinkFor({ type: "discussion", id: "c1" }) === "/?section=conversation&disc=c1", "consoleLinkFor discussion");
|
||||
ok(rp.resolveConsoleBase({ host: "x.example", tabHrefs: { agent: "https://agent-perso.example/" } }) === "https://agent-perso.example/", "console : href onglet barre agent accepte");
|
||||
|
||||
console.log("recherche.test.js : " + n + " assertions OK");
|
||||
@@ -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,90 @@
|
||||
"""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
|
||||
# Correctif 2026-10-28 : une origine sœur dev passe aussi.
|
||||
r5 = client.options("/api/notifications",
|
||||
headers={"Origin": "https://tiktak-dev.dracodev.net",
|
||||
"Access-Control-Request-Method": "GET"})
|
||||
assert r5.headers.get("access-control-allow-origin") == "https://tiktak-dev.dracodev.net"
|
||||
assert r5.headers.get("access-control-allow-credentials") == "true"
|
||||
|
||||
|
||||
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
|
||||
@@ -0,0 +1,48 @@
|
||||
"""Barre d'apps Flowdeck : aucun texte de chantier rendu hors commentaire.
|
||||
|
||||
Defaut 5 : les lignes « Chantier 5 (fusion #69/#71, sans bump) : ... »
|
||||
etaient placees APRES la fermeture `#}` du commentaire Jinja d'en-tete
|
||||
de `app/templates/_draco_bar.html` (lignes 9-11) : elles etaient donc
|
||||
rendues telles quelles, juste au-dessus du <header> de la barre, sur
|
||||
toutes les pages (la barre est incluse par base.html).
|
||||
|
||||
Test sans dependance (lecture du gabarit seul, comme
|
||||
test_draco_alertes.py) : il tourne sans serveur ni base, donc aussi
|
||||
en sandbox. La note documentaire peut vivre DANS le commentaire Jinja
|
||||
(jamais rendu) ; elle ne doit jamais en sortir.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
|
||||
def _hors_commentaires(texte):
|
||||
"""Texte du gabarit sans les commentaires Jinja {# ... #}."""
|
||||
return re.sub(r"\{#.*?#\}", "", texte, flags=re.S)
|
||||
|
||||
|
||||
def test_aucun_texte_chantier_rendu():
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
assert "Chantier 5 (fusion" not in rendu
|
||||
assert "sans bump" not in rendu
|
||||
# Aucune ligne de texte libre entre le commentaire d'en-tete et <link> :
|
||||
# le rendu doit commencer (apres commentaires) par <link> ou <header>.
|
||||
assert rendu.lstrip().startswith("<link")
|
||||
|
||||
|
||||
def test_barre_intacte_apres_suppression():
|
||||
"""Le 5e onglet Console Spark et la marque survivent a la suppression."""
|
||||
gabarit = (ROOT / "app/templates/_draco_bar.html").read_text()
|
||||
rendu = _hors_commentaires(gabarit)
|
||||
nav = rendu.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
|
||||
assert 'data-app="agent"' in nav
|
||||
assert 'class="draco-brand"' in rendu
|
||||
# L'entree « Console Spark » du menu de la marque est construite par
|
||||
# draco-barre.js depuis les onglets : l'onglet agent suffit a l'alimenter.
|
||||
js_barre = (ROOT / "static/js/vendor/draco-barre.js").read_text()
|
||||
assert "Console Spark" in js_barre
|
||||
@@ -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"]
|
||||
@@ -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,103 @@
|
||||
"""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():
|
||||
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
|
||||
# quelle que soit la résolution.
|
||||
assert sister_origins(RESOLVED_DEV) == {
|
||||
"https://og.dracodev.net",
|
||||
"https://flowdeck.dracodev.net",
|
||||
"https://tiktak.dracodev.net",
|
||||
"https://kanbante.dracodev.net",
|
||||
"https://og-dev.dracodev.net",
|
||||
"https://flowdeck-dev.dracodev.net",
|
||||
"https://tiktak-dev.dracodev.net",
|
||||
"https://kanbante-dev.dracodev.net",
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent — l'hôte ne
|
||||
# restreint plus l'allowlist.
|
||||
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" in sister_origins(
|
||||
{}, request_host="flowdeck.dracodev.net"
|
||||
)
|
||||
assert "https://og.dracodev.net" in sister_origins(
|
||||
{}, request_host="flowdeck-dev.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-dev.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
# Autre environnement autorisé (correctif 2026-10-28).
|
||||
assert cors_headers_for("https://og.dracodev.net",
|
||||
RESOLVED_DEV) is not 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
|
||||
@@ -0,0 +1,120 @@
|
||||
"""Défaut 3b — /api/search Flowdeck depuis la console agent-dev.
|
||||
|
||||
Cause prouvée en déployé (flowdeck-dev, 2026-10-28) : sur une réponse
|
||||
GET /api/search, le CORSMiddleware global pose déjà
|
||||
``Access-Control-Allow-Credentials: true`` (comportement Starlette
|
||||
avec ``allow_credentials=True``, même hors allowlist), et l'ancien
|
||||
middleware de recherche EMPILAIT ses en-têtes par-dessus — d'où, pour
|
||||
l'origine agent-dev, ``Allow-Credentials`` en double (« true, true »
|
||||
≠ « true » pour le contrôle CORS du navigateur → fetch rejeté →
|
||||
« injoignable (CORS ou réseau) » dans la console, alors que TikTak et
|
||||
Kanbante, sans ce cumul, répondent avec des en-têtes uniques) et,
|
||||
pour une origine sœur déjà dans la allowlist globale, ``Allow-Origin``
|
||||
en double (rejet certain). Le correctif : le middleware de recherche
|
||||
REMPLACE les en-têtes CORS existants au lieu de les empiler (même
|
||||
patron que app/recherche_cors.py de la console).
|
||||
|
||||
Ces tests pilotent le middleware en ASGI pur, avec une application
|
||||
interne qui imite le CORSMiddleware global (un en-tête
|
||||
Allow-Credentials déjà posé, et, pour une sœur, Allow-Origin aussi).
|
||||
``sister_origins`` est figé sur les listes officielles (les fonctions
|
||||
pures sont déjà couvertes par tests/test_recherche.py) pour garder le
|
||||
test hermétique (aucune résolution draco_urls, aucune base).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
|
||||
import pytest
|
||||
|
||||
from app.middleware import recherche_cors as rc
|
||||
|
||||
ORIGINES = rc.PROD_ORIGINS | rc.DEV_ORIGINS | rc.AGENT_ORIGINS
|
||||
|
||||
|
||||
def _lancer(middleware, scope):
|
||||
"""Exécute le middleware ASGI et retourne les messages envoyés."""
|
||||
recus = []
|
||||
|
||||
async def recevoir():
|
||||
return {"type": "http.request", "body": b"", "more_body": False}
|
||||
|
||||
async def envoyer(message):
|
||||
recus.append(message)
|
||||
|
||||
asyncio.run(middleware(scope, recevoir, envoyer))
|
||||
return recus
|
||||
|
||||
|
||||
def _application_interne(entetes):
|
||||
async def appli(scope, recevoir, envoyer):
|
||||
await envoyer({"type": "http.response.start", "status": 401,
|
||||
"headers": list(entetes)})
|
||||
await envoyer({"type": "http.response.body", "body": b"{}"})
|
||||
return appli
|
||||
|
||||
|
||||
def _scope(methode, origine):
|
||||
return {"type": "http", "method": methode, "path": "/api/search",
|
||||
"headers": [(b"origin", origine.encode("latin-1")),
|
||||
(b"host", b"flowdeck-dev.dracodev.net")]}
|
||||
|
||||
|
||||
def _compter(entetes, nom):
|
||||
return sum(1 for k, _v in entetes if k == nom.encode("latin-1"))
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _origines_figees(monkeypatch):
|
||||
monkeypatch.setattr(rc, "sister_origins", lambda *a, **k: ORIGINES)
|
||||
|
||||
|
||||
def test_get_agent_dev_un_seul_jeu_d_entetes():
|
||||
"""Cas du défaut : globale (ACAC seul) + recherche → un seul jeu."""
|
||||
interne = _application_interne([
|
||||
(b"content-type", b"application/json"),
|
||||
(b"access-control-allow-credentials", b"true")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://agent-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "vary") == 1
|
||||
assert (b"access-control-allow-origin",
|
||||
b"https://agent-dev.dracodev.net") in entetes
|
||||
assert (b"content-type", b"application/json") in entetes
|
||||
assert recus[0]["status"] == 401 # le statut traverse inchangé
|
||||
|
||||
|
||||
def test_get_origine_soeur_pas_d_allow_origin_double():
|
||||
"""Sœur déjà dans la allowlist globale : pas de doublon non plus."""
|
||||
interne = _application_interne([
|
||||
(b"access-control-allow-origin", b"https://og-dev.dracodev.net"),
|
||||
(b"access-control-allow-credentials", b"true"),
|
||||
(b"vary", b"Origin")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://og-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
assert _compter(entetes, "vary") == 1
|
||||
|
||||
|
||||
def test_origine_non_autorisee_traverse_sans_modification():
|
||||
"""Une origine refusée ne reçoit aucun Allow-Origin ajouté."""
|
||||
interne = _application_interne([
|
||||
(b"access-control-allow-credentials", b"true")])
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(interne),
|
||||
_scope("GET", "https://example.com"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert _compter(entetes, "access-control-allow-origin") == 0
|
||||
assert entetes == [(b"access-control-allow-credentials", b"true")]
|
||||
|
||||
|
||||
def test_preflight_agent_dev_toujours_unique():
|
||||
"""Le preflight (court-circuit avant l'interne) reste propre."""
|
||||
recus = _lancer(rc.RechercheCorsMiddleware(_application_interne([])),
|
||||
_scope("OPTIONS", "https://agent-dev.dracodev.net"))
|
||||
entetes = recus[0]["headers"]
|
||||
assert recus[0]["status"] == 204
|
||||
assert _compter(entetes, "access-control-allow-origin") == 1
|
||||
assert _compter(entetes, "access-control-allow-credentials") == 1
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Page /recherche Flowdeck : onglets, base console, anti-cache (defaut 6).
|
||||
|
||||
Audit prealable (main 7.84.4, tag v7.84.4 = 984e92b) : la route
|
||||
(app/routers/search.py:51), le gabarit (app/templates/recherche.html),
|
||||
le JS (static/js/recherche.js) et le CSS (static/css/recherche.css)
|
||||
contenaient DEJA la page a onglets des PR #33/#36 — aucune route
|
||||
fantome, aucun bloc `scripts` manquant dans base.html:869, aucune
|
||||
regle CSP ne bloquant le script (script-src 'self'). Le symptome
|
||||
deploie (« pas d'onglets, resultats Flowdeck seuls ») ne s'explique
|
||||
donc PAS par un mauvais gabarit dans main, mais par deux causes
|
||||
reelles, corrigees ici :
|
||||
|
||||
1. Assets recherche servis CACHE-FIRST par le service worker
|
||||
(static/sw.js) SANS version (?v= absent) sous un CACHE_NAME
|
||||
inchange depuis A20 ('flowdeck-v8') : un navigateur ayant visite
|
||||
Flowdeck avant #33 gardait le recherche.js pre-#33 (sans onglets,
|
||||
sans compteurs, sans logique console, « Tout voir » naviguant
|
||||
ailleurs) et le recherche.css pre-#33 (sans .rp-tabs) — la page
|
||||
paraissait donc « non corrigee » malgre les deplois 7.84.3/7.84.4,
|
||||
et le repli networkFirst du HTML vers le cache couvrait aussi le
|
||||
cas reseau lent. Correctif : liens versionnes (?v=asset_version,
|
||||
comme tous les autres assets de base.html) + bump CACHE_NAME
|
||||
(purge a l'activation du nouveau worker).
|
||||
2. Flowdeck etait le seul hote a ne pas rendre data-base-console
|
||||
(son propre commentaire l'admettait) : la base console de l'onglet
|
||||
Console Spark etait devine (derivation -dev / href d'onglet de
|
||||
barre) au lieu d'etre explicite. Correctif : la route expose
|
||||
`bases.agent` et le gabarit le rend en data-base-console.
|
||||
|
||||
La logique d'onglets elle-meme (focus, compteurs, « Tout voir » sur
|
||||
place, etats par application, liens -dev, console sur
|
||||
GET /api/v1/recherche) est deja couverte par tests/recherche.test.js
|
||||
(suite Node, lancee en sandbox) ; ce fichier-ci verrouille le
|
||||
branchement gabarit/route/service worker, sans serveur ni base.
|
||||
"""
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
ONGLETS_ATTENDUS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante",
|
||||
"console"]
|
||||
|
||||
|
||||
def test_gabarit_recherche_onglets_complets():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
trouves = re.findall(r'data-rp-tab="([^"]+)"', gabarit)
|
||||
assert trouves == ONGLETS_ATTENDUS, trouves
|
||||
for section in ("obsigate", "flowdeck", "tiktak", "kanbante", "console"):
|
||||
assert f'data-rp-section="{section}"' in gabarit
|
||||
assert 'id="rp-q"' in gabarit and "autofocus" in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_base_console_explicite():
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert 'data-base-console="{{ bases.agent }}"' in gabarit
|
||||
|
||||
|
||||
def test_gabarit_recherche_assets_versionnes():
|
||||
"""Sans ?v=, le SW cache-first ressert les copies pre-#33."""
|
||||
gabarit = (ROOT / "app/templates/recherche.html").read_text()
|
||||
assert "/static/css/recherche.css?v={{ asset_version }}" in gabarit
|
||||
assert "/static/js/recherche.js?v={{ asset_version }}" in gabarit
|
||||
|
||||
|
||||
def test_route_recherche_expose_base_agent():
|
||||
route = (ROOT / "app/routers/search.py").read_text()
|
||||
assert '"agent"' in route or "'agent'" in route
|
||||
assert "bases.agent" in (ROOT / "app/templates/recherche.html").read_text()
|
||||
|
||||
|
||||
def test_service_worker_purge_anciens_caches():
|
||||
"""Le bump de CACHE_NAME purge les caches pre-#33 a l'activation."""
|
||||
sw = (ROOT / "static/sw.js").read_text()
|
||||
assert "const CACHE_NAME = 'flowdeck-v9'" in sw
|
||||
assert "flowdeck-v8" not in sw
|
||||
assert "cache-first" in sw or "cacheFirst" in sw
|
||||
|
||||
|
||||
def test_recherche_js_branche_console_v1():
|
||||
"""Rappel : l'onglet console appelle GET /api/v1/recherche (cf. #36)."""
|
||||
js = (ROOT / "static/js/recherche.js").read_text()
|
||||
assert 'CONSOLE_SEARCH_PATH = "api/v1/recherche"' in js
|
||||
assert "CONSOLE_SEARCH_LIMIT = 20" in js
|
||||