Compare commits

..
Author SHA1 Message Date
bruno 7f9eb080b1 Fusion #69 : Alertes unifiees (tests/draco-alertes.test.js)
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 2s
FlowDeck CI / test (pull_request) Failing after 2s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 12:11:15 -04:00
bruno 9abd6c9d4d Fusion #69 : Alertes unifiees (tests/test_draco_alertes.py)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 3s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:10 -04:00
bruno fbe9151ab7 Fusion #69 : Alertes unifiees (static/css/draco-alertes.css)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:11:05 -04:00
bruno ab63000f5d Fusion #69 : Alertes unifiees (static/js/draco-alertes.js)
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Failing after 1m55s
2026-10-28 12:11:00 -04:00
bruno 52b62b48b8 Fusion #69 : Alertes unifiees (app/middleware/security.py)
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 16m42s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:10:55 -04:00
bruno acaae92e3a Fusion #69 : Alertes unifiees (app/main.py)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 12:10:51 -04:00
bruno 1065ffb017 Fusion #69 : Alertes unifiees (app/templates/_draco_bar.html)
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 1m57s
2026-10-28 12:10:47 -04:00
bruno e4be8578ff Bump 7.84.1 (CHANGELOG.md)
FlowDeck CI / lint (push) Failing after 4s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:49 -04:00
bruno e733f3309d Bump 7.84.1 (app/main.py)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:47 -04:00
bruno 371908ed1d Bump 7.84.1 (VERSION)
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:39:44 -04:00
bruno da205feec1 Merge pull request 'barre-apps v0.1.6 : vendange (dérivation -dev, menu •••, avatar compte)' (#27) from barre-apps-v0.1.6-flowdeck into main
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 2m8s
2026-10-28 10:34:18 -04:00
bruno c582856051 barre-apps v0.1.6 : CSS vendange (menus, avatar bouton)
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 2s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (pull_request) Failing after 1s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-28 10:31:45 -04:00
bruno ecafeecda0 barre-apps v0.1.6 : JS vendange (derivation -dev, menus)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:31:41 -04:00
bruno 7a43ca00ad barre-apps v0.1.6 : fragment (avatar bouton compte, menu par defaut demande, nonce CSP inchange)
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-28 10:31:36 -04:00
bruno e5e98ed08f Merge pull request '[Coffre Secrets] Connecteurs par reference ObsiGate, isolation workspace (Phase 4)' (#26) from feat/coffre-reference-obsigate into main
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-28 00:15:17 -04:00
bruno 62b536c865 coffre ObsiGate Phase 4 (issue #22) : docs/coffre-reference-flowdeck.md
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Successful in 11m19s
FlowDeck CI / docker (push) Failing after 2m5s
FlowDeck CI / lint (pull_request) Failing after 0s
FlowDeck CI / test (pull_request) Failing after 0s
FlowDeck CI / docker (pull_request) Skipped
2026-10-27 23:48:35 -04:00
bruno a3431b18c9 coffre ObsiGate Phase 4 (issue #22) : tests/test_coffre_reference_flowdeck.py
FlowDeck CI / lint (push) Failing after 0s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:31 -04:00
bruno 45940c26bb coffre ObsiGate Phase 4 (issue #22) : .env.example
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:26 -04:00
bruno f827e9fc7d coffre ObsiGate Phase 4 (issue #22) : app/routers/agent.py
FlowDeck CI / lint (push) Failing after 2s
FlowDeck CI / test (push) Failing after 0s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:20 -04:00
bruno cd0835116d coffre ObsiGate Phase 4 (issue #22) : app/config.py
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
FlowDeck CI / lint (push) Successful in 1m56s
2026-10-27 23:48:13 -04:00
bruno b751c81120 coffre ObsiGate Phase 4 (issue #22) : app/migrations.py
FlowDeck CI / lint (push) Failing after 3s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:09 -04:00
bruno 30387c4ed4 coffre ObsiGate Phase 4 (issue #22) : app/services/mcp_client.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:48:03 -04:00
bruno 4f13068c85 coffre ObsiGate Phase 4 (issue #22) : app/services/connectors.py
FlowDeck CI / lint (push) Failing after 1s
FlowDeck CI / test (push) Failing after 1s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:58 -04:00
bruno 417c0a23d7 coffre ObsiGate Phase 4 (issue #22) : app/services/coffre_reference.py
FlowDeck CI / lint (push) Successful in 1m56s
FlowDeck CI / test (push) Failing after 3h10m16s
FlowDeck CI / docker (push) Skipped
2026-10-27 23:47:54 -04:00
20 changed files with 2013 additions and 104 deletions
+7
View File
@@ -28,6 +28,13 @@ OAUTH_REDIRECT_URI=
# par release (passé par environment:/env_file: au conteneur).
# Laisser vide = défaut officiel indiqué en commentaire.
DRACO_SSO_URL= # ObsiGate, émettrice SSO — défaut https://og.dracodev.net ; dev : https://og-dev.dracodev.net
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue ObsiGate #22) ──
# OBSIGATE_COFFRE_URL vide = dérivée de DRACO_SSO_URL (même hôte).
# OBSIGATE_CONSUMER_TOKEN = jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois) — distinct du jeton MCP de notes et de la session SSO.
# Ne jamais le copier dans un log, un rapport ou une réponse API.
OBSIGATE_COFFRE_URL=
OBSIGATE_CONSUMER_TOKEN=
DRACO_AGENT_URL= # Service d'agents Spark — défaut https://agent.dracodev.net ; dev : https://agent-dev.dracodev.net
TIKTAK_URL= # TikTak (preset MCP, barre, refs) — défaut https://tiktak.dracodev.net ; dev : https://tiktak-dev.dracodev.net
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
+4
View File
@@ -1,5 +1,9 @@
# 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`,
+1 -1
View File
@@ -1 +1 @@
7.84.0
7.84.1
+7
View File
@@ -158,6 +158,13 @@ class Settings(BaseSettings):
# KANBANTE_URL=https://kanbante-dev.dracodev.net
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
# ── Coffre ObsiGate, côté consommateur Flowdeck (Phase 4, issue #22) ──
# URL de distribution : vide = dérivée de draco_sso_url (même hôte).
# Jeton : le jeton CONSOMMATEUR Flowdeck enregistré dans ObsiGate
# (octrois), distinct du jeton MCP de notes et de la session SSO.
# Jamais en clair dans un log, un rapport ou une réponse API.
obsigate_coffre_url: str = ""
obsigate_consumer_token: str = ""
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
tiktak_url: str = "https://tiktak.dracodev.net"
kanbante_url: str = "https://kanbante.dracodev.net"
+13 -1
View File
@@ -216,7 +216,7 @@ def _lire_version() -> str:
# Littéral de version synchronisé avec le fichier VERSION :
# version="7.84.0"
# version="7.84.1"
# Patron historique des bumps (voir scripts/bump_version.py, qui cherche
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
@@ -239,6 +239,18 @@ app.add_middleware(RateLimitMiddleware)
_CORS_ORIGINS = sorted(
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
)
# Fusion #69 : le panneau Alertes unifiées lit /api/notifications
# depuis les hôtes soeurs, avec credentials — leurs origines doivent
# donc figurer dans allow_origins (résolution draco_urls : explicite
# > hôte de la requête > dérivation > défaut officiel ; hors requête,
# la configuration seule). Lecture seule (GET) ; les POST restent
# soumis au CSRFMiddleware.
from app.services import draco_urls as _draco_urls69
_toutes69 = _draco_urls69.toutes()
_CORS_ORIGINS = sorted(set(_CORS_ORIGINS) | {
_draco_urls69.origine(_toutes69[k])
for k in ("obsigate", "flowdeck", "tiktak", "kanbante")
} - {""})
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
app.add_middleware(
+11 -2
View File
@@ -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
+28
View File
@@ -2076,3 +2076,31 @@ def _migration_draco_links(conn: sqlite3.Connection) -> None:
)
"""
)
@register(52, "coffre ObsiGate (Phase 4) : workspace_id + coffre_reference sur agent_connectors")
def _migration_coffre_reference(conn: sqlite3.Connection) -> None:
"""Migration 52 — références ObsiGate pour les connecteurs (issue #22).
* ``workspace_id`` : le connecteur appartient à un workspace (NULL =
connecteur historique global, conservé tel quel — migration
opportuniste, pas de big-bang).
* ``coffre_reference`` : ``coffre:<slug>/<nom>`` (métadonnée, pas une
valeur) ; vide = jeton chiffré local historique.
Idempotente (gardée par ``columns()``), comme les migrations 32/34.
"""
cols = columns(conn, "agent_connectors")
if "workspace_id" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)"
)
if "coffre_reference" not in cols:
conn.execute(
"ALTER TABLE agent_connectors "
"ADD COLUMN coffre_reference TEXT NOT NULL DEFAULT ''"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_agent_connectors_workspace "
"ON agent_connectors(workspace_id)"
)
+10 -1
View File
@@ -673,6 +673,8 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
kind=str(body.get("kind") or "custom"),
auth=str(body.get("auth") or "bearer"),
workspace_id=body.get("workspace_id"),
coffre_reference=body.get("coffre_reference"),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -682,11 +684,17 @@ def create_connectors_route(request: Request, body: dict = Body(default={})):
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
_current_user_id(request)
try:
kwargs = {}
if "workspace_id" in body:
kwargs["workspace_id"] = body.get("workspace_id")
if "coffre_reference" in body:
kwargs["coffre_reference"] = body.get("coffre_reference")
row = connectors.update_connector(
connector_id,
name=body.get("name"),
enabled=body.get("enabled"),
secret=body.get("secret"),
**kwargs,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@@ -711,7 +719,8 @@ async def probe_connectors_route(request: Request, body: dict = Body(default={})
raise HTTPException(status_code=400, detail="connector est requis")
user_id = _current_user_id(request)
try:
return await connectors.probe(target, user_id)
return await connectors.probe(target, user_id,
workspace_id=body.get("workspace_id"))
except ValueError as exc:
raise HTTPException(status_code=404, detail=str(exc)) from exc
+206
View File
@@ -0,0 +1,206 @@
"""Références ObsiGate pour les connecteurs Flowdeck (Phase 4, issue ObsiGate #22).
Un connecteur d'un workspace ne stocke plus un jeton en clair ni même un
jeton chiffré local : il stocke une **référence** ``coffre:<slug>/<nom>``,
toujours liée à un ``workspace_id``. À l'appel, le serveur Flowdeck appelle
``POST /api/coffres/distribuer`` d'ObsiGate avec le jeton consommateur
Flowdeck, utilise la valeur **en mémoire uniquement**, puis l'oublie :
* aucune valeur n'est persistée (ni en base, ni en fichier, ni en cache) ;
* aucune valeur n'est journalisée (les logs ne portent que le statut) ;
* si ObsiGate est indisponible, l'appel échoue proprement — il n'y a
**aucun repli** sur une valeur en cache, puisqu'il n'y en a pas.
Format validé ici, aligné sur ObsiGate (``backend/coffre_store.py``) :
``slug`` et ``nom`` en minuscules, 3 à 64 / 3 à 128 caractères,
``[a-z0-9_-]`` après le premier caractère. La référence elle-même n'est
pas un secret (c'est une métadonnée, comme un chemin) : seule la valeur
distribuée l'est, et elle ne transite que dans la réponse d'ObsiGate.
Isolation workspace (critère central) : l'octroi ObsiGate est accordé au
**consommateur Flowdeck** (un jeton par application), pas à un workspace.
L'isolation par workspace est donc appliquée **côté Flowdeck, avant tout
appel** — voir ``connectors.resolve_secret`` : un connecteur dont le
``workspace_id`` diffère du workspace demandeur est refusé explicitement
et le distributeur n'est jamais appelé. Une isolation appliquée côté
ObsiGate exigerait un consommateur par workspace (reste à faire, voir
``docs/coffre-reference-flowdeck.md``).
"""
from __future__ import annotations
import json
import logging
import re
import urllib.error
import urllib.request
logger = logging.getLogger(__name__)
#: Contexte envoyé à ObsiGate : le workspace Flowdeck demandeur, pour que
#: la distribution soit traçable côté ObsiGate (journal des distributions).
CONTEXTE_TYPE = "flowdeck_workspace"
#: Regex ObsiGate, recopiées à l'identique (slug 3–64, nom 3–128).
SLUG_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,63}$")
NOM_SECRET_RE = re.compile(r"^[a-z0-9][a-z0-9_-]{2,127}$")
#: Délai maximal d'une distribution (secondes) — un coffre injoignable ne
#: doit pas bloquer indéfiniment un appel de connecteur.
TIMEOUT_S = 10
class CoffreReferenceInvalide(ValueError):
"""La référence ne respecte pas le format ``coffre:<slug>/<nom>``."""
class CoffreWorkspaceMismatch(ValueError):
"""Le connecteur appartient à un autre workspace que le demandeur."""
class CoffreAccesRefuse(ValueError):
"""ObsiGate a refusé la distribution (octroi absent, révoqué, expiré…).
``detail`` reprend le code ObsiGate (``octroi_revoque``, …) sans
jamais contenir la valeur du secret.
"""
def __init__(self, message: str, detail: str = "") -> None:
super().__init__(message)
self.detail = detail
class CoffreIndisponible(ValueError):
"""ObsiGate est injoignable ou en erreur technique (pas de repli)."""
def parse_reference(reference: str) -> tuple[str, str]:
"""Valide ``coffre:<slug>/<nom>`` et renvoie ``(slug, nom)`` normalisés.
La casse est ramenée en minuscules, comme côté ObsiGate
(``valider_slug`` / ``valider_nom_secret``).
"""
value = (reference or "").strip().lower()
if not value.startswith("coffre:") or "/" not in value[7:]:
raise CoffreReferenceInvalide(
"référence invalide : format attendu coffre:<slug>/<nom>"
)
slug, nom = value[7:].split("/", 1)
if not SLUG_RE.match(slug):
raise CoffreReferenceInvalide(
"slug invalide : 3 à 64 caractères, minuscules, chiffres, - ou _"
)
if not NOM_SECRET_RE.match(nom):
raise CoffreReferenceInvalide(
"nom de secret invalide : 3 à 128 caractères, minuscules, "
"chiffres, - ou _"
)
return slug, nom
def is_reference(value: str | None) -> bool:
"""Vrai si la valeur ressemble à une référence (sans lever d'erreur)."""
try:
parse_reference(value or "")
except CoffreReferenceInvalide:
return False
return True
def _config() -> tuple[str, str]:
"""(base ObsiGate, jeton consommateur) depuis la configuration.
L'URL explicite ``obsigate_coffre_url`` prime ; sinon on dérive de
``draco_sso_url`` (ObsiGate, émettrice SSO — même hôte). Le jeton est
le jeton **consommateur** Flowdeck enregistré dans ObsiGate, distinct
du jeton MCP de notes et de la session SSO.
"""
from app.config import settings
base = (getattr(settings, "obsigate_coffre_url", "") or "").strip()
if not base:
base = (settings.draco_sso_url or "").strip()
token = (getattr(settings, "obsigate_consumer_token", "") or "").strip()
return base.rstrip("/"), token
def distribuer(reference: str, workspace_id: int, *, base_url: str | None = None,
consumer_token: str | None = None, request=None) -> str:
"""Distribue une valeur ObsiGate **en mémoire** pour un workspace.
``request`` est le point d'injection des tests : un appelable
``(url, payload, headers) -> (status, body_dict)`` qui remplace
l'appel HTTP réel (faux endpoint de distribution, clairement
identifié comme tel dans les tests). Sans lui, l'appel HTTP réel
est effectué via la bibliothèque standard (aucune dépendance).
Ne conserve rien : la valeur renvoyée doit être utilisée puis
oubliée par l'appelant, jamais écrite ni mise en cache.
"""
slug, nom = parse_reference(reference)
if not workspace_id or int(workspace_id) <= 0:
raise ValueError("workspace_id est requis pour distribuer une référence")
workspace_id = int(workspace_id)
base, token = _config()
if base_url is not None:
base = base_url.rstrip("/")
if consumer_token is not None:
token = consumer_token
if not base:
raise CoffreIndisponible("URL ObsiGate non configurée (obsigate_coffre_url)")
if not token:
raise CoffreIndisponible(
"jeton consommateur ObsiGate non configuré (obsigate_consumer_token)"
)
url = f"{base}/api/coffres/distribuer"
payload = {
"reference": f"coffre:{slug}/{nom}",
"contexte_type": CONTEXTE_TYPE,
"contexte_id": str(workspace_id),
}
headers = {"Authorization": f"Bearer {token}", "Content-Type": "application/json"}
try:
if request is not None:
status, body = request(url, payload, headers)
else:
req = urllib.request.Request(
url, data=json.dumps(payload).encode(), headers=headers, method="POST"
)
try:
with urllib.request.urlopen(req, timeout=TIMEOUT_S) as resp:
status, body = resp.status, json.loads(resp.read() or b"{}")
except urllib.error.HTTPError as exc:
try:
body = json.loads(exc.read() or b"{}")
except (ValueError, TypeError):
body = {}
status = exc.code
except CoffreAccesRefuse:
raise
except (urllib.error.URLError, TimeoutError, ConnectionError, OSError) as exc:
# ObsiGate injoignable : échec propre, SANS repli sur un cache.
logger.warning("Distribution ObsiGate injoignable (statut seul journalisé)")
raise CoffreIndisponible(
"ObsiGate est indisponible : distribution impossible, "
"aucune valeur en cache n'est utilisée"
) from exc
if status == 200:
valeur = (body or {}).get("valeur") or ""
if not valeur:
raise CoffreIndisponible("ObsiGate a répondu sans valeur")
return valeur
detail = str((body or {}).get("detail") or "")
if status in (401, 403):
if detail in ("octroi_revoque", "consommateur_revoque"):
message = ("accès révoqué, à ré-accorder dans ObsiGate "
"(l'octroi de cette référence a été révoqué)")
elif detail == "octroi_expire":
message = "accès expiré, à ré-accorder dans ObsiGate"
else:
message = "accès refusé par ObsiGate (octroi absent ou invalide)"
raise CoffreAccesRefuse(message, detail=detail)
if status == 410:
raise CoffreAccesRefuse("secret expiré côté ObsiGate", detail="secret_expire")
if status in (400,):
raise CoffreReferenceInvalide(f"référence rejetée par ObsiGate ({detail})")
raise CoffreIndisponible(f"ObsiGate a répondu HTTP {status} (distribution impossible)")
+244 -38
View File
@@ -1,5 +1,15 @@
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
Références ObsiGate (Phase 4, issue ObsiGate #22) : un connecteur personnel
peut pointer vers ``coffre:<slug>/<nom>`` au lieu d'un jeton chiffré local.
La référence est **toujours liée à un workspace_id** (voir
``app/services/coffre_reference.py``) ; à l'appel, la valeur est distribuée
en mémoire par le serveur puis oubliée, jamais persistée ni mise en cache.
Les secrets déjà chiffrés localement restent valides (migration
opportuniste, connecteur par connecteur — voir
``docs/coffre-reference-flowdeck.md``).
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
@@ -18,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]}
+19 -9
View File
@@ -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",
+18 -9
View File
@@ -1,11 +1,12 @@
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.6
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
passe par une nouvelle version de commons.
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
v7.82.0 (dev/prod) : les href des onglets ne sont plus figés —
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
<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>
@@ -56,17 +57,18 @@
<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-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</button>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script nonce="{{ csp_nonce() }}">
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
Phase 1 : navigation entre apps et domaine mémorisé localement ;
le rail pilote le hamburger natif, les alertes ouvrent la cloche
native de la page ; la recherche fédérée et Spark arrivent avec
les phases suivantes du programme Fusion. */
le rail pilote le hamburger natif, les alertes ouvrent le
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
pas une fédération temps réel ; la cloche native reste le
repli si le module ne charge pas). */
window.DRACO_BAR_CONFIG = {
current: "flowdeck",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
@@ -82,12 +84,18 @@
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; }
/* v0.1.6 : le toast placeholder "fédération" est remplacé
par le menu par défaut du nouveau JS — retourner false le
demande explicitement (un onAction existant garde sinon la
main). Contenu : Console agent + applications, URL résolues
donc dérivées -dev sur un hôte -dev. Nonce CSP du script
inchangé. */
if (act === "menu") { return false; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
@@ -101,3 +109,4 @@
}
};
</script>
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
+117
View File
@@ -0,0 +1,117 @@
# Flowdeck — Connecteurs à référence ObsiGate (Phase 4, issue ObsiGate #22)
## Principe
Un connecteur personnel d'un workspace peut pointer vers une référence
ObsiGate au lieu d'un jeton :
```
coffre:<slug>/<nom> ex. coffre:flowdeck/gitea-lecture
```
* Le champ jeton du connecteur **devient** le champ référence : aucun
jeton n'est stocké pour ce connecteur (`secret_encrypted` reste vide).
* L'octroi est **toujours lié à un `workspace_id`** : créer un connecteur
à référence sans workspace est refusé (HTTP 400 / `ValueError`).
* À l'appel, le **serveur** Flowdeck appelle
`POST /api/coffres/distribuer` d'ObsiGate avec le jeton consommateur
Flowdeck et un contexte `flowdeck_workspace / <workspace_id>`, utilise
la valeur **en mémoire**, puis l'oublie. La valeur n'est jamais
persistée, jamais mise en cache, jamais journalisée, jamais renvoyée
à une réponse API ni au LLM.
## Configuration (serveur Flowdeck)
| Variable | Défaut | Rôle |
| --- | --- | --- |
| `OBSIGATE_COFFRE_URL` | dérivée de `DRACO_SSO_URL` | Base ObsiGate pour `/api/coffres/distribuer` |
| `OBSIGATE_CONSUMER_TOKEN` | (vide = distribution désactivée) | Jeton **consommateur** Flowdeck enregistré dans ObsiGate |
Le jeton consommateur est distinct du jeton MCP de notes et de la
session SSO. Il ne figure dans aucun log, rapport ou réponse.
## Isolation workspace — ce qui est garanti, et sa limite honnête
**Garanti côté Flowdeck, avant tout appel** (`connectors.resolve_secret`) :
* un connecteur à référence ne se résout que pour son propre
`workspace_id` ;
* workspace A tentant d'utiliser le connecteur (donc la référence /
l'octroi) du workspace B reçoit un refus explicite
(`CoffreWorkspaceMismatch` : « … ne peut pas utiliser ce connecteur
(il appartient au workspace …) : octroi d'un autre workspace refusé »)
et **le distributeur n'est pas appelé** ;
* un `workspace_id` déjà posé sur un connecteur est immuable : on ne
transfère pas un octroi en éditant le connecteur ;
* un connecteur à référence testé/appelé sans `workspace_id` échoue
proprement, jamais avec la valeur d'un autre workspace.
**Limite à connaître** : côté ObsiGate, l'octroi est accordé au
consommateur **Flowdeck** (un jeton par application), pas à un workspace
— le `contexte_id` transmis sert à la traçabilité, pas à un contrôle
d'accès côté ObsiGate. L'isolation par workspace repose donc sur le
contrôle Flowdeck ci-dessus. Une isolation appliquée côté ObsiGate
exigerait un consommateur par workspace : c'est un reste à faire
décidé côté ObsiGate (enregistrement de consommateurs par workspace),
pas un prérequis pour utiliser ce mécanisme.
## Échecs propres, sans repli
| Situation | Comportement Flowdeck |
| --- | --- |
| ObsiGate injoignable / timeout / HTTP 5xx | `CoffreIndisponible` — l'appel échoue, **aucune** valeur en cache n'est utilisée (il n'y en a pas) |
| Octroi révoqué / consommateur révoqué (403) | `CoffreAccesRefuse` — message « accès révoqué, à ré-accorder dans ObsiGate » |
| Octroi expiré (403) / secret expiré (410) | `CoffreAccesRefuse` avec le code ObsiGate, sans la valeur |
| Référence mal formée | `CoffreReferenceInvalide` (format `coffre:<slug>/<nom>`, slug 3–64, nom 3–128, minuscules, chiffres, `-`/`_`, aligné sur ObsiGate) |
## Secrets déjà chiffrés : pas de big-bang
Les connecteurs historiques (`secret_encrypted` Fernet) restent valides
tels quels. La migration est **opportuniste, connecteur par
connecteur** :
1. (optionnel) rattacher d'abord le connecteur à son workspace
(`PATCH /api/agent/connectors/{id}` avec `workspace_id`) ;
2. puis basculer vers la référence (`PATCH …` avec
`coffre_reference: "coffre:<slug>/<nom>"`) — le jeton local dormant
n'est pas effacé automatiquement, il est simplement ignoré tant que
la référence est posée ; effacer la référence (`""`) le réactive ;
3. vérifier par un probe avec `workspace_id`, puis supprimer le jeton
local si besoin (rotation côté ObsiGate).
## Chemin de migration par connecteur
| Connecteur actuel | État dans cette branche | Chemin |
| --- | --- | --- |
| Personnels (`custom`, `discord`, `telegram`, `mcp`, `tiktak`, `obsigate`) | **Implémenté** : `workspace_id` + `coffre_reference`, résolution en mémoire, isolation testée | Basculer connecteur par connecteur (ci-dessus) |
| Natifs Gitea / GitHub | **Inchangés** : jetons globaux d'instance (`GITEA_TOKEN`, `GITHUB_TOKEN` en `.env`) | Créer un connecteur personnel workspace-scopé à référence vers le même service et l'utiliser à la place du natif, quand le besoin workspace par workspace se présente ; les natifs ne sont pas supprimés (pas de big-bang) |
| LLM global (`llm_config` id=1) et clés par utilisateur (`user_llm_keys`) | **Inchangés** : clés en clair en base (globale / par utilisateur, pas par workspace) | Travail ultérieur dédié : ajouter une colonne référence sur `user_llm_keys` + résolution par workspace, avec le même patron (distribution en mémoire, workspace obligatoire) ; hors périmètre de cette branche |
| OAuth Google / M365 (`connector_tokens`) | **Inchangés** : jetons OAuth chiffrés par (kind, utilisateur) | Hors périmètre : le flux OAuth ne se prête pas à une référence coffre sans repenser le refresh ; à traiter séparément si besoin |
## API
* `POST /api/agent/connectors` accepte `workspace_id` et
`coffre_reference` (référence sans workspace → 400).
* `PATCH /api/agent/connectors/{id}` accepte `workspace_id`
(premier rattachement seulement) et `coffre_reference` (`""` =
effacer la référence).
* `POST /api/agent/connectors/probe` accepte `workspace_id`.
* Réponses : `workspace_id`, `coffre_reference`, `secret_source`
(`coffre` | `chiffre_local` | `aucun`) — jamais de valeur.
* `GET /api/agent/connectors` liste les connecteurs avec ces champs.
## Ce qui reste à faire (validation réelle)
* Valider contre une vraie instance ObsiGate : créer le consommateur
Flowdeck + les octrois, puis un probe réel avec `workspace_id`
(les tests ici utilisent un faux endpoint de distribution).
* Threader `workspace_id` depuis l'agent : `AgentEngine.workspace_id`
→ `connector_fetch` (outil LLM) et `McpTool.call_tool` — aujourd'hui
ces chemins appellent sans workspace et échouent proprement pour les
connecteurs à référence (refus, pas de fuite) ; les connecteurs
historiques ne sont pas affectés.
* Le cas échéant, consommateur ObsiGate par workspace (voir limite
ci-dessus) et bascule des natifs Gitea/GitHub et des clés LLM
(tableau ci-dessus).
* Interface : sélecteur de référence dans le formulaire connecteur
(aujourd'hui, champ `coffre_reference` via l'API seulement).
+39
View File
@@ -0,0 +1,39 @@
/* 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; }
+30 -3
View File
@@ -2,7 +2,9 @@
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 : menus « ••• » et compte (construits par le JS, mêmes jetons
que le menu des applications) ; l'avatar est un vrai <button>.
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. */
@@ -42,6 +44,29 @@
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
.draco-apps-menu[hidden] { display: none; }
/* Menus « ••• » et compte (v0.1.6) — même boîte que le menu des apps,
* ancrés à droite ; leurs items réutilisent .draco-apps-item. */
.draco-more-menu, .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-more-menu[hidden], .draco-compte-menu[hidden] { display: none; }
.draco-menu-item {
display: flex; align-items: center; gap: 10px; width: 100%; box-sizing: border-box;
padding: 8px 10px; border: 0; border-radius: 10px;
background: transparent; color: var(--ink); font: inherit; text-align: left;
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
border-bottom: 1px solid var(--line); margin-bottom: 6px;
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
@@ -122,8 +147,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) */
+427
View File
@@ -0,0 +1,427 @@
/* Alertes unifiées dracodev — Fusion #69 (chantier 2).
*
* 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 (« Connexion requise »)
* avec un lien profond vers sa page native, jamais du vide déguisé.
*
* Sources réelles (audit #69, 2026-10-28) :
* - 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. La section
* ObsiGate le dit et renvoie vers l'accueil d'ObsiGate.
*
* 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] ;
* 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).
* 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.
*
* 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/"
};
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/"
};
var APP_LABELS = {
obsigate: "ObsiGate", flowdeck: "Flowdeck",
tiktak: "TikTak", kanbante: "Kanbante"
};
var APP_ORDER = ["obsigate", "flowdeck", "tiktak", "kanbante"];
var EVENT_LABELS = {
created: "créée", moved: "déplacée", closed: "fermée",
reopened: "rouverte", commented: "commentée"
};
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 s ? String(s).slice(0, 16) : "";
try { return t.toLocaleString("fr-FR"); } catch (e) { return t.toISOString().slice(0, 16); }
}
/* Résolution des bases. `env` permet les tests sans navigateur :
* { hostname, barUrls, configUrls, tabHrefs, dracoUrls }. */
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 = {};
APP_ORDER.forEach(function (app) {
var u = cleanUrl(env.barUrls && env.barUrls[app])
|| cleanUrl(env.configUrls && env.configUrls[app]);
if (!u && dev) u = DEV_URLS[app] || null;
if (!u) {
var href = null;
if (env.tabHrefs && env.tabHrefs[app]) href = 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[app]) u = cleanUrl(env.dracoUrls[app]);
if (!u) {
/* ObsiGate : window.DRACO_URLS injecté par le backend. */
try {
if (global.DRACO_URLS && global.DRACO_URLS[app]) u = cleanUrl(global.DRACO_URLS[app]);
} catch (e) { /* rien */ }
}
if (!u) u = DEFAULT_URLS[app];
urls[app] = 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
};
}
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 || ""))
};
});
}
/* Actives d'abord, puis date décroissante (sans date en dernier). */
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;
}
/* ---- Rendu (navigateur seulement) ---- */
var panel = null, listEl = null, badgeTimer = null;
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 note = 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.");
var close = el("button", "draco-alertes-close", "✕");
close.setAttribute("aria-label", "Fermer");
close.addEventListener("click", closePanel);
head.appendChild(close);
box.appendChild(head);
box.appendChild(note);
listEl = el("div", "draco-alertes-list");
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") closePanel();
});
document.body.appendChild(ov);
panel = ov;
return ov;
}
function closePanel() { if (panel) panel.style.display = "none"; }
function renderSection(title, items, fallback) {
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", title + " (" + items.length + ")"));
if (!items.length) {
sec.appendChild(el("p", "draco-alertes-empty", fallback));
return sec;
}
items.forEach(function (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));
var meta = el("div", "draco-alertes-meta",
APP_LABELS[a.app] + (a.kind ? " · " + a.kind : "") +
" · " + (a.date ? fmtDate(a.date) : "sans date") + " · " + a.state);
main.appendChild(meta);
row.appendChild(main);
sec.appendChild(row);
});
return sec;
}
function render(results, urls) {
ensurePanel();
listEl.textContent = "";
var all = [];
APP_ORDER.forEach(function (app) {
(results[app] && results[app].items || []).forEach(function (a) { all.push(a); });
});
var actives = sortAlerts(all.filter(function (a) { return a.active; }));
var history = sortAlerts(all.filter(function (a) { return !a.active; }));
listEl.appendChild(el("h3", "draco-alertes-group", "Alertes actives"));
APP_ORDER.forEach(function (app) {
var items = actives.filter(function (a) { return a.app === app; });
if (results[app] && results[app].error) {
var sec = renderSection(APP_LABELS[app], [], results[app].error);
listEl.appendChild(sec);
} else if (app === "obsigate") {
listEl.appendChild(renderSection(APP_LABELS[app], [],
"ObsiGate n'expose aucune notification entrante consultable " +
"(ses canaux /api/notify sont des envois sortants, réservés aux administrateurs)."));
} else if (items.length) {
listEl.appendChild(renderSection(APP_LABELS[app], items, ""));
}
});
if (!actives.length) {
listEl.appendChild(el("p", "draco-alertes-empty", "Aucune alerte active dans les applications joignables."));
}
listEl.appendChild(el("h3", "draco-alertes-group", "Historique"));
APP_ORDER.forEach(function (app) {
if (results[app] && results[app].error) return; /* déjà signalé ci-dessus */
if (app === "obsigate") {
var link = el("div", "draco-alertes-empty");
var a = el("a", "", "Ouvrir ObsiGate");
a.href = urls.obsigate;
link.appendChild(a);
var sec = el("section", "draco-alertes-section");
sec.appendChild(el("h3", "", "ObsiGate (0)"));
sec.appendChild(link);
listEl.appendChild(sec);
return;
}
var items = history.filter(function (x) { return x.app === app; });
listEl.appendChild(renderSection(APP_LABELS[app], items,
"Aucun historique disponible pour cette application."));
});
}
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 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) }; })
};
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]; });
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 all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
return countActive(all);
}).catch(function () { /* pastille optionnelle : la barre vit sans */ });
}
function openPanel() {
ensurePanel();
panel.style.display = "flex";
listEl.textContent = "";
listEl.appendChild(el("p", "draco-alertes-empty", "Chargement des alertes…"));
loadAll().then(function (r) {
render(r.results, r.urls);
var all = [];
APP_ORDER.forEach(function (app) {
(r.results[app] && r.results[app].items || []).forEach(function (a) { all.push(a); });
});
setBadge(countActive(all));
}).catch(function () {
render({ obsigate: { items: [] } }, resolveUrls(browserEnv()));
});
}
var api = {
open: openPanel, close: closePanel, refreshBadge: refreshBadge,
_test: {
resolveUrls: resolveUrls, normalizeFlowdeck: normalizeFlowdeck,
normalizeTiktak: normalizeTiktak, normalizeKanbante: normalizeKanbante,
sortAlerts: sortAlerts, countActive: countActive,
isDevHost: isDevHost, DEFAULT_URLS: DEFAULT_URLS, DEV_URLS: DEV_URLS
}
};
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). */
if (typeof document !== "undefined" && document.addEventListener) {
document.addEventListener("DOMContentLoaded", function () {
if (badgeTimer) clearTimeout(badgeTimer);
badgeTimer = setTimeout(refreshBadge, 300);
});
}
})(typeof window !== "undefined" ? window : globalThis);
+403 -40
View File
@@ -1,5 +1,10 @@
/* 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 « ••• » par défaut et menu compte fonctionnels, voir ci-dessous).
*
* 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 +15,158 @@
* 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).
* Les clés agent et spark ne correspondent à aucun onglet de la barre :
* elles sont résolues et exposées via DRACO_BAR.urls pour le panneau
* Spark (panneau-agent), qui suit le même contrat d'injection.
* 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 ;
* c'est le moyen de changer d'app en mobile, où les onglets sont
* repliés (v0.1.2). Menu « ••• » et compte : voir ci-dessous.
*
* Menu « ••• » (v0.1.6) — audit des hôtes : ObsiGate est le seul hôte
* connu à gérer l'action "menu" via onAction (il déclenche son propre
* bouton d'en-tête #header-menu-btn) ; les autres hôtes ne la gèrent
* pas et le bouton restait mort (relais vers un onAction absent, puis
* événement draco:bar sans écouteur). Comportement désormais :
* - sans onAction : un menu par défaut FONCTIONNEL s'ouvre — accès à
* la console agent (URL agent résolue, donc dérivée -dev sur un
* hôte -dev) et aux quatre applications (URL résolues) ; fermeture
* par clic extérieur, Échap ou choix d'un lien ;
* - avec onAction : l'hôte garde la main (comportement historique,
* zéro régression pour ObsiGate) ; il peut demander explicitement
* le menu par défaut en retournant false pour l'action "menu".
* 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 +187,122 @@
});
}
/* ── 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 moreBtn = bar ? bar.querySelector('[data-act="menu"]') : null;
var avatarBtn = document.querySelector(".draco-avatar");
var appsMenu = null, moreMenu = null, compteMenu = 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() {
if (appsMenu) togglePopup(appsMenu, brand, false);
if (moreMenu) togglePopup(moreMenu, moreBtn, false);
if (compteMenu) togglePopup(compteMenu, avatarBtn, false);
}
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 : "";
}
function menuLink(app, nom, role) {
var a = document.createElement("a");
a.className = "draco-apps-item draco-menu-item";
a.href = resolvedUrls[app] || "#";
a.dataset.app = app;
a.setAttribute("role", "menuitem");
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var n = document.createElement("span");
n.className = "draco-apps-item-nom";
n.textContent = nom;
txt.appendChild(n);
if (role) {
var r = document.createElement("span");
r.className = "draco-apps-item-role";
r.textContent = role;
txt.appendChild(r);
}
a.appendChild(txt);
return a;
}
/* 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) {
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 +324,167 @@
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);
bar.appendChild(appsMenu);
}
function toggleAppsMenu(force) {
buildAppsMenu();
if (!appsMenu) return;
if (force !== false) refreshMenuHrefs(appsMenu);
togglePopup(appsMenu, brand, force);
}
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 « ••• » par défaut (v0.1.6) — console agent + applications.
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
function buildMoreMenu() {
if (!bar || !moreBtn || moreMenu) return;
moreMenu = document.createElement("div");
moreMenu.className = "draco-more-menu";
moreMenu.hidden = true;
moreMenu.setAttribute("role", "menu");
moreMenu.setAttribute("aria-label", "Plus d'actions");
moreMenu.appendChild(menuLink("agent", "Console agent", "Agent commun"));
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
moreMenu.appendChild(sep);
["obsigate", "flowdeck", "tiktak", "kanbante"].forEach(function (app) {
moreMenu.appendChild(menuLink(app, tabLabel(app), tabRole(app)));
});
bar.appendChild(moreMenu);
}
function toggleMoreMenu(force) {
buildMoreMenu();
if (!moreMenu) return;
if (force !== false) refreshMenuHrefs(moreMenu);
togglePopup(moreMenu, moreBtn, force);
}
/* 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);
}
/* 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 } }));
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
/* 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 (moreMenu) toggleMoreMenu(false);
if (compteMenu) toggleCompteMenu(false);
toggleAppsMenu();
return;
}
if (el && el.dataset.act === "menu") {
if (appsMenu) toggleAppsMenu(false);
if (compteMenu) toggleCompteMenu(false);
/* Hôte prioritaire : il garde la main, sauf s'il demande
* explicitement le menu par défaut en retournant false.
* Sans onAction, le menu par défaut s'ouvre (il ne restait
* auparavant qu'un événement draco:bar sans écouteur). */
if (typeof cfg.onAction === "function") {
var handled = cfg.onAction("menu", el, ev);
if (handled === false) toggleMoreMenu();
} else {
toggleMoreMenu();
}
return;
}
if (el && el.dataset.act === "compte") {
if (appsMenu) toggleAppsMenu(false);
if (moreMenu) toggleMoreMenu(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-more-menu") || ev.target.closest(".draco-compte-menu")) : null;
if (!inPopup && ((appsMenu && !appsMenu.hidden) || (moreMenu && !moreMenu.hidden) || (compteMenu && !compteMenu.hidden))) {
closeAllPopups();
}
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);
if (ev.key === "Escape") closeAllPopups();
});
/* 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();
buildMoreMenu();
buildCompteMenu();
syncAvatar();
refreshCompteMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+84
View File
@@ -0,0 +1,84 @@
// Tests unitaires (node:test) du module partagé draco-alertes.js.
// Exécutés dans la sandbox : node --test draco-alertes.test.js
// Aucune donnée inventée : les payloads reprennent la forme exacte
// des API réelles (Flowdeck /api/notifications, TikTak
// /api/notifications, Kanbante /api/events).
const test = require("node:test");
const assert = require("node:assert/strict");
const t = require("../static/js/draco-alertes.js");
test("resolveUrls: config explicite > hote -dev > href > defaut prod", () => {
const prod = t.resolveUrls({ hostname: "tiktak.dracodev.net", tabHrefs: {} });
assert.equal(prod.tiktak, "https://tiktak.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/");
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/" },
});
assert.equal(href.kanbante, "https://kanbante-dev.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);
});
+282
View File
@@ -0,0 +1,282 @@
"""Phase 4 (issue ObsiGate #22) — connecteurs Flowdeck à référence ObsiGate.
Couverture : format de référence, création liée à un workspace, isolation
(A ne distribue pas avec l'octroi de B — le faux distributeur n'est même
pas appelé), distribution en mémoire sans persistance ni cache, octroi
révoqué, ObsiGate indisponible (aucun repli), connecteurs historiques
chiffrés toujours valides, routes API.
Le distributeur est un FAUX endpoint (appelable injecté), clairement
identifié comme tel : aucun test ne parle à une vraie instance ObsiGate.
Les valeurs de test sont fictives (``TEST-VALUE-XXXX``).
"""
import pytest
from conftest import anon_csrf
from app.db import get_conn
from app.services import coffre_reference, connectors
from app.services.coffre_reference import (
CoffreAccesRefuse,
CoffreIndisponible,
CoffreReferenceInvalide,
CoffreWorkspaceMismatch,
)
PUBLIC_URL = "https://example.com/api"
REFERENCE = "coffre:flowdeck/gitea-lecture"
FAKE_VALUE = "TEST-VALUE-XXXX"
@pytest.fixture(autouse=True)
def _faux_jeton_consommateur():
"""Le distributeur exige un jeton consommateur : valeur fictive de test.
Sans elle, ``distribuer`` refuserait avant même d'appeler le faux
endpoint (comportement voulu en production).
"""
import app.config
app.config.settings.obsigate_consumer_token = "TEST-CONSUMER-TOKEN-XXXX"
yield
def _workspaces():
with get_conn() as conn:
conn.execute(
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) "
"VALUES (1, 'tester', 'Tester', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier A', 1)"
)
conn.execute(
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier B', 1)"
)
conn.commit()
rows = conn.execute(
"SELECT id FROM workspaces ORDER BY id"
).fetchall()
return int(rows[0]["id"]), int(rows[1]["id"])
class _FakeDistributeur:
"""Faux endpoint ObsiGate : compte les appels, ne persiste rien."""
def __init__(self, behaviour="ok"):
self.behaviour = behaviour
self.calls = []
def __call__(self, url, payload, headers):
self.calls.append((url, dict(payload)))
assert url.endswith("/api/coffres/distribuer"), url
assert headers.get("Authorization", "").startswith("Bearer "), headers
if self.behaviour == "ok":
return 200, {"reference": payload["reference"], "valeur": FAKE_VALUE,
"version": 1}
if self.behaviour == "revoque":
return 403, {"detail": "octroi_revoque"}
if self.behaviour == "indisponible":
raise ConnectionError("faux ObsiGate injoignable")
raise AssertionError(self.behaviour)
def _create_coffre(client, ws, **kw):
body = {"name": "Conn coffre", "url": PUBLIC_URL,
"workspace_id": ws, "coffre_reference": REFERENCE, **kw}
r = client.post("/api/agent/connectors", json=body)
assert r.status_code == 200, r.text
return r.json()
# ── Format ─────────────────────────────────────────────────────────────────
@pytest.mark.parametrize("bad", [
"gitea-lecture", "coffre:", "coffre:slug-seul", "coffre:AB/mauvais",
"coffre:a/b", "coffre:ok/mauvais nom", "coffre:ok/",
])
def test_reference_invalide_rejetee(bad):
with pytest.raises(CoffreReferenceInvalide):
coffre_reference.parse_reference(bad)
def test_reference_normalisee_minuscules():
assert coffre_reference.parse_reference("Coffre:FlowDeck/Gitea-Lecture") == (
"flowdeck", "gitea-lecture")
# ── Création liée au workspace ─────────────────────────────────────────────
def test_creation_coffre_exige_workspace(client):
r = client.post("/api/agent/connectors",
json={"name": "Sans WS", "url": PUBLIC_URL,
"coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
assert "workspace" in r.text.lower()
def test_creation_coffre_refuse_secret_et_reference(client):
ws_a, _ = _workspaces()
r = client.post("/api/agent/connectors",
json={"name": "Ambigu", "url": PUBLIC_URL, "secret": "x",
"workspace_id": ws_a, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
def test_creation_coffre_ne_stocke_aucun_jeton(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
assert row["coffre_reference"] == REFERENCE
assert row["workspace_id"] == ws_a
assert row["secret_source"] == "coffre"
assert row["has_secret"] is True
assert FAKE_VALUE not in str(row)
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted, coffre_reference FROM agent_connectors "
"WHERE id=?", (row["id"],)
).fetchone()
assert stored["secret_encrypted"] == "" # aucun jeton stocké
assert stored["coffre_reference"] == REFERENCE # métadonnée seulement
def test_creation_workspace_introuvable(client):
r = client.post("/api/agent/connectors",
json={"name": "WS ?", "url": PUBLIC_URL,
"workspace_id": 999999, "coffre_reference": REFERENCE})
assert r.status_code == 400, r.text
# ── Résolution + isolation (critère central) ───────────────────────────────
def test_resolve_bon_workspace_distribue_en_memoire(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
valeur = connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert valeur == FAKE_VALUE
assert len(fake.calls) == 1
_, payload = fake.calls[0]
assert payload["reference"] == REFERENCE
assert payload["contexte_type"] == "flowdeck_workspace"
assert payload["contexte_id"] == str(ws_a)
# Rien n'a été persisté ni mis en cache par la distribution.
with get_conn() as conn:
stored = conn.execute(
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
(row["id"],)
).fetchone()
assert stored["secret_encrypted"] == ""
assert FAKE_VALUE not in str(connectors.get(row["id"]))
def test_isolation_workspace_a_ne_distribue_pas_octroi_de_b(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_b) # octroi du workspace B
fake = _FakeDistributeur()
with pytest.raises(CoffreWorkspaceMismatch) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "ne peut pas utiliser" in str(exc.value)
assert fake.calls == [] # distributeur jamais appelé
def test_resolve_sans_workspace_refuse(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
with pytest.raises(ValueError, match="workspace_id"):
connectors.resolve_secret(row["id"], distribute=fake)
assert fake.calls == []
def test_headers_bon_workspace_mauvais_workspace(client):
ws_a, ws_b = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur()
headers = connectors._headers(row["id"], workspace_id=ws_a,
distribute=fake)
assert headers["Authorization"] == f"Bearer {FAKE_VALUE}"
with pytest.raises(CoffreWorkspaceMismatch):
connectors._headers(row["id"], workspace_id=ws_b, distribute=fake)
# ── Échecs propres ─────────────────────────────────────────────────────────
def test_octroi_revoque_message_explicite_sans_repli(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="revoque")
with pytest.raises(CoffreAccesRefuse) as exc:
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert "révoqué" in str(exc.value)
assert "ré-accorder" in str(exc.value)
assert exc.value.detail == "octroi_revoque"
def test_obsigate_indisponible_sans_valeur_en_cache(client):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
fake = _FakeDistributeur(behaviour="indisponible")
for _ in range(2): # deux appels : aucun ne doit renvoyer une valeur
with pytest.raises(CoffreIndisponible):
connectors.resolve_secret(row["id"], workspace_id=ws_a,
distribute=fake)
assert len(fake.calls) == 2 # chaque appel retente, rien en cache
# ── Migration opportuniste ─────────────────────────────────────────────────
def test_connecteur_historique_chiffre_toujours_valide(client):
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc"}
row = client.post("/api/agent/connectors", json=body).json()
assert row["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(row["id"]) == "sk-live-abc"
def test_bascule_patch_vers_reference_puis_retour(client):
ws_a, ws_b = _workspaces()
row = client.post("/api/agent/connectors",
json={"name": "Migrable", "url": PUBLIC_URL,
"secret": "sk-live-abc"}).json()
cid = row["id"]
# 1. rattacher au workspace, 2. basculer vers la référence.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_a})
assert r.status_code == 200, r.text
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": REFERENCE})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "coffre"
# Le workspace est immuable : pas de transfert d'octroi vers B.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"workspace_id": ws_b})
assert r.status_code == 400, r.text
# Effacer la référence réactive le jeton local dormant.
r = client.patch(f"/api/agent/connectors/{cid}",
json={"coffre_reference": ""})
assert r.status_code == 200, r.text
assert r.json()["secret_source"] == "chiffre_local"
assert connectors.resolve_secret(cid) == "sk-live-abc"
def test_probe_coffre_sans_workspace_echoue_proprement(client, monkeypatch):
ws_a, _ = _workspaces()
row = _create_coffre(client, ws_a)
async def ok_get(url, headers=None):
return 200, "{}"
monkeypatch.setattr(connectors, "_get", ok_get)
r = client.post("/api/agent/connectors/probe",
json={"connector": str(row["id"])})
assert r.status_code == 200
assert r.json()["status"] == "error" # refus, pas de fuite inter-workspace
def test_routes_exigent_session(client):
anon_csrf(client)
assert client.post("/api/agent/connectors",
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
+63
View File
@@ -0,0 +1,63 @@
"""Fusion #69 — Alertes unifiées (chantier 2, 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