Compare commits

..
4 Commits
Author SHA1 Message Date
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
Spark e5577b9996 fix: planchers de sécurité des dépendances — pip-audit vierge (aiohttp, mcp, pillow, certifi)
CI / lint (push) Successful in 3m40s
CI / security (push) Successful in 2m23s
CI / test (push) Successful in 5m38s
CI / build (push) Successful in 3m16s
CI / e2e (push) Successful in 21m7s
2026-10-20 23:10:34 -04:00
Spark 9bc31d069b feat: barre d'apps commune et références croisées des notes (#215)
CI / lint (push) Successful in 3m32s
CI / security (push) Failing after 2m27s
CI / test (push) Successful in 5m36s
CI / build (push) Successful in 2m12s
CI / e2e (push) Successful in 18m18s
2026-10-20 22:20:48 -04:00
33 changed files with 1978 additions and 23 deletions
+72 -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.69.0**.
> [Unreleased](#unreleased). La dernière version livrée est **2.72.0**.
---
@@ -14,6 +14,77 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [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é
- **Sécurité des dépendances (CI `pip-audit` rouge sur 2.70.0)** —
planchers relevés dans `backend/requirements.txt`, selon le patron
déjà en place dans le fichier : `aiohttp>=3.14.4` (8 CVE corrigées en
3.14.4, dont CVE-2026-111063/111064), `mcp==1.30.0` (CVE-2026-59951),
`pillow>=13.0.0` (8 CVE corrigées en 13.0.0, dont CVE-2026-112895) et
plancher explicite `certifi>=2026.10.19` (PYSEC-2026-4320, dépendance
transitive). `pip-audit` redevient vierge ; aucune ligne de code
applicatif n'est touchée.
---
## [2.70.0] — 2026-10-20
### Ajouté
- **#215 — Fusion bloc 1 : barre d'apps commune et références croisées des
notes** (programme Fusion, issues Gitea #5/#6/#7 ; contrats
`dracodev-commons` v0.1.0) :
- **Barre d'apps commune** en tête de l'interface (snippet figé vendangé :
`frontend/draco-barre.css/.js`, markup dans `index.html`) — navigation
vers ObsiGate, Flowdeck, TikTak et Kanbante, ObsiGate marquée active,
sélecteur de domaine mémorisé ; la recherche, le rail et le menu délèguent
aux contrôles natifs d'ObsiGate.
- **Références croisées `external_ref`** : section « Liens » dans la vue
d'une note — coller une URL profonde (Flowdeck, TikTak, issue Gitea =
carte Kanbante, ObsiGate) crée la référence (libellé automatique ou
libre, pastille de couleur de l'app cible). Backend : stockage JSON par
utilisateur (`backend/external_refs.py`), endpoints `GET/POST /api/refs`
et `DELETE /api/refs/{id}`, parseur d'URL conforme au contrat figé
(422 explicite sinon), schémas et tag OpenAPI « ExternalRefs ».
- **Notes projetées TikTak reconnues** (frontmatter `tiktak_id`) : bandeau
« Tâche TikTak projetée » et bouton « Ouvrir dans TikTak » dans la même
section (module `frontend/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`).
- i18n FR/EN (clés `draco.links.*`) ; tests `test_external_refs.py`.
---
## [2.69.0] — 2026-10-15
### Ajouté
+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.69.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.72.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.69.0).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.72.0).
---
*Projet : ObsiGate | Version : 2.69.0 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.72.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.69.0-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.72.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.69.0).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.72.0).
---
*Project: ObsiGate | Version: 2.69.0 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.72.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.69.0
2.72.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
+172
View File
@@ -0,0 +1,172 @@
"""Références croisées ``external_ref`` des notes — Fusion #215 (ACC-1.3).
Modèle figé par le programme Fusion dans ``dracodev-commons``
(``contrats/external_ref.schema.json``, v0.1.0) : la référence vit chez
le propriétaire de l'objet source — ici la note ObsiGate, identifiée
par ``(vault, path)`` — et pointe vers un objet d'une autre application
(projet ou page Flowdeck, tâche ou liste TikTak, carte Kanbante, note
ObsiGate) par son URL profonde (``contrats/liens-profonds.md``). Une
seule source de vérité par objet : on stocke des références, jamais
des copies du contenu cible.
Stockage : JSON par utilisateur dans ``data/external_refs/``, même
patron que :mod:`backend.history` (pas de base de données, cf.
``AGENTS.md``). Le répertoire est surchargeable par la variable
d'environnement ``OBSIGATE_EXTERNAL_REFS_DIR`` (tests).
"""
import json
import logging
import os
import re
import shutil
import uuid
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
from urllib.parse import parse_qs, unquote, urlparse
logger = logging.getLogger("obsigate.external_refs")
def _refs_dir() -> Path:
override = os.environ.get("OBSIGATE_EXTERNAL_REFS_DIR")
directory = Path(override) if override else Path("data/external_refs")
directory.mkdir(parents=True, exist_ok=True)
return directory
def _refs_file(username: str) -> Path:
return _refs_dir() / f"{username}_refs.json"
def _read_refs(username: str) -> list[dict[str, Any]]:
file = _refs_file(username)
if not file.exists():
return []
try:
data = json.loads(file.read_text(encoding="utf-8"))
return data if isinstance(data, list) else []
except Exception as e:
logger.error(f"Failed to read external refs from {file.name}: {e}")
return []
def _write_refs(username: str, refs: list[dict[str, Any]]) -> None:
file = _refs_file(username)
try:
tmp = file.with_suffix(".tmp")
tmp.write_text(json.dumps(refs, indent=2, ensure_ascii=False), encoding="utf-8")
shutil.move(str(tmp), str(file))
except Exception as e:
logger.error(f"Failed to write external refs to {file.name}: {e}")
# ------------------------------------------------------------ parseur d'URL
def parse_deep_link(url: str) -> tuple[str, str, str, str] | None:
"""Reconnaît une URL profonde des applications dracodev.
Formes figées par ``dracodev-commons/contrats/liens-profonds.md``.
Retourne ``(target_app, target_type, target_id, label_par_défaut)``
ou ``None`` si l'URL ne correspond à aucune forme du contrat.
"""
try:
u = urlparse((url or "").strip())
except ValueError:
return None
host = (u.hostname or "").lower()
if host == "tiktak.dracodev.net":
q = parse_qs(u.query)
if q.get("task"):
return ("tiktak", "task", q["task"][0], f"TikTak · tâche {q['task'][0]}")
if q.get("list"):
return ("tiktak", "list", q["list"][0], f"TikTak · liste {q['list'][0]}")
return None
if host == "og.dracodev.net":
frag = u.fragment or ""
if frag.startswith("file="):
cible = unquote(frag[5:])
if ":" in cible:
nom = cible.rsplit("/", 1)[-1]
return ("obsigate", "note", cible, f"ObsiGate · {nom}")
return None
if host == "flowdeck.dracodev.net":
m = re.fullmatch(r"/pages/(\d+)", u.path or "")
if m:
return ("flowdeck", "page", m.group(1), f"Flowdeck · page {m.group(1)}")
m = re.fullmatch(r"/collections/(\d+)", u.path or "")
if m:
return ("flowdeck", "project", m.group(1), f"Flowdeck · projet {m.group(1)}")
return None
if host == "git.dracodev.net":
m = re.fullmatch(r"/([^/]+)/([^/]+)/issues/(\d+)", u.path or "")
if m:
owner, repo, num = m.groups()
return ("kanbante", "card", f"{owner}/{repo}#{num}", f"Kanbante · {repo} #{num}")
return None
return None
# ------------------------------------------------------------ CRUD
def list_refs(username: str, vault: str, path: str) -> list[dict[str, Any]]:
"""Références posées sur une note, dans l'ordre de création."""
if not username:
return []
return [
r for r in _read_refs(username)
if r.get("owner_vault") == vault and r.get("owner_path") == path
]
def get_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Une référence par son identifiant, ou ``None``."""
for r in _read_refs(username):
if r.get("id") == ref_id:
return r
return None
def add_ref(
username: str,
vault: str,
path: str,
target_app: str,
target_type: str,
target_id: str,
target_url: str,
label: str,
) -> dict[str, Any]:
"""Ajoute une référence à une note et la retourne (avec son ``id``)."""
refs = _read_refs(username)
ref = {
"id": uuid.uuid4().hex,
"owner_type": "obsigate.note",
"owner_id": f"{vault}:{path}",
"owner_vault": vault,
"owner_path": path,
"target_app": target_app,
"target_type": target_type,
"target_id": target_id,
"target_url": target_url,
"label": label,
"domain": None,
"status": "ok",
"created_by": "user",
"created_at": datetime.now(UTC).isoformat(),
}
refs.append(ref)
_write_refs(username, refs)
return ref
def delete_ref(username: str, ref_id: str) -> dict[str, Any] | None:
"""Retire une référence ; retourne la référence retirée ou ``None``."""
refs = _read_refs(username)
kept = [r for r in refs if r.get("id") != ref_id]
if len(kept) == len(refs):
return None
removed = next(r for r in refs if r.get("id") == ref_id)
_write_refs(username, kept)
return removed
+10
View File
@@ -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
@@ -522,6 +528,7 @@ from backend.routers.config import _load_config
from backend.routers.config import router as config_router
from backend.routers.conflicts import router as conflicts_router
from backend.routers.duplicates import router as duplicates_router
from backend.routers.external_refs import router as external_refs_router
from backend.routers.file_chat import router as file_chat_router
from backend.routers.files_media import router as files_media_router
from backend.routers.files_read import router as files_read_router
@@ -533,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
@@ -543,6 +551,8 @@ app.include_router(bookslm_router)
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
+4
View File
@@ -28,6 +28,8 @@ TAGS_METADATA: list[dict[str, str]] = [
{"name": "Vaults", "description": "List, add and remove vaults; per-vault display settings; attachment indexing."},
{"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)."},
@@ -114,6 +116,8 @@ _TAG_RULES: list[tuple[re.Pattern[str], str]] = [
(re.compile(r"^/api/graph"), "Search"),
(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"),
+14 -3
View File
@@ -5,7 +5,10 @@ python-frontmatter==1.1.0
mistune==3.3.3
python-multipart==0.0.31
aiofiles==23.2.1
aiohttp>=3.9.0
# Plancher de sécurité : 3.14.3 est vulnérable à 8 CVE (dont
# CVE-2026-111063/111064 et GHSA-2g87-pp6c-29x5), toutes corrigées en
# 3.14.4 — pip-audit (CI, bloquant) exige de rester au-dessus.
aiohttp>=3.14.4
watchdog>=4.0.0
argon2-cffi>=23.1.0
python-jose>=3.3.0
@@ -25,7 +28,9 @@ segno>=1.5.0
webauthn==2.6.0
psutil>=5.9
pywebpush>=2.3.0
mcp==1.28.1
# 1.28.1 vulnérable (CVE-2026-59951, fix 1.30.0) — épinglé sur la
# première version corrigée de la même ligne 1.x.
mcp==1.30.0
# Plancher de sécurité (BUG-091, BUG-095) : pyjwt est une dépendance transitive
# (mcp). 2.12.x → PYSEC-2026-178 (fix 2.13.0) ; 2.13.0 → CVE-2026-102274
# (fix 2.14.0). pip-audit étant bloquant, on reste au-dessus du dernier correctif.
@@ -36,7 +41,9 @@ xlrd==2.0.2
odfpy==1.4.1
python-docx>=1.1
reportlab>=4.0
pillow>=10.0
# Plancher de sécurité : 12.3.0 est vulnérable à 8 CVE (dont
# CVE-2026-112895 et CVE-2026-112400), toutes corrigées en 13.0.0.
pillow>=13.0.0
# Plancher urllib3 >= 2.8.0 (CVE-2026-97687, CVE-2026-97688, CVE-2026-97689)
urllib3>=2.8.0
# Plancher de sécurité (CVE-2026-104874, fix 6.9.1) : multidict est transitive
@@ -44,3 +51,7 @@ urllib3>=2.8.0
# l'image du runner — sans plancher, pip répond « already satisfied » et
# n'aligne jamais (même piège que pypdf, BUG-093).
multidict>=6.9.1
# Plancher de sécurité (PYSEC-2026-4320, fix 2026.10.19) : certifi est
# transitive (httpx/requests) — sans plancher explicite, pip-audit
# signale la version héritée de l'environnement.
certifi>=2026.10.19
+84
View File
@@ -0,0 +1,84 @@
"""Endpoints des références croisées des notes — Fusion #215 (ACC-1.3).
Création par collage d'une URL profonde d'une autre application
(Flowdeck, TikTak, Kanbante, ObsiGate) — zéro couplage serveur :
l'URL est reconnue par les formes figées du contrat
``dracodev-commons/contrats/liens-profonds.md`` (voir
:mod:`backend.external_refs`), la référence est stockée chez la note
propriétaire et affichée dans sa section « Liens ».
"""
import logging
from fastapi import APIRouter, Depends, HTTPException, Query
from backend.auth.middleware import check_vault_access, require_auth
from backend.external_refs import add_ref, delete_ref, get_ref, list_refs, parse_deep_link
from backend.schemas import ExternalRef, ExternalRefCreateRequest, ExternalRefsResponse
logger = logging.getLogger("obsigate")
router = APIRouter(tags=["ExternalRefs"])
_FORMES_ACCEPTEES = (
"URL non reconnue — formes acceptées : TikTak ?task= ou ?list=, "
"ObsiGate #file=, Flowdeck /pages/ ou /collections/, "
"issue Gitea (carte Kanbante)"
)
@router.get("/api/refs", response_model=ExternalRefsResponse)
async def api_list_refs(
vault: str = Query(...),
path: str = Query(...),
current_user=Depends(require_auth),
):
"""Références croisées posées sur une note."""
if not check_vault_access(vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
username = current_user.get("username") or ""
return {"refs": list_refs(username, vault, path)}
@router.post("/api/refs", response_model=ExternalRef, status_code=201)
async def api_add_ref(
payload: ExternalRefCreateRequest,
current_user=Depends(require_auth),
):
"""Crée une référence sur une note à partir d'une URL profonde collée."""
if not check_vault_access(payload.vault, current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
parsed = parse_deep_link(payload.url)
if parsed is None:
raise HTTPException(status_code=422, detail=_FORMES_ACCEPTEES)
target_app, target_type, target_id, default_label = parsed
label = (payload.label or "").strip() or default_label
username = current_user.get("username") or ""
return add_ref(
username,
payload.vault,
payload.path,
target_app,
target_type,
target_id,
payload.url.strip(),
label,
)
@router.delete("/api/refs/{ref_id}", response_model=ExternalRef)
async def api_delete_ref(
ref_id: str,
current_user=Depends(require_auth),
):
"""Retire une référence d'une note."""
username = current_user.get("username") or ""
ref = get_ref(username, ref_id)
if ref is None:
raise HTTPException(status_code=404, detail="Lien introuvable")
if not check_vault_access(ref["owner_vault"], current_user):
raise HTTPException(status_code=403, detail="Accès au vault refusé")
removed = delete_ref(username, ref_id)
if removed is None: # course improbable : disparue entre get et delete
raise HTTPException(status_code=404, detail="Lien introuvable")
return removed
+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()}
+38
View File
@@ -619,6 +619,44 @@ class BookmarkToggleRequest(BaseModel):
title: str | None = None
class ExternalRef(BaseModel):
"""Référence croisée d'une note vers un objet d'une autre application.
Modèle figé par dracodev-commons (contrats/external_ref.schema.json,
v0.1.0) — Fusion #215 (ACC-1.3).
"""
id: str
owner_type: str = Field(description="Toujours 'obsigate.note' côté ObsiGate")
owner_id: str = Field(description="'<vault>:<chemin>' de la note propriétaire")
owner_vault: str
owner_path: str
target_app: str = Field(description="obsigate | flowdeck | tiktak | kanbante")
target_type: str = Field(description="note | project | page | task | list | card")
target_id: str
target_url: str = Field(description="URL profonde selon contrats/liens-profonds.md")
label: str = Field(description="Instantané d'affichage du titre de la cible")
domain: str | None = None
status: str = Field(default="ok", description="ok | target_missing")
created_by: str = Field(default="user")
created_at: str
class ExternalRefCreateRequest(BaseModel):
"""Création d'une référence par collage d'URL profonde."""
vault: str
path: str
url: str
label: str | None = None
class ExternalRefsResponse(BaseModel):
"""Liste des références d'une note."""
refs: list[ExternalRef]
# ---------------------------------------------------------------------------
# Search / suggest / graph (#85 — extrait de backend.main, inchangé)
# ---------------------------------------------------------------------------
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.69.0"
version = "2.72.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.69.0"
version = "2.72.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.69.0",
"version": "2.72.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+66 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.69.0 | **Dernière mise à jour :** 2026-10-15
> **Version :** 2.72.0 | **Dernière mise à jour :** 2026-10-20
> **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,71 @@
## 🔵 En cours
### 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)
- **Fiche :** [features/fusion-bloc1-barre-refs-215.md](./features/fusion-bloc1-barre-refs-215.md)
- **Description :** première tranche du programme **Fusion** (accès centralisé
ObsiGate / Flowdeck / TikTak / Kanbante — dépôt programme
`Projets/fusion-trois-apps`, épopée ACC-1 ; contrats versionnés dans
`Projets/dracodev-commons` v0.1.0). ID 215 choisi hors des plages déjà
utilisées ou proposées (#191–#201 livrées/Assistant V2, #202–#214
proposées par ASSISTANT-V2.1). Issues Gitea ObsiGate #5 (ACC-1.1),
#6 (ACC-1.2), #7 (ACC-1.3).
- **Sous-tâches :**
- [x] Barre d'apps commune vendangée du snippet figé de commons
(`frontend/draco-barre.css/.js`, markup dans `index.html`) :
ObsiGate marquée active, navigation vers les 4 apps, domaine mémorisé
- [x] Backend `external_ref` : stockage JSON par utilisateur
(`backend/external_refs.py`), endpoints `GET/POST /api/refs` et
`DELETE /api/refs/{id}` (schémas + tag OpenAPI), parseur d'URL
profondes conforme au contrat figé
- [x] Section « Liens » dans la vue d'une note (module
`frontend/js/draco-liens.js`, point d'extension
`obsigate:file-rendered` émis par `renderFile`)
- [x] Notes projetées TikTak reconnues (frontmatter `tiktak_id`) :
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é.
@@ -0,0 +1,73 @@
# #215 — Fusion bloc 1 : barre d'apps commune et références croisées des notes
> Programme **Fusion** (accès centralisé entre ObsiGate, Flowdeck, TikTak et
> Kanbante). Dépôt programme : `Projets/fusion-trois-apps` (épopée ACC-1,
> issues ObsiGate #5, #6, #7). Contrats : `Projets/dracodev-commons` v0.1.0
> (`contrats/liens-profonds.md`, `contrats/external_ref.schema.json`,
> snippet `ui/barre-apps/`).
## Ce qui est livré
### ACC-1.1 — Barre d'apps commune (#5)
Le snippet figé de commons est **vendangé** (copié, jamais chargé depuis une
autre app — la barre survit à l'arrêt des autres applications) :
- `frontend/draco-barre.css` / `frontend/draco-barre.js` : copie v0.1.0 ;
- markup + configuration dans `frontend/index.html`, au-dessus de `#app`
(`body.has-draco-bar` ajuste la hauteur de `.app-container` dans
`style.css`).
Phase 1 : navigation entre les quatre applications (ObsiGate marquée
active), sélecteur de domaine mémorisé en `localStorage` (`draco_domain`,
même clé que TikTak), recherche = focus de la recherche native, rail =
bouton latéral natif, menu = menu d'en-tête natif. Alertes unifiées et
panneau Spark affichent un message d'arrivée (fédération et agent commun :
phases ultérieures du programme) plutôt que des boutons morts. Les compteurs
croisés viendront avec la fédération ; seul le compteur local de chaque app
est alimenté en phase 1.
### ACC-1.3 — Références croisées `external_ref` (#7)
- **Modèle** : le schéma figé de commons. La note est propriétaire de ses
références (`owner_type = obsigate.note`, `owner_id = "<vault>:<chemin>"`).
- **Stockage** : JSON par utilisateur dans `data/external_refs/`
(`backend/external_refs.py`, même patron que `backend/history.py` —
pas de base de données). Surchargable par `OBSIGATE_EXTERNAL_REFS_DIR`.
- **API** (auth + `check_vault_access`) :
- `GET /api/refs?vault=&path=` — références d'une note ;
- `POST /api/refs` `{vault, path, url, label?}` — création par collage
d'URL profonde ; le parseur reconnaît les formes du contrat (TikTak
`?task=`/`?list=`, ObsiGate `#file=`, Flowdeck `/pages/`/`/collections/`,
issue Gitea = carte Kanbante) et répond **422** explicite sinon ;
- `DELETE /api/refs/{id}`.
- **UI** : section « Liens » en fin de note — liste avec pastille de couleur
de l'app cible, formulaire d'ajout, retrait en un clic. Rendue par le
module autonome `frontend/draco-liens.js`, branché sur l'événement
`obsigate:file-rendered` que `renderFile` émet (point d'extension
documenté dans `frontend/app.js`).
### ACC-1.2 — Notes projetées TikTak (#6)
La synchro TikTak ↔ ObsiGate écrit `tiktak_id: <id>` dans le frontmatter des
fichiers projetés. Le module `draco-liens.js` le détecte (lecture du brut via
`/api/file/{vault}/raw`) et affiche un bandeau « Tâche TikTak projetée » avec
le bouton **« Ouvrir dans TikTak »** (`https://tiktak.dracodev.net/?task=<id>`),
au lieu de traiter la note comme une note ordinaire.
## Limites assumées (v1)
- Le libellé d'une référence est un instantané (titre par défaut dérivé de
l'URL ou libellé libre) ; le rafraîchissement automatique du titre viendra
avec la fédération MCP (phase 3 du programme).
- Un lien dont la cible a disparu n'est pas détecté automatiquement
(`status` reste `ok`) — la marque « cible introuvable » viendra avec la
couche données.
- La section « Liens » n'apparaît que dans la vue lecture d'une note
(pas dans l'éditeur).
## Tests
`tests/test_external_refs.py` : parseur (6 formes reconnues, 7 refusées),
stockage (cycle + isolation par utilisateur), API (cycle complet, 422, 404,
403 vault inconnu).
+7
View File
@@ -3424,6 +3424,13 @@
// Initialize outline/TOC for this document
OutlineManager.init();
// Fusion #215 (ACC-1.2/1.3) — point d'extension : le module
// draco-liens.js rend la section « Liens » de la note
// (références croisées + « ouvrir dans TikTak » si projetée).
document.dispatchEvent(new CustomEvent("obsigate:file-rendered", {
detail: { vault: data.vault, path: data.path },
}));
}
// ---------------------------------------------------------------------------
+127
View File
@@ -0,0 +1,127 @@
/* Barre d'apps commune dracodev — v0.1.0 (bloc 0, ACC-0.2)
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. */
.draco-bar {
--bg: #f2f2f0; --surface: #ffffff; --surface-2: #e9eae6; --surface-3: #f6f6f3;
--ink: #1b1d1a; --ink-2: #5c615a; --ink-3: #787d75; --line: #dedfd9;
--agent: #c2437e; --danger: #cf4444;
height: 60px; box-sizing: border-box;
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
align-items: center; gap: 16px; padding: 0 12px;
background: var(--surface); border-bottom: 1px solid var(--line);
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
}
@media (prefers-color-scheme: dark) {
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
}
[data-theme="dark"] .draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
--ink:#eceee9; --ink-2:#a6aba2; --ink-3:#8b9089; --line:#2e302c; }
.draco-bar-zone { display: flex; align-items: center; gap: 8px; min-width: 0; }
.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); }
/* Onglets d'apps — accordéon : seule l'app courante est déployée */
.draco-apptabs { display: flex; gap: 6px; }
.draco-apptab {
position: relative; display: flex; align-items: center; gap: 8px;
height: 44px; padding: 0 9px; border-radius: 12px;
color: var(--ink); text-decoration: none; background: transparent;
transition: background .18s ease;
}
.draco-apptab:hover { background: var(--surface-2); }
.draco-apptab-ico {
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
}
.draco-apptab-txt { display: none; flex-direction: column; line-height: 1.15; }
.draco-apptab-txt strong { font-size: 14px; font-weight: 700; }
.draco-apptab-txt small { font-size: 11.5px; color: var(--ink-2); }
.draco-apptab[data-active="true"] { background: var(--surface-3); box-shadow: inset 0 -2px 0 var(--app, var(--agent)); }
.draco-apptab[data-active="true"] .draco-apptab-txt { display: flex; }
/* Pastilles compteurs — petites, rouges, coin bas-droit (standard v7.1) */
.draco-badge {
position: absolute; right: -4px; bottom: -4px; min-width: 17px; height: 17px;
padding: 0 4px; border-radius: 9px; box-sizing: border-box;
background: #e5484d; color: #fff; font-size: 10.5px; font-weight: 700;
display: grid; place-items: center; border: 2px solid var(--surface);
}
.draco-badge[hidden] { display: none; }
/* Recherche */
.draco-search {
display: flex; align-items: center; gap: 8px; width: 100%; height: 38px;
padding: 0 12px; border-radius: 10px; border: 1px solid var(--line);
background: var(--surface-3); color: var(--ink-3); cursor: pointer; font: inherit;
}
.draco-search kbd {
margin-left: auto; font: 600 11px system-ui; color: var(--ink-2);
border: 1px solid var(--line); border-radius: 5px; padding: 1px 5px;
}
/* Domaines — contrôle segmenté */
.draco-domaines { display: flex; align-items: center; gap: 2px; background: var(--surface-3); border-radius: 10px; padding: 3px; }
.draco-domaines-lbl { font-size: 10.5px; letter-spacing: .06em; color: var(--ink-3); padding: 0 6px; text-transform: uppercase; }
.draco-domaines button {
border: 0; background: transparent; color: var(--ink-2); font: inherit; font-size: 13px;
height: 30px; padding: 0 10px; border-radius: 8px; cursor: pointer;
}
.draco-domaines button[data-on="true"] { background: var(--surface); color: var(--ink); font-weight: 700; box-shadow: 0 1px 3px rgb(0 0 0 / .18); }
/* Alertes — la cloche ne disparaît JAMAIS (correctif v7.2) */
.draco-alertes {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 11px; border-radius: 10px; border: 1px solid var(--line);
background: transparent; color: var(--ink); font: inherit; cursor: pointer;
}
/* Spark */
.draco-spark {
position: relative; display: flex; align-items: center; gap: 7px; height: 38px;
padding: 0 14px; border: 0; border-radius: 10px; cursor: pointer;
background: var(--agent); color: #fff; font: inherit; font-weight: 700;
}
.draco-spark .draco-badge { border-color: var(--agent); }
.draco-icon-btn {
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
background: transparent; color: var(--ink-2); font: inherit; cursor: pointer;
}
.draco-icon-btn:hover { background: var(--surface-2); }
.draco-avatar {
width: 34px; height: 34px; border-radius: 50%; display: grid; place-items: center;
background: #2f6fed; color: #fff; font-weight: 700;
}
/* Rappel de l'en-tête masqué — onglet ancré au bord supérieur (v6) */
.draco-bar-rappel {
position: fixed; top: 0; left: 16px; z-index: 60; cursor: pointer;
background: var(--surface, #1a1c1a); border: 1px solid var(--line, #2e302c); border-top: 0;
border-radius: 0 0 10px 10px; padding: 5px 12px; font: 600 12px system-ui; color: var(--ink-2, #a6aba2);
}
/* Paliers — les zones latérales cèdent, la barre ne casse jamais */
@media (max-width: 1440px) {
.draco-brand-name, .draco-domaines-lbl { display: none; }
.draco-bar { grid-template-columns: auto minmax(200px, 440px) auto; }
}
@media (max-width: 1200px) {
.draco-search-txt, .draco-search kbd, .draco-alertes-txt, .draco-spark-txt { display: none; }
.draco-search { width: 38px; padding: 0; justify-content: center; }
.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; }
}
+56
View File
@@ -0,0 +1,56 @@
/* Barre d'apps commune dracodev — comportement (v0.1.0, ACC-0.2)
*
* 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
* proscrits dans tout le programme).
*
* Configuration attendue avant le chargement :
* window.DRACO_BAR_CONFIG = {
* current: "tiktak", // app courante
* domain: "pro", // domaine actif
* counts: { tiktak: 3, kanbante: 3, alertes: 3, spark: 0 },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
* 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…).
*/
(function () {
"use strict";
var cfg = window.DRACO_BAR_CONFIG || {};
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);
});
}
function setCurrent(app) {
document.querySelectorAll(".draco-apptab").forEach(function (t) {
t.dataset.active = t.dataset.app === app ? "true" : "false";
});
}
function setDomain(dom) {
document.querySelectorAll('.draco-domaines [data-dom]').forEach(function (b) {
b.dataset.on = b.dataset.dom === dom ? "true" : "false";
});
}
document.addEventListener("click", function (ev) {
var el = ev.target.closest("[data-act]");
if (!el) return;
var act = el.dataset.act;
if (act === "domaine") setDomain(el.dataset.dom);
if (typeof cfg.onAction === "function") cfg.onAction(act, el, ev);
else document.dispatchEvent(new CustomEvent("draco:bar", { detail: { act: act, el: el } }));
});
/* API publique : l'app met les compteurs à jour sans recharger. */
window.DRACO_BAR = { setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain };
if (cfg.current) setCurrent(cfg.current);
if (cfg.domain) setDomain(cfg.domain);
if (cfg.counts) setCounts(cfg.counts);
})();
+187
View File
@@ -0,0 +1,187 @@
/* Section « Liens » des notes — Fusion #215 (ACC-1.2 + ACC-1.3).
*
* Module autonome (vanilla, zéro dépendance, zéro build) : il écoute
* l'événement « obsigate:file-rendered » émis par renderFile
* (frontend/app.js) et rend sa section à la fin de #content-area :
*
* - ACC-1.2 : si la note est une projection TikTak (frontmatter
* « tiktak_id » écrit par la synchro TikTak ↔ ObsiGate), un bandeau
* « Tâche TikTak projetée » avec le bouton « Ouvrir dans TikTak »
* (lien profond https://tiktak.dracodev.net/?task=<id>) ;
* - ACC-1.3 : les références croisées de la note (GET /api/refs),
* ajout par collage d'une URL profonde, retrait en un clic.
*
* Textes via window.t (i18n FR/EN — clés « draco.links.* » des locales).
*/
(function () {
"use strict";
var COLORS = {
obsigate: "#7a5af5",
flowdeck: "#2f6fed",
tiktak: "#e8762c",
kanbante: "#0d9488",
};
function t(key, fallback) {
try {
var v = window.t && window.t(key);
return v && v !== key ? v : fallback;
} catch (e) {
return fallback;
}
}
function authHeaders() {
var tok = null;
try {
tok = sessionStorage.getItem("obsigate_access_token");
} catch (e) { /* stockage indisponible */ }
return tok ? { Authorization: "Bearer " + tok } : {};
}
async function api(path, opts) {
var options = opts || {};
options.headers = Object.assign(
{ "Content-Type": "application/json" },
authHeaders(),
options.headers || {}
);
options.credentials = "same-origin";
var resp = await fetch(path, options);
if (!resp.ok) {
var detail = "";
try {
detail = (await resp.json()).detail || "";
} catch (e) { /* corps non JSON */ }
throw new Error(detail || "HTTP " + resp.status);
}
return resp.json();
}
function el(tag, cls, text) {
var node = document.createElement(tag);
if (cls) node.className = cls;
if (text !== undefined) node.textContent = text;
return node;
}
async function render(vault, path) {
var area = document.getElementById("content-area");
if (!area || !vault || !path) return;
var old = document.getElementById("draco-liens");
if (old) old.remove();
var section = el("section", "draco-liens");
section.id = "draco-liens";
var title = el("h3", "draco-liens-title", t("draco.links.title", "Liens"));
section.appendChild(title);
area.appendChild(section);
// ACC-1.2 — note projetée depuis TikTak ?
if (/\.md$/i.test(path)) {
try {
var rawUrl = "/api/file/" + encodeURIComponent(vault) + "/raw?path=" +
encodeURIComponent(path);
var rawData = await api(rawUrl);
var m = /^---\r?\n([\s\S]*?)\r?\n---/.exec(rawData.raw || "");
var tid = m && /^tiktak_id:\s*(\d+)\s*$/m.exec(m[1]);
if (tid) {
var bandeau = el("div", "draco-liens-projete");
bandeau.appendChild(el("span", "draco-liens-badge",
t("draco.links.projected", "Tâche TikTak projetée")));
var ouvrir = el("a", "btn-action draco-liens-ouvrir",
t("draco.links.openInTikTak", "Ouvrir dans TikTak"));
ouvrir.href = "https://tiktak.dracodev.net/?task=" + tid[1];
ouvrir.target = "_blank";
ouvrir.rel = "noopener";
bandeau.appendChild(ouvrir);
section.appendChild(bandeau);
}
} catch (e) { /* la détection est opportuniste : silence */ }
}
// ACC-1.3 — références croisées de la note
var list = el("ul", "draco-liens-list");
section.appendChild(list);
var erreur = el("p", "draco-liens-erreur");
erreur.hidden = true;
section.appendChild(erreur);
async function refresh() {
list.innerHTML = "";
try {
var data = await api("/api/refs?vault=" + encodeURIComponent(vault) +
"&path=" + encodeURIComponent(path));
(data.refs || []).forEach(function (r) {
var li = el("li", "draco-liens-item");
var dot = el("span", "draco-ref-dot");
dot.style.background = COLORS[r.target_app] || "#888";
li.appendChild(dot);
var a = el("a", "", r.label);
a.href = r.target_url;
a.target = "_blank";
a.rel = "noopener";
li.appendChild(a);
var del = el("button", "draco-liens-del", "✕");
del.type = "button";
del.title = t("draco.links.remove", "Retirer le lien");
del.addEventListener("click", async function () {
try {
await api("/api/refs/" + encodeURIComponent(r.id), { method: "DELETE" });
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
li.appendChild(del);
list.appendChild(li);
});
} catch (e) { /* section facultative : ne jamais casser la note */ }
}
var form = el("form", "draco-liens-form");
var urlInput = el("input");
urlInput.type = "url";
urlInput.required = true;
urlInput.placeholder = t("draco.links.urlPlaceholder",
"Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…");
var labelInput = el("input");
labelInput.type = "text";
labelInput.placeholder = t("draco.links.labelPlaceholder", "Libellé (optionnel)");
var submit = el("button", "btn-action", t("draco.links.add", "Lier"));
submit.type = "submit";
form.appendChild(urlInput);
form.appendChild(labelInput);
form.appendChild(submit);
form.addEventListener("submit", async function (ev) {
ev.preventDefault();
erreur.hidden = true;
try {
await api("/api/refs", {
method: "POST",
body: JSON.stringify({
vault: vault,
path: path,
url: urlInput.value.trim(),
label: labelInput.value.trim() || null,
}),
});
urlInput.value = "";
labelInput.value = "";
refresh();
} catch (e) {
erreur.textContent = e.message;
erreur.hidden = false;
}
});
section.appendChild(form);
refresh();
}
document.addEventListener("obsigate:file-rendered", function (ev) {
if (ev.detail) render(ev.detail.vault, ev.detail.path);
});
})();
+112 -1
View File
@@ -75,6 +75,7 @@
@media (prefers-reduced-motion: reduce) { .bs-spinner { animation-duration: 1.8s; } }
</style>
<link rel="stylesheet" href="/static/style.css" />
<link rel="stylesheet" href="/static/draco-barre.css" />
<link
rel="stylesheet"
href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/styles/github-dark.min.css"
@@ -222,7 +223,7 @@
};
</script>
</head>
<body>
<body class="has-draco-bar">
<!-- Boot splash : visible tant que l'app n'a pas booté avec son CSS.
Sur desktop, la page embarquée (tauri://) n'a pas /static/style.css →
sans ce splash, le squelette HTML brut serait visible pendant le
@@ -329,6 +330,114 @@
</div>
<!-- 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
(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">
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
</a>
<nav class="draco-apptabs" aria-label="Applications">
<a class="draco-apptab" data-app="obsigate" href="https://og.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
</a>
<a class="draco-apptab" data-app="flowdeck" href="https://flowdeck.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
</a>
<a class="draco-apptab" data-app="tiktak" href="https://tiktak.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
<span class="draco-apptab-txt"><strong>TikTak</strong><small>Tâches</small></span>
<span class="draco-badge" data-badge="tiktak" hidden>0</span>
</a>
<a class="draco-apptab" data-app="kanbante" href="https://kanbante.dracodev.net/">
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
</a>
</nav>
</div>
<div class="draco-bar-zone draco-bar-centre">
<button type="button" class="draco-search" data-act="palette" aria-label="Rechercher partout (Ctrl+K)">
<span class="draco-search-ico">⌕</span>
<span class="draco-search-txt">Rechercher partout…</span>
<kbd>⌘K</kbd>
</button>
</div>
<div class="draco-bar-zone draco-bar-droite">
<div class="draco-domaines" role="group" aria-label="Domaine">
<span class="draco-domaines-lbl">Domaine</span>
<button type="button" data-act="domaine" data-dom="pro">Pro</button>
<button type="button" data-act="domaine" data-dom="famille">Famille</button>
<button type="button" data-act="domaine" data-dom="perso">Perso</button>
</div>
<button type="button" class="draco-alertes" data-act="alertes" aria-label="Alertes unifiées">
<svg viewBox="0 0 24 24" width="19" height="19" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9"/><path d="M10.3 21a1.94 1.94 0 0 0 3.4 0"/></svg>
<span class="draco-alertes-txt">Alertes</span>
<span class="draco-badge" data-badge="alertes" hidden>0</span>
</button>
<button type="button" class="draco-spark" data-act="spark" aria-label="Ouvrir Spark, l'agent commun">
<span class="draco-spark-ico">✦</span><span class="draco-spark-txt">Spark</span>
<span class="draco-badge" data-badge="spark" hidden>0</span>
</button>
<button type="button" class="draco-icon-btn" data-act="menu" aria-label="Plus d'actions" aria-haspopup="menu">•••</button>
<span class="draco-avatar" id="draco-avatar" title="">✦</span>
</div>
</header>
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
<script>
/* Configuration ObsiGate de la barre commune (Fusion #215).
Phase 1 : navigation entre apps et domaine mémorisé
localement ; la recherche focus la recherche native
d'ObsiGate ; Alertes unifiées et Spark arrivent avec la
fédération / l'agent commun (phases suivantes). */
window.DRACO_BAR_CONFIG = {
current: "obsigate",
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
var dire = function (m) {
var d = document.createElement("div");
d.textContent = m;
d.style.cssText = "position:fixed;left:50%;bottom:22px;transform:translateX(-50%);z-index:20000;background:#161b22;color:#e6edf3;padding:10px 16px;border-radius:10px;font:500 13.5px system-ui;box-shadow:0 6px 24px rgb(0 0 0/.35)";
document.body.appendChild(d);
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 === "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 === "rappel-entete") {
var bar = document.querySelector(".draco-bar");
if (bar) bar.style.display = "";
el.hidden = true;
}
}
};
/* Avatar : initiale de l'utilisateur courant (best effort). */
window.addEventListener("DOMContentLoaded", function () {
var tok = null;
try { tok = sessionStorage.getItem("obsigate_access_token"); } catch (e) { }
fetch("/api/auth/me", {
credentials: "same-origin",
headers: tok ? { Authorization: "Bearer " + tok } : {}
}).then(function (r) { return r.ok ? r.json() : null; })
.then(function (u) {
var nom = u && (u.display_name || u.username);
if (nom) {
var av = document.getElementById("draco-avatar");
if (av) { av.textContent = nom.charAt(0).toUpperCase(); av.title = nom; }
}
}).catch(function () { /* avatar générique conservé */ });
});
</script>
<div id="app" class="app-container">
<!-- Header -->
<header class="header">
@@ -5931,5 +6040,7 @@ curl -X POST https://votre-serveur.com/webhook \
</div>
</div>
</div>
<script defer src="/static/draco-barre.js"></script>
<script defer src="/static/draco-liens.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() {
+7
View File
@@ -752,6 +752,13 @@
"upload.custom_folder": "Or enter a new subfolder path...",
"upload.import_summary": "Items to import: {files} file(s), {dirs} folder(s)",
"upload.import_button": "Import",
"draco.links.add": "Link",
"draco.links.labelPlaceholder": "Label (optional)",
"draco.links.openInTikTak": "Open in TikTak",
"draco.links.projected": "Projected TikTak task",
"draco.links.remove": "Remove link",
"draco.links.title": "Links",
"draco.links.urlPlaceholder": "Paste a deep link (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Cancel",
"editor.close": "Close",
"editor.delete_error": "Delete error",
+7
View File
@@ -752,6 +752,13 @@
"upload.custom_folder": "Ou saisir un nouveau sous-dossier...",
"upload.import_summary": "Éléments à importer : {files} fichier(s), {dirs} dossier(s)",
"upload.import_button": "Importer",
"draco.links.add": "Lier",
"draco.links.labelPlaceholder": "Libellé (optionnel)",
"draco.links.openInTikTak": "Ouvrir dans TikTak",
"draco.links.projected": "Tâche TikTak projetée",
"draco.links.remove": "Retirer le lien",
"draco.links.title": "Liens",
"draco.links.urlPlaceholder": "Coller une URL profonde (Flowdeck, TikTak, Gitea, ObsiGate)…",
"editor.cancel": "Annuler",
"editor.close": "Fermer",
"editor.delete_error": "Erreur de suppression",
+39
View File
@@ -14292,3 +14292,42 @@ 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) {
body.has-draco-bar .app-container { height: calc(100vh - 52px); }
}
.draco-liens {
margin: 28px 0 8px; padding: 16px 18px;
border: 1px solid var(--border); border-radius: 12px;
background: var(--bg-secondary);
}
.draco-liens-title { margin: 0 0 10px; font-size: 15px; color: var(--text-primary); }
.draco-liens-projete {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
margin-bottom: 12px; padding: 10px 12px; border-radius: 10px;
background: var(--bg-hover);
}
.draco-liens-badge { font-weight: 700; color: #e8762c; font-size: 13px; }
.draco-liens-ouvrir { text-decoration: none; }
.draco-liens-list { list-style: none; margin: 0 0 12px; padding: 0; }
.draco-liens-item { display: flex; align-items: center; gap: 4px; padding: 4px 0; }
.draco-ref-dot {
display: inline-block; width: 9px; height: 9px; border-radius: 50%;
margin-right: 7px; flex: none;
}
.draco-liens-item a { color: var(--text-primary); }
.draco-liens-del {
margin-left: 8px; border: 0; background: transparent; cursor: pointer;
color: var(--text-secondary); font-size: 12px;
}
.draco-liens-del:hover { color: var(--err); }
.draco-liens-form { display: flex; gap: 6px; flex-wrap: wrap; }
.draco-liens-form input {
flex: 1 1 200px; min-width: 0; padding: 7px 10px; border-radius: 8px;
border: 1px solid var(--border); background: var(--bg-primary);
color: var(--text-primary); font: inherit; font-size: 13.5px;
}
.draco-liens-erreur { color: var(--err); font-size: 13px; margin: 8px 0 0; }
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.69.0",
"version": "2.72.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": {
+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"]
+131
View File
@@ -0,0 +1,131 @@
"""Références croisées des notes — Fusion #215 (ACC-1.3).
Parseur d'URL profondes (formes figées de dracodev-commons
``contrats/liens-profonds.md``), stockage JSON par utilisateur et
endpoints ``/api/refs`` (création par collage d'URL, liste, retrait).
"""
import pytest
from backend.external_refs import (
add_ref,
delete_ref,
get_ref,
list_refs,
parse_deep_link,
)
@pytest.fixture()
def refs_env(tmp_path, monkeypatch):
monkeypatch.setenv("OBSIGATE_EXTERNAL_REFS_DIR", str(tmp_path / "refs"))
return tmp_path
# ------------------------------------------------------------ parseur
@pytest.mark.parametrize("url,attendu", [
("https://tiktak.dracodev.net/?task=42",
("tiktak", "task", "42", "TikTak · tâche 42")),
("https://tiktak.dracodev.net/?list=7",
("tiktak", "list", "7", "TikTak · liste 7")),
("https://og.dracodev.net/#file=home-bruno:Projets%2FAlpha.md",
("obsigate", "note", "home-bruno:Projets/Alpha.md", "ObsiGate · Alpha.md")),
("https://flowdeck.dracodev.net/pages/15",
("flowdeck", "page", "15", "Flowdeck · page 15")),
("https://flowdeck.dracodev.net/collections/3",
("flowdeck", "project", "3", "Flowdeck · projet 3")),
("https://git.dracodev.net/Projets/fusion-trois-apps/issues/5",
("kanbante", "card", "Projets/fusion-trois-apps#5", "Kanbante · fusion-trois-apps #5")),
])
def test_parse_formes_reconnues(url, attendu):
assert parse_deep_link(url) == attendu
@pytest.mark.parametrize("url", [
"https://example.com/?task=1",
"https://og.dracodev.net/#file=sansdeuxpoints",
"https://og.dracodev.net/",
"https://flowdeck.dracodev.net/autre/12",
"https://git.dracodev.net/Projets/fusion-trois-apps",
"pas une url",
"",
])
def test_parse_formes_refusees(url):
assert parse_deep_link(url) is None
# ------------------------------------------------------------ stockage
def test_stockage_cycle(refs_env):
ref = add_ref("alice", "TestVault", "Note A.md", "tiktak", "task", "42",
"https://tiktak.dracodev.net/?task=42", "TikTak · tâche 42")
assert ref["owner_type"] == "obsigate.note"
assert ref["owner_id"] == "TestVault:Note A.md"
assert get_ref("alice", ref["id"])["target_id"] == "42"
assert [r["id"] for r in list_refs("alice", "TestVault", "Note A.md")] == [ref["id"]]
assert list_refs("alice", "TestVault", "Autre.md") == []
assert list_refs("bob", "TestVault", "Note A.md") == [] # par utilisateur
removed = delete_ref("alice", ref["id"])
assert removed["id"] == ref["id"]
assert delete_ref("alice", ref["id"]) is None
assert list_refs("alice", "TestVault", "Note A.md") == []
# ------------------------------------------------------------ API
def test_api_cycle_complet(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://flowdeck.dracodev.net/collections/3",
})
assert r.status_code == 201, r.text
ref = r.json()
assert ref["target_app"] == "flowdeck"
assert ref["target_type"] == "project"
assert ref["label"] == "Flowdeck · projet 3"
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://tiktak.dracodev.net/?task=9", "label": "Ma tâche",
})
assert r.status_code == 201, r.text
assert r.json()["label"] == "Ma tâche"
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert r.status_code == 200
assert [x["target_app"] for x in r.json()["refs"]] == ["flowdeck", "tiktak"]
r = client.delete(f"/api/refs/{ref['id']}")
assert r.status_code == 200
r = client.get("/api/refs", params={"vault": "TestVault", "path": "Note A.md"})
assert [x["target_app"] for x in r.json()["refs"]] == ["tiktak"]
def test_api_url_inconnue_422(client, refs_env):
r = client.post("/api/refs", json={
"vault": "TestVault", "path": "Note A.md",
"url": "https://example.com/whatever",
})
assert r.status_code == 422
def test_api_delete_introuvable_404(client, refs_env):
r = client.delete("/api/refs/inexistant")
assert r.status_code == 404
def test_api_vault_sans_acces_403(admin_client, refs_env):
"""Auth activée : un utilisateur limité à TestVault reçoit 403 ailleurs."""
r = admin_client.post("/api/auth/login",
json={"username": "normaluser", "password": "normal123"})
assert r.status_code == 200, r.text
headers = {"Authorization": f"Bearer {r.json()['access_token']}"}
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "VaultFantome", "path": "x.md"})
assert r.status_code == 403
r = admin_client.get("/api/refs", headers=headers,
params={"vault": "TestVault", "path": "x.md"})
assert r.status_code == 200