Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f9eb080b1 | ||
|
|
9abd6c9d4d | ||
|
|
fbe9151ab7 | ||
|
|
ab63000f5d | ||
|
|
52b62b48b8 | ||
|
|
acaae92e3a | ||
|
|
1065ffb017 | ||
|
|
e4be8578ff | ||
|
|
e733f3309d | ||
|
|
371908ed1d | ||
|
|
da205feec1 | ||
|
|
c582856051 | ||
|
|
ecafeecda0 | ||
|
|
7a43ca00ad | ||
|
|
e5e98ed08f | ||
|
|
62b536c865 | ||
|
|
a3431b18c9 | ||
|
|
45940c26bb | ||
|
|
f827e9fc7d | ||
|
|
cd0835116d | ||
|
|
b751c81120 | ||
|
|
30387c4ed4 | ||
|
|
4f13068c85 | ||
|
|
417c0a23d7 |
@@ -28,6 +28,13 @@ OAUTH_REDIRECT_URI=
|
|||||||
# par release (passé par environment:/env_file: au conteneur).
|
# par release (passé par environment:/env_file: au conteneur).
|
||||||
# Laisser vide = défaut officiel indiqué en commentaire.
|
# 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
|
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
|
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
|
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
|
KANBANTE_URL= # Kanbante (barre, refs) — défaut https://kanbante.dracodev.net ; dev : https://kanbante-dev.dracodev.net
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
# Changelog - FlowDeck
|
# 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)
|
## 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`,
|
- **ACC-4.5 — format de skill commun** (`app/services/skill_format.py`,
|
||||||
|
|||||||
@@ -158,6 +158,13 @@ class Settings(BaseSettings):
|
|||||||
# KANBANTE_URL=https://kanbante-dev.dracodev.net
|
# KANBANTE_URL=https://kanbante-dev.dracodev.net
|
||||||
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
|
# FLOWDECK_URL=https://flowdeck-dev.dracodev.net
|
||||||
draco_sso_url: str = "https://og.dracodev.net" # ObsiGate (émettrice SSO)
|
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)
|
draco_agent_url: str = "https://agent.dracodev.net" # service d'agents (Spark)
|
||||||
tiktak_url: str = "https://tiktak.dracodev.net"
|
tiktak_url: str = "https://tiktak.dracodev.net"
|
||||||
kanbante_url: str = "https://kanbante.dracodev.net"
|
kanbante_url: str = "https://kanbante.dracodev.net"
|
||||||
|
|||||||
+13
-1
@@ -216,7 +216,7 @@ def _lire_version() -> str:
|
|||||||
|
|
||||||
|
|
||||||
# Littéral de version synchronisé avec le fichier VERSION :
|
# 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
|
# 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
|
# ce littéral) et contrôle de tests/test_pwa_offline.py. La version
|
||||||
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
# runtime reste lue dynamiquement depuis VERSION par _lire_version()
|
||||||
@@ -239,6 +239,18 @@ app.add_middleware(RateLimitMiddleware)
|
|||||||
_CORS_ORIGINS = sorted(
|
_CORS_ORIGINS = sorted(
|
||||||
{o.rstrip("/") for o in (settings.app_base_url or "").split() if o.startswith(("http://", "https://"))}
|
{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 —
|
# Hors prod : dev local + origines d'extension (Web Clipper, Bearer uniquement —
|
||||||
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
# pas de cookie → `allow_credentials` ne s'applique pas à ces origines).
|
||||||
app.add_middleware(
|
app.add_middleware(
|
||||||
|
|||||||
@@ -139,7 +139,7 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
# l'agent connu en config ; défaut = nom officiel).
|
# l'agent connu en config ; défaut = nom officiel).
|
||||||
# Même origine pour frame-src : la console du service est
|
# Même origine pour frame-src : la console du service est
|
||||||
# encadrée en iframe par le panneau (surimpression v3).
|
# encadrée en iframe par le panneau (surimpression v3).
|
||||||
"connect-src 'self' {agent} ws://{host} wss://{host}; "
|
"connect-src 'self' {agent} {sisters} ws://{host} wss://{host}; "
|
||||||
"media-src 'self' blob:; "
|
"media-src 'self' blob:; "
|
||||||
"frame-src 'self' {agent}; "
|
"frame-src 'self' {agent}; "
|
||||||
"object-src 'none'; "
|
"object-src 'none'; "
|
||||||
@@ -174,8 +174,17 @@ class ContentSecurityPolicyMiddleware(BaseHTTPMiddleware):
|
|||||||
request.headers.get("host", ""))
|
request.headers.get("host", ""))
|
||||||
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
agent = re.sub(r"[^0-9A-Za-z.\-:/]", "",
|
||||||
draco_urls.origine(draco_urls.agent_url()))
|
draco_urls.origine(draco_urls.agent_url()))
|
||||||
|
# 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(
|
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
|
return response
|
||||||
|
|
||||||
|
|||||||
@@ -2076,3 +2076,31 @@ def _migration_draco_links(conn: sqlite3.Connection) -> None:
|
|||||||
)
|
)
|
||||||
"""
|
"""
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@register(52, "coffre ObsiGate (Phase 4) : workspace_id + coffre_reference sur agent_connectors")
|
||||||
|
def _migration_coffre_reference(conn: sqlite3.Connection) -> None:
|
||||||
|
"""Migration 52 — références ObsiGate pour les connecteurs (issue #22).
|
||||||
|
|
||||||
|
* ``workspace_id`` : le connecteur appartient à un workspace (NULL =
|
||||||
|
connecteur historique global, conservé tel quel — migration
|
||||||
|
opportuniste, pas de big-bang).
|
||||||
|
* ``coffre_reference`` : ``coffre:<slug>/<nom>`` (métadonnée, pas une
|
||||||
|
valeur) ; vide = jeton chiffré local historique.
|
||||||
|
Idempotente (gardée par ``columns()``), comme les migrations 32/34.
|
||||||
|
"""
|
||||||
|
cols = columns(conn, "agent_connectors")
|
||||||
|
if "workspace_id" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE agent_connectors "
|
||||||
|
"ADD COLUMN workspace_id INTEGER REFERENCES workspaces(id)"
|
||||||
|
)
|
||||||
|
if "coffre_reference" not in cols:
|
||||||
|
conn.execute(
|
||||||
|
"ALTER TABLE agent_connectors "
|
||||||
|
"ADD COLUMN coffre_reference TEXT NOT NULL DEFAULT ''"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"CREATE INDEX IF NOT EXISTS idx_agent_connectors_workspace "
|
||||||
|
"ON agent_connectors(workspace_id)"
|
||||||
|
)
|
||||||
|
|||||||
+10
-1
@@ -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 ""),
|
body.get("name") or "", body.get("url") or "", str(body.get("secret") or ""),
|
||||||
kind=str(body.get("kind") or "custom"),
|
kind=str(body.get("kind") or "custom"),
|
||||||
auth=str(body.get("auth") or "bearer"),
|
auth=str(body.get("auth") or "bearer"),
|
||||||
|
workspace_id=body.get("workspace_id"),
|
||||||
|
coffre_reference=body.get("coffre_reference"),
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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={})):
|
def update_connectors_route(request: Request, connector_id: int, body: dict = Body(default={})):
|
||||||
_current_user_id(request)
|
_current_user_id(request)
|
||||||
try:
|
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(
|
row = connectors.update_connector(
|
||||||
connector_id,
|
connector_id,
|
||||||
name=body.get("name"),
|
name=body.get("name"),
|
||||||
enabled=body.get("enabled"),
|
enabled=body.get("enabled"),
|
||||||
secret=body.get("secret"),
|
secret=body.get("secret"),
|
||||||
|
**kwargs,
|
||||||
)
|
)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=400, detail=str(exc)) from 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")
|
raise HTTPException(status_code=400, detail="connector est requis")
|
||||||
user_id = _current_user_id(request)
|
user_id = _current_user_id(request)
|
||||||
try:
|
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:
|
except ValueError as exc:
|
||||||
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
raise HTTPException(status_code=404, detail=str(exc)) from exc
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -1,5 +1,15 @@
|
|||||||
"""Connecteurs de l'agent (v7.55.0) — socle : catalogue, statut, fetch gardé SSRF.
|
"""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,
|
5 connecteurs **natifs** (gitea, github, web, google, ms365) servis à la volée,
|
||||||
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
plus des connecteurs **personnels** persistés (URL + clé) en base avec un
|
||||||
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
``kind`` : ``custom`` | ``discord`` (auth « Bot ») | ``telegram`` (jeton dans
|
||||||
@@ -18,7 +28,9 @@ import logging
|
|||||||
|
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.db import get_conn
|
from app.db import get_conn
|
||||||
|
from app.services import coffre_reference as coffre_module
|
||||||
from app.services import oauth_connectors
|
from app.services import oauth_connectors
|
||||||
|
from app.services.coffre_reference import CoffreWorkspaceMismatch
|
||||||
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
from app.services.sso_provisioning import decrypt_secret, encrypt_secret
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
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'}")
|
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:
|
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:
|
try:
|
||||||
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
tools = json.loads(r["tools_json"] or "[]") if "tools_json" in r.keys() else []
|
||||||
except (ValueError, TypeError):
|
except (ValueError, TypeError):
|
||||||
tools = []
|
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 {
|
return {
|
||||||
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
"id": r["id"], "builtin": False, "kind": r["kind"], "name": r["name"],
|
||||||
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
"url": r["url"], "auth": r["auth"], "enabled": bool(r["enabled"]),
|
||||||
"status": r["status"], "detail": r["detail"] or "",
|
"status": r["status"], "detail": r["detail"] or "",
|
||||||
"has_secret": bool(r["secret_encrypted"]), "oauth": False,
|
"has_secret": bool(stored or reference), "oauth": False,
|
||||||
"tools_count": len(tools),
|
"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,
|
"has_secret": False, "oauth": kind in OAUTH_KINDS,
|
||||||
})
|
})
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
cols = _select_cols(conn)
|
||||||
rows = conn.execute(
|
rows = conn.execute(
|
||||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
f"SELECT {cols} FROM agent_connectors ORDER BY id"
|
||||||
"kind, auth, tools_json FROM agent_connectors ORDER BY id"
|
|
||||||
).fetchall()
|
).fetchall()
|
||||||
return out + [_row(r) for r in rows]
|
return out + [_row(r) for r in rows]
|
||||||
|
|
||||||
|
|
||||||
def get(connector_id: int) -> dict | None:
|
def get(connector_id: int) -> dict | None:
|
||||||
with get_conn() as conn:
|
with get_conn() as conn:
|
||||||
|
cols = _select_cols(conn)
|
||||||
r = conn.execute(
|
r = conn.execute(
|
||||||
"SELECT id, name, url, secret_encrypted, enabled, status, detail, "
|
f"SELECT {cols} FROM agent_connectors WHERE id=?",
|
||||||
"kind, auth, tools_json FROM agent_connectors WHERE id=?",
|
|
||||||
(connector_id,),
|
(connector_id,),
|
||||||
).fetchone()
|
).fetchone()
|
||||||
return _row(r) if r else None
|
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) ────────────────────────────────────────────────────────
|
# ── CRUD (personnels) ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
def create_connector(name: str, url: str, secret: str = "", *,
|
def create_connector(name: str, url: str, secret: str = "", *,
|
||||||
kind: str = "custom", auth: str = "bearer") -> dict:
|
kind: str = "custom", auth: str = "bearer",
|
||||||
"""Valide l'URL (garde SSRF) puis stocke la clé **chiffrée** (Fernet)."""
|
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
|
from app.services.importers.url_fetch import _validate_url
|
||||||
|
|
||||||
name = (name or "").strip()
|
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)})")
|
raise ValueError(f"kind invalide: {kind} (attendu {', '.join(CUSTOM_KINDS)})")
|
||||||
if auth not in AUTH_SCHEMES:
|
if auth not in AUTH_SCHEMES:
|
||||||
raise ValueError(f"auth invalide: {auth} (attendu {', '.join(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()
|
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}")
|
raise ValueError("clé requise : l'URL contient {secret}")
|
||||||
url = _validate_url(url) # lève ValueError si hôte interne
|
url = _validate_url(url) # lève ValueError si hôte interne
|
||||||
with get_conn() as conn:
|
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(
|
cur = conn.execute(
|
||||||
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
"INSERT INTO agent_connectors (name, url, secret_encrypted, status, "
|
||||||
"detail, kind, auth) VALUES (?,?,?,?,?,?,?)",
|
f"detail, kind, auth{extra_cols}) VALUES (?,?,?,?,?,?,?{',?' * len(extra_vals)})",
|
||||||
(name, url, encrypt_secret(secret or ""), "unknown", "", kind, auth),
|
(name, url, encrypt_secret(secret or "") if not reference else "",
|
||||||
|
"unknown", "", kind, auth, *extra_vals),
|
||||||
)
|
)
|
||||||
conn.commit()
|
conn.commit()
|
||||||
cid = cur.lastrowid
|
cid = cur.lastrowid
|
||||||
return get(cid)
|
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)
|
row = get(connector_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
return None
|
return None
|
||||||
@@ -206,7 +319,40 @@ def update_connector(connector_id: int, *, name=None, enabled=None, secret=None)
|
|||||||
if enabled is not None:
|
if enabled is not None:
|
||||||
sets.append("enabled=?")
|
sets.append("enabled=?")
|
||||||
params.append(1 if enabled else 0)
|
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 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=?")
|
sets.append("secret_encrypted=?")
|
||||||
params.append(encrypt_secret(str(secret)))
|
params.append(encrypt_secret(str(secret)))
|
||||||
if sets:
|
if sets:
|
||||||
@@ -226,22 +372,72 @@ def delete_connector(connector_id: int) -> bool:
|
|||||||
|
|
||||||
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
# ── Réseau (toujours gardé SSRF) ─────────────────────────────────────────────
|
||||||
|
|
||||||
def _headers(connector_id: int | None = None) -> dict:
|
def resolve_secret(connector_id: int, *, workspace_id: int | None = None,
|
||||||
"""En-têtes d'appel : la clé n'est lue (et déchiffrée) qu'ici, jamais renvoyée.
|
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).
|
``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"}
|
headers = {"User-Agent": "FlowDeck-Connectors/1.0"}
|
||||||
secret, auth = "", "bearer"
|
secret, auth = "", "bearer"
|
||||||
if connector_id:
|
if connector_id:
|
||||||
with get_conn() as conn:
|
row = get(connector_id)
|
||||||
r = conn.execute(
|
if row:
|
||||||
"SELECT secret_encrypted, auth FROM agent_connectors WHERE id=?",
|
auth = row["auth"] or "bearer"
|
||||||
(connector_id,),
|
secret = resolve_secret(connector_id, workspace_id=workspace_id,
|
||||||
).fetchone()
|
distribute=distribute)
|
||||||
if r:
|
|
||||||
secret = decrypt_secret(r["secret_encrypted"] or "")
|
|
||||||
auth = r["auth"] or "bearer"
|
|
||||||
if secret and auth == "bot":
|
if secret and auth == "bot":
|
||||||
headers["Authorization"] = f"Bot {secret}"
|
headers["Authorization"] = f"Bot {secret}"
|
||||||
elif secret and auth != "none":
|
elif secret and auth != "none":
|
||||||
@@ -250,16 +446,13 @@ def _headers(connector_id: int | None = None) -> dict:
|
|||||||
return headers
|
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)."""
|
"""Substitue ``{secret}`` (Telegram : le jeton vit dans l'URL, jamais stocké clair)."""
|
||||||
if "{secret}" not in url:
|
if "{secret}" not in url:
|
||||||
return url
|
return url
|
||||||
with get_conn() as conn:
|
secret = resolve_secret(connector_id, workspace_id=workspace_id,
|
||||||
r = conn.execute(
|
distribute=distribute)
|
||||||
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
|
||||||
(connector_id,),
|
|
||||||
).fetchone()
|
|
||||||
secret = decrypt_secret((r["secret_encrypted"] if r else "") or "")
|
|
||||||
if not secret:
|
if not secret:
|
||||||
raise ValueError("clé manquante pour une URL contenant {secret}")
|
raise ValueError("clé manquante pour une URL contenant {secret}")
|
||||||
return url.replace("{secret}", secret)
|
return url.replace("{secret}", secret)
|
||||||
@@ -303,13 +496,20 @@ def _resolve(connector: str) -> tuple[str, str | int]:
|
|||||||
raise ValueError(f"Connecteur inconnu: {token}")
|
raise ValueError(f"Connecteur inconnu: {token}")
|
||||||
|
|
||||||
|
|
||||||
async def probe(connector: str, user_id: int | None = None) -> dict:
|
async def probe(connector: str, user_id: int | None = None, *,
|
||||||
"""Teste un connecteur et **persiste** le résultat (personnel uniquement)."""
|
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)
|
kind, ref = _resolve(connector)
|
||||||
try:
|
try:
|
||||||
if kind in MCP_KINDS:
|
if kind in MCP_KINDS:
|
||||||
from app.services import mcp_client
|
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)"
|
status, detail = "ok", f"MCP · {len(tools)} outil(s)"
|
||||||
elif kind in OAUTH_KINDS:
|
elif kind in OAUTH_KINDS:
|
||||||
res = await oauth_connectors.api_get(kind, user_id,
|
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]
|
status, detail = "ok", native_state("web")[1]
|
||||||
else:
|
else:
|
||||||
row = get(int(ref))
|
row = get(int(ref))
|
||||||
url = _with_secret(row["url"], int(ref))
|
url = _with_secret(row["url"], int(ref), workspace_id=workspace_id,
|
||||||
code, _ = await _get(url, _headers(int(ref)))
|
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}"
|
status, detail = ("ok" if code < 400 else "error"), f"HTTP {code}"
|
||||||
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
except Exception as exc: # noqa: BLE001 — un probe ne casse jamais l'UI
|
||||||
logger.info("Connector probe failed (%s): %s", connector, exc)
|
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 = "",
|
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."""
|
"""Lit un connecteur pour l'LLM : natif = API, web = recherche, perso = GET."""
|
||||||
kind, ref = _resolve(connector)
|
kind, ref = _resolve(connector)
|
||||||
path = (path or "").strip()
|
path = (path or "").strip()
|
||||||
@@ -381,10 +585,12 @@ async def connector_fetch(connector: str, path: str = "", query: str = "",
|
|||||||
return {"status": "error", "text": "Connecteur supprimé"}
|
return {"status": "error", "text": "Connecteur supprimé"}
|
||||||
if not row["enabled"]:
|
if not row["enabled"]:
|
||||||
return {"status": "error", "text": "Connecteur désactivé"}
|
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:
|
if path:
|
||||||
url += "/" + path.lstrip("/")
|
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:
|
if code >= 400:
|
||||||
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
return {"status": "error", "text": f"HTTP {code} — {text[:400]}"}
|
||||||
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|
return {"status": "ok", "text": text[:FETCH_LIMIT]}
|
||||||
|
|||||||
@@ -189,7 +189,8 @@ async def _handshake(url: str, headers: dict | None) -> dict:
|
|||||||
return session
|
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)
|
row = get(connector_id)
|
||||||
if row is None:
|
if row is None:
|
||||||
raise ValueError("Serveur MCP introuvable")
|
raise ValueError("Serveur MCP introuvable")
|
||||||
@@ -198,7 +199,8 @@ def _server(connector_id: int) -> dict:
|
|||||||
if not row["enabled"]:
|
if not row["enabled"]:
|
||||||
raise ValueError("Serveur MCP désactivé")
|
raise ValueError("Serveur MCP désactivé")
|
||||||
return {"id": row["id"], "name": row["name"], "url": row["url"],
|
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]:
|
def _normalize(server: dict, raw: list) -> list[dict]:
|
||||||
@@ -226,10 +228,16 @@ def _save_tools(connector_id: int, tools: list[dict]) -> None:
|
|||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
||||||
|
|
||||||
async def initialize_and_list(connector_id: int) -> list[dict]:
|
async def initialize_and_list(connector_id: int, *, workspace_id: int | None = None,
|
||||||
"""Handshake + ``tools/list`` → met à jour le cache de la base."""
|
distribute=None) -> list[dict]:
|
||||||
srv = _server(connector_id)
|
"""Handshake + ``tools/list`` → met à jour le cache de la base.
|
||||||
url = _with_secret(srv["url"], connector_id)
|
|
||||||
|
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"])
|
session = await _handshake(url, srv["headers"])
|
||||||
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
data = await _rpc(url, {"jsonrpc": "2.0", "id": 2, "method": "tools/list",
|
||||||
"params": {}}, session)
|
"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]
|
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.).
|
"""``tools/call`` → {status, text} (isError → error, borné à 20 000 car.).
|
||||||
|
|
||||||
Le handshake (avec session éventuelle) précède systématiquement
|
Le handshake (avec session éventuelle) précède systématiquement
|
||||||
l'appel : c'est la condition T0 des serveurs à session.
|
l'appel : c'est la condition T0 des serveurs à session.
|
||||||
"""
|
"""
|
||||||
srv = _server(connector_id)
|
srv = _server(connector_id, workspace_id=workspace_id, distribute=distribute)
|
||||||
url = _with_secret(srv["url"], connector_id)
|
url = _with_secret(srv["url"], connector_id, workspace_id=workspace_id,
|
||||||
|
distribute=distribute)
|
||||||
session = await _handshake(url, srv["headers"])
|
session = await _handshake(url, srv["headers"])
|
||||||
data = await _rpc(url, {
|
data = await _rpc(url, {
|
||||||
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
"jsonrpc": "2.0", "id": 3, "method": "tools/call",
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
{# Barre d'apps commune dracodev — Fusion ACC-1.1 (issue #17).
|
{# 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
|
(ui/barre-apps/) : ne pas modifier la structure ici ; toute évolution
|
||||||
passe par une nouvelle version de commons.
|
passe par une nouvelle version de commons.
|
||||||
CSS/JS : /static/css/draco-barre.css, /static/js/vendor/draco-barre.js.
|
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 —
|
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
|
l'hôte les injecte depuis sa config (draco_urls(), noms officiels
|
||||||
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
|
par défaut, noms en -dev déclarés dans le .env de l'environnement). #}
|
||||||
|
<link rel="stylesheet" href="/static/css/draco-alertes.css?v={{ asset_version }}">
|
||||||
<header class="draco-bar" data-current="flowdeck">
|
<header class="draco-bar" data-current="flowdeck">
|
||||||
<div class="draco-bar-zone draco-bar-gauche">
|
<div class="draco-bar-zone draco-bar-gauche">
|
||||||
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
|
<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-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
|
||||||
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
<span class="draco-badge" data-badge="spark" hidden>0</span>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
|
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu" aria-expanded="false">•••</button>
|
||||||
<span class="draco-avatar" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</span>
|
<button type="button" class="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="{{ user.get('full_name') if user and user.get('full_name') else '' }}">{{ user.full_name[:1].upper() if user and user.get('full_name') else 'F' }}</button>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||||
<script nonce="{{ csp_nonce() }}">
|
<script nonce="{{ csp_nonce() }}">
|
||||||
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
/* Configuration Flowdeck de la barre commune (Fusion ACC-1.1).
|
||||||
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
Phase 1 : navigation entre apps et domaine mémorisé localement ;
|
||||||
le rail pilote le hamburger natif, les alertes ouvrent la cloche
|
le rail pilote le hamburger natif, les alertes ouvrent le
|
||||||
native de la page ; la recherche fédérée et Spark arrivent avec
|
panneau draco-alertes.js (Fusion #69, agrégation à la demande,
|
||||||
les phases suivantes du programme Fusion. */
|
pas une fédération temps réel ; la cloche native reste le
|
||||||
|
repli si le module ne charge pas). */
|
||||||
window.DRACO_BAR_CONFIG = {
|
window.DRACO_BAR_CONFIG = {
|
||||||
current: "flowdeck",
|
current: "flowdeck",
|
||||||
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
|
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 === "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 === "rail") { var h = document.querySelector(".hamburger-btn"); if (h) h.click(); return; }
|
||||||
if (act === "alertes") {
|
if (act === "alertes") {
|
||||||
var b = document.querySelector(".fd-notif-bell button");
|
if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); }
|
||||||
if (b) { b.click(); } else { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); }
|
else { var b = document.querySelector(".fd-notif-bell button"); if (b) { b.click(); } }
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (act === "palette") { dire("Recherche fédérée ⌘K : arrive avec la fédération (programme Fusion)."); 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 (act === "spark") {
|
||||||
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
|
||||||
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
else { dire("Spark : le panneau n'a pas pu se charger."); }
|
||||||
@@ -101,3 +109,4 @@
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
<script src="/static/js/draco-alertes.js?v={{ asset_version }}"></script>
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
# Flowdeck — Connecteurs à référence ObsiGate (Phase 4, issue ObsiGate #22)
|
||||||
|
|
||||||
|
## Principe
|
||||||
|
|
||||||
|
Un connecteur personnel d'un workspace peut pointer vers une référence
|
||||||
|
ObsiGate au lieu d'un jeton :
|
||||||
|
|
||||||
|
```
|
||||||
|
coffre:<slug>/<nom> ex. coffre:flowdeck/gitea-lecture
|
||||||
|
```
|
||||||
|
|
||||||
|
* Le champ jeton du connecteur **devient** le champ référence : aucun
|
||||||
|
jeton n'est stocké pour ce connecteur (`secret_encrypted` reste vide).
|
||||||
|
* L'octroi est **toujours lié à un `workspace_id`** : créer un connecteur
|
||||||
|
à référence sans workspace est refusé (HTTP 400 / `ValueError`).
|
||||||
|
* À l'appel, le **serveur** Flowdeck appelle
|
||||||
|
`POST /api/coffres/distribuer` d'ObsiGate avec le jeton consommateur
|
||||||
|
Flowdeck et un contexte `flowdeck_workspace / <workspace_id>`, utilise
|
||||||
|
la valeur **en mémoire**, puis l'oublie. La valeur n'est jamais
|
||||||
|
persistée, jamais mise en cache, jamais journalisée, jamais renvoyée
|
||||||
|
à une réponse API ni au LLM.
|
||||||
|
|
||||||
|
## Configuration (serveur Flowdeck)
|
||||||
|
|
||||||
|
| Variable | Défaut | Rôle |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| `OBSIGATE_COFFRE_URL` | dérivée de `DRACO_SSO_URL` | Base ObsiGate pour `/api/coffres/distribuer` |
|
||||||
|
| `OBSIGATE_CONSUMER_TOKEN` | (vide = distribution désactivée) | Jeton **consommateur** Flowdeck enregistré dans ObsiGate |
|
||||||
|
|
||||||
|
Le jeton consommateur est distinct du jeton MCP de notes et de la
|
||||||
|
session SSO. Il ne figure dans aucun log, rapport ou réponse.
|
||||||
|
|
||||||
|
## Isolation workspace — ce qui est garanti, et sa limite honnête
|
||||||
|
|
||||||
|
**Garanti côté Flowdeck, avant tout appel** (`connectors.resolve_secret`) :
|
||||||
|
|
||||||
|
* un connecteur à référence ne se résout que pour son propre
|
||||||
|
`workspace_id` ;
|
||||||
|
* workspace A tentant d'utiliser le connecteur (donc la référence /
|
||||||
|
l'octroi) du workspace B reçoit un refus explicite
|
||||||
|
(`CoffreWorkspaceMismatch` : « … ne peut pas utiliser ce connecteur
|
||||||
|
(il appartient au workspace …) : octroi d'un autre workspace refusé »)
|
||||||
|
et **le distributeur n'est pas appelé** ;
|
||||||
|
* un `workspace_id` déjà posé sur un connecteur est immuable : on ne
|
||||||
|
transfère pas un octroi en éditant le connecteur ;
|
||||||
|
* un connecteur à référence testé/appelé sans `workspace_id` échoue
|
||||||
|
proprement, jamais avec la valeur d'un autre workspace.
|
||||||
|
|
||||||
|
**Limite à connaître** : côté ObsiGate, l'octroi est accordé au
|
||||||
|
consommateur **Flowdeck** (un jeton par application), pas à un workspace
|
||||||
|
— le `contexte_id` transmis sert à la traçabilité, pas à un contrôle
|
||||||
|
d'accès côté ObsiGate. L'isolation par workspace repose donc sur le
|
||||||
|
contrôle Flowdeck ci-dessus. Une isolation appliquée côté ObsiGate
|
||||||
|
exigerait un consommateur par workspace : c'est un reste à faire
|
||||||
|
décidé côté ObsiGate (enregistrement de consommateurs par workspace),
|
||||||
|
pas un prérequis pour utiliser ce mécanisme.
|
||||||
|
|
||||||
|
## Échecs propres, sans repli
|
||||||
|
|
||||||
|
| Situation | Comportement Flowdeck |
|
||||||
|
| --- | --- |
|
||||||
|
| ObsiGate injoignable / timeout / HTTP 5xx | `CoffreIndisponible` — l'appel échoue, **aucune** valeur en cache n'est utilisée (il n'y en a pas) |
|
||||||
|
| Octroi révoqué / consommateur révoqué (403) | `CoffreAccesRefuse` — message « accès révoqué, à ré-accorder dans ObsiGate » |
|
||||||
|
| Octroi expiré (403) / secret expiré (410) | `CoffreAccesRefuse` avec le code ObsiGate, sans la valeur |
|
||||||
|
| Référence mal formée | `CoffreReferenceInvalide` (format `coffre:<slug>/<nom>`, slug 3–64, nom 3–128, minuscules, chiffres, `-`/`_`, aligné sur ObsiGate) |
|
||||||
|
|
||||||
|
## Secrets déjà chiffrés : pas de big-bang
|
||||||
|
|
||||||
|
Les connecteurs historiques (`secret_encrypted` Fernet) restent valides
|
||||||
|
tels quels. La migration est **opportuniste, connecteur par
|
||||||
|
connecteur** :
|
||||||
|
|
||||||
|
1. (optionnel) rattacher d'abord le connecteur à son workspace
|
||||||
|
(`PATCH /api/agent/connectors/{id}` avec `workspace_id`) ;
|
||||||
|
2. puis basculer vers la référence (`PATCH …` avec
|
||||||
|
`coffre_reference: "coffre:<slug>/<nom>"`) — le jeton local dormant
|
||||||
|
n'est pas effacé automatiquement, il est simplement ignoré tant que
|
||||||
|
la référence est posée ; effacer la référence (`""`) le réactive ;
|
||||||
|
3. vérifier par un probe avec `workspace_id`, puis supprimer le jeton
|
||||||
|
local si besoin (rotation côté ObsiGate).
|
||||||
|
|
||||||
|
## Chemin de migration par connecteur
|
||||||
|
|
||||||
|
| Connecteur actuel | État dans cette branche | Chemin |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Personnels (`custom`, `discord`, `telegram`, `mcp`, `tiktak`, `obsigate`) | **Implémenté** : `workspace_id` + `coffre_reference`, résolution en mémoire, isolation testée | Basculer connecteur par connecteur (ci-dessus) |
|
||||||
|
| Natifs Gitea / GitHub | **Inchangés** : jetons globaux d'instance (`GITEA_TOKEN`, `GITHUB_TOKEN` en `.env`) | Créer un connecteur personnel workspace-scopé à référence vers le même service et l'utiliser à la place du natif, quand le besoin workspace par workspace se présente ; les natifs ne sont pas supprimés (pas de big-bang) |
|
||||||
|
| LLM global (`llm_config` id=1) et clés par utilisateur (`user_llm_keys`) | **Inchangés** : clés en clair en base (globale / par utilisateur, pas par workspace) | Travail ultérieur dédié : ajouter une colonne référence sur `user_llm_keys` + résolution par workspace, avec le même patron (distribution en mémoire, workspace obligatoire) ; hors périmètre de cette branche |
|
||||||
|
| OAuth Google / M365 (`connector_tokens`) | **Inchangés** : jetons OAuth chiffrés par (kind, utilisateur) | Hors périmètre : le flux OAuth ne se prête pas à une référence coffre sans repenser le refresh ; à traiter séparément si besoin |
|
||||||
|
|
||||||
|
## API
|
||||||
|
|
||||||
|
* `POST /api/agent/connectors` accepte `workspace_id` et
|
||||||
|
`coffre_reference` (référence sans workspace → 400).
|
||||||
|
* `PATCH /api/agent/connectors/{id}` accepte `workspace_id`
|
||||||
|
(premier rattachement seulement) et `coffre_reference` (`""` =
|
||||||
|
effacer la référence).
|
||||||
|
* `POST /api/agent/connectors/probe` accepte `workspace_id`.
|
||||||
|
* Réponses : `workspace_id`, `coffre_reference`, `secret_source`
|
||||||
|
(`coffre` | `chiffre_local` | `aucun`) — jamais de valeur.
|
||||||
|
* `GET /api/agent/connectors` liste les connecteurs avec ces champs.
|
||||||
|
|
||||||
|
## Ce qui reste à faire (validation réelle)
|
||||||
|
|
||||||
|
* Valider contre une vraie instance ObsiGate : créer le consommateur
|
||||||
|
Flowdeck + les octrois, puis un probe réel avec `workspace_id`
|
||||||
|
(les tests ici utilisent un faux endpoint de distribution).
|
||||||
|
* Threader `workspace_id` depuis l'agent : `AgentEngine.workspace_id`
|
||||||
|
→ `connector_fetch` (outil LLM) et `McpTool.call_tool` — aujourd'hui
|
||||||
|
ces chemins appellent sans workspace et échouent proprement pour les
|
||||||
|
connecteurs à référence (refus, pas de fuite) ; les connecteurs
|
||||||
|
historiques ne sont pas affectés.
|
||||||
|
* Le cas échéant, consommateur ObsiGate par workspace (voir limite
|
||||||
|
ci-dessus) et bascule des natifs Gitea/GitHub et des clés LLM
|
||||||
|
(tableau ci-dessus).
|
||||||
|
* Interface : sélecteur de référence dans le formulaire connecteur
|
||||||
|
(aujourd'hui, champ `coffre_reference` via l'API seulement).
|
||||||
@@ -0,0 +1,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; }
|
||||||
@@ -2,7 +2,9 @@
|
|||||||
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
|
||||||
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
|
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) ;
|
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.
|
Jetons repris du prototype validé Console v7.2.
|
||||||
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
|
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. */
|
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);
|
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
|
||||||
}
|
}
|
||||||
.draco-apps-menu[hidden] { display: none; }
|
.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 {
|
.draco-apps-item {
|
||||||
display: flex; align-items: center; gap: 10px;
|
display: flex; align-items: center; gap: 10px;
|
||||||
padding: 8px 10px; border-radius: 10px;
|
padding: 8px 10px; border-radius: 10px;
|
||||||
@@ -122,8 +147,10 @@
|
|||||||
}
|
}
|
||||||
.draco-icon-btn:hover { background: var(--surface-2); }
|
.draco-icon-btn:hover { background: var(--surface-2); }
|
||||||
.draco-avatar {
|
.draco-avatar {
|
||||||
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
|
width: 34px; height: 34px; border: 0; padding: 0; border-radius: 50%;
|
||||||
background: #2f6fed; color: #fff; font-weight: 700;
|
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) */
|
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
|
||||||
|
|||||||
@@ -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);
|
||||||
Vendored
+403
-40
@@ -1,5 +1,10 @@
|
|||||||
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2 ;
|
/* 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
|
* 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
|
* (patron validé par le prototype — les onclick du HTML généré sont
|
||||||
@@ -10,19 +15,158 @@
|
|||||||
* current: "tiktak", // app courante
|
* current: "tiktak", // app courante
|
||||||
* domain: "pro", // domaine actif
|
* domain: "pro", // domaine actif
|
||||||
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
|
* 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
|
* onAction: function (act, el) { … } // l'app relaie les actions
|
||||||
* } // qu'elle ne connaît pas
|
* } // 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),
|
* Actions émises (data-act) : rail, palette, domaine (data-dom),
|
||||||
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
|
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
|
||||||
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
|
* (ouvrir la palette, le panneau Spark, changer de domaine…).
|
||||||
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
|
* Actions INTERNES (jamais relayées comme avant) : apps — la marque ✦
|
||||||
* des applications, construit depuis les onglets ; c'est le moyen de
|
* ouvre le menu des applications, construit depuis les onglets ;
|
||||||
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
|
* 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 () {
|
(function () {
|
||||||
"use strict";
|
"use strict";
|
||||||
var cfg = window.DRACO_BAR_CONFIG || {};
|
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) {
|
function setCounts(counts) {
|
||||||
Object.keys(counts || {}).forEach(function (k) {
|
Object.keys(counts || {}).forEach(function (k) {
|
||||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||||
@@ -43,26 +187,122 @@
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── Menu des applications (v0.1.3) ──────────────────────────────
|
/* ── Identité / avatar (v0.1.6) ───────────────────────────────────
|
||||||
* Construit depuis les onglets présents dans la barre : mêmes
|
* Nom : cfg.user (chaîne, ou objet display_name / name / username),
|
||||||
* liens, mêmes icônes, mêmes badges. Ouvert par la marque ✦
|
* sinon le title de l'avatar quand le jeton {{UTILISATEUR}} a bien été
|
||||||
* (data-act="apps"), fermé par un clic extérieur, Échap, ou le
|
* remplacé côté serveur. L'initiale en découle ; sans nom, le neutre
|
||||||
* choix d'une application (la navigation suit son cours). */
|
* ✦ 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 bar = document.querySelector(".draco-bar");
|
||||||
var brand = bar ? bar.querySelector(".draco-brand") : null;
|
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() {
|
function setExpanded(btn, open) {
|
||||||
if (!bar || !brand || menu) return;
|
if (btn) btn.setAttribute("aria-expanded", open ? "true" : "false");
|
||||||
menu = document.createElement("div");
|
}
|
||||||
menu.className = "draco-apps-menu";
|
function refreshMenuHrefs(menu) {
|
||||||
menu.hidden = true;
|
if (!menu) return;
|
||||||
menu.setAttribute("role", "menu");
|
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
|
||||||
menu.setAttribute("aria-label", "Applications");
|
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) {
|
document.querySelectorAll(".draco-apptab").forEach(function (t) {
|
||||||
var a = document.createElement("a");
|
var a = document.createElement("a");
|
||||||
a.className = "draco-apps-item";
|
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.dataset.app = t.dataset.app;
|
||||||
a.setAttribute("role", "menuitem");
|
a.setAttribute("role", "menuitem");
|
||||||
var ico = t.querySelector(".draco-apptab-ico");
|
var ico = t.querySelector(".draco-apptab-ico");
|
||||||
@@ -84,44 +324,167 @@
|
|||||||
a.appendChild(txt);
|
a.appendChild(txt);
|
||||||
var badge = t.querySelector(".draco-badge");
|
var badge = t.querySelector(".draco-badge");
|
||||||
if (badge) a.appendChild(badge.cloneNode(true));
|
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) {
|
/* Menu « ••• » par défaut (v0.1.6) — console agent + applications.
|
||||||
buildMenu();
|
* Construit depuis les URL résolues (donc dérivées -dev sur un hôte
|
||||||
if (!menu) return;
|
* -dev) ; l'hôte qui gère "menu" via onAction garde la main (voir
|
||||||
var open = typeof force === "boolean" ? force : menu.hidden;
|
* l'en-tête) et ne voit jamais ce menu, sauf s'il retourne false. */
|
||||||
menu.hidden = !open;
|
function buildMoreMenu() {
|
||||||
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
|
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) {
|
document.addEventListener("click", function (ev) {
|
||||||
var el = ev.target.closest("[data-act]");
|
/* Items du menu compte : relais + fermeture, jamais de navigation. */
|
||||||
if (el && el.dataset.act === "apps") {
|
var compteItem = ev.target.closest ? ev.target.closest("[data-compte-act]") : null;
|
||||||
ev.preventDefault();
|
if (compteItem) {
|
||||||
toggleMenu();
|
relay(compteItem.getAttribute("data-compte-act"), compteItem, ev);
|
||||||
|
toggleCompteMenu(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
|
var el = ev.target.closest ? ev.target.closest("[data-act]") : null;
|
||||||
toggleMenu(false);
|
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;
|
if (!el) return;
|
||||||
var act = el.dataset.act;
|
var act = el.dataset.act;
|
||||||
if (act === "domaine") setDomain(el.dataset.dom);
|
if (act === "domaine") setDomain(el.dataset.dom);
|
||||||
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
|
relay(act, el, ev);
|
||||||
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.addEventListener("keydown", function (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. */
|
/* API publique : l'app met les compteurs à jour sans recharger.
|
||||||
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
|
* 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.current) setCurrent(cfg.current);
|
||||||
if (cfg.domain) setDomain(cfg.domain);
|
if (cfg.domain) setDomain(cfg.domain);
|
||||||
if (cfg.counts) setCounts(cfg.counts);
|
if (cfg.counts) setCounts(cfg.counts);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
@@ -0,0 +1,282 @@
|
|||||||
|
"""Phase 4 (issue ObsiGate #22) — connecteurs Flowdeck à référence ObsiGate.
|
||||||
|
|
||||||
|
Couverture : format de référence, création liée à un workspace, isolation
|
||||||
|
(A ne distribue pas avec l'octroi de B — le faux distributeur n'est même
|
||||||
|
pas appelé), distribution en mémoire sans persistance ni cache, octroi
|
||||||
|
révoqué, ObsiGate indisponible (aucun repli), connecteurs historiques
|
||||||
|
chiffrés toujours valides, routes API.
|
||||||
|
|
||||||
|
Le distributeur est un FAUX endpoint (appelable injecté), clairement
|
||||||
|
identifié comme tel : aucun test ne parle à une vraie instance ObsiGate.
|
||||||
|
Les valeurs de test sont fictives (``TEST-VALUE-XXXX``).
|
||||||
|
"""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from conftest import anon_csrf
|
||||||
|
|
||||||
|
from app.db import get_conn
|
||||||
|
from app.services import coffre_reference, connectors
|
||||||
|
from app.services.coffre_reference import (
|
||||||
|
CoffreAccesRefuse,
|
||||||
|
CoffreIndisponible,
|
||||||
|
CoffreReferenceInvalide,
|
||||||
|
CoffreWorkspaceMismatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
PUBLIC_URL = "https://example.com/api"
|
||||||
|
REFERENCE = "coffre:flowdeck/gitea-lecture"
|
||||||
|
FAKE_VALUE = "TEST-VALUE-XXXX"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def _faux_jeton_consommateur():
|
||||||
|
"""Le distributeur exige un jeton consommateur : valeur fictive de test.
|
||||||
|
|
||||||
|
Sans elle, ``distribuer`` refuserait avant même d'appeler le faux
|
||||||
|
endpoint (comportement voulu en production).
|
||||||
|
"""
|
||||||
|
import app.config
|
||||||
|
|
||||||
|
app.config.settings.obsigate_consumer_token = "TEST-CONSUMER-TOKEN-XXXX"
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
def _workspaces():
|
||||||
|
with get_conn() as conn:
|
||||||
|
conn.execute(
|
||||||
|
"INSERT OR IGNORE INTO users (id, login, full_name, is_admin) "
|
||||||
|
"VALUES (1, 'tester', 'Tester', 1)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier A', 1)"
|
||||||
|
)
|
||||||
|
conn.execute(
|
||||||
|
"INSERT INTO workspaces (name, owner_id) VALUES ('Atelier B', 1)"
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
rows = conn.execute(
|
||||||
|
"SELECT id FROM workspaces ORDER BY id"
|
||||||
|
).fetchall()
|
||||||
|
return int(rows[0]["id"]), int(rows[1]["id"])
|
||||||
|
|
||||||
|
|
||||||
|
class _FakeDistributeur:
|
||||||
|
"""Faux endpoint ObsiGate : compte les appels, ne persiste rien."""
|
||||||
|
|
||||||
|
def __init__(self, behaviour="ok"):
|
||||||
|
self.behaviour = behaviour
|
||||||
|
self.calls = []
|
||||||
|
|
||||||
|
def __call__(self, url, payload, headers):
|
||||||
|
self.calls.append((url, dict(payload)))
|
||||||
|
assert url.endswith("/api/coffres/distribuer"), url
|
||||||
|
assert headers.get("Authorization", "").startswith("Bearer "), headers
|
||||||
|
if self.behaviour == "ok":
|
||||||
|
return 200, {"reference": payload["reference"], "valeur": FAKE_VALUE,
|
||||||
|
"version": 1}
|
||||||
|
if self.behaviour == "revoque":
|
||||||
|
return 403, {"detail": "octroi_revoque"}
|
||||||
|
if self.behaviour == "indisponible":
|
||||||
|
raise ConnectionError("faux ObsiGate injoignable")
|
||||||
|
raise AssertionError(self.behaviour)
|
||||||
|
|
||||||
|
|
||||||
|
def _create_coffre(client, ws, **kw):
|
||||||
|
body = {"name": "Conn coffre", "url": PUBLIC_URL,
|
||||||
|
"workspace_id": ws, "coffre_reference": REFERENCE, **kw}
|
||||||
|
r = client.post("/api/agent/connectors", json=body)
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
return r.json()
|
||||||
|
|
||||||
|
|
||||||
|
# ── Format ─────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("bad", [
|
||||||
|
"gitea-lecture", "coffre:", "coffre:slug-seul", "coffre:AB/mauvais",
|
||||||
|
"coffre:a/b", "coffre:ok/mauvais nom", "coffre:ok/",
|
||||||
|
])
|
||||||
|
def test_reference_invalide_rejetee(bad):
|
||||||
|
with pytest.raises(CoffreReferenceInvalide):
|
||||||
|
coffre_reference.parse_reference(bad)
|
||||||
|
|
||||||
|
|
||||||
|
def test_reference_normalisee_minuscules():
|
||||||
|
assert coffre_reference.parse_reference("Coffre:FlowDeck/Gitea-Lecture") == (
|
||||||
|
"flowdeck", "gitea-lecture")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Création liée au workspace ─────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_creation_coffre_exige_workspace(client):
|
||||||
|
r = client.post("/api/agent/connectors",
|
||||||
|
json={"name": "Sans WS", "url": PUBLIC_URL,
|
||||||
|
"coffre_reference": REFERENCE})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
assert "workspace" in r.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_coffre_refuse_secret_et_reference(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
r = client.post("/api/agent/connectors",
|
||||||
|
json={"name": "Ambigu", "url": PUBLIC_URL, "secret": "x",
|
||||||
|
"workspace_id": ws_a, "coffre_reference": REFERENCE})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_coffre_ne_stocke_aucun_jeton(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
assert row["coffre_reference"] == REFERENCE
|
||||||
|
assert row["workspace_id"] == ws_a
|
||||||
|
assert row["secret_source"] == "coffre"
|
||||||
|
assert row["has_secret"] is True
|
||||||
|
assert FAKE_VALUE not in str(row)
|
||||||
|
with get_conn() as conn:
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT secret_encrypted, coffre_reference FROM agent_connectors "
|
||||||
|
"WHERE id=?", (row["id"],)
|
||||||
|
).fetchone()
|
||||||
|
assert stored["secret_encrypted"] == "" # aucun jeton stocké
|
||||||
|
assert stored["coffre_reference"] == REFERENCE # métadonnée seulement
|
||||||
|
|
||||||
|
|
||||||
|
def test_creation_workspace_introuvable(client):
|
||||||
|
r = client.post("/api/agent/connectors",
|
||||||
|
json={"name": "WS ?", "url": PUBLIC_URL,
|
||||||
|
"workspace_id": 999999, "coffre_reference": REFERENCE})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
|
||||||
|
|
||||||
|
# ── Résolution + isolation (critère central) ───────────────────────────────
|
||||||
|
|
||||||
|
def test_resolve_bon_workspace_distribue_en_memoire(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
fake = _FakeDistributeur()
|
||||||
|
valeur = connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||||
|
distribute=fake)
|
||||||
|
assert valeur == FAKE_VALUE
|
||||||
|
assert len(fake.calls) == 1
|
||||||
|
_, payload = fake.calls[0]
|
||||||
|
assert payload["reference"] == REFERENCE
|
||||||
|
assert payload["contexte_type"] == "flowdeck_workspace"
|
||||||
|
assert payload["contexte_id"] == str(ws_a)
|
||||||
|
# Rien n'a été persisté ni mis en cache par la distribution.
|
||||||
|
with get_conn() as conn:
|
||||||
|
stored = conn.execute(
|
||||||
|
"SELECT secret_encrypted FROM agent_connectors WHERE id=?",
|
||||||
|
(row["id"],)
|
||||||
|
).fetchone()
|
||||||
|
assert stored["secret_encrypted"] == ""
|
||||||
|
assert FAKE_VALUE not in str(connectors.get(row["id"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_isolation_workspace_a_ne_distribue_pas_octroi_de_b(client):
|
||||||
|
ws_a, ws_b = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_b) # octroi du workspace B
|
||||||
|
fake = _FakeDistributeur()
|
||||||
|
with pytest.raises(CoffreWorkspaceMismatch) as exc:
|
||||||
|
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||||
|
distribute=fake)
|
||||||
|
assert "ne peut pas utiliser" in str(exc.value)
|
||||||
|
assert fake.calls == [] # distributeur jamais appelé
|
||||||
|
|
||||||
|
|
||||||
|
def test_resolve_sans_workspace_refuse(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
fake = _FakeDistributeur()
|
||||||
|
with pytest.raises(ValueError, match="workspace_id"):
|
||||||
|
connectors.resolve_secret(row["id"], distribute=fake)
|
||||||
|
assert fake.calls == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_headers_bon_workspace_mauvais_workspace(client):
|
||||||
|
ws_a, ws_b = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
fake = _FakeDistributeur()
|
||||||
|
headers = connectors._headers(row["id"], workspace_id=ws_a,
|
||||||
|
distribute=fake)
|
||||||
|
assert headers["Authorization"] == f"Bearer {FAKE_VALUE}"
|
||||||
|
with pytest.raises(CoffreWorkspaceMismatch):
|
||||||
|
connectors._headers(row["id"], workspace_id=ws_b, distribute=fake)
|
||||||
|
|
||||||
|
|
||||||
|
# ── Échecs propres ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_octroi_revoque_message_explicite_sans_repli(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
fake = _FakeDistributeur(behaviour="revoque")
|
||||||
|
with pytest.raises(CoffreAccesRefuse) as exc:
|
||||||
|
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||||
|
distribute=fake)
|
||||||
|
assert "révoqué" in str(exc.value)
|
||||||
|
assert "ré-accorder" in str(exc.value)
|
||||||
|
assert exc.value.detail == "octroi_revoque"
|
||||||
|
|
||||||
|
|
||||||
|
def test_obsigate_indisponible_sans_valeur_en_cache(client):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
fake = _FakeDistributeur(behaviour="indisponible")
|
||||||
|
for _ in range(2): # deux appels : aucun ne doit renvoyer une valeur
|
||||||
|
with pytest.raises(CoffreIndisponible):
|
||||||
|
connectors.resolve_secret(row["id"], workspace_id=ws_a,
|
||||||
|
distribute=fake)
|
||||||
|
assert len(fake.calls) == 2 # chaque appel retente, rien en cache
|
||||||
|
|
||||||
|
|
||||||
|
# ── Migration opportuniste ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
def test_connecteur_historique_chiffre_toujours_valide(client):
|
||||||
|
body = {"name": "Conn perso", "url": PUBLIC_URL, "secret": "sk-live-abc"}
|
||||||
|
row = client.post("/api/agent/connectors", json=body).json()
|
||||||
|
assert row["secret_source"] == "chiffre_local"
|
||||||
|
assert connectors.resolve_secret(row["id"]) == "sk-live-abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_bascule_patch_vers_reference_puis_retour(client):
|
||||||
|
ws_a, ws_b = _workspaces()
|
||||||
|
row = client.post("/api/agent/connectors",
|
||||||
|
json={"name": "Migrable", "url": PUBLIC_URL,
|
||||||
|
"secret": "sk-live-abc"}).json()
|
||||||
|
cid = row["id"]
|
||||||
|
# 1. rattacher au workspace, 2. basculer vers la référence.
|
||||||
|
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||||
|
json={"workspace_id": ws_a})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||||
|
json={"coffre_reference": REFERENCE})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["secret_source"] == "coffre"
|
||||||
|
# Le workspace est immuable : pas de transfert d'octroi vers B.
|
||||||
|
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||||
|
json={"workspace_id": ws_b})
|
||||||
|
assert r.status_code == 400, r.text
|
||||||
|
# Effacer la référence réactive le jeton local dormant.
|
||||||
|
r = client.patch(f"/api/agent/connectors/{cid}",
|
||||||
|
json={"coffre_reference": ""})
|
||||||
|
assert r.status_code == 200, r.text
|
||||||
|
assert r.json()["secret_source"] == "chiffre_local"
|
||||||
|
assert connectors.resolve_secret(cid) == "sk-live-abc"
|
||||||
|
|
||||||
|
|
||||||
|
def test_probe_coffre_sans_workspace_echoue_proprement(client, monkeypatch):
|
||||||
|
ws_a, _ = _workspaces()
|
||||||
|
row = _create_coffre(client, ws_a)
|
||||||
|
|
||||||
|
async def ok_get(url, headers=None):
|
||||||
|
return 200, "{}"
|
||||||
|
|
||||||
|
monkeypatch.setattr(connectors, "_get", ok_get)
|
||||||
|
r = client.post("/api/agent/connectors/probe",
|
||||||
|
json={"connector": str(row["id"])})
|
||||||
|
assert r.status_code == 200
|
||||||
|
assert r.json()["status"] == "error" # refus, pas de fuite inter-workspace
|
||||||
|
|
||||||
|
|
||||||
|
def test_routes_exigent_session(client):
|
||||||
|
anon_csrf(client)
|
||||||
|
assert client.post("/api/agent/connectors",
|
||||||
|
json={"name": "x", "url": PUBLIC_URL}).status_code == 401
|
||||||
@@ -0,0 +1,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
|
||||||
Reference in New Issue
Block a user