Compare commits

..
9 Commits
Author SHA1 Message Date
Spark 014df322e7 feat: panneau Spark v2 — Markdown, largeur, thème, discussions #219
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 12:06:12 -04:00
bruno 39f9cc16e4 fix(deploy): retirer DIR_1 Bruno — doublon avec le vault par défaut home-user (#194), surconsommation mémoire
CI / lint (push) Canceled after 0s
CI / test (push) Canceled after 0s
CI / security (push) Canceled after 0s
CI / build (push) Canceled after 0s
CI / e2e (push) Canceled after 0s
2026-10-22 10:10:18 -04:00
Spark b4b3032456 docs: #218 livré (2.74.0) — statut Roadmap
CI / lint (push) Successful in 2m49s
CI / security (push) Failing after 1m40s
CI / test (push) Successful in 5m4s
CI / build (push) Successful in 1m29s
CI / e2e (push) Successful in 18m6s
2026-10-22 03:43:24 +00:00
Spark edef1ea708 feat: panneau Spark intégré à la barre commune (Fusion) #218 2026-10-22 03:43:17 +00:00
Spark 7523670949 fix: barre commune v0.1.4, palier mobile élargi à 900 px (suite BUG-118, #215)
CI / lint (push) Successful in 2m54s
CI / security (push) Failing after 1m34s
CI / test (push) Successful in 4m54s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m28s
2026-10-21 12:47:16 -04:00
Spark d81f328859 feat: menu des applications par la marque ✦ de la barre commune (commons v0.1.3, #215)
CI / lint (push) Successful in 2m47s
CI / security (push) Successful in 1m34s
CI / test (push) Successful in 4m55s
CI / build (push) Successful in 1m28s
CI / e2e (push) Successful in 18m20s
2026-10-21 12:27:12 -04:00
Spark 1a39d601de fix: barre d'apps mobile — CSS commune v0.1.2 et rail contextuel (BUG-118, #215)
CI / lint (push) Successful in 2m51s
CI / security (push) Successful in 1m33s
CI / test (push) Successful in 4m58s
CI / build (push) Successful in 1m27s
CI / e2e (push) Successful in 18m3s
2026-10-21 11:36:35 -04:00
Spark 68d3dd85a9 feat: validation locale du jeton draco_session — repli SSO dans get_current_user (#217)
CI / lint (push) Successful in 3m34s
CI / security (push) Successful in 2m17s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 2m15s
CI / e2e (push) Successful in 18m31s
2026-10-20 23:47:45 -04:00
Spark cb80b08f77 feat: émettrice SSO draco_session — cookie partagé .dracodev.net, renouvellement et révocation (#216)
CI / lint (push) Successful in 3m32s
CI / security (push) Successful in 2m18s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m52s
CI / e2e (push) Successful in 19m0s
2026-10-20 23:33:52 -04:00
30 changed files with 2122 additions and 43 deletions
+107 -1
View File
@@ -6,7 +6,7 @@ Format basé sur [Keep a Changelog](https://keepachangelog.com/fr/1.1.0/),
et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
> **En cours de développement** : les changements à venir sont listés dans la section
> [Unreleased](#unreleased). La dernière version livrée est **2.70.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.75.0**.
---
@@ -14,6 +14,112 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.75.0] — 2026-10-22
### Ajouté
- **Panneau Spark v2 (#219, Fusion — Lot 1)** — les assets partagés
`frontend/draco-panneau.js` / `frontend/draco-panneau.css` passent
en v2, à l'identique dans les quatre applications : réponses de
l'agent en **Markdown rendu** (HTML produit par le service
d'agents 0.10.0, sanitizé côté serveur), **largeur réglable**
persistée, **thème clair/sombre** aligné sur l'application hôte
(suivi en direct de `html[data-theme]`), et **discussions façon
Muse** — discussion principale perpétuelle et discussions
parallèles créées, archivées, supprimées depuis le tiroir du
panneau. Aucun code propre à ObsiGate n'est modifié ;
redéploiement Docker requis.
---
## [2.74.0] — 2026-10-22
### Ajouté
- **Panneau Spark intégré (#218, Fusion)** — le bouton Spark de la
barre commune ouvre le panneau de l'agent commun (conversation,
Aujourd'hui, approbations, missions longues), comme dans TikTak,
Flowdeck et Kanbante : assets partagés `frontend/draco-panneau.js`
et `frontend/draco-panneau.css` vendangés à l'identique, action
`spark` branchée sur `DRACO_PANNEAU.toggle()`. La CSP nomme
`https://agent.dracodev.net` en `connect-src` (seul hôte ajouté).
Redéploiement Docker requis.
---
## [2.73.1] — 2026-10-21
### Corrigé
- **Barre commune v0.1.4 — palier mobile élargi de 700 à 900 px**
(suite de BUG-118) : entre les deux seuils, la barre complète
ne tenait pas (fenêtres réduites, tablettes, webviews) alors
que les apps sont déjà en mise en page mobile ; le resserré
(app courante, alertes, Spark, avatar) s'applique maintenant
dès 900 px, et le décalage de `.app-container` suit le même
seuil. Le changement d'app reste assuré par le menu du ✦.
---
## [2.73.0] — 2026-10-21
### Ajouté
- **Menu des applications par la marque ✦** (barre commune
**v0.1.3**, #215) : cliquer le ✦ — un peu plus gros (24 px) —
ouvre le menu des 4 applications (liens, icônes, badges), sur
mobile comme sur bureau. C'est le changement d'app en mobile,
où les onglets sont repliés depuis la v0.1.2. Fermeture au
clic extérieur ou à Échap. Choix de Bruno (2026-10-21).
---
## [2.72.1] — 2026-10-21
### Corrigé
- **BUG-118 — barre d'apps Fusion en mobile** : la barre commune
passe en **v0.1.2** (dracodev-commons) — au palier ≤ 700 px elle
ne garde que la marque, l'onglet de l'app courante, les alertes,
Spark et l'avatar (avant : onglets et pastilles domaines entiers,
barre débordante sur téléphone). Le bouton ☰ de la barre pilote
désormais le bon bouton selon la largeur : barre latérale bureau
au-delà de 768 px, **vrai hamburger mobile** (tiroir) en dessous —
avant, il cliquait le bouton bureau et ne faisait rien sur
téléphone, donnant l'impression d'un hamburger mort à côté de
celui d'ObsiGate. Test : `tests/test_draco_barre.py`.
---
## [2.72.0] — 2026-10-20
### Ajouté
- **Fusion ACC-3.2 (#217) — validation locale du jeton
`draco_session`** : `get_current_user` accepte le cookie partagé à
défaut de jeton natif valide ; le `sub` étant l'identifiant
ObsiGate, la correspondance vers le compte local est directe
(compte actif et garde-fou « changement de mot de passe » exigés).
Fiche : `docs/features/fusion-acc32-validation-locale-217.md`.
---
## [2.71.0] — 2026-10-20
### Ajouté
- **Fusion ACC-3.1 (#216) — émettrice SSO `draco_session`** : ObsiGate
émet le cookie de session partagé de l'écosystème dracodev (JWS
HS256, claims minimaux, 12 h, domaine `.dracodev.net`) à chaque
connexion, le renouvelle de façon glissante (middleware dédié), le
révoque à la déconnexion (liste des `jti` exposée aux autres apps
sous clé machine) et offre `GET /sso/authorize` avec `return_to`
en liste blanche anti open-redirect. Inerte tant que
`DRACO_SESSION_SECRET` n'est pas configuré. Fiche :
`docs/features/fusion-acc31-sso-draco-session-216.md`.
---
## [2.70.1] — 2026-10-20
### Corrigé
+3 -3
View File
@@ -4,7 +4,7 @@
**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.
[![Version](https://img.shields.io/badge/Version-2.70.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.75.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -977,8 +977,8 @@ Ce projet est sous licence **MIT** — voir le fichier [LICENSE](LICENSE) pour l
## 📝 Changelog
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.70.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.75.0).
---
*Projet : ObsiGate | Version : 2.70.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.75.0 | Dernière mise à jour : Septembre 2026*
+3 -3
View File
@@ -2,7 +2,7 @@
**Ultra-light web gateway for your Obsidian vaults** — Access, browse, and search all your Obsidian notes from any device via a modern, responsive web interface.
[![Version](https://img.shields.io/badge/Version-2.70.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.75.0-blue.svg)]()
[![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
[![Docker](https://img.shields.io/badge/Docker-Ready-blue.svg)](https://www.docker.com/)
[![Python](https://img.shields.io/badge/Python-3.11+-green.svg)](https://www.python.org/)
@@ -1152,8 +1152,8 @@ This project is licensed under the **MIT License** - see the [LICENSE](LICENSE)
## 📝 Changelog
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.70.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.75.0).
---
*Project: ObsiGate | Version: 2.70.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.75.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.70.1
2.75.0
+20 -6
View File
@@ -9,6 +9,7 @@ import sys
from fastapi import Depends, HTTPException, Request
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
from backend import draco_session
from backend.services.net import get_client_ip
from .jwt_handler import decode_token, is_token_revoked, maybe_touch_api_token
@@ -86,20 +87,22 @@ def get_current_user(
token = request.cookies["access_token"]
if not token:
return None
return _draco_fallback(request)
payload = decode_token(token)
if not payload or payload.get("type") != "access":
return None
return _draco_fallback(request)
# BUG-027: access tokens revoked at logout must be rejected immediately.
# (Le repli draco reste sûr : la déconnexion globale révoque aussi
# le jti draco et efface son cookie.)
jti = payload.get("jti")
if jti and is_token_revoked(jti):
return None
return _draco_fallback(request)
user = get_user(payload["sub"])
if not user or not user.get("active"):
return None
return _draco_fallback(request)
# BUG-028: a password change invalidates every token issued before it.
pca = user.get("password_changed_at")
@@ -107,9 +110,9 @@ def get_current_user(
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
return _draco_fallback(request)
except (TypeError, ValueError):
return None
return _draco_fallback(request)
# Attach vault permissions from the token (snapshot at login time)
user["_token_vaults"] = payload.get("vaults", [])
@@ -127,6 +130,17 @@ def get_current_user(
return user
def _draco_fallback(request: Request) -> dict | None:
"""Fusion ACC-3.2 (#9) : à défaut de jeton natif valide, le cookie
partagé draco_session authentifie l'utilisateur (sub = id
ObsiGate, correspondance directe — voir draco_session.user_for_draco).
"""
user = draco_session.user_for_draco(request)
if user is not None:
user["_request_ip"] = get_client_ip(request)
return user
def require_auth(current_user=Depends(get_current_user)):
"""Dependency: require a valid authenticated user."""
if not current_user:
+19
View File
@@ -11,6 +11,7 @@ import re
from fastapi import APIRouter, Body, Depends, HTTPException, Request, Response
from pydantic import BaseModel, validator
from backend import draco_session
from backend.ratelimit import is_account_rate_limited, is_rate_limited
from backend.ratelimit import record_account_failure as rl_record_account_failure
from backend.ratelimit import record_account_success as rl_record_account_success
@@ -280,6 +281,11 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
secure=secure,
path="/",
)
# Fusion ACC-3.1 (#216) : ObsiGate est l'émettrice SSO — chaque
# émission de jetons natifs s'accompagne du cookie partagé
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas
# configuré).
draco_session.set_cookie(response, user, secure)
return {
"access_token": access_token,
# OAuth2 token_type, pas un mot de passe (B105) :
@@ -396,6 +402,19 @@ async def logout(
except Exception:
pass
# Fusion ACC-3.1 (#216) : déconnexion globale — révoquer le jti du
# cookie partagé et effacer le cookie sur le domaine parent.
draco_tok = request.cookies.get(draco_session.COOKIE_NAME)
if draco_tok:
draco_claims = draco_session.decode_claims(draco_tok)
if draco_claims and draco_claims.get("jti"):
try:
draco_session.revoke(str(draco_claims["jti"]),
int(draco_claims.get("exp", 0)))
except Exception:
pass
draco_session.clear_cookie(response)
response.delete_cookie("refresh_token", path="/api/auth/refresh")
response.delete_cookie("access_token", path="/")
response.delete_cookie("access_token", path="/api") # just in case
+307
View File
@@ -0,0 +1,307 @@
# backend/draco_session.py
# Fusion ACC-3.1 (#216) — Émettrice SSO « draco_session » (option 2a, C2).
#
# ObsiGate est l'unique émettrice du cookie de session partagé
# ``draco_session`` (Domain=.dracodev.net) que les autres applications
# de l'écosystème (Flowdeck, TikTak, Kanbante) valideront localement
# (ACC-3.2, leurs propres tickets). Design : architecture Fusion §5.2 —
# JWS compact HS256, claims minimaux (sub, name, iat, exp 12 h, jti),
# aucun rôle dans le jeton, renouvellement glissant par l'émettrice,
# révocation par jti exposée aux autres apps.
#
# Configuration (environnement) :
# DRACO_SESSION_SECRET secret de signature partagé (obligatoire
# pour activer la fonction ; sans lui,
# aucun cookie n'est émis et rien ne change)
# DRACO_SESSION_SECRET_PREV ancien secret accepté en vérification
# pendant la fenêtre de rotation (24 h)
# DRACO_SESSION_COOKIE_DOMAIN domaine du cookie (défaut .dracodev.net ;
# vide = cookie lié à l'hôte, utile en dev)
# DRACO_SSO_MACHINE_KEY clé machine protégeant la lecture de la
# liste de révocation (GET /api/sso/revocations)
from __future__ import annotations
import json
import logging
import os
import threading
import time
import uuid
from pathlib import Path
from urllib.parse import urlparse
from jose import JWTError, jwt
logger = logging.getLogger("obsigate.draco_session")
COOKIE_NAME = "draco_session"
ALGORITHM = "HS256"
TOKEN_TTL_SECONDS = 12 * 3600 # durée de vie du jeton (design §5.2)
RENEWAL_WINDOW_SECONDS = 2 * 3600 # réémettre si exp - now < 2 h
REVOKED_FILE = Path("data/draco_revoked.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
def _secret() -> str:
return os.environ.get("DRACO_SESSION_SECRET", "")
def _secret_prev() -> str:
return os.environ.get("DRACO_SESSION_SECRET_PREV", "")
def _cookie_domain() -> str:
return os.environ.get("DRACO_SESSION_COOKIE_DOMAIN", ".dracodev.net")
def enabled() -> bool:
"""La fonction est active seulement si le secret partagé est configuré."""
return bool(_secret())
# ── Révocation (jti) ────────────────────────────────────────────────────
def _load_revoked() -> dict[str, int]:
global _revoked
if _revoked is None:
try:
data = json.loads(REVOKED_FILE.read_text(encoding="utf-8"))
_revoked = {str(k): int(v) for k, v in data.items()} \
if isinstance(data, dict) else {}
except (OSError, ValueError):
_revoked = {}
return _revoked
def _save_revoked() -> None:
REVOKED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = REVOKED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_revoked or {}), encoding="utf-8")
tmp.replace(REVOKED_FILE)
def revoke(jti: str, exp: int) -> None:
"""Révoque un jeton par son jti (déconnexion globale)."""
with _lock:
store = _load_revoked()
store[jti] = int(exp)
now = int(time.time())
for k in [k for k, v in store.items() if v <= now]:
del store[k] # purge des expirés
_save_revoked()
def is_revoked(jti: str) -> bool:
with _lock:
return jti in _load_revoked()
def revoked_list() -> list[dict]:
"""Liste des révocations actives (pour les autres apps, cache 60 s)."""
with _lock:
now = int(time.time())
return [{"jti": jti, "exp": exp}
for jti, exp in _load_revoked().items() if exp > now]
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
"""Émet un JWS pour l'utilisateur. Retourne (jeton, jti, exp)."""
now = int(time.time())
exp = now + TOKEN_TTL_SECONDS
jti = uuid.uuid4().hex
claims = {
"sub": str(user.get("id") or user.get("username") or ""),
"name": str(user.get("display_name") or user.get("username") or ""),
"iat": now,
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
def decode_claims(token: str) -> dict | None:
"""Vérifie signature et expiration (pas la révocation)."""
if not token or not enabled():
return None
for secret in (_secret(), _secret_prev()):
if not secret:
continue
try:
return jwt.decode(token, secret, algorithms=[ALGORITHM])
except JWTError:
continue
return None
def verify_token(token: str) -> dict | None:
"""decode_claims + contrôle de révocation du jti."""
claims = decode_claims(token)
if claims is None:
return None
if claims.get("jti") and is_revoked(str(claims["jti"])):
return None
return claims
def needs_renewal(claims: dict) -> bool:
"""Vrai si le jeton entre dans la fenêtre de renouvellement (< 2 h)."""
try:
return int(claims["exp"]) - int(time.time()) < RENEWAL_WINDOW_SECONDS
except (KeyError, TypeError, ValueError):
return True
def set_cookie(response, user: dict, secure: bool) -> None:
"""Pose le cookie draco_session sur une réponse (émission initiale)."""
if not enabled():
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.set_cookie(COOKIE_NAME, token, max_age=TOKEN_TTL_SECONDS,
httponly=True, samesite="lax", secure=secure,
path="/", **extra)
def clear_cookie(response) -> None:
domain = _cookie_domain()
extra = {"domain": domain} if domain else {}
response.delete_cookie(COOKIE_NAME, path="/", **extra)
# ── Validation locale (ACC-3.2, #9) ─────────────────────────────────────
def user_for_draco(request) -> dict | None:
"""Utilisateur ObsiGate prouvé par le cookie draco_session.
ObsiGate est à la fois émettrice et consommatrice : le `sub` du
jeton EST son identifiant d'utilisateur — la correspondance est
directe, par id. Le garde-fou « jeton antérieur au dernier
changement de mot de passe » (BUG-028 côté natif) est appliqué
à l'identique via le claim `iat`.
"""
claims = verify_token(request.cookies.get(COOKIE_NAME, ""))
if not claims:
return None
sub = str(claims.get("sub", ""))
if not sub:
return None
from backend.auth.user_store import get_all_users
for candidate in get_all_users():
if str(candidate.get("id")) != sub:
continue
if not candidate.get("active"):
return None
pca = candidate.get("password_changed_at")
iat = claims.get("iat")
if pca is not None and iat is not None:
try:
if int(iat) < int(float(pca)):
return None
except (TypeError, ValueError):
return None
user = dict(candidate)
user["_token_vaults"] = candidate.get("vaults", [])
user["_token_scope"] = "full"
user["_draco_sub"] = sub
return user
return None
# ── Renouvellement glissant (middleware) ────────────────────────────────
class DracoRenewalMiddleware:
"""Réémet le cookie quand l'utilisateur est actif chez l'émettrice.
Middleware ASGI pur (pas de BaseHTTPMiddleware : la pile d'ObsiGate
streame des réponses SSE/WS que BaseHTTPMiddleware mettrait en
tampon). On enveloppe l'envoi de la réponse : si la requête porte
un cookie d'accès natif valide et que le cookie draco est absent,
invalide ou dans sa fenêtre de renouvellement (< 2 h), on ajoute
un Set-Cookie frais aux en-têtes de la réponse. La révocation
ciblée d'un jti draco est ainsi « guérie » par la prochaine
activité authentifiée locale — la déconnexion globale, elle,
révoque aussi les jetons natifs, donc plus rien n'est réémis.
"""
def __init__(self, app):
self.app = app
async def __call__(self, scope, receive, send):
if scope.get("type") != "http" or not enabled():
await self.app(scope, receive, send)
return
cookie_header = ""
for name, value in scope.get("headers") or []:
if name == b"cookie":
cookie_header = value.decode("latin-1")
break
cookies = {}
for part in cookie_header.split(";"):
if "=" in part:
k, v = part.strip().split("=", 1)
cookies[k] = v
user = self._user_from_native(cookies.get("access_token", ""))
if user is None:
await self.app(scope, receive, send)
return
claims = verify_token(cookies.get(COOKIE_NAME, "")) \
if cookies.get(COOKIE_NAME) else None
if claims is not None and not needs_renewal(claims):
await self.app(scope, receive, send)
return
token, _jti, _exp = issue_token(user)
domain = _cookie_domain()
cookie = (f"{COOKIE_NAME}={token}; Max-Age={TOKEN_TTL_SECONDS}; "
f"Path=/; HttpOnly; SameSite=lax")
if domain:
cookie += f"; Domain={domain}"
if scope.get("scheme") == "https":
cookie += "; Secure"
async def send_with_cookie(message):
if message.get("type") == "http.response.start":
headers = list(message.get("headers") or [])
headers.append((b"set-cookie", cookie.encode("latin-1")))
message = {**message, "headers": headers}
await send(message)
await self.app(scope, receive, send_with_cookie)
@staticmethod
def _user_from_native(access_token: str):
"""Utilisateur local prouvé par le cookie d'accès natif, ou None."""
if not access_token:
return None
try:
from backend.auth.jwt_handler import decode_token
from backend.auth.user_store import get_user
payload = decode_token(access_token)
if not payload or payload.get("type") != "access":
return None
return get_user(str(payload.get("sub", "")))
except Exception: # le renouvellement ne casse jamais une requête
return None
# ── return_to (anti open-redirect) ──────────────────────────────────────
def validate_return_to(url: str) -> str | None:
"""Accepte uniquement https vers dracodev.net ou un sous-domaine."""
if not url:
return None
parsed = urlparse(url)
host = (parsed.hostname or "").lower()
if parsed.scheme != "https" or not host:
return None
if host == ALLOWED_RETURN_SUFFIX or host.endswith("." + ALLOWED_RETURN_SUFFIX):
return url
return None
+9 -1
View File
@@ -191,7 +191,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
f"script-src 'self' 'nonce-{nonce}' blob: https://cdnjs.cloudflare.com https://unpkg.com https://esm.sh https://cdn.jsdelivr.net https://static.cloudflareinsights.com; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://fonts.googleapis.com https://cdn.jsdelivr.net https://esm.sh; "
"img-src 'self' data: blob:; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net; "
"connect-src 'self' blob: https://esm.sh https://unpkg.com https://cdnjs.cloudflare.com https://fonts.googleapis.com https://fonts.gstatic.com https://cdn.jsdelivr.net https://agent.dracodev.net; "
"font-src 'self' data: https://fonts.gstatic.com https://esm.sh; "
"worker-src 'self' blob:; "
"frame-src 'self' blob:; "
@@ -493,6 +493,12 @@ from backend.auth.scope import TokenScopeMiddleware
app.add_middleware(TokenScopeMiddleware)
# Fusion ACC-3.1 (#216) : renouvellement glissant du cookie SSO
# draco_session (inerte tant que DRACO_SESSION_SECRET n'est pas défini).
from backend.draco_session import DracoRenewalMiddleware
app.add_middleware(DracoRenewalMiddleware)
# Explicit same-origin CORS policy (#87 T8 — finit BUG-034).
# `allow_origins=[]` : le navigateur n'émet aucun `Access-Control-Allow-*`,
# donc toute lecture cross-origin est refusée (défense explicite, plus
@@ -534,6 +540,7 @@ from backend.routers.realtime import router as realtime_router
from backend.routers.scheduler import router as scheduler_router
from backend.routers.search import router as search_router
from backend.routers.sharing import router as sharing_router
from backend.routers.sso import router as sso_router
from backend.routers.vaults import router as vaults_router
from backend.routers.webhooks import router as webhooks_router
from backend.skills_routes import router as skills_router
@@ -545,6 +552,7 @@ app.include_router(skills_router)
app.include_router(health_router) # ROADMAP #85 T1 — System / health
app.include_router(history_router) # ROADMAP #85 T8 — History
app.include_router(external_refs_router) # Fusion #215 — Références croisées (ACC-1.3)
app.include_router(sso_router) # Fusion #216 — Émettrice SSO draco_session (ACC-3.1)
app.include_router(realtime_router) # ROADMAP #85 T9 — SSE + collab WS
app.include_router(search_router) # ROADMAP #85 T5 — Search
app.include_router(backups_router) # ROADMAP #85 T4 — Backups
+2
View File
@@ -29,6 +29,7 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Search", "description": "Full-text and advanced search, tags, title suggestions and the link graph."},
{"name": "Bookmarks", "description": "Recently opened files, bookmarks and saved searches."},
{"name": "ExternalRefs", "description": "Cross-application references from a note to Flowdeck, TikTak, Kanbante or ObsiGate objects (Fusion program, ACC-1.3)."},
{"name": "SSO", "description": "draco_session single sign-on emitter: authorize entry point and jti revocation list for the other dracodev apps (Fusion program, ACC-3.1)."},
{"name": "Backups", "description": "Automatic file backups, diffs, restore, compression and purge."},
{"name": "Export", "description": "Export notes or whole vaults to HTML, Markdown bundle or ePub."},
{"name": "Guide", "description": "Download the in-app user guide as Markdown or PDF (mirrors the help modal, FR/EN)."},
@@ -116,6 +117,7 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/recent"), "Bookmarks"),
(re.compile(r"^/api/bookmarks"), "Bookmarks"),
(re.compile(r"^/api/refs"), "ExternalRefs"),
(re.compile(r"^/api/sso"), "SSO"),
(re.compile(r"^/api/saved-searches"), "Bookmarks"),
(re.compile(r"^/api/vaults"), "Vaults"),
(re.compile(r"^/api/vault/"), "Vaults"),
+44
View File
@@ -0,0 +1,44 @@
# backend/routers/sso.py
# Fusion ACC-3.1 (#216) — Points d'entrée SSO de l'émettrice.
#
# GET /sso/authorize?return_to=<url> : point d'entrée des autres
# applications — valide le return_to (liste blanche *.dracodev.net,
# anti open-redirect) puis redirige vers la page de connexion
# d'ObsiGate en conservant le paramètre ; après connexion, le
# frontend repart vers l'application demanderesse (le cookie
# draco_session posé à la connexion couvre tout le domaine parent).
#
# GET /api/sso/revocations : liste des jti révoqués, réservée aux
# autres applications (clé machine DRACO_SSO_MACHINE_KEY, en-tête
# X-Draco-Machine-Key) qui la mettent en cache 60 s (design §5.2).
from __future__ import annotations
import os
from urllib.parse import quote
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from backend import draco_session
router = APIRouter(tags=["SSO"])
@router.get("/sso/authorize")
async def sso_authorize(return_to: str = ""):
target = draco_session.validate_return_to(return_to)
if target:
return RedirectResponse(
url=f"/?return_to={quote(target, safe='')}", status_code=302)
return RedirectResponse(url="/", status_code=302)
@router.get("/api/sso/revocations")
async def sso_revocations(request: Request):
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
if not machine_key:
raise HTTPException(404, "Non configuré")
if request.headers.get("X-Draco-Machine-Key") != machine_key:
raise HTTPException(401, "Clé machine invalide")
return {"revoked": draco_session.revoked_list()}
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.70.1"
version = "2.75.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.70.1"
version = "2.75.0"
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
authors = ["Bruno Charest"]
edition = "2021"
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
"productName": "ObsiGate",
"version": "2.70.1",
"version": "2.75.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
-2
View File
@@ -51,8 +51,6 @@ services:
- VAULT_4_PATH=/vaults/Obsidian_WORKOUT
- VAULT_5_NAME=Sessions
- VAULT_5_PATH=/vaults/SessionsManager
- DIR_1_NAME=Bruno
- DIR_1_PATH=/vaults/bruno
# #194 : dossier personnel par utilisateur = <racine>/home-<user>
- OBSIGATE_HOME_ROOT=/vaults/Home
- OBSIGATE_AUTH_ENABLED=true
+1
View File
@@ -354,6 +354,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
| 2026-10-14 | BUG-114 | Correction | `frontend/js/ui.js`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-114 — popup « currentPath is not defined » après suppression/renommage de répertoire.** Les branches répertoire de `confirmDeleteDirectory` (ligne ~1054) et du renommage inline (lignes ~967-968) référençaient `currentPath` nu au lieu de `state.currentPath` (ui.js ne déclare pas `currentPath`, contrairement à app.js) : `ReferenceError` levée dans le `try`, avalée par le `catch`, affichée en toast d'erreur alors que l'opération (DELETE/PATCH) avait réussi. Trois usages corrigés. Vérifié en Playwright sur l'instance E2E (flux réel clic droit → Supprimer) : toast de succès, 0 fantôme dans paths, 0 erreur JS ; validate-imports 42 modules / 373 exports, unit.test.mjs 13/13 | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-115 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-115 — trois ajustements de l'éditeur Forge (aperçu + menus).** (1) Cases à cocher collées au texte : le rendu serveur (mistune task_lists) ne met pas d'espace après l'`<input>` et l'`<style>` de `editor-poc.html` n'avait pas l'équivalent de la règle `.md-content .task-list-item` de `style.css` → deux règles ajoutées (margin-right 6 px, list-style none). (2) Flash de l'aperçu : `renderPreview` écrasait le contenu par « Rendering... » à chaque frappe (debounce 500 ms) — remplacé par un rafraîchissement silencieux (l'ancien rendu reste affiché, spinner seulement au premier rendu) + `_previewDirty` qui rejoue la dernière frappe tombée pendant un fetch (état fini correct, sans clignotement). (3) Menu « dimensions du tableau » coupé sur un document d'une ligne : `.inline-form`/`.lang-picker` en `position: absolute` recevaient des coordonnées **viewport** (`wr.left+pt.x`) → double décalage et clipping par `.editor-pane { overflow: hidden }` ; passés en `position: fixed` avec clamp viewport (convention du menu slash, déjà dans le fichier) ; au passage le clic sur un item qi/slash bullait jusqu'au listener document qui refermait la forme aussitôt ouverte → `stopPropagation`. Vérifié en Playwright sur l'instance E2E (serveur réel) : checkbox `marginRight: 6px`, aucun « Rendering... » observé pendant la frappe et dernier état rendu, forme table entièrement visible (`bottom 193 < vh 900`) et insertion OK. | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-14 | BUG-116 | Correction | `frontend/editor-poc.html`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-116 — la textarea Forge ne grandissait pas après une insertion programmatique.** Après un tableau de 4 lignes inséré via le menu, l'éditeur restait d'une ligne : le contenu était bien multiligne mais `autoHeight()` n'était rappelé que par l'événement `input`, que `ta.value = …` ne déclenche pas. Les handlers clavier appelaient `autoHeight(); updateLineNumbers();` à la main ; `insertAtCursor` et `wrapSelection` — les deux fonctions par lesquelles passent toutes les insertions (tableau, bloc de code, templates mermaid, checklist, mise en forme) — oubliaient les deux appels. Corrigé à la source dans les deux fonctions plutôt que chez chaque appelant. Vérifié en Playwright (instance E2E, chemin réel qi → Table, 4 lignes sur document d'une ligne) : hauteur 51 → 189 px, gutter = nb de lignes | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-21 | BUG-118 | Correction | `frontend/index.html`, `frontend/draco-barre.css`, `tests/test_draco_barre.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md` | **BUG-118 — la barre d'apps Fusion était inutilisable en mobile** (retours de Bruno sur captures téléphone). Deux causes : (1) le palier ≤ 700 px de la CSS commune gardait onglets entiers et pastilles domaines — la barre débordait ; la source dracodev-commons passe en **v0.1.2** (spec des barres, § Mobile : marque, onglet de l'app courante, alertes, Spark, avatar ; rail/recherche/domaines/menu masqués au profit des contrôles natifs). (2) Le bouton ☰ de la barre cliquait `sidebar-toggle-btn` (bureau) au lieu de `hamburger-btn` (tiroir mobile) : sur téléphone il ne faisait rien, doublé du vrai hamburger d'ObsiGate juste en dessous. Rail devenu contextuel (`matchMedia` ≤ 768 px, point de bascule d'ObsiGate). Test de non-régression `tests/test_draco_barre.py` (2 tests) | 🟢 corrigé (en attente vérif utilisateur) |
| 2026-10-15 | BUG-117 + #201 | Correction + feature | `frontend/js/dragdrop.js`, `frontend/js/ui.js`, `frontend/js/upload.js`, `frontend/locales/fr.json`, `frontend/locales/en.json`, `backend/services/mutations.py`, `backend/routers/files_write.py`, `backend/schemas.py`, `tests/test_api_main.py`, `CHANGELOG.md`, `docs/ROADMAP.md`, `docs/ISSUES_TODOLIST.md` | **BUG-117 — le drag & drop interne de la sidebar était mort en silence.** `onDrop` classait tout dépôt « externe » dès que `dataTransfer.items` était non vide — or un glisser interne dépose 4 formats string (`tree:*`) : chaque déplacement partait dans la branche upload où le filtre `kind === 'file'` ne trouvait rien — aucun appel API, aucun toast. Détection corrigée (vrais fichiers seulement, `dragState` prioritaire). Livré avec **#201 : menu contextuel Déplacer/Dupliquer** (fichiers et dossiers), modal `promptTargetFolder` réutilisé (+ `opts`, emplacement actuel pré-sélectionné), endpoint `POST /api/duplicate/{vault}` (`duplicate_path`, index incrémental, SSE/webhooks, refus inside-self), i18n FR/EN, 4 tests `TestDuplicateApi` + validation Playwright E2E complète | 🟢 corrigé/livré (en attente vérif utilisateur) |
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
+75 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.70.1 | **Dernière mise à jour :** 2026-10-20
> **Version :** 2.75.0 | **Dernière mise à jour :** 2026-10-22
> **Ce fichier ne contient que le travail à venir** (🔵 En cours + ⚪ Backlog) et un index compact
> vers les fonctionnalités livrées.
> - **Méthode de livraison à appliquer pour toute tâche : [DELIVERY_WORKFLOW.md](./DELIVERY_WORKFLOW.md)**
@@ -30,6 +30,41 @@
## 🔵 En cours
### 218. Fusion — panneau Spark intégré (agent commun)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.74.0, redéploiement Docker en attente)
- **Fiche :** [features/fusion-panneau-spark-218.md](./features/fusion-panneau-spark-218.md)
- **Description :** le bouton Spark de la barre commune (#215) ouvre le
panneau de l'agent commun (service d'agents dracodev : conversation,
Aujourd'hui, approbations, missions longues), comme dans TikTak,
Flowdeck et Kanbante. Assets partagés vendangés à l'identique ;
la CSP nomme le service en `connect-src`.
- **Sous-tâches :**
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
vendangés depuis le panneau partagé du programme
- [x] `frontend/index.html` : link CSS + script, action `spark` →
`DRACO_PANNEAU.toggle()` (repli honnête si le panneau ne charge pas)
- [x] CSP `connect-src` : `https://agent.dracodev.net` ajouté
(`backend/main.py`, seul hôte ajouté)
- [x] Tests `test_draco_barre.py` (intégration + CSP verrouillées)
### 219. Fusion — panneau Spark v2 (Lot 1)
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-22 | **Statut :** 🔵 en cours
- **Fiche :** [features/fusion-panneau-spark-v2-219.md](./features/fusion-panneau-spark-v2-219.md)
- **Description :** le panneau Spark (#218) passe en v2, à l'identique
dans les quatre applications (carte fusion-trois-apps #17) :
réponses de l'agent en **Markdown rendu** (HTML produit par le
service d'agents 0.10.0, sanitizé côté serveur), **largeur
réglable** persistée, **thème clair/sombre aligné** sur
l'application hôte, **discussions façon Muse** (principale
perpétuelle + parallèles créées/archivées/supprimées). Côté
ObsiGate : assets vendangés uniquement, aucun code propre touché.
- **Sous-tâches :**
- [x] Assets `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
remplacés par la v2 partagée du programme
- [x] Fiche de conception `features/fusion-panneau-spark-v2-219.md`
### 215. Fusion bloc 1 — barre d'apps commune et références croisées des notes
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-20 | **Statut :** ✅ livré le 2026-10-20 (2.70.0)
@@ -56,6 +91,45 @@
bandeau « Tâche TikTak projetée » + « Ouvrir dans TikTak »
- [x] i18n FR/EN (clés `draco.links.*`), tests `test_external_refs.py`
### 216. Fusion ACC-3.1 — émettrice SSO `draco_session`
- **Effort :** ~1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.71.0)
- **Fiche :** [features/fusion-acc31-sso-draco-session-216.md](./features/fusion-acc31-sso-draco-session-216.md)
- **Description :** couche 2 (identité) du programme **Fusion** — décision C2
de Bruno : cookie de session partagé sous `.dracodev.net` (architecture
v2.0 §5.2, option 2a). ObsiGate est l'unique émettrice ; les autres apps
valideront localement (ACC-3.2). Issue Gitea ObsiGate #8 ; épopée
Projets/fusion-trois-apps#7.
- **Sous-tâches :**
- [x] Module `backend/draco_session.py` : JWS HS256, claims minimaux
(sub, name, iat, exp 12 h, jti — aucun rôle), rotation par
`DRACO_SESSION_SECRET_PREV`, inerte sans `DRACO_SESSION_SECRET`
- [x] Émission du cookie à chaque `_issue_tokens` (mot de passe ou MFA)
- [x] Renouvellement glissant (middleware ASGI `DracoRenewalMiddleware`,
fenêtre 2 h, conditionné au cookie d'accès natif valide)
- [x] Déconnexion globale : révocation du jti (`data/draco_revoked.json`)
+ effacement du cookie ; `GET /api/sso/revocations` sous clé machine
- [x] `GET /sso/authorize` : `return_to` en liste blanche
`*.dracodev.net` (anti open-redirect), retour post-connexion
re-validé côté frontend (`auth.js`)
- [x] Tag OpenAPI « SSO », tests `test_draco_session.py` (22)
### 217. Fusion ACC-3.2 — validation locale du jeton `draco_session`
- **Effort :** ~0,5 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-21 | **Statut :** ✅ livré le 2026-10-21 (2.72.0)
- **Fiche :** [features/fusion-acc32-validation-locale-217.md](./features/fusion-acc32-validation-locale-217.md)
- **Description :** pendant consommateur de #216 — `get_current_user`
accepte le cookie partagé à défaut de jeton natif valide ; le `sub`
étant l'identifiant ObsiGate, la correspondance vers le compte local
est directe (compte actif exigé, garde-fou BUG-028 appliqué).
Issue Gitea ObsiGate #9 ; épopée Projets/fusion-trois-apps#7.
- **Sous-tâches :**
- [x] `draco_session.user_for_draco()` : résolution par id, `_draco_sub`
- [x] Repli `_draco_fallback()` sur toutes les sorties « non
authentifié » du chemin natif (`backend/auth/middleware.py`)
- [x] Tests `test_draco_session.py` (+4 : cookie seul, révoqué,
sub inconnu, inerte sans secret)
### 196. Partage dirigé entre utilisateurs (document → utilisateur)
- **Effort :** 0,5-1 jour | **Impact :** 🟡 | **Ouvert :** 2026-10-10 | **Statut :** ✅ livré le 2026-10-10
@@ -0,0 +1,67 @@
# #216 — Émettrice SSO `draco_session` (Fusion ACC-3.1)
Statut : ✅ livré le 2026-10-21 (2.71.0)
## Contexte
Programme Fusion (accès centralisé ObsiGate / Flowdeck / TikTak /
Kanbante), couche 2 — identité. Décision **C2** de Bruno (2026-10-20) :
cookie de session partagé `draco_session` sous `.dracodev.net`
(architecture v2.0 §5.2, option 2a). ObsiGate est l'**unique
émettrice** ; les autres applications valideront le jeton localement
(ACC-3.2, leurs tickets respectifs) et redirigeront leur connexion
vers `GET /sso/authorize`.
## Ce qui est livré
- **`backend/draco_session.py`** — émission et vérification du JWS
(HS256, jose) : claims minimaux `sub` (id utilisateur ObsiGate),
`name`, `iat`, `exp` (**12 h**), `jti` ; **aucun rôle** dans le
jeton. Rotation : `DRACO_SESSION_SECRET_PREV` accepté en
vérification pendant la fenêtre de bascule.
- **Émission à la connexion** : `_issue_tokens` (auth/router.py) pose
le cookie à chaque émission de jetons natifs (mot de passe ou MFA) —
attributs `Domain=.dracodev.net; Path=/; HttpOnly; Secure (si la
requête est en https, via `is_secure_cookies`); SameSite=Lax`.
- **Renouvellement glissant** : `DracoRenewalMiddleware` (ASGI pur,
aucun tamponnage des flux SSE/WS) — si la requête porte un cookie
d'accès natif valide et que le cookie draco est absent, invalide ou
à moins de 2 h de son expiration, un cookie frais est ajouté à la
réponse.
- **Déconnexion globale** : le logout révoque le `jti` draco
(en plus des jetons natifs) et efface le cookie du domaine parent.
Révocations persistées dans `data/draco_revoked.json`
(jti → exp, purge des expirés), exposées aux autres apps par
**`GET /api/sso/revocations`** protégé par la clé machine
`DRACO_SSO_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, 404 si la
clé n'est pas configurée) — cache 60 s côté consommatrices.
- **`GET /sso/authorize?return_to=`** : valide le `return_to`
(https + `dracodev.net` ou sous-domaine, liste blanche stricte
anti open-redirect) puis redirige vers la connexion ObsiGate ;
après connexion, `frontend/js/auth.js` repart vers l'application
demanderesse (re-validation du domaine côté client).
- **Inerte par défaut** : sans `DRACO_SESSION_SECRET`, aucun cookie
n'est émis et le comportement d'ObsiGate est inchangé.
## Configuration (déploiement)
| Variable | Rôle |
|---|---|
| `DRACO_SESSION_SECRET` | Secret partagé de signature — **le même** devra être configuré dans Flowdeck et TikTak pour leurs validateurs ACC-3.2. À générer fort (ex. 64 hex) et à ne jamais committer. |
| `DRACO_SESSION_SECRET_PREV` | Ancien secret pendant une rotation (24 h). |
| `DRACO_SESSION_COOKIE_DOMAIN` | Domaine du cookie (défaut `.dracodev.net` ; vide = hôte seul, dev local). |
| `DRACO_SSO_MACHINE_KEY` | Clé machine de lecture des révocations, à partager avec les apps consommatrices. |
⚠️ **Déploiement** : la fonction ne s'active qu'au redéploiement
d'ObsiGate ≥ 2.71.0 **avec** `DRACO_SESSION_SECRET` posé dans
l'environnement du conteneur. Le SSO complet exige ensuite les
validateurs ACC-3.2 (ObsiGate #9, Flowdeck #21, TikTak #213).
## Tests
`tests/test_draco_session.py` — 22 tests : claims minimaux, durée de
vie, falsification, expiration, rotation, révocation, fenêtre de
renouvellement, liste blanche `return_to` (8 cas), cookie posé à la
connexion (attributs), inactivité sans secret, révocation au logout,
renouvellement glissant via middleware, endpoint de révocations
(404/401/200).
@@ -0,0 +1,51 @@
# #217 — Validation locale du jeton draco_session (Fusion ACC-3.2)
Statut : ✅ livré le 2026-10-21 (2.72.0)
## Contexte
Suite de l'émettrice SSO (#216, ACC-3.1). Ici, ObsiGate joue son rôle
de **consommatrice** : `get_current_user` accepte le cookie partagé
`draco_session` à défaut de jeton natif valide. C'est la brique qui
servira aussi à l'API de l'agent commun (ACC-4) pour authentifier les
requêtes SSO. Les validateurs des autres applications sont leurs
tickets respectifs (Flowdeck #21, TikTak #213 — TikTak 0.52.0).
## Correspondance sub → compte local
ObsiGate est émettrice ET consommatrice : le `sub` du jeton **est**
son identifiant d'utilisateur — la résolution est directe, par `id`,
dans `draco_session.user_for_draco()` :
- signature/expiration/révocation vérifiées par `verify_token` (#216) ;
- compte **actif** exigé ;
- garde-fou BUG-028 appliqué à l'identique : un jeton draco émis
avant le dernier changement de mot de passe (`password_changed_at`)
est refusé ;
- le dict utilisateur retourné porte `_token_vaults` (droits du
compte), `_token_scope = "full"` et `_draco_sub` (traçabilité).
## Branchement
Dans `backend/auth/middleware.py`, chaque sortie « non authentifié »
du chemin natif (aucun jeton, jeton illisible, jti natif révoqué,
compte introuvable/inactif, jeton antérieur au changement de mot de
passe) passe par `_draco_fallback()`. Sûreté du repli après
déconnexion globale : le logout révoque aussi le `jti` draco et
efface le cookie (#216), donc un ancien cookie rejoué est refusé par
`verify_token`.
## Limite connue
Le repli authentifie les **appels d'API**. Le bootstrap d'une
session complète du SPA (jetons natifs émis à partir du seul cookie
draco) n'est pas couvert : pour ObsiGate, émettrice, la connexion
native EST la connexion SSO — le cas d'usage du repli est la
continuité d'API quand le jeton natif a expiré avant le cookie
partagé, et les consommateurs machine de l'API.
## Tests
`tests/test_draco_session.py` (+4) : `/api/auth/me` avec le seul
cookie draco (200, bon utilisateur), jti révoqué refusé, sub inconnu
refusé, inactivité sans secret configuré.
+38
View File
@@ -0,0 +1,38 @@
# #218 — Fusion : panneau Spark intégré (agent commun)
**Contexte.** Le programme Fusion a livré le service d'agents commun
(agent-commun : conversation LLM, lectures/écritures des quatre
applications via approbations, sous-agents, missions longues). Trois
applications affichaient déjà son panneau partagé (TikTak, Flowdeck,
Kanbante) ; ObsiGate, pourtant émettrice de l'identité `draco_session`,
gardait le message d'attente « arrive en phase 4 » sur le bouton Spark
de sa barre commune (#215).
**Livraison.**
- Assets vendangés à l'identique des autres applications :
`frontend/draco-panneau.js` et `frontend/draco-panneau.css`, à la
racine de `frontend/` comme les autres assets draco (servis par le
montage `/static`, `Cache-Control: no-cache` déjà en place — aucun
mécanisme de versionnage à ajouter, `index.html` n'est pas un
gabarit Jinja).
- `frontend/index.html` : lien CSS après celui de la barre, script
différé après `draco-liens.js`, action `spark` de
`DRACO_BAR_CONFIG.onAction` branchée sur
`window.DRACO_PANNEAU.toggle()` avec repli explicite si l'asset ne
charge pas.
- CSP (`backend/main.py`, `SecurityHeadersMiddleware`) :
`https://agent.dracodev.net` ajouté à `connect-src` — le panneau
appelle le service en `fetch` avec le cookie de session ; aucun
autre hôte n'est ouvert (précédent : Flowdeck, même exception).
**Vérifications.** Tests `tests/test_draco_barre.py` étendus
(présence et références des assets, branche spark, hôte CSP) ;
vérification visuelle et E2E à confirmer après redéploiement Docker
(le panneau, `z-index: 400`, cohabite avec des surfaces ObsiGate à
z-index plus élevé — à observer en réel).
**Hors périmètre.** Les textes du panneau vivent dans l'asset partagé
du programme (français, identique dans les quatre applications) ; ils
ne passent pas par les locales FR/EN d'ObsiGate — écart assumé au
niveau du programme, pas de cette fiche.
@@ -0,0 +1,29 @@
# #219 — Fusion : panneau Spark v2 (Lot 1)
**Contexte.** Le panneau Spark livré en 2.74.0 (#218) affichait les
réponses de l'agent en texte brut (les `##` et `**` du Markdown
restaient visibles), avait une largeur fixe, un thème clair codé en
dur déconnecté de l'application hôte, et une seule conversation sans
gestion. Le Lot 1 du « Panneau v2 » (carte fusion-trois-apps #17,
arbitrage de Bruno du 2026-10-22) corrige ces quatre points, à
l'identique dans les quatre applications du programme.
**Livraison (côté ObsiGate : assets vendangés uniquement).**
- `frontend/draco-panneau.js` / `frontend/draco-panneau.css`
remplacés par la v2 partagée (octet pour octet avec TikTak,
Flowdeck et Kanbante) :
- **Markdown rendu** : les réponses et les rapports de missions
affichent le HTML produit par le service d'agents (0.10.0 :
mistune + sanitizer côté serveur) ;
- **largeur réglable** à la souris (poignée, persistée,
double-clic = largeur par défaut) ;
- **thème clair/sombre aligné** sur l'application hôte
(`html[data-theme]`, ici celui d'ObsiGate, suivi en direct) ;
- **discussions façon Muse** : discussion principale
perpétuelle + discussions parallèles créées, archivées et
supprimées depuis le tiroir du panneau.
- Le travail de fond est dans le service d'agents (agent-commun
0.10.0) ; ObsiGate ne change aucun code propre. Le `/static` est
déjà servi en `Cache-Control: no-cache` : aucun versionnage
d'asset à ajouter. Redéploiement Docker requis.
+48 -9
View File
@@ -1,4 +1,8 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2) ;
v0.1.2 : palier mobile resserré selon la spec (voir @media 700px) ;
v0.1.3 : la marque ✦ (plus grande) ouvre le menu des applications —
c'est LE changement d'app en mobile (choix de Bruno, 2026-10-21) ;
v0.1.4 : palier mobile élargi de 700 à 900 px (zone morte comblée).
Jetons repris du prototype validé Console v7.2.
Thème : clair par défaut, sombre via [data-theme="dark"] sur <html>
ou prefers-color-scheme si l'app intégratrice ne force rien. */
@@ -7,6 +11,7 @@
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
position: relative;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
@@ -24,9 +29,29 @@
.draco-bar-centre { justify-content: center; }
.draco-bar-droite { justify-content: flex-end; }
/* Marque */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; }
.draco-brand-mark { color: var(--agent); }
/* Marque — bouton du menu des applications (v0.1.3) */
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
.draco-brand:hover { background: var(--surface-2); }
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
/* Menu des applications — construit par le JS depuis les onglets */
.draco-apps-menu {
position: absolute; top: calc(100% + 6px); left: 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-apps-menu[hidden] { display: none; }
.draco-apps-item {
display: flex; align-items: center; gap: 10px;
padding: 8px 10px; border-radius: 10px;
color: var(--ink); text-decoration: none;
}
.draco-apps-item:hover, .draco-apps-item:focus-visible { background: var(--surface-2); }
.draco-apps-item[data-active="true"] { background: var(--surface-3); box-shadow: inset 2px 0 0 var(--agent); }
.draco-apps-item-txt { display: flex; flex-direction: column; min-width: 0; flex: 1; }
.draco-apps-item-nom { font-weight: 700; font-size: 14px; }
.draco-apps-item-role { font-size: 12px; color: var(--ink-2); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
@@ -119,9 +144,23 @@
.draco-bar { grid-template-columns: auto auto 1fr; }
.draco-bar-droite { justify-content: flex-end; }
}
@media (max-width: 700px) {
/* Mobile : la barre du haut se condense ; la navigation d'apps passe
en barre basse propre à chaque app (patron du prototype v5+). */
.draco-apptabs, .draco-domaines-lbl { display: none; }
.draco-bar { height: 52px; }
@media (max-width: 900px) {
/* Mobile (spec des barres, § Mobile) : la barre se resserre sur
l'essentiel — marque, onglet de l'app courante, alertes, Spark,
avatar. Le rail, la recherche, les domaines et le menu « ••• »
disparaissent : chaque app garde ses contrôles natifs (barre
basse, en-tête) — un seul hamburger visible, celui de l'app.
Le changement d'app passe par le menu du ✦ (v0.1.3).
(v0.1.2 — avant, les onglets entiers et les pastilles domaines
restaient affichés et la barre débordait sur téléphone.
v0.1.4 — palier élargi de 700 à 900 px : entre les deux, la
barre complète ne tenait pas (fenêtres réduites, tablettes,
webviews) alors que les apps sont déjà en mise en page mobile.) */
.draco-bar { height: 52px; grid-template-columns: auto 1fr auto; }
.draco-bar-centre, .draco-domaines, .draco-bar .draco-icon-btn { display: none; }
.draco-apptab { display: none; }
.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"],
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"] { display: flex; }
}
+79 -7
View File
@@ -1,4 +1,5 @@
/* 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 ✦).
*
* 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
@@ -14,6 +15,9 @@
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, menu, rappel-entete. L'app intégratrice décide de
* chacune (ouvrir la palette, le panneau Spark, changer de domaine…).
* Action INTERNE (jamais relayée) : apps — la marque ✦ ouvre le menu
* des applications, construit depuis les onglets ; c'est le moyen de
* changer d'app en mobile, où les onglets sont repliés (v0.1.2).
*/
(function () {
"use strict";
@@ -21,15 +25,16 @@
function setCounts(counts) {
Object.keys(counts || {}).forEach(function (k) {
var b = document.querySelector('.draco-badge[data-badge="' + k + '"]');
if (!b) return;
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
document.querySelectorAll('.draco-badge[data-badge="' + k + '"]').forEach(function (b) {
b.textContent = counts[k];
b.hidden = !(counts[k] > 0);
});
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
document.querySelectorAll(".draco-apptab, .draco-apps-item").forEach(function (t) {
if (t.dataset.app) t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
@@ -38,8 +43,70 @@
});
}
/* ── 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). */
var bar = document.querySelector(".draco-bar");
var brand = bar ? bar.querySelector(".draco-brand") : null;
var menu = null;
function buildMenu() {
if (!bar || !brand || menu) return;
menu = document.createElement("div");
menu.className = "draco-apps-menu";
menu.hidden = true;
menu.setAttribute("role", "menu");
menu.setAttribute("aria-label", "Applications");
document.querySelectorAll(".draco-apptab").forEach(function (t) {
var a = document.createElement("a");
a.className = "draco-apps-item";
a.href = t.getAttribute("href");
a.dataset.app = t.dataset.app;
a.setAttribute("role", "menuitem");
var ico = t.querySelector(".draco-apptab-ico");
if (ico) a.appendChild(ico.cloneNode(true));
var txt = document.createElement("span");
txt.className = "draco-apps-item-txt";
var fort = t.querySelector(".draco-apptab-txt strong");
var petit = t.querySelector(".draco-apptab-txt small");
var nom = document.createElement("span");
nom.className = "draco-apps-item-nom";
nom.textContent = fort ? fort.textContent : t.dataset.app;
txt.appendChild(nom);
if (petit) {
var role = document.createElement("span");
role.className = "draco-apps-item-role";
role.textContent = petit.textContent;
txt.appendChild(role);
}
a.appendChild(txt);
var badge = t.querySelector(".draco-badge");
if (badge) a.appendChild(badge.cloneNode(true));
menu.appendChild(a);
});
bar.appendChild(menu);
}
function toggleMenu(force) {
buildMenu();
if (!menu) return;
var open = typeof force === "boolean" ? force : menu.hidden;
menu.hidden = !open;
if (brand) brand.setAttribute("aria-expanded", open ? "true" : "false");
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (el && el.dataset.act === "apps") {
ev.preventDefault();
toggleMenu();
return;
}
if (menu && !menu.hidden && !ev.target.closest(".draco-apps-menu") && !ev.target.closest(".draco-brand")) {
toggleMenu(false);
}
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
@@ -47,9 +114,14 @@
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape") toggleMenu(false);
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
buildMenu();
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
+189
View File
@@ -0,0 +1,189 @@
/* Panneau Spark — service d'agents (Fusion, J1 → Panneau v2 Lot 1).
Thème clair/sombre piloté par [data-draco-theme] sur le panneau :
le JS le cale sur le thème de l'app hôte (html[data-theme], repli
prefers-color-scheme) et suit les bascules en direct. Toutes les
couleurs passent par les variables ci-dessous — en-tête et fenêtre
partagent donc toujours le même mode. */
.draco-panneau {
--p-fond: #fbfbfa; --p-surface: #ffffff; --p-bord: #e4e5e0;
--p-texte: #242624; --p-doux: #6c7268; --p-faint: #8a8f84;
--p-chip: #f4f5f1; --p-chip-bord: #d6d8d1; --p-chip-survol: #e9ebe4;
--p-agent: #eef0ea; --p-champ: #ffffff; --p-code: #e8eae3;
--p-appro-fond: #fdf6ec; --p-danger-fond: #f6e3de; --p-danger: #a03a28;
--p-accent: #7c5cd6; --p-lien: #5b3df0;
position: fixed; top: 0; right: 0; height: 100dvh; width: 388px; max-width: 96vw;
background: var(--p-fond); color: var(--p-texte); z-index: 400;
box-shadow: -12px 0 36px rgb(0 0 0 / .22);
display: flex; flex-direction: column;
transform: translateX(105%); transition: transform .28s ease;
font: 14px/1.5 system-ui, "Segoe UI", sans-serif;
}
.draco-panneau[data-draco-theme="dark"] {
--p-fond: #17181a; --p-surface: #1f2023; --p-bord: #33353a;
--p-texte: #eceeeb; --p-doux: #a6aba0; --p-faint: #83887e;
--p-chip: #26272b; --p-chip-bord: #3a3c41; --p-chip-survol: #2e3036;
--p-agent: #26272b; --p-champ: #1f2023; --p-code: #2c2e33;
--p-appro-fond: #2a2118; --p-danger-fond: #40241f; --p-danger: #e8a08d;
--p-lien: #b39df5;
}
.draco-panneau.open { transform: translateX(0); }
.draco-poignee {
position: absolute; left: 0; top: 0; bottom: 0; width: 8px;
cursor: ew-resize; z-index: 5; touch-action: none;
}
.draco-poignee:hover { background: color-mix(in srgb, var(--p-accent) 35%, transparent); }
@media (max-width: 700px) { .draco-poignee { display: none; } }
.draco-panneau-hd {
display: flex; align-items: center; gap: 9px;
padding: 13px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-panneau-hd .titre { font-weight: 700; font-size: 15px; }
.draco-panneau-hd .soustitre { font-size: 12px; color: var(--p-doux); }
.draco-panneau-hd .fermer {
margin-left: auto; border: 0; background: var(--p-agent); color: var(--p-texte);
border-radius: 8px; width: 30px; height: 30px; cursor: pointer; font-size: 14px;
}
.draco-panneau-etat { display: flex; align-items: center; gap: 7px; font-size: 12.5px; }
.draco-panneau-etat .point { width: 9px; height: 9px; border-radius: 50%; background: #b9bdb2; flex: none; }
.draco-panneau-etat.ok .point { background: #2ea36b; }
.draco-panneau-etat.err .point { background: #cf4b32; }
/* Barre des discussions (façon Muse) */
.draco-disc-bar {
display: flex; align-items: center; gap: 7px;
padding: 8px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
}
.draco-disc-bar .d-titre {
flex: 1; font-weight: 600; font-size: 13px; min-width: 0;
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.draco-disc-bar .d-btn {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 4px 10px; font: 600 12px system-ui; cursor: pointer; flex: none;
}
.draco-disc-bar .d-btn:hover { background: var(--p-chip-survol); }
.draco-discussions {
border-bottom: 1px solid var(--p-bord); background: var(--p-surface);
max-height: 46%; overflow-y: auto; padding: 8px 12px;
}
.draco-discussions .d-section {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: .5px; color: var(--p-doux); margin: 8px 2px 4px;
}
.draco-discussions .d-section:first-child { margin-top: 0; }
.draco-discussions .d-item {
border: 1px solid transparent; border-radius: 9px; padding: 6px 8px; cursor: pointer;
}
.draco-discussions .d-item:hover { background: var(--p-chip); }
.draco-discussions .d-item.actif { border-color: var(--p-accent); background: var(--p-chip); }
.draco-discussions .d-meta { font-weight: 600; font-size: 13px; }
.draco-discussions .d-sous { font-size: 12px; color: var(--p-doux); }
.draco-discussions .d-actions { display: flex; gap: 6px; margin-top: 5px; }
.draco-discussions .d-actions button {
border: 1px solid var(--p-chip-bord); background: var(--p-surface); color: var(--p-texte);
border-radius: 7px; padding: 3px 9px; font: 600 11.5px system-ui; cursor: pointer;
}
.draco-discussions .d-actions button.danger { color: var(--p-danger); border-color: var(--p-danger-fond); }
.draco-discussions .d-vide, .draco-discussions .d-chargement {
font-size: 12.5px; color: var(--p-doux); padding: 2px 8px 6px;
}
.draco-panneau-outils { display: flex; gap: 8px; padding: 9px 15px; border-bottom: 1px solid var(--p-bord); background: var(--p-surface); }
.draco-panneau-outils button {
border: 1px solid var(--p-chip-bord); background: var(--p-chip); color: var(--p-texte);
border-radius: 999px; padding: 5px 12px; font: 600 12.5px system-ui; cursor: pointer;
}
.draco-panneau-outils button:hover { background: var(--p-chip-survol); }
.draco-appro {
align-self: stretch; background: var(--p-appro-fond); border: 1.6px solid #e8a13c;
border-radius: 12px; padding: 10px 12px;
}
.draco-appro .titre { font-weight: 700; font-size: 12px; color: #9a6a1c; text-transform: uppercase; letter-spacing: .4px; }
.draco-panneau[data-draco-theme="dark"] .draco-appro .titre { color: #e8bc7a; }
.draco-appro .resume { margin: 5px 0 8px; }
.draco-appro .btns { display: flex; gap: 7px; }
.draco-appro .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-appro .btns button.go { background: #2ea36b; color: #fff; }
.draco-auj { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-auj h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 6px 0 4px; }
.draco-auj h5:first-child { margin-top: 0; }
.draco-auj ul { margin: 0; padding-left: 17px; }
.draco-auj .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-panneau-msgs { flex: 1; overflow-y: auto; padding: 14px 15px; display: flex; flex-direction: column; gap: 9px; }
.draco-msg { max-width: 88%; padding: 9px 12px; border-radius: 13px; white-space: pre-wrap; word-wrap: break-word; }
.draco-msg.user { align-self: flex-end; background: #2f6fed; color: #fff; border-bottom-right-radius: 4px; }
.draco-msg.agent { align-self: flex-start; background: var(--p-agent); border-bottom-left-radius: 4px; }
.draco-msg.sys { align-self: center; background: none; color: var(--p-doux); font-size: 12.5px; text-align: center; max-width: 96%; }
.draco-panneau-ft { border-top: 1px solid var(--p-bord); padding: 11px 12px; background: var(--p-surface); }
.draco-panneau-form { display: flex; gap: 8px; }
.draco-panneau-form input {
flex: 1; border: 1px solid var(--p-chip-bord); border-radius: 10px; padding: 9px 11px;
font: inherit; background: var(--p-champ); color: var(--p-texte);
}
.draco-panneau-form button {
border: 0; border-radius: 10px; background: var(--p-accent); color: #fff;
padding: 9px 14px; font: inherit; font-weight: 600; cursor: pointer;
}
.draco-panneau-form button:disabled { opacity: .5; cursor: default; }
.draco-panneau-note { font-size: 11.5px; color: var(--p-faint); margin-top: 8px; text-align: center; }
.draco-missions { align-self: stretch; background: var(--p-surface); border: 1px solid var(--p-bord); border-radius: 12px; padding: 10px 12px; }
.draco-missions h5 { font-size: 12px; text-transform: uppercase; letter-spacing: .4px; color: var(--p-doux); margin: 0 0 6px; }
.draco-missions label { display: block; font-size: 12px; font-weight: 600; color: var(--p-doux); margin: 8px 0 3px; }
.draco-missions select, .draco-missions input, .draco-missions textarea {
width: 100%; box-sizing: border-box; border: 1px solid var(--p-chip-bord); border-radius: 8px;
padding: 7px 9px; font: inherit; font-size: 13px; background: var(--p-champ); color: var(--p-texte);
}
.draco-missions .btns { display: flex; gap: 7px; margin-top: 10px; }
.draco-missions .btns button { border: 0; border-radius: 8px; padding: 6px 11px; font: 600 12.5px system-ui; cursor: pointer; background: var(--p-agent); color: var(--p-texte); }
.draco-missions .btns button.go { background: var(--p-accent); color: #fff; }
.draco-missions .m-liste { margin-top: 12px; display: flex; flex-direction: column; gap: 8px; }
.draco-missions .etat-source { color: var(--p-doux); font-size: 12.5px; }
.draco-missions .m-item { border: 1px solid var(--p-bord); border-radius: 10px; padding: 8px 10px; }
.draco-missions .m-entete { display: flex; justify-content: space-between; gap: 8px; align-items: baseline; }
.draco-missions .m-statut { font-size: 11.5px; font-weight: 700; color: var(--p-doux); white-space: nowrap; }
.draco-missions .m-statut.st-succeeded { color: #2ea36b; }
.draco-missions .m-statut.st-failed { color: #cf4b32; }
.draco-missions .m-statut.st-running, .draco-missions .m-statut.st-paused_question { color: #9a6a1c; }
.draco-missions .m-meta { font-size: 12px; color: var(--p-doux); }
.draco-missions .m-rapport { font-size: 12.5px; margin-top: 5px; white-space: pre-wrap; word-wrap: break-word; }
.draco-missions .m-annuler { margin-top: 6px; border: 0; border-radius: 7px; padding: 4px 9px; font: 600 12px system-ui; cursor: pointer; background: var(--p-danger-fond); color: var(--p-danger); }
/* ── Rendu Markdown (HTML produit par le service) ──────────── */
.draco-msg.md, .draco-missions .m-rapport.md { white-space: normal; }
.draco-msg.md > *:first-child, .m-rapport.md > *:first-child { margin-top: 0; }
.draco-msg.md > *:last-child, .m-rapport.md > *:last-child { margin-bottom: 0; }
.draco-msg.md p, .m-rapport.md p { margin: 6px 0; }
.draco-msg.md h1, .draco-msg.md h2, .draco-msg.md h3,
.draco-msg.md h4, .draco-msg.md h5, .draco-msg.md h6 { margin: 10px 0 4px; line-height: 1.3; }
.draco-msg.md h1 { font-size: 16.5px; } .draco-msg.md h2 { font-size: 15.5px; }
.draco-msg.md h3 { font-size: 14.5px; } .draco-msg.md h4,
.draco-msg.md h5, .draco-msg.md h6 { font-size: 13.5px; }
.draco-msg.md ul, .draco-msg.md ol, .m-rapport.md ul, .m-rapport.md ol { margin: 6px 0; padding-left: 20px; }
.draco-msg.md li, .m-rapport.md li { margin: 2px 0; }
.draco-msg.md code, .m-rapport.md code {
background: var(--p-code); border-radius: 5px; padding: 1px 5px;
font: 12.5px ui-monospace, "Cascadia Mono", Consolas, monospace;
}
.draco-msg.md pre, .m-rapport.md pre {
background: var(--p-code); border-radius: 9px; padding: 9px 11px;
overflow-x: auto; margin: 7px 0;
}
.draco-msg.md pre code, .m-rapport.md pre code { background: none; padding: 0; }
.draco-msg.md blockquote, .m-rapport.md blockquote {
border-left: 3px solid var(--p-accent); margin: 7px 0; padding: 2px 0 2px 11px;
color: var(--p-doux);
}
.draco-msg.md blockquote.callout, .m-rapport.md blockquote.callout {
border-radius: 0 8px 8px 0; padding: 6px 11px; color: var(--p-texte);
background: var(--p-chip);
}
.draco-msg.md blockquote.callout-warning { border-left-color: #e8a13c; }
.draco-msg.md blockquote.callout-danger { border-left-color: #cf4b32; }
.draco-msg.md blockquote.callout-tip { border-left-color: #2ea36b; }
.draco-msg.md a, .m-rapport.md a { color: var(--p-lien); }
.draco-msg.md table, .m-rapport.md table {
border-collapse: collapse; margin: 7px 0; font-size: 12.5px;
display: block; overflow-x: auto; max-width: 100%;
}
.draco-msg.md th, .draco-msg.md td, .m-rapport.md th, .m-rapport.md td {
border: 1px solid var(--p-bord); padding: 4px 8px; text-align: left;
}
.draco-msg.md hr, .m-rapport.md hr { border: 0; border-top: 1px solid var(--p-bord); margin: 9px 0; }
.draco-msg.md input[type="checkbox"], .m-rapport.md input[type="checkbox"] { margin-right: 5px; }
+679
View File
@@ -0,0 +1,679 @@
/* Panneau Spark — branchement au service d'agents (Fusion, J1 → Panneau v2).
*
* Parle à l'API du service d'agents (contrat de dracodev-commons) :
* GET /api/v1/me — identité (cookie draco_session)
* POST /api/v1/chat — conversation réelle du noyau (J2) :
* LLM DeepSeek + outils MCP des apps ;
* la réponse arrive avec son HTML
* Markdown rendu côté service (v2)
* GET /api/v1/conversations — discussions façon Muse (v2) : une
* principale perpétuelle + des
* parallèles créées/archivées/
* supprimées par l'utilisateur
* GET /api/v1/conversations/… — historique d'une discussion (les
* messages agent portent leur html)
* GET /api/v1/executors — registre (noyau, sous-agents J3,
* externes J4 : état de configuration)
* POST /api/v1/missions — missions longues (J5)
* Panneau v2 (Lot 1) : rendu Markdown, largeur réglable à la souris
* (persistée), thème clair/sombre aligné sur l'app hôte
* (html[data-theme], repli prefers-color-scheme, suivi en direct).
* États honnêtes : service injoignable / session non reconnue / LLM non
* configuré / exécuteur non configuré sont affichés tels quels. */
(function () {
"use strict";
var API = "https://agent.dracodev.net";
var convId = null;
var convKind = null; // 'principale' | 'parallele'
var convTitre = "";
var etatConnu = false;
var root = null, msgsEl = null, etatEl = null, inputEl = null, sendBtn = null;
var titreEl = null, tiroirEl = null;
function domaine() {
try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; }
}
function stockerConv() {
try {
if (convId) sessionStorage.setItem("draco_panneau_conv", convId);
else sessionStorage.removeItem("draco_panneau_conv");
} catch (e) { /* stockage indisponible */ }
}
function convStockee() {
try { return sessionStorage.getItem("draco_panneau_conv") || null; }
catch (e) { return null; }
}
/* ── Thème : suivre l'app hôte ─────────────────────────────── */
function themeHote() {
var t = document.documentElement.getAttribute("data-theme");
if (t === "dark" || t === "light") return t;
if (document.documentElement.classList.contains("dark") ||
document.body.classList.contains("dark")) return "dark";
try {
if (window.matchMedia("(prefers-color-scheme: dark)").matches) return "dark";
} catch (e) { /* pas de matchMedia */ }
return "light";
}
function appliquerTheme() {
if (root) root.setAttribute("data-draco-theme", themeHote());
}
function surveillerTheme() {
try {
new MutationObserver(appliquerTheme).observe(document.documentElement,
{ attributes: true, attributeFilter: ["data-theme", "class"] });
window.matchMedia("(prefers-color-scheme: dark)")
.addEventListener("change", appliquerTheme);
} catch (e) { /* surveillance facultative */ }
}
/* ── Largeur réglable ──────────────────────────────────────── */
function largeurStockee() {
try {
var v = parseInt(localStorage.getItem("draco_panneau_largeur") || "", 10);
return isNaN(v) ? null : v;
} catch (e) { return null; }
}
function appliquerLargeur() {
if (!root) return;
var v = largeurStockee();
root.style.width = v ? Math.min(v, Math.round(window.innerWidth * 0.96)) + "px" : "";
}
function brancherPoignee(poignee) {
poignee.addEventListener("pointerdown", function (ev) {
if (window.innerWidth <= 700) return; // mobile : pleine largeur
ev.preventDefault();
var x0 = ev.clientX, w0 = root.getBoundingClientRect().width;
function bouger(e2) {
var w = Math.max(300, Math.min(760, w0 + (x0 - e2.clientX)));
root.style.width = w + "px";
}
function lacher(e2) {
document.removeEventListener("pointermove", bouger);
document.removeEventListener("pointerup", lacher);
var w = Math.round(root.getBoundingClientRect().width);
try { localStorage.setItem("draco_panneau_largeur", String(w)); } catch (e) { /* ok */ }
}
document.addEventListener("pointermove", bouger);
document.addEventListener("pointerup", lacher);
});
poignee.addEventListener("dblclick", function () {
try { localStorage.removeItem("draco_panneau_largeur"); } catch (e) { /* ok */ }
appliquerLargeur();
});
}
function construire() {
root = document.createElement("aside");
root.className = "draco-panneau";
root.setAttribute("aria-label", "Spark, l'agent commun");
root.innerHTML =
'<div class="draco-poignee" title="Glisser pour régler la largeur — double-clic : largeur par défaut"></div>' +
'<div class="draco-panneau-hd">' +
' <span style="font-size:19px;color:#7c5cd6">✦</span>' +
' <div><div class="titre">Spark — agent commun</div>' +
' <div class="soustitre">Service d\'agents dracodev</div></div>' +
' <button type="button" class="fermer" aria-label="Fermer le panneau">✕</button>' +
'</div>' +
'<div class="draco-panneau-hd draco-panneau-etat" style="padding-top:8px;padding-bottom:8px">' +
' <span class="point"></span><span class="txt">Connexion au service…</span>' +
'</div>' +
'<div class="draco-disc-bar">' +
' <span class="d-titre">Discussion principale</span>' +
' <button type="button" class="d-btn" data-disc="liste">☰ Discussions</button>' +
' <button type="button" class="d-btn" data-disc="nouvelle">+ Nouvelle</button>' +
'</div>' +
'<div class="draco-discussions" hidden></div>' +
'<div class="draco-panneau-outils">' +
' <button type="button" data-outil="aujourdhui">☀ Aujourd\'hui</button>' +
' <button type="button" data-outil="approbations">Approbations</button>' +
' <button type="button" data-outil="missions">🚀 Missions</button>' +
'</div>' +
'<div class="draco-panneau-msgs"></div>' +
'<div class="draco-panneau-ft"><form class="draco-panneau-form">' +
' <input type="text" placeholder="Écrire à Spark…" autocomplete="off" aria-label="Message à Spark">' +
' <button type="submit">Envoyer</button></form>' +
' <div class="draco-panneau-note">Conversation réelle, sous-agents (J3) et missions longues (J5) vers les exécuteurs configurés ; toute écriture dans les apps passe par une approbation.</div>' +
'</div>';
document.body.appendChild(root);
msgsEl = root.querySelector(".draco-panneau-msgs");
etatEl = root.querySelector(".draco-panneau-etat");
inputEl = root.querySelector("input");
sendBtn = root.querySelector("button[type=submit]");
titreEl = root.querySelector(".d-titre");
tiroirEl = root.querySelector(".draco-discussions");
root.querySelector(".fermer").addEventListener("click", fermer);
root.querySelector('[data-disc="liste"]').addEventListener("click", basculerTiroir);
root.querySelector('[data-disc="nouvelle"]').addEventListener("click", nouvelleDiscussion);
root.querySelector('[data-outil="aujourdhui"]').addEventListener("click", chargerAujourdhui);
root.querySelector('[data-outil="approbations"]').addEventListener("click", chargerApprobations);
root.querySelector('[data-outil="missions"]').addEventListener("click", ouvrirMissions);
root.querySelector("form").addEventListener("submit", envoyer);
brancherPoignee(root.querySelector(".draco-poignee"));
appliquerTheme();
surveillerTheme();
appliquerLargeur();
window.addEventListener("resize", appliquerLargeur);
document.addEventListener("keydown", function (ev) {
if (ev.key === "Escape" && root.classList.contains("open")) fermer();
});
}
function ajouterMsg(kind, texte) {
var d = document.createElement("div");
d.className = "draco-msg " + kind;
d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
/* Message agent rendu en Markdown : le HTML vient du service
* (rendu + sanitizer côté serveur) ; repli texte si absent. */
function ajouterMsgMd(texte, html) {
var d = document.createElement("div");
d.className = "draco-msg agent md";
if (html) d.innerHTML = html;
else d.textContent = texte;
msgsEl.appendChild(d);
msgsEl.scrollTop = msgsEl.scrollHeight;
return d;
}
function majEtat(cls, texte) {
etatEl.className = "draco-panneau-hd draco-panneau-etat " + cls;
etatEl.querySelector(".txt").textContent = texte;
}
function majTitre() {
titreEl.textContent = convTitre || "Discussion principale";
titreEl.title = titreEl.textContent;
}
function verifierIdentite() {
if (etatConnu) return;
fetch(API + "/api/v1/me", { credentials: "include" })
.then(function (r) {
if (r.status === 401) {
majEtat("err", "Session partagée non reconnue");
ajouterMsg("sys", "Le service ne reconnaît pas ta session. Connecte-toi à ObsiGate (session unique draco_session), puis rouvre le panneau. Si le SSO n'est pas encore activé, c'est l'étape d'activation du programme.");
etatConnu = true;
return null;
}
if (!r.ok) throw new Error("HTTP " + r.status);
return r.json();
})
.then(function (me) {
if (!me) return;
etatConnu = true;
majEtat("ok", "Connecté au service — " + (me.name || ("utilisateur " + me.sub)));
})
.catch(function () {
majEtat("err", "Service d'agents injoignable");
ajouterMsg("sys", "Le service d'agents (agent.dracodev.net) ne répond pas. Vérifie que le conteneur dracodev-agent est démarré.");
});
}
function esc(s) {
return String(s == null ? "" : s).replace(/[&<>"]/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c];
});
}
/* ── Discussions (façon Muse) ────────────────────────────── */
function chargerListeDiscussions() {
return fetch(API + "/api/v1/conversations?domain=" + encodeURIComponent(domaine()),
{ credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); });
}
function demarrerDiscussions() {
chargerListeDiscussions()
.then(function (d) {
var precedente = convStockee();
var candidates = (d.paralleles || []).concat(d.principale ? [d.principale] : []);
var trouvee = candidates.filter(function (c) { return c.id === precedente; })[0];
var active = trouvee || d.principale;
if (!active) return;
convId = active.id; convKind = active.kind; convTitre = active.title;
stockerConv(); majTitre();
chargerHistorique();
})
.catch(function () {
ajouterMsg("sys", "Discussions : service injoignable pour le moment.");
});
}
function basculerDiscussion(c) {
convId = c.id; convKind = c.kind; convTitre = c.title;
stockerConv(); majTitre();
tiroirEl.hidden = true;
msgsEl.innerHTML = "";
ajouterMsg("sys", (c.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + c.title) + " · domaine : " + domaine() + ".");
chargerHistorique();
inputEl.focus();
}
function nouvelleDiscussion() {
fetch(API + "/api/v1/conversations", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ domain: domaine() })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (c) {
basculerDiscussion(c);
ajouterMsg("sys", "Nouvelle discussion parallèle créée — elle prendra le titre de ton premier message.");
})
.catch(function () { ajouterMsg("sys", "Création impossible (service injoignable ?)."); });
}
function actionDiscussion(id, action) {
var methode = action === "supprimer" ? "DELETE" : "POST";
var url = API + "/api/v1/conversations/" + id + (action === "supprimer" ? "" : "/" + action);
return fetch(url, { method: methode, credentials: "include" })
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
});
}
function ligneDiscussion(c, actions) {
var ligne = document.createElement("div");
ligne.className = "d-item" + (c.id === convId ? " actif" : "");
var meta = document.createElement("div");
meta.className = "d-meta";
var quand = "";
try {
quand = new Date(c.updated_at * 1000).toLocaleDateString("fr-CA",
{ day: "numeric", month: "short" });
} catch (e) { /* date illisible */ }
meta.textContent = (c.kind === "principale" ? "📌 " : "") + c.title;
ligne.appendChild(meta);
var sous = document.createElement("div");
sous.className = "d-sous";
sous.textContent = c.nb_messages + " message(s)" + (quand ? " · " + quand : "") +
(c.kind === "principale" ? " · perpétuelle" : "");
ligne.appendChild(sous);
meta.addEventListener("click", function () { basculerDiscussion(c); });
sous.addEventListener("click", function () { basculerDiscussion(c); });
if (actions && actions.length) {
var btns = document.createElement("div");
btns.className = "d-actions";
actions.forEach(function (a) {
var b = document.createElement("button");
b.type = "button"; b.textContent = a.label;
if (a.danger) b.className = "danger";
b.addEventListener("click", function (ev) {
ev.stopPropagation();
if (a.confirmer && !window.confirm(a.confirmer)) return;
actionDiscussion(c.id, a.action)
.then(function () {
if (c.id === convId && a.action !== "desarchiver") {
chargerListeDiscussions().then(function (d) {
if (d.principale) {
basculerDiscussion(d.principale);
ajouterMsg("sys", a.action === "supprimer"
? "Discussion supprimée — retour à la discussion principale."
: "Discussion archivée — retour à la discussion principale.");
}
});
}
remplirTiroir();
})
.catch(function (e) { ajouterMsg("sys", "Action refusée : " + e.message); });
});
btns.appendChild(b);
});
ligne.appendChild(btns);
}
return ligne;
}
function remplirTiroir() {
tiroirEl.innerHTML = "<div class='d-chargement'>Chargement des discussions…</div>";
chargerListeDiscussions()
.then(function (d) {
tiroirEl.innerHTML = "";
var h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussion principale";
tiroirEl.appendChild(h);
if (d.principale) tiroirEl.appendChild(ligneDiscussion(d.principale, []));
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Discussions parallèles";
tiroirEl.appendChild(h);
if (!d.paralleles.length) {
var vide = document.createElement("div");
vide.className = "d-vide";
vide.textContent = "Aucune pour le moment — bouton « + Nouvelle ».";
tiroirEl.appendChild(vide);
}
d.paralleles.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Archiver", action: "archive" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
if (d.archivees && d.archivees.length) {
h = document.createElement("div");
h.className = "d-section";
h.textContent = "Archivées";
tiroirEl.appendChild(h);
d.archivees.forEach(function (c) {
tiroirEl.appendChild(ligneDiscussion(c, [
{ label: "Désarchiver", action: "desarchiver" },
{ label: "Supprimer", action: "supprimer", danger: true,
confirmer: "Supprimer définitivement la discussion « " + c.title + " » ?" }
]));
});
}
})
.catch(function () {
tiroirEl.innerHTML = "<div class='d-chargement'>Discussions : service injoignable.</div>";
});
}
function basculerTiroir() {
tiroirEl.hidden = !tiroirEl.hidden;
if (!tiroirEl.hidden) remplirTiroir();
}
function chargerHistorique() {
if (!convId) return;
fetch(API + "/api/v1/conversations/" + convId, { credentials: "include" })
.then(function (r) {
if (r.status === 404) { convId = null; stockerConv(); return null; }
if (!r.ok) return null;
return r.json();
})
.then(function (d) {
if (!d) return;
convKind = d.kind; convTitre = d.title || convTitre;
majTitre();
if (!msgsEl.querySelector(".draco-msg")) {
ajouterMsg("sys", (d.kind === "principale"
? "Discussion principale — le fil sans fin."
: "Discussion parallèle : " + (d.title || "")) + " · domaine : " + domaine() + ".");
}
var sys = msgsEl.querySelector(".draco-msg.sys");
(d.messages || []).forEach(function (m) {
var el = m.role === "user"
? ajouterMsg("user", m.content)
: ajouterMsgMd(m.content, m.html);
if (sys) { msgsEl.insertBefore(el, sys.nextSibling); sys = el; }
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { /* historique indisponible : on repart à neuf */ });
}
function chargerAujourdhui() {
var bloc = document.createElement("div");
bloc.className = "draco-auj";
bloc.innerHTML = "<h5>Aujourd'hui — vu du service d'agents</h5><div class='etat-source'>Chargement…</div>";
msgsEl.appendChild(bloc); msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/aujourdhui", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var h = "<h5>Aujourd'hui — " + esc(d.date) + " · domaine " + esc(d.domain) + "</h5>";
var t = d.sources.tiktak;
h += "<h5>Tâches du jour — TikTak</h5>";
if (t.status === "ok") {
if (!t.tasks.length) h += "<div class='etat-source'>Aucune tâche échue aujourd'hui. 🎉</div>";
else h += "<ul>" + t.tasks.map(function (x) { return "<li>" + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (t.status === "non_configure") {
h += "<div class='etat-source'>Source non configurée : il manque la clé tck_ du service (voir le .env du conteneur agent).</div>";
} else if (t.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : la clé du service est refusée par TikTak (clé révoquée ?).</div>";
} else {
h += "<div class='etat-source'>Source TikTak " + esc(t.status) + ".</div>";
}
var k = d.sources.kanbante;
h += "<h5>Cartes en Révision — Kanbante</h5>";
if (k.status === "ok") {
if (!k.review.length) h += "<div class='etat-source'>Rien à valider. 🎉</div>";
else h += "<ul>" + k.review.map(function (x) { return "<li>#" + esc(x.number) + " — " + esc(x.title) + "</li>"; }).join("") + "</ul>";
} else if (k.status === "non_autorise") {
h += "<div class='etat-source'>Source non autorisée : Kanbante exige un jeton d'API pour le service — il arrive avec Kanbante 0.25.0, puis AGENT_MCP_KANBANTE_KEY dans le .env de l'agent.</div>";
} else {
h += "<div class='etat-source'>Source Kanbante " + esc(k.status) + ".</div>";
}
bloc.innerHTML = h;
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () {
bloc.innerHTML = "<h5>Aujourd'hui</h5><div class='etat-source'>Service injoignable.</div>";
});
}
function chargerApprobations() {
fetch(API + "/api/v1/approvals?status=pending", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.approvals.length) { ajouterMsg("sys", "Aucune approbation en attente."); return; }
d.approvals.forEach(function (a) {
var bloc = document.createElement("div");
bloc.className = "draco-appro";
bloc.innerHTML = "<div class='titre'>Approbation demandée — " + esc(a.action) + "</div>" +
"<div class='resume'>" + esc(a.summary) + "</div>" +
"<div class='btns'><button type='button' class='go'>Approuver</button>" +
"<button type='button' class='non'>Rejeter</button></div>";
bloc.querySelector(".go").addEventListener("click", function () { decider(a.id, "approved", bloc); });
bloc.querySelector(".non").addEventListener("click", function () { decider(a.id, "rejected", bloc); });
msgsEl.appendChild(bloc);
});
msgsEl.scrollTop = msgsEl.scrollHeight;
})
.catch(function () { ajouterMsg("sys", "Approbations : service injoignable."); });
}
function decider(id, decision, bloc) {
fetch(API + "/api/v1/approvals/" + id + "/decide", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ decision: decision })
})
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
bloc.style.opacity = ".55";
bloc.querySelector(".btns").innerHTML = "<span style='font-size:12.5px'>Décision enregistrée : " + esc(d.status) + "</span>";
})
.catch(function () { ajouterMsg("sys", "La décision n'a pas pu être envoyée (service injoignable ?)."); });
}
/* ── Missions longues (J5) ───────────────────────────────────
* Sélecteur d'exécuteur alimenté par le registre du service :
* les externes non configurés sont montrés mais désactivés —
* jamais un lancement qui simule. La liste se rafraîchit à
* l'ouverture et après chaque lancement/annulation. */
var STATUTS_MISSION = {
queued: "En file", running: "En cours…", paused_question: "Question en attente",
succeeded: "Terminée", failed: "Échouée", cancelled: "Annulée"
};
function ouvrirMissions() {
var bloc = document.createElement("div");
bloc.className = "draco-missions";
bloc.innerHTML =
"<h5>Missions longues</h5>" +
"<label>Exécuteur</label><select class='m-exec'><option>Chargement…</option></select>" +
"<label>Titre</label><input type='text' class='m-titre' placeholder='Ex. Veille des projets du matin'>" +
"<label>Consigne</label><textarea class='m-prompt' rows='3' placeholder='Ce que l\u2019exécuteur doit faire…'></textarea>" +
"<label>Carte Kanbante liée (n°, optionnel)</label><input type='number' class='m-carte' min='1' placeholder='—'>" +
"<div class='btns'><button type='button' class='go'>Lancer la mission</button>" +
"<button type='button' class='rafraichir'>Rafraîchir la liste</button></div>" +
"<div class='m-liste'><div class='etat-source'>Chargement des missions…</div></div>";
msgsEl.appendChild(bloc);
msgsEl.scrollTop = msgsEl.scrollHeight;
fetch(API + "/api/v1/executors", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
var sel = bloc.querySelector(".m-exec");
sel.innerHTML = "";
d.executors.forEach(function (e) {
if (e.family !== "sous-agent" && e.family !== "externe") return;
var opt = document.createElement("option");
opt.value = e.id;
opt.textContent = e.name + (e.family === "sous-agent" ? " (sous-agent)" : "") +
(e.status === "non_configure" ? " — non configuré" : "");
if (e.status === "non_configure") opt.disabled = true;
sel.appendChild(opt);
});
if (!sel.options.length) sel.innerHTML = "<option value=''>Aucun exécuteur lançable</option>";
})
.catch(function () { bloc.querySelector(".m-exec").innerHTML = "<option value=''>Registre injoignable</option>"; });
bloc.querySelector(".go").addEventListener("click", function () { lancerMission(bloc); });
bloc.querySelector(".rafraichir").addEventListener("click", function () { chargerMissions(bloc); });
chargerMissions(bloc);
}
function chargerMissions(bloc) {
var zone = bloc.querySelector(".m-liste");
fetch(API + "/api/v1/missions", { credentials: "include" })
.then(function (r) { if (!r.ok) throw new Error("HTTP " + r.status); return r.json(); })
.then(function (d) {
if (!d.missions.length) { zone.innerHTML = "<div class='etat-source'>Aucune mission pour le moment.</div>"; return; }
zone.innerHTML = "";
d.missions.slice(0, 10).forEach(function (m) {
var ligne = document.createElement("div");
ligne.className = "m-item";
var entete = document.createElement("div");
entete.className = "m-entete";
entete.innerHTML = "<b>" + esc(m.title) + "</b>" +
"<span class='m-statut st-" + esc(m.status) + "'>" + esc(STATUTS_MISSION[m.status] || m.status) + "</span>";
ligne.appendChild(entete);
var meta = document.createElement("div");
meta.className = "m-meta";
meta.textContent = m.executor +
(m.meta && m.meta.duree_s ? " · " + m.meta.duree_s + " s" : "") +
(m.meta && m.meta.cout ? " · coût : " + m.meta.cout : "");
ligne.appendChild(meta);
if (m.result) {
var rapport = document.createElement("div");
rapport.className = "m-rapport md";
if (m.result_html) rapport.innerHTML = m.result_html;
else rapport.textContent = String(m.result).slice(0, 1200);
ligne.appendChild(rapport);
}
if (m.status === "queued" || m.status === "running" || m.status === "paused_question") {
var btn = document.createElement("button");
btn.type = "button"; btn.className = "m-annuler"; btn.textContent = "Annuler";
btn.addEventListener("click", function () {
fetch(API + "/api/v1/missions/" + m.id + "/cancel", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" }, body: "{}"
}).then(function () { chargerMissions(bloc); })
.catch(function () { ajouterMsg("sys", "Annulation non envoyée (service injoignable ?)."); });
});
ligne.appendChild(btn);
}
zone.appendChild(ligne);
});
})
.catch(function () { zone.innerHTML = "<div class='etat-source'>Missions : service injoignable.</div>"; });
}
function lancerMission(bloc) {
var executor = bloc.querySelector(".m-exec").value;
var titre = bloc.querySelector(".m-titre").value.trim();
var prompt = bloc.querySelector(".m-prompt").value.trim();
var carte = bloc.querySelector(".m-carte").value;
if (!executor) { ajouterMsg("sys", "Choisis un exécuteur configuré pour lancer une mission."); return; }
if (!titre || !prompt) { ajouterMsg("sys", "Mission : il faut un titre et une consigne."); return; }
var corps = { executor: executor, title: titre, prompt: prompt, domain: domaine() };
if (carte) corps.kanbante_number = parseInt(carte, 10);
fetch(API + "/api/v1/missions", {
method: "POST", credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(corps)
})
.then(function (r) {
if (!r.ok) return r.json().catch(function () { return null; }).then(function (e) {
throw new Error(e && e.detail ? e.detail : "HTTP " + r.status);
});
return r.json();
})
.then(function () {
ajouterMsg("sys", "Mission « " + titre + " » lancée — suis-la dans la liste des missions.");
chargerMissions(bloc);
})
.catch(function (e) { ajouterMsg("sys", "Lancement refusé : " + e.message); });
}
function envoyer(ev) {
ev.preventDefault();
var texte = inputEl.value.trim();
if (!texte) return;
inputEl.value = "";
ajouterMsg("user", texte);
sendBtn.disabled = true;
var attente = ajouterMsg("agent", "Spark réfléchit…");
fetch(API + "/api/v1/chat", {
method: "POST",
credentials: "include",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ message: texte, domain: domaine(), conversation_id: convId })
})
.then(function (r) {
if (r.status === 401) {
attente.textContent = "Le service ne reconnaît pas ta session partagée (401). Connecte-toi à ObsiGate puis réessaie.";
majEtat("err", "Session partagée non reconnue");
return null;
}
if (!r.ok) {
return r.json().catch(function () { return null; }).then(function (e) {
attente.textContent = (e && e.detail) ? e.detail :
"Le service a répondu HTTP " + r.status + " — réessaie dans un instant.";
return null;
});
}
return r.json();
})
.then(function (d) {
if (!d) return;
convId = d.conversation_id;
convKind = d.kind || convKind;
if (d.title) convTitre = d.title;
stockerConv(); majTitre();
if (d.html) {
attente.className = "draco-msg agent md";
attente.innerHTML = d.html;
} else {
attente.textContent = d.reply;
}
msgsEl.scrollTop = msgsEl.scrollHeight;
if (d.tools_used && d.tools_used.length) {
ajouterMsg("sys", "Outils utilisés : " + d.tools_used.join(", ") + " · modèle : " + (d.model || "?"));
}
majEtat("ok", "Connecté au service — domaine " + d.domain);
})
.catch(function () {
attente.textContent = "Service injoignable. Vérifie que le conteneur dracodev-agent est démarré, puis réessaie.";
majEtat("err", "Service d'agents injoignable");
})
.finally(function () { sendBtn.disabled = false; inputEl.focus(); });
}
function ouvrir() {
if (!root) { construire(); demarrerDiscussions(); }
root.classList.add("open");
appliquerTheme();
verifierIdentite();
setTimeout(function () { inputEl.focus(); }, 300);
}
function fermer() { if (root) root.classList.remove("open"); }
window.DRACO_PANNEAU = {
open: ouvrir,
close: fermer,
toggle: function () {
if (root && root.classList.contains("open")) fermer(); else ouvrir();
}
};
})();
+20 -4
View File
@@ -76,6 +76,7 @@
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link rel="stylesheet" href="/static/draco-panneau.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -331,14 +332,14 @@
<!-- Main Application (wrapped for show/hide) -->
<!-- Barre d'apps commune dracodev — Fusion #215 (ACC-1.1).
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.0
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.1.4
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
évolution passe par une nouvelle version de commons.
CSS/JS : /static/draco-barre.css, /static/draco-barre.js. -->
<header class="draco-bar" data-current="obsigate">
<div class="draco-bar-zone draco-bar-gauche">
<button type="button" class="draco-icon-btn" data-act="rail" aria-label="Barre latérale">☰</button>
<a class="draco-brand" href="/" aria-label="dracodev">
<a class="draco-brand" href="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d&#39;application" title="Changer d&#39;application">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
@@ -409,11 +410,25 @@
setTimeout(function () { d.remove(); }, 3400);
};
if (act === "domaine") { try { localStorage.setItem("draco_domain", el.dataset.dom); } catch (e) { } return; }
if (act === "rail") { var b = document.getElementById("sidebar-toggle-btn"); if (b) b.click(); return; }
if (act === "rail") {
// Bureau : afficher/masquer la barre latérale.
// Mobile (≤ 768 px, point de bascule d'ObsiGate) :
// le vrai hamburger, qui ouvre le tiroir — avant
// ce correctif (BUG-118), le rail cliquait le
// bouton bureau et ne faisait rien sur téléphone.
var mobile = window.matchMedia("(max-width: 768px)").matches;
var b = document.getElementById(mobile ? "hamburger-btn" : "sidebar-toggle-btn");
if (b) b.click();
return;
}
if (act === "palette") { var s = document.getElementById("search-input"); if (s) { s.focus(); s.scrollIntoView({ block: "center" }); } return; }
if (act === "menu") { var m = document.getElementById("header-menu-btn"); if (m) m.click(); return; }
if (act === "alertes") { dire("Alertes unifiées : arrive avec la fédération (programme Fusion)."); return; }
if (act === "spark") { dire("Spark, l'agent commun : arrive en phase 4 du programme Fusion."); return; }
if (act === "spark") {
if (window.DRACO_PANNEAU) { window.DRACO_PANNEAU.toggle(); }
else { dire("Spark : le panneau n'a pas pu se charger."); }
return;
}
if (act === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
@@ -6042,5 +6057,6 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.js"></script>
<script defer src="/static/draco-panneau.js"></script>
</body>
</html>
+19
View File
@@ -121,6 +121,25 @@ const AuthManager = {
if (tokenData.user) {
sessionStorage.setItem(this.USER_KEY, JSON.stringify(tokenData.user));
}
this._honorReturnTo();
},
// Fusion ACC-3.1 (#216) : après connexion chez l'émettrice SSO,
// repartir vers l'application demanderesse. Le return_to est
// re-validé ici (https + domaine dracodev.net, jamais l'hôte
// courant) en plus de la validation serveur de /sso/authorize.
_honorReturnTo() {
try {
const rt = new URLSearchParams(window.location.search).get("return_to");
if (!rt) return;
const u = new URL(rt);
const okHost = u.hostname === "dracodev.net"
|| u.hostname.endsWith(".dracodev.net");
if (u.protocol === "https:" && okHost
&& u.hostname !== window.location.hostname) {
window.location.replace(rt);
}
} catch (e) { /* return_to illisible : on reste sur place */ }
},
getToken() {
+1 -1
View File
@@ -14296,7 +14296,7 @@ textarea.file-chat-input {
/* — Barre d'apps commune dracodev + section « Liens » des notes
(Fusion #215, snippet figé dracodev-commons v0.1.0) — */
body.has-draco-bar .app-container { height: calc(100vh - 60px); }
@media (max-width: 700px) {
@media (max-width: 900px) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.70.1",
"version": "2.75.0",
"description": "**Porte d'entrée web ultra-léger pour vos vaults Obsidian** — Accédez, naviguez et recherchez dans toutes vos notes Obsidian depuis n'importe quel appareil via une interface web moderne et responsive.",
"main": "patch.js",
"directories": {
+73
View File
@@ -0,0 +1,73 @@
"""BUG-118 — barre d'apps commune en mobile (Fusion ACC-1, #215).
Retours de Bruno (2026-10-21) sur téléphone : la barre débordait
(pastilles domaines et onglets entiers affichés) et son bouton ☰
ne faisait rien — il cliquait le bouton bureau
``sidebar-toggle-btn`` au lieu du vrai hamburger mobile
``hamburger-btn``. Correctifs verrouillés ici : CSS commune v0.1.2
(palier 700 px resserré selon la spec) et rail contextuel dans
index.html (mobile ≤ 768 px → hamburger-btn).
"""
from pathlib import Path
FRONTEND = Path(__file__).resolve().parent.parent / "frontend"
def _css() -> str:
return (FRONTEND / "draco-barre.css").read_text(encoding="utf-8")
def _index() -> str:
return (FRONTEND / "index.html").read_text(encoding="utf-8")
def test_palier_mobile_resserre_v012():
css = _css()
assert "@media (max-width: 900px)" in css
# Seul l'onglet de l'app courante reste visible en mobile.
assert '.draco-bar[data-current="obsigate"] .draco-apptab[data-app="obsigate"]' in css
# Domaines et boutons icônes (rail, menu) masqués en mobile.
bloc = css.split("@media (max-width: 900px)", 1)[1]
assert ".draco-domaines" in bloc
assert "display: none" in bloc
def test_menu_des_applications_par_la_marque():
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
# (changement d'app en mobile), logo à 24 px.
html = _index()
assert 'data-act="apps"' in html
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "draco-apps-menu" in js
assert "font-size: 24px" in _css()
def test_panneau_spark_integre_218():
# #218 : le bouton Spark de la barre ouvre le panneau de l'agent
# commun (mêmes assets partagés que TikTak / Flowdeck / Kanbante).
html = _index()
assert (FRONTEND / "draco-panneau.js").is_file()
assert (FRONTEND / "draco-panneau.css").is_file()
assert '<link rel="stylesheet" href="/static/draco-panneau.css" />' in html
assert '<script defer src="/static/draco-panneau.js"></script>' in html
# L'action spark bascule sur le panneau — plus le message d'attente.
assert "window.DRACO_PANNEAU.toggle()" in html
assert "arrive en phase 4 du programme Fusion" not in html
panneau = (FRONTEND / "draco-panneau.js").read_text(encoding="utf-8")
assert "https://agent.dracodev.net" in panneau
def test_csp_ouvre_le_service_d_agents_218():
# Le panneau parle au service d'agents : la CSP doit nommer son
# hôte en connect-src, sans ouvrir de générique supplémentaire.
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
ligne = next(l for l in main.splitlines() if "connect-src" in l)
assert "https://agent.dracodev.net" in ligne
def test_rail_mobile_vers_le_vrai_hamburger():
html = _index()
assert 'matchMedia("(max-width: 768px)")' in html
assert '"hamburger-btn"' in html
assert '"sidebar-toggle-btn"' in html
+234
View File
@@ -0,0 +1,234 @@
# tests/test_draco_session.py
# Fusion ACC-3.1 (#216) — émettrice SSO draco_session : émission à la
# connexion, vérification (signature, expiration, révocation, rotation),
# renouvellement glissant, return_to en liste blanche, révocations.
import time
import pytest
from jose import jwt as jose_jwt
from backend import draco_session
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
@pytest.fixture(autouse=True)
def _draco_env(monkeypatch, tmp_path):
monkeypatch.setenv("DRACO_SESSION_SECRET", "secret-de-test")
monkeypatch.setattr(draco_session, "REVOKED_FILE",
tmp_path / "draco_revoked.json")
draco_session._revoked = None
yield
draco_session._revoked = None
# ── Jeton : émission / vérification ─────────────────────────────────────
def test_roundtrip_claims_minimaux():
token, jti, exp = draco_session.issue_token(USER)
claims = draco_session.verify_token(token)
assert claims is not None
assert claims["sub"] == "admin-1"
assert claims["name"] == "Admin"
assert claims["jti"] == jti
assert exp - claims["iat"] == 12 * 3600 # durée de vie du design §5.2
assert "role" not in claims # aucun rôle dans le jeton
def test_jeton_falsifie_rejete():
token, _j, _e = draco_session.issue_token(USER)
assert draco_session.verify_token(token + "x") is None
autre = jose_jwt.encode({"sub": "admin-1"}, "mauvais-secret",
algorithm="HS256")
assert draco_session.verify_token(autre) is None
def test_jeton_expire_rejete():
now = int(time.time())
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 7200,
"exp": now - 3600, "jti": "ancien"},
"secret-de-test", algorithm="HS256")
assert draco_session.verify_token(token) is None
def test_rotation_secret_precedent_accepte(monkeypatch):
token = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": int(time.time()),
"exp": int(time.time()) + 3600, "jti": "rotation"},
"ancien-secret", algorithm="HS256")
assert draco_session.verify_token(token) is None # pas encore configuré
monkeypatch.setenv("DRACO_SESSION_SECRET_PREV", "ancien-secret")
assert draco_session.verify_token(token)["jti"] == "rotation"
def test_revocation():
token, jti, exp = draco_session.issue_token(USER)
assert draco_session.verify_token(token) is not None
draco_session.revoke(jti, exp)
assert draco_session.verify_token(token) is None
assert {"jti": jti, "exp": exp} in draco_session.revoked_list()
def test_inerte_sans_secret(monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
assert draco_session.enabled() is False
assert draco_session.verify_token("peu-importe") is None
def test_fenetre_de_renouvellement():
assert draco_session.needs_renewal({"exp": int(time.time()) + 1800})
assert not draco_session.needs_renewal({"exp": int(time.time()) + 12 * 3600})
# ── return_to : liste blanche ────────────────────────────────────────────
@pytest.mark.parametrize("url,ok", [
("https://tiktak.dracodev.net/?task=3", True),
("https://og.dracodev.net/", True),
("https://dracodev.net/x", True),
("http://tiktak.dracodev.net/", False), # https seulement
("https://evil.com/", False),
("https://tiktak.dracodev.net.evil.com/", False),
("https://notdracodev.net/", False),
("", False),
])
def test_validate_return_to(url, ok):
assert (draco_session.validate_return_to(url) is not None) == ok
# ── Intégration : connexion / déconnexion / renouvellement ──────────────
def _login(client):
r = client.post("/api/auth/login",
json={"username": "admin", "password": "chab30"})
assert r.status_code == 200, r.text
return r
def _draco_cookie(response):
for raw in response.headers.get_list("set-cookie"):
if raw.startswith("draco_session="):
return raw
return None
def test_login_pose_le_cookie(admin_client):
r = _login(admin_client)
cookie = _draco_cookie(r)
assert cookie is not None
assert "Domain=.dracodev.net" in cookie
assert "HttpOnly" in cookie and "SameSite=lax" in cookie
token = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(token)["sub"] == "admin-1"
def test_login_sans_secret_aucun_cookie(admin_client, monkeypatch):
monkeypatch.delenv("DRACO_SESSION_SECRET")
r = _login(admin_client)
assert _draco_cookie(r) is None
def _token_depuis_login(client):
# Le jar du TestClient refuse le cookie Domain=.dracodev.net (hôte
# de test différent) : on lit le jeton dans l'en-tête set-cookie.
cookie = _draco_cookie(_login(client))
assert cookie is not None
return cookie.split(";")[0].split("=", 1)[1]
def test_logout_revoque_le_jti(admin_client):
token = _token_depuis_login(admin_client)
assert draco_session.verify_token(token)
r = admin_client.post("/api/auth/logout",
headers={"Cookie": f"draco_session={token}"})
assert r.status_code == 200, r.text
assert draco_session.verify_token(token) is None # révoqué
def test_renouvellement_glissant(admin_client):
_login(admin_client)
# Cookie draco presque expiré (dans la fenêtre des 2 h) :
now = int(time.time())
vieux = jose_jwt.encode(
{"sub": "admin-1", "name": "Admin", "iat": now - 11 * 3600,
"exp": now + 600, "jti": "vieux-jti"},
"secret-de-test", algorithm="HS256")
admin_client.cookies.set("draco_session", vieux)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
cookie = _draco_cookie(r)
assert cookie is not None # réémis
nouveau = cookie.split(";")[0].split("=", 1)[1]
assert draco_session.verify_token(nouveau)["jti"] != "vieux-jti"
def test_pas_de_renouvellement_si_frais(admin_client):
token = _token_depuis_login(admin_client)
admin_client.cookies.set("draco_session", token) # cookie frais porté
r = admin_client.get("/api/auth/me")
assert r.status_code == 200
assert _draco_cookie(r) is None # cookie frais : rien
def test_sso_authorize_liste_blanche(client):
r = client.get("/sso/authorize",
params={"return_to": "https://tiktak.dracodev.net/?task=3"},
follow_redirects=False)
assert r.status_code == 302
assert "return_to=" in r.headers["location"]
r = client.get("/sso/authorize",
params={"return_to": "https://evil.com/phish"},
follow_redirects=False)
assert r.status_code == 302
assert r.headers["location"] == "/" # refusé, pas de relais
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
def test_me_avec_cookie_draco_seul(admin_client):
# Aucune session native : le cookie partagé suffit (sub = id).
token, _jti, _exp = draco_session.issue_token(USER)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 200, r.text
assert r.json()["username"] == "admin"
def test_me_draco_revoque_refuse(admin_client):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_sub_inconnu_refuse(admin_client):
token, _j, _e = draco_session.issue_token(
{"id": "personne-99", "username": "x", "display_name": "X"})
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_me_draco_inerte_sans_secret(admin_client, monkeypatch):
token, _j, _e = draco_session.issue_token(USER)
monkeypatch.delenv("DRACO_SESSION_SECRET")
admin_client.cookies.set("draco_session", token)
r = admin_client.get("/api/auth/me")
assert r.status_code == 401
def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
token, jti, exp = draco_session.issue_token(USER)
draco_session.revoke(jti, exp)
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 404 # clé non configurée
monkeypatch.setenv("DRACO_SSO_MACHINE_KEY", "cle-machine")
r = admin_client.get("/api/sso/revocations")
assert r.status_code == 401 # en-tête absent
r = admin_client.get("/api/sso/revocations",
headers={"X-Draco-Machine-Key": "cle-machine"})
assert r.status_code == 200
assert {"jti": jti, "exp": exp} in r.json()["revoked"]