Compare commits

...
Author SHA1 Message Date
Spark 151d9faeab chore: relance de la vérification de la PR (commit vide, aucun changement de code)
CI / lint (pull_request) Successful in 1m17s
CI / security (pull_request) Successful in 16s
CI / test (pull_request) Successful in 3m46s
CI / build (pull_request) Successful in 11s
CI / e2e (pull_request) Successful in 15m53s
2026-11-06 13:34:29 +00:00
Spark c27fd977a8 ObsiGate 2.84.0 : version et changelog (HUB-13, fusion #127) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m23s
CI / security (pull_request) Successful in 23s
CI / test (pull_request) Successful in 3m34s
CI / build (pull_request) Successful in 1m32s
CI / e2e (pull_request) Successful in 15m8s
2026-11-06 00:58:19 +00:00
Spark 65d01f91b8 ObsiGate 2.84.0 : API d'administration des comptes machine-à-machine (HUB-13, fusion #127, #226) — routes /api/admin/machine/comptes protégées par DRACO_ADMIN_MACHINE_KEY, révocation des jti draco à la désactivation, must_change_password, journalisation d'audit 2026-11-06 00:58:16 +00:00
Spark b83de53447 ObsiGate 2.83.5 : barre v0.4.1 — menu compte resserré à 232 px, re-vendange verbatim (fusion #125)
CI / lint (pull_request) Successful in 1m21s
CI / security (pull_request) Successful in 28s
CI / test (pull_request) Successful in 5m18s
CI / build (pull_request) Successful in 15s
CI / e2e (pull_request) Successful in 16m40s
2026-11-05 07:08:02 +00:00
Spark 5eba3e2558 ObsiGate 2.83.5 : version et changelog (barre v0.4.1, fusion #125) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m28s
CI / security (pull_request) Successful in 17s
CI / test (pull_request) Successful in 3m57s
CI / build (pull_request) Successful in 10s
CI / e2e (pull_request) Successful in 17m0s
2026-11-05 05:53:14 +00:00
Spark 10aa88e03c ObsiGate 2.83.5 : barre v0.4.1 — correctif des icônes invisibles du menu compte, vendange verbatim (fusion #125) + garde 2026-11-05 05:53:05 +00:00
bruno e0e0f9af0f Merge pull request 'ObsiGate 2.83.4 — barre v0.4.0 : icônes monoline dans le menu compte (HUB-11)' (#64) from hub11-barre-v040 into main
CI / lint (push) Successful in 1m24s
CI / security (push) Successful in 17s
CI / test (push) Successful in 3m35s
CI / build (push) Successful in 10s
CI / e2e (push) Successful in 16m38s
2026-11-05 00:33:34 -05:00
Spark 35b828a4c5 ObsiGate 2.83.4 : version et changelog (barre v0.4.0, HUB-11) — VERSION + fichiers dérivés synchronisés par scripts/bump_version.py
CI / lint (pull_request) Successful in 1m19s
CI / security (pull_request) Successful in 21s
CI / test (pull_request) Successful in 3m37s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Successful in 16m51s
2026-11-05 05:03:01 +00:00
Spark 6ebb3b3eae ObsiGate 2.83.4 : barre v0.4.0 — icônes monoline du menu compte, vendange verbatim (HUB-11) + garde 2026-11-05 05:02:10 +00:00
bruno 569d0e33a6 Merge pull request 'ObsiGate 2.83.3 : barre v0.3.0 — liens Hub du menu compte (HUB-1)' (#63) from hub1-barre-v030 into main
CI / lint (push) Successful in 1m31s
CI / security (push) Successful in 22s
CI / test (push) Successful in 3m55s
CI / build (push) Successful in 11s
CI / e2e (push) Successful in 17m28s
2026-11-04 19:48:18 -05:00
Spark 1d1ce5a848 ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (VERSION + fichiers dérivés synchronisés)
CI / lint (pull_request) Successful in 1m18s
CI / security (pull_request) Successful in 18s
CI / test (pull_request) Successful in 3m45s
CI / build (pull_request) Successful in 12s
CI / e2e (pull_request) Successful in 16m20s
2026-11-04 21:57:41 +00:00
Spark f04369343d ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (tests) 2026-11-04 21:57:41 +00:00
Spark 8290abd675 ObsiGate 2.83.3 : barre v0.3.0, liens Hub du menu compte (frontend + draco_config) 2026-11-04 21:57:41 +00:00
bruno ea60d7e172 Merge pull request 'ObsiGate 2.83.2 : recherche fédérée — ShaarIt, 5e source de « Rechercher partout »' (#62) from recherche-shaarit into main
CI / lint (push) Successful in 1m29s
CI / security (push) Successful in 18s
CI / test (push) Successful in 3m48s
CI / build (push) Successful in 46s
CI / e2e (push) Successful in 17m17s
2026-11-04 12:23:55 -05:00
Spark 606d59f483 ObsiGate 2.83.2 : recherche fédérée — ShaarIt, 5e source de « Rechercher partout »
CI / lint (pull_request) Successful in 1m25s
CI / security (pull_request) Successful in 20s
CI / test (pull_request) Successful in 4m4s
CI / build (pull_request) Failing after 10m20s
CI / e2e (pull_request) Skipped
- frontend/js/recherche.js re-vendangé verbatim depuis commons
  v0.2.1 (source canonique ui/recherche/) : source ShaarIt (bases,
  onglet, normaliseur — lien profond {base}shaare/<shorturl>).
- frontend/recherche.html : data-base-shaarit (jeton URL_SHAARIT),
  onglet et section « ShaarIt — bookmarks ».
- backend/recherche_cors.py : origines ShaarIt prod/dev + clé
  sœur ; backend/main.py : origine ShaarIt dans le connect-src
  CSP de la page /recherche.
- Version synchronisée par scripts/bump_version.py --set 2.83.2
  (VERSION, package.json, desktop, README, ROADMAP, CHANGELOG).
- Tests : recherche.test.mjs 67 assertions (5 sources),
  test_recherche_cors allowlist 12 origines ; suite backend
  complète 1916 passés / 0 échec, ruff et mypy verts.
2026-11-04 17:23:38 +00:00
29 changed files with 1389 additions and 55 deletions
+1
View File
@@ -130,6 +130,7 @@ DEEPSEEK_MODEL=deepseek-chat
# DRACO_TIKTAK_URL=https://tiktak.dracodev.net
# DRACO_KANBANTE_URL=https://kanbante.dracodev.net
# DRACO_SHAARIT_URL=https://shaarit.dracodev.net
# DRACO_HUB_URL=https://hub.dracodev.net
# DRACO_GITEA_URL=https://git.dracodev.net
# ── Fusion — émission d'événements vers le service d'agents (#220) ──
+34 -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.83.1**.
> [Unreleased](#unreleased). La dernière version livrée est **2.84.0**.
---
@@ -14,6 +14,39 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
---
## [2.84.0] — 2026-11-06
- **HUB-13 — API d'administration des comptes, machine-à-machine (fusion #127, #226)** : nouvelles routes `/api/admin/machine/comptes` (liste, profil étendu, création, modification, désactivation/réactivation, suppression, réinitialisation du mot de passe et du MFA), protégées par la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` (en-tête `X-Draco-Machine-Key`, distincte de `DRACO_SSO_MACHINE_KEY` ; aucune session utilisateur ne les ouvre ; API inerte si la clé n'est pas configurée). Le profil étendu expose nom d'utilisateur, nom affiché, langue, avatar (valeur + présence), rôle global, état actif, MFA activé oui/non — jamais le secret TOTP ni le haché du mot de passe. La désactivation arrête l'émission de nouveaux jetons et révoque les `jti` draco actifs : `draco_session` tient désormais un registre des émissions (`data/draco_issued.json`) et `revoke_all_for_user()` révoque via la liste existante ; le repère `password_changed_at` (BUG-028) est aussi avancé. Nouveau champ utilisateur `must_change_password` : posé à la création machine et à la réinitialisation du mot de passe, signalé par la connexion et `GET /api/auth/me`, effacé au changement effectif. Chaque action est journalisée dans `data/audit.log` (acteur = identité de service `X-Draco-Actor`, action, compte cible, horodatage). Fiche : `docs/features/hub-api-admin-comptes-226.md`. Tests : `tests/test_comptes_machine.py`.
---
## [2.83.5] — 2026-11-05
- **Barre commune v0.4.1 — correctif des icônes invisibles du menu compte (fusion #125)** : seul `frontend/draco-barre.js` change, re-vendangé **verbatim** depuis dracodev-commons (branche `menu-compte-icones-svg-ns-v0-4-1`, PR commons #11) ; le CSS est inchangé. En v0.4.0, `menuIcone()` créait le `<svg>` des icônes et ses primitives via `document.createElement()` — espace de noms **HTML**, jamais dessinés par le navigateur : les libellés du menu compte étaient décalés d'un cran d'icône, sans aucun glyphe. La v0.4.1 crée tout via `document.createElementNS()` (espace de noms SVG). Glyphes, CSS et comportements inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.4] — 2026-11-05
- **Barre commune v0.4.0 — icônes monoline du menu compte (HUB-11, fusion #125)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.4.0 (branche `menu-compte-icones-v0-4-0`, PR commons #10). Les items du menu compte — **Télécharger**, **À Propos**, **Aides**, **Profil**, **Déconnexion** — reçoivent des **icônes SVG monoline** (contour, trait fin, `currentColor`, ~18 px) dans le style des icônes du rail de la console Spark ; aucun emoji. L'en-tête d'identité reste sans icône ; les liens Hub et le relais « Déconnexion » sont inchangés. Garde ajoutée à `tests/test_draco_barre.py`.
---
## [2.83.3] — 2026-11-04
- **Barre commune v0.3.0 — liens Hub du menu compte (HUB-1, fusion #115)** : `frontend/draco-barre.js` et `frontend/draco-barre.css` re-vendangés **verbatim** depuis dracodev-commons v0.3.0 (branche `hub-menu-compte-v0-3-0`, PR commons #9). Le menu compte gagne les liens **Télécharger**, **À Propos**, **Aides** et **Profil** (devenu un lien, plus un relais) vers le Hub. Branchement hôte : `backend/draco_config.py` gagne la clé `hub` (défaut officiel `https://hub.dracodev.net`, variable `DRACO_HUB_URL`, dérivation `-dev` → `hub-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` ; `frontend/index.html` la passe en `urls.hub` de `DRACO_BAR_CONFIG`. « Déconnexion » reste relayée, inchangée. Aucun nouvel onglet : le Hub n'apparaît que dans le menu compte.
---
## [2.83.2] — 2026-11-04
- **Recherche fédérée — ShaarIt, 5e source de « Rechercher partout »** (demande de Bruno, 2026-11-04 ; contrat dracodev-commons v0.2.1, `contrats/recherche-federee.md`) : `frontend/js/recherche.js` **re-vendangé verbatim** depuis la source canonique de commons (`ui/recherche/`) — ShaarIt devient une source de la page `/recherche` comme les autres (bases prod/dev, onglet, normaliseur : lien profond canonique `{base}shaare/<shorturl>`, contexte « Bookmark · privé · tags », repli recherche native `/?q=`). Le fichier reste strictement identique chez les cinq hôtes.
- **Page `/recherche`** : onglet et section « ShaarIt — bookmarks », `data-base-shaarit` rendu par le jeton `{{URL_SHAARIT}}` (déjà résolu par la route depuis la 2.83.1) ; la CSP `connect-src` de la page ajoute l'origine ShaarIt résolue (`backend/main.py`).
- **CORS de recherche** (`backend/recherche_cors.py`) : origines officielles ShaarIt prod et dev ajoutées aux listes explicites, `shaarit` ajoutée aux clés sœurs résolues — `GET /api/search` d'ObsiGate devient lisible depuis les pages de ShaarIt, et réciproquement.
- Tests : `tests/frontend/recherche.test.mjs` porté à 5 sections (normaliseur ShaarIt inclus) ; `tests/test_recherche_cors.py` — allowlist attendue à 12 origines.
---
## [2.83.1] — 2026-11-03
- **Barre commune (dracodev-commons v0.2.0 vendangée, @70c837a)** : nouvel onglet **ShaarIt** (bookmarks, réécriture Rust de Shaarli) après Kanbante et avant l'onglet Console Spark — `frontend/draco-barre.css` et `frontend/draco-barre.js` remplacés **verbatim** par la v0.2.0 (hachages vérifiés), logo `frontend/img/logos/shaarit.svg` ajouté tel quel ; le fragment porté dans `frontend/index.html` et `frontend/recherche.html` (jeton `{{URL_SHAARIT}}` remplacé par la route `/recherche`). L'URL ShaarIt suit le **même mécanisme** que les autres apps : `backend/draco_config.py` gagne la clé `shaarit` (défaut officiel `https://shaarit.dracodev.net`, variable `DRACO_SHAARIT_URL`, dérivation `-dev` → `shaarit-dev` depuis l'hôte de l'agent), servie dans `window.DRACO_URLS` et réécrite sur l'onglet comme les autres.
+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.83.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.84.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.83.1).
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.84.0).
---
*Projet : ObsiGate | Version : 2.83.1 | Dernière mise à jour : Septembre 2026*
*Projet : ObsiGate | Version : 2.84.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.83.1-blue.svg)]()
[![Version](https://img.shields.io/badge/Version-2.84.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.83.1).
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.84.0).
---
*Project: ObsiGate | Version: 2.83.1 | Last updated: September 2026*
*Project: ObsiGate | Version: 2.84.0 | Last updated: September 2026*
+1 -1
View File
@@ -1 +1 @@
2.83.1
2.84.0
+29
View File
@@ -154,6 +154,35 @@ def log_coffre_event(
_write_entry(entry)
def log_compte_admin(
acteur: str,
action: str,
cible: str,
ip: str | None = None,
details: dict | None = None,
):
"""Journal d'une action d'administration de compte (HUB-13, fusion #127).
``acteur`` est l'identité de service appelante (API machine des
comptes, en-tête ``X-Draco-Actor``), ``cible`` le compte visé.
Règle absolue : ``details`` ne contient jamais de mot de passe, de
secret TOTP ni de jeton — seulement des métadonnées (champs
modifiés, nombre de jti révoqués…). Le champ ``username`` reprend
l'acteur pour rester filtrable par l'écran d'audit existant.
"""
entry: dict = {
"timestamp": datetime.now(UTC).isoformat(),
"action": action,
"username": acteur,
"acteur": acteur,
"cible": cible,
"ip": ip or "unknown",
}
if details:
entry["details"] = details
_write_entry(entry)
def get_recent_entries(limit: int = 100, action: str | None = None) -> list:
"""Read the most recent audit log entries.
+9 -1
View File
@@ -298,6 +298,8 @@ def _issue_tokens(user: dict, username: str, remember_me: bool, response: Respon
"role": user["role"],
"vaults": user["vaults"],
"avatar": user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(user.get("must_change_password")),
},
}
@@ -433,6 +435,8 @@ async def get_me(current_user=Depends(require_auth)):
"language": current_user.get("language", "fr"),
"last_login": current_user.get("last_login"),
"avatar": current_user.get("avatar"),
# HUB-13 : mot de passe temporaire — changement forcé signalé.
"must_change_password": bool(current_user.get("must_change_password")),
}
@@ -487,7 +491,11 @@ async def change_password(
assert user is not None, f"User {current_user['username']} not found"
if not verify_password(req.current_password, user["password_hash"]):
raise HTTPException(400, "Mot de passe actuel incorrect")
update_user(current_user["username"], {"password": req.new_password})
# HUB-13 : le changement effectif efface le marqueur de changement forcé.
update_user(current_user["username"], {
"password": req.new_password,
"must_change_password": False,
})
updated = get_user(current_user["username"])
result: dict = {"message": "Mot de passe mis à jour"}
if updated is not None:
+10 -1
View File
@@ -80,8 +80,16 @@ def create_user(
role: str = "user",
vaults: list[str] | None = None,
display_name: str | None = None,
must_change_password: bool = False,
) -> dict:
"""Create a new user. Raises ValueError if username already taken."""
"""Create a new user. Raises ValueError if username already taken.
``must_change_password`` (HUB-13) marque un mot de passe temporaire
(création/réinitialisation par l'administration machine) : le
changement est forcé à la première connexion. Les comptes créés
avant ce champ n'en portent pas — ils valent ``False`` partout où
le champ est lu via ``.get(..., False)``.
"""
with _users_lock:
data = _read()
if username in data["users"]:
@@ -95,6 +103,7 @@ def create_user(
"role": role,
"vaults": vaults or [],
"active": True,
"must_change_password": bool(must_change_password), # HUB-13
"language": "fr", # default UI language
"avatar": None, # profile picture data-URL (#113)
"created_at": datetime.now(UTC).isoformat(),
+6
View File
@@ -18,6 +18,9 @@ Variables (voir ``.env.example``) :
- ``DRACO_TIKTAK_URL`` — base publique de TikTak.
- ``DRACO_KANBANTE_URL`` — base publique de Kanbante.
- ``DRACO_SHAARIT_URL`` — base publique de ShaarIt (barre v0.2.0).
- ``DRACO_HUB_URL`` — base publique du Hub (barre v0.3.0,
HUB-1 : liens Télécharger / À Propos / Aides / Profil du menu
compte ; le Hub n'a pas d'onglet dans la barre).
- ``DRACO_GITEA_URL`` — base publique de Gitea (les cartes
Kanbante *sont* des issues Gitea, contrat liens-profonds) ;
distincte de ``OBSIGATE_GITEA_URL`` (source connectée de
@@ -52,6 +55,7 @@ DEFAUTS: dict[str, str] = {
"tiktak": "https://tiktak.dracodev.net",
"kanbante": "https://kanbante.dracodev.net",
"shaarit": "https://shaarit.dracodev.net",
"hub": "https://hub.dracodev.net",
"gitea": "https://git.dracodev.net",
}
@@ -62,6 +66,7 @@ _VARIABLES: dict[str, str] = {
"tiktak": "DRACO_TIKTAK_URL",
"kanbante": "DRACO_KANBANTE_URL",
"shaarit": "DRACO_SHAARIT_URL",
"hub": "DRACO_HUB_URL",
"gitea": "DRACO_GITEA_URL",
}
@@ -75,6 +80,7 @@ _SLUGS_HOTE: dict[str, str] = {
"tiktak": "tiktak",
"kanbante": "kanbante",
"shaarit": "shaarit",
"hub": "hub",
}
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
+74 -1
View File
@@ -19,6 +19,12 @@
# 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)
#
# Registre des émissions (HUB-13, fusion #127) : chaque jti émis est
# consigné par `sub` dans data/draco_issued.json (jti -> exp, purgé à
# l'expiration) pour que l'administration des comptes puisse révoquer
# toutes les sessions draco d'un utilisateur (désactivation,
# suppression, réinitialisation du mot de passe) via revoke().
from __future__ import annotations
@@ -40,10 +46,12 @@ 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")
ISSUED_FILE = Path("data/draco_issued.json")
ALLOWED_RETURN_SUFFIX = "dracodev.net"
_lock = threading.Lock()
_revoked: dict[str, int] | None = None # jti -> exp (chargé paresseusement)
_issued: dict[str, dict[str, int]] | None = None # sub -> {jti -> exp}
def _secret() -> str:
@@ -108,6 +116,69 @@ def revoked_list() -> list[dict]:
for jti, exp in _load_revoked().items() if exp > now]
# ── Registre des émissions (par utilisateur) ────────────────────────────
def _load_issued() -> dict[str, dict[str, int]]:
global _issued
if _issued is None:
try:
data = json.loads(ISSUED_FILE.read_text(encoding="utf-8"))
_issued = {
str(sub): {str(j): int(e) for j, e in tokens.items()}
for sub, tokens in data.items()
if isinstance(tokens, dict)
} if isinstance(data, dict) else {}
except (OSError, ValueError):
_issued = {}
return _issued
def _save_issued() -> None:
ISSUED_FILE.parent.mkdir(parents=True, exist_ok=True)
tmp = ISSUED_FILE.with_suffix(".tmp")
tmp.write_text(json.dumps(_issued or {}), encoding="utf-8")
tmp.replace(ISSUED_FILE)
def _register_issued(sub: str, jti: str, exp: int) -> None:
"""Consigne un jti émis pour ``sub`` (purge des expirés au passage)."""
if not sub:
return
try:
with _lock:
store = _load_issued()
now = int(time.time())
for key in list(store.keys()):
store[key] = {j: e for j, e in store[key].items() if e > now}
if not store[key]:
del store[key]
store.setdefault(sub, {})[jti] = int(exp)
_save_issued()
except Exception: # le registre ne casse jamais une émission
logger.warning("Registre des émissions draco non écrit", exc_info=True)
def revoke_all_for_user(sub: str) -> int:
"""Révoque tous les jti draco actifs émis pour ``sub``.
Retourne le nombre de jti révoqués. Utilisé par l'administration
des comptes (HUB-13) : désactivation, suppression, réinitialisation
du mot de passe.
"""
if not sub:
return 0
with _lock:
store = _load_issued()
tokens = store.pop(str(sub), {})
now = int(time.time())
actifs = {j: e for j, e in tokens.items() if e > now}
if tokens:
_save_issued()
for jti, exp in actifs.items():
revoke(jti, exp)
return len(actifs)
# ── Émission / vérification ─────────────────────────────────────────────
def issue_token(user: dict) -> tuple[str, str, int]:
@@ -122,7 +193,9 @@ def issue_token(user: dict) -> tuple[str, str, int]:
"exp": exp,
"jti": jti,
}
return jwt.encode(claims, _secret(), algorithm=ALGORITHM), jti, exp
token = jwt.encode(claims, _secret(), algorithm=ALGORITHM)
_register_issued(str(claims["sub"]), jti, exp)
return token, jti, exp
def decode_claims(token: str) -> dict | None:
+3 -1
View File
@@ -550,6 +550,7 @@ from backend.auth.router import router as auth_router
from backend.bookslm_routes import router as bookslm_router
from backend.routers.backups import router as backups_router
from backend.routers.coffres import router as coffres_router
from backend.routers.comptes_machine import router as comptes_machine_router
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
@@ -585,6 +586,7 @@ app.include_router(backups_router) # ROADMAP #85 T4 — Backups
app.include_router(conflicts_router) # ROADMAP #85 T8 — Conflicts
app.include_router(config_router) # ROADMAP #85 T7 — Config
app.include_router(coffres_router) # Coffre Secrets Phase 1b — JAMAIS dans vaults.py
app.include_router(comptes_machine_router) # HUB-13 (fusion #127) — Admin des comptes, machine-à-machine
app.include_router(file_chat_router) # ROADMAP #169 — Chat par fichier
app.include_router(files_read_router) # ROADMAP #85 T6a — Files read
app.include_router(files_media_router) # ROADMAP #85 T6c — Media/export
@@ -1010,7 +1012,7 @@ if FRONTEND_DIR.exists():
soeurs = " ".join(sorted({
origin_of(urls.get(cle, "")) or ""
for cle in ("obsigate", "flowdeck", "tiktak", "kanbante",
"agent")
"shaarit", "agent")
} - {""}))
csp = (
"default-src 'self'; "
+3 -1
View File
@@ -43,6 +43,7 @@ PROD_ORIGINS = frozenset({
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
})
DEV_ORIGINS = frozenset({
@@ -50,6 +51,7 @@ DEV_ORIGINS = frozenset({
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
})
AGENT_ORIGINS = frozenset({
@@ -59,7 +61,7 @@ AGENT_ORIGINS = frozenset({
# Clés de draco_config.urls_publiques() qui sont des applications sœurs.
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante")
_SOEUR_KEYS = ("obsigate", "flowdeck", "tiktak", "kanbante", "shaarit")
def is_dev_host(hostname: str | None) -> bool:
+351
View File
@@ -0,0 +1,351 @@
# backend/routers/comptes_machine.py
# HUB-13 (fusion #127) — API d'administration des comptes, machine-à-machine.
#
# Le Hub (interface d'administration des utilisateurs) administre les
# comptes dont ObsiGate reste le magasin (décisions D1(a)/D2(a) du plan
# Hub v2) via ces routes, en lecture et en écriture : lister, lire le
# profil étendu, créer, modifier, désactiver/réactiver, supprimer,
# réinitialiser le mot de passe et le MFA.
#
# Emplacement : ``/api/admin/machine/comptes`` — dans l'espace
# d'administration existant (``/api/admin/…``), avec un segment
# ``machine`` explicite qui le distingue des routes d'administration par
# session (``/api/auth/admin/users``, garde ``require_admin``). Aucune
# session utilisateur — même administratrice — n'ouvre ces routes :
# seule la clé machine dédiée les protège.
#
# Authentification machine (même famille que ``GET /api/sso/revocations``) :
# DRACO_ADMIN_MACHINE_KEY clé dédiée à cette API (en-tête
# ``X-Draco-Machine-Key``). Distincte de
# ``DRACO_SSO_MACHINE_KEY`` : le périmètre
# (écriture de comptes) est plus sensible que
# la seule lecture des révocations (plan §3).
# Sans elle, l'API est inerte (404, comme la
# liste de révocations non configurée).
# X-Draco-Actor identité de service appelante, facultative
# (défaut ``service-machine``) — consignée
# comme acteur dans le journal d'audit.
#
# Chaque action est journalisée côté ObsiGate dans le journal d'audit
# existant (``data/audit.log``, voir backend.audit.log_compte_admin).
from __future__ import annotations
import hmac
import logging
import os
import re
import time
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, validator
from backend import draco_session
from backend.audit import log_compte_admin
from backend.auth.password import validate_password_strength
from backend.auth.user_store import (
create_user,
delete_user,
get_all_users,
get_user,
update_user,
)
from backend.services.net import get_client_ip
logger = logging.getLogger("obsigate.comptes_machine")
router = APIRouter(prefix="/api/admin/machine/comptes", tags=["admin"])
#: Variable d'environnement portant la clé machine dédiée à cette API.
MACHINE_KEY_ENV = "DRACO_ADMIN_MACHINE_KEY"
#: En-tête de la clé machine (même nom que pour /api/sso/revocations).
MACHINE_KEY_HEADER = "X-Draco-Machine-Key"
#: En-tête facultatif : identité de service appelante (journal d'audit).
ACTOR_HEADER = "X-Draco-Actor"
#: Acteur consigné quand l'appelant ne se nomme pas.
DEFAULT_ACTOR = "service-machine"
# ── Garde machine ───────────────────────────────────────────────────────
def require_machine_admin(request: Request) -> str:
"""Exige la clé machine dédiée ; retourne l'identité de service.
Jamais de repli sur une session utilisateur : sans en-tête valide,
la réponse est 401 (ou 404 si l'API n'est pas configurée, patron de
``GET /api/sso/revocations``).
"""
machine_key = os.environ.get(MACHINE_KEY_ENV, "")
if not machine_key:
raise HTTPException(404, "Non configuré")
provided = request.headers.get(MACHINE_KEY_HEADER, "")
if not provided or not hmac.compare_digest(provided, machine_key):
raise HTTPException(401, "Clé machine invalide")
actor = (request.headers.get(ACTOR_HEADER, "") or "").strip()
return actor[:64] if actor else DEFAULT_ACTOR
# ── Profil étendu (liste blanche — jamais de secret) ────────────────────
def _profil(user: dict) -> dict:
"""Profil étendu d'un compte, sûr à exposer à un service.
Liste blanche stricte : ni ``password_hash``, ni secret TOTP
(``mfa_secret``/``mfa_secret_pending``), ni codes de récupération,
ni identifiants WebAuthn ne sortent — seul l'état MFA (oui/non)
est exposé. L'avatar suit le modèle existant : la valeur stockée
(data-URL ou ``None``, comme ``GET /api/auth/me``) plus le booléen
de présence ``avatar_present`` pour les listes légères.
"""
return {
"id": user.get("id"),
"username": user.get("username"),
"display_name": user.get("display_name"),
"language": user.get("language", "fr"),
"avatar": user.get("avatar"),
"avatar_present": bool(user.get("avatar")),
"role": user.get("role"),
"active": bool(user.get("active")),
"vaults": user.get("vaults", []),
"mfa_enabled": bool(user.get("mfa_enabled")),
"mfa_method": user.get("mfa_method"),
"must_change_password": bool(user.get("must_change_password")),
"created_at": user.get("created_at"),
"last_login": user.get("last_login"),
}
def _compte_ou_404(username: str) -> dict:
user = get_user(username)
if user is None:
raise HTTPException(404, "Compte introuvable")
return user
def _revoquer_sessions(user: dict) -> int:
"""Révoque les sessions d'un compte ; retourne le nombre de jti draco.
Les ``jti`` du cookie partagé émis pour ce compte sont révoqués via
le mécanisme existant (``draco_session.revoke``, exposé aux autres
applications par ``GET /api/sso/revocations``). Les jetons natifs
sont invalidés par le garde ``password_changed_at`` (BUG-028),
avancé par l'appelant (désactivation, réinitialisation du mot de
passe) ou par le drapeau ``active`` tant que le compte est inactif.
"""
sub = str(user.get("id") or user.get("username") or "")
return draco_session.revoke_all_for_user(sub)
def _audit(request: Request, acteur: str, action: str, cible: str,
details: dict | None = None) -> None:
log_compte_admin(acteur, action, cible,
ip=get_client_ip(request), details=details)
# ── Modèles de requête ──────────────────────────────────────────────────
class CreerCompteRequest(BaseModel):
username: str
password: str
display_name: str | None = None
role: str = "user"
vaults: list[str] = []
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
@validator("username")
def username_valid(cls, v):
if not re.match(r"^[a-zA-Z0-9_-]{2,32}$", v):
raise ValueError("2-32 caractères alphanumériques, _ ou -")
return v.lower()
@validator("role")
def role_valid(cls, v):
if v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
class ModifierCompteRequest(BaseModel):
display_name: str | None = None
role: str | None = None
language: str | None = None
vaults: list[str] | None = None
@validator("role")
def role_valid(cls, v):
if v is not None and v not in ("admin", "user"):
raise ValueError("Rôle invalide")
return v
@validator("language")
def language_valid(cls, v):
if v is not None and v not in ("fr", "en"):
raise ValueError("Langue invalide (fr | en)")
return v
class ReinitialiserMotDePasseRequest(BaseModel):
password: str
@validator("password")
def password_valid(cls, v):
return validate_password_strength(v)
# ── Lecture ─────────────────────────────────────────────────────────────
@router.get("")
async def lister_comptes(acteur: str = Depends(require_machine_admin)):
"""Liste tous les comptes (profils étendus, sans aucun secret)."""
comptes = [_profil(u) for u in get_all_users()]
return {"comptes": comptes, "total": len(comptes)}
@router.get("/{username}")
async def lire_compte(username: str,
acteur: str = Depends(require_machine_admin)):
"""Lit un compte et son profil étendu."""
return _profil(_compte_ou_404(username))
# ── Écriture ────────────────────────────────────────────────────────────
@router.post("", status_code=201)
async def creer_compte(req: CreerCompteRequest, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Crée un compte. Le mot de passe initial est temporaire :
``must_change_password`` est posé (changement forcé signalé à la
première connexion, comme après une réinitialisation)."""
if get_user(req.username) is not None:
raise HTTPException(409, "Identifiant déjà utilisé")
try:
user = create_user(
req.username, req.password, req.role, req.vaults,
req.display_name, must_change_password=True,
)
except ValueError as e:
raise HTTPException(409, str(e)) from e
# #194 : dossier personnel — même provisionnement que la création
# par session administratrice (best effort).
from backend.user_home import ensure_user_home
await ensure_user_home(req.username)
fresh = get_user(req.username)
if fresh:
user = fresh
_audit(request, acteur, "compte_cree", req.username,
{"role": req.role})
return _profil(user)
@router.patch("/{username}")
async def modifier_compte(username: str, req: ModifierCompteRequest,
request: Request,
acteur: str = Depends(require_machine_admin)):
"""Modifie un compte (nom affiché, rôle global, langue, vaults)."""
_compte_ou_404(username)
updates = req.dict(exclude_none=True)
if not updates:
raise HTTPException(400, "Aucun champ à modifier")
updated = update_user(username, updates)
_audit(request, acteur, "compte_modifie", username,
{"champs": sorted(updates.keys())})
return _profil(updated)
@router.post("/{username}/desactiver")
async def desactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Désactive un compte : plus aucun nouveau jeton n'est émis
(connexion refusée) et les ``jti`` draco actifs sont révoqués."""
user = _compte_ou_404(username)
updated = update_user(username, {
"active": False,
# BUG-028 : avancer le repère invalide aussi les jetons natifs
# déjà émis — ils ne ressuscitent pas à la réactivation.
"password_changed_at": time.time(),
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "compte_desactive", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reactiver")
async def reactiver_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Réactive un compte désactivé (les anciennes sessions restent
révoquées : une nouvelle connexion est nécessaire)."""
_compte_ou_404(username)
updated = update_user(username, {"active": True})
_audit(request, acteur, "compte_reactive", username)
return _profil(updated)
@router.delete("/{username}")
async def supprimer_compte(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Supprime un compte et révoque ses ``jti`` draco actifs."""
user = _compte_ou_404(username)
nb_jti = _revoquer_sessions(user)
try:
delete_user(username)
except ValueError as e:
raise HTTPException(404, str(e)) from e
# #194 : fermer le vault du dossier personnel (dossier conservé),
# comme la suppression par session administratrice.
from backend.user_home import release_user_home
await release_user_home(username)
_audit(request, acteur, "compte_supprime", username,
{"jti_revoques": nb_jti})
return {"message": f"Compte '{username}' supprimé"}
@router.post("/{username}/reinitialiser-mot-de-passe")
async def reinitialiser_mot_de_passe(
username: str, req: ReinitialiserMotDePasseRequest,
request: Request, acteur: str = Depends(require_machine_admin)):
"""Pose un nouveau mot de passe temporaire : changement forcé à la
première connexion (``must_change_password``, signalé par la
connexion et ``GET /api/auth/me``, effacé au changement effectif).
Les sessions existantes sont révoquées (repère BUG-028 + ``jti``
draco). Le mot de passe n'est jamais journalisé."""
user = _compte_ou_404(username)
updated = update_user(username, {
"password": req.password,
"must_change_password": True,
})
nb_jti = _revoquer_sessions(user)
_audit(request, acteur, "mot_de_passe_reinitialise", username,
{"jti_revoques": nb_jti})
return _profil(updated)
@router.post("/{username}/reinitialiser-mfa")
async def reinitialiser_mfa(username: str, request: Request,
acteur: str = Depends(require_machine_admin)):
"""Efface l'enrôlement TOTP d'un compte (utilisateur verrouillé
hors de son second facteur) : secret, secret en attente et codes
de récupération sont supprimés. Les clés WebAuthn éventuelles sont
conservées et gardent alors le MFA actif. Le secret n'est jamais
exposé — ni avant, ni après."""
user = _compte_ou_404(username)
webauthn_restantes = len(user.get("webauthn_credentials") or [])
updates: dict = {
"mfa_secret": None,
"mfa_secret_pending": None,
"mfa_recovery_codes": [],
}
if webauthn_restantes:
updates["mfa_method"] = "webauthn"
else:
updates["mfa_enabled"] = False
updates["mfa_method"] = None
updated = update_user(username, updates)
_audit(request, acteur, "mfa_reinitialise", username,
{"webauthn_conserves": webauthn_restantes})
return _profil(updated)
+1 -1
View File
@@ -2626,7 +2626,7 @@ dependencies = [
[[package]]
name = "obsigate-desktop"
version = "2.83.1"
version = "2.84.0"
dependencies = [
"chrono",
"env_logger",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "obsigate-desktop"
version = "2.83.1"
version = "2.84.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.83.1",
"version": "2.84.0",
"identifier": "com.obsigate.desktop",
"build": {
"frontendDist": "../frontend",
+19 -1
View File
@@ -1,6 +1,6 @@
# ObsiGate — Roadmap
> **Version :** 2.83.1 | **Dernière mise à jour :** 2026-11-03
> **Version :** 2.84.0 | **Dernière mise à jour :** 2026-11-06
> **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,24 @@
## 🔵 En cours
### 226. Hub HUB-13 — API d'administration des comptes (machine-à-machine)
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-05 | **Statut :** ✅ livré en 2.84.0 (PR en attente de fusion — voir CHANGELOG ; redéploiement Docker requis)
- **Fiche :** [features/hub-api-admin-comptes-226.md](./features/hub-api-admin-comptes-226.md)
- **Description :** carte fusion #127 (plan Hub v2 §2.2, D1(a)/D2(a)) : le Hub
administre les comptes dont ObsiGate reste le magasin, via
`/api/admin/machine/comptes` (liste, profil étendu, création,
modification, désactivation/réactivation avec révocation des `jti`
draco, suppression, réinitialisation du mot de passe avec changement
forcé `must_change_password`, réinitialisation du MFA), protégée par
la clé machine dédiée `DRACO_ADMIN_MACHINE_KEY` — jamais par une
session. Chaque action est journalisée dans `data/audit.log`.
- **Sous-tâches :**
- [x] Routes machine lecture/écriture + garde par clé dédiée (`backend/routers/comptes_machine.py`)
- [x] Registre des émissions draco + `revoke_all_for_user` (`backend/draco_session.py`)
- [x] Champ `must_change_password` (modèle, connexion, `/me`, changement de mot de passe)
- [x] Journalisation `log_compte_admin` (`backend/audit.py`) + tests `tests/test_comptes_machine.py`
### 225. Fusion — panneau Spark : version du service d'agents visible en mode sidebar
- **Effort :** ~0,1 jour | **Impact :** 🟢 | **Ouvert :** 2026-10-26 | **Statut :** ✅ livré le 2026-10-26 (version du hook — voir CHANGELOG ; redéploiement Docker requis)
+105
View File
@@ -0,0 +1,105 @@
# #226 — HUB-13 : API d'administration des comptes (machine-à-machine)
**Carte :** fusion #127 (HUB-13, plan Hub v2 §2.2 et §4 vague A ; décisions
D1(a) — magasin des comptes chez ObsiGate — et D2(a) — ObsiGate émettrice).
**Version :** 2.84.0.
## Objet
Le Hub administre les comptes de la suite depuis son interface
d'administration ; ObsiGate reste le magasin des comptes. Cette API est
le bras armé de cette interface : administration complète des comptes,
en lecture et en écriture, réservée aux appels machine-à-machine.
## Routes — `/api/admin/machine/comptes`
Emplacement choisi : l'espace d'administration existant (`/api/admin/…`),
segment `machine` explicite. Il distingue ces routes de l'administration
par session (`/api/auth/admin/users`, garde `require_admin`) : aucune
session utilisateur, même administratrice, ne les ouvre.
| Méthode | Chemin | Effet |
|---|---|---|
| GET | `/api/admin/machine/comptes` | Liste des comptes (profils étendus) |
| GET | `/api/admin/machine/comptes/{username}` | Profil étendu d'un compte |
| POST | `/api/admin/machine/comptes` | Création (201 ; 409 si identifiant pris) |
| PATCH | `/api/admin/machine/comptes/{username}` | Modification (nom affiché, rôle global, langue, vaults) |
| POST | `/api/admin/machine/comptes/{username}/desactiver` | Désactivation + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reactiver` | Réactivation |
| DELETE | `/api/admin/machine/comptes/{username}` | Suppression + révocation des sessions |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mot-de-passe` | Nouveau mot de passe temporaire, changement forcé |
| POST | `/api/admin/machine/comptes/{username}/reinitialiser-mfa` | Effacement de l'enrôlement TOTP |
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
configurée), 409 conflit d'identifiant, 422 corps invalide.
## Protection
- Clé machine **dédiée** : `DRACO_ADMIN_MACHINE_KEY`, en-tête
`X-Draco-Machine-Key` (même famille et même en-tête que
`GET /api/sso/revocations`). Distincte de `DRACO_SSO_MACHINE_KEY` :
l'écriture de comptes est plus sensible que la lecture des
révocations (plan §3). Comparaison à temps constant.
- Sans la variable, l'API est inerte (404 « Non configuré »).
- Acteur consigné : en-tête facultatif `X-Draco-Actor` (défaut
`service-machine`).
## Profil étendu
Liste blanche stricte : `id`, `username`, `display_name`, `language`,
`avatar` (valeur du modèle — data-URL ou `null`, comme
`GET /api/auth/me`) et `avatar_present`, `role`, `active`, `vaults`,
`mfa_enabled` (oui/non), `mfa_method`, `must_change_password`,
`created_at`, `last_login`. **Jamais** le haché du mot de passe, le
secret TOTP, les codes de récupération ni les identifiants WebAuthn.
## Désactivation et sessions
- `active = false` → la connexion est refusée (401 uniforme, BUG-039)
et plus aucun jeton n'est émis.
- Les `jti` draco émis pour le compte sont révoqués via le mécanisme
existant (`draco_session.revoke`, liste publiée par
`GET /api/sso/revocations`). Pour les connaître, `draco_session`
tient désormais un registre des émissions (`data/draco_issued.json`,
`jti → exp` par `sub`, purgé à l'expiration) et
`revoke_all_for_user(sub)`.
- Le repère `password_changed_at` (BUG-028) est avancé : les jetons
natifs déjà émis sont invalidés et ne ressuscitent pas à la
réactivation. Les anciennes sessions draco restent révoquées après
réactivation : une nouvelle connexion est nécessaire.
## Changement forcé du mot de passe
Nouveau champ du modèle utilisateur : `must_change_password` (booléen,
défaut `false` — ajouté par `create_user`, aucun fichier à migrer :
les comptes existants sans le champ valent `false`). Posé à la
création machine et à la réinitialisation du mot de passe ; signalé
dans la réponse de connexion et `GET /api/auth/me` ; effacé par
`POST /api/auth/change-password`.
## Réinitialisation du MFA
Efface `mfa_secret`, `mfa_secret_pending` et `mfa_recovery_codes`.
Les clés WebAuthn éventuelles sont conservées et gardent le MFA
actif (méthode `webauthn`) ; sans elles, `mfa_enabled` passe à `false`.
## Journalisation
Chaque action est consignée dans le journal d'audit existant
(`data/audit.log`, JSON lignes) via `backend.audit.log_compte_admin` :
horodatage, `acteur` (identité de service), `action` (`compte_cree`,
`compte_modifie`, `compte_desactive`, `compte_reactive`,
`compte_supprime`, `mot_de_passe_reinitialise`, `mfa_reinitialise`),
`cible` (compte visé), IP et détails non sensibles (champs modifiés,
nombre de `jti` révoqués — jamais de mot de passe ni de secret).
## Tests
`tests/test_comptes_machine.py` : refus sans clé / mauvaise clé / clé
SSO / session admin, API inerte non configurée, liste et profil étendu
sans secret, création puis lecture et connexion, doublon 409,
modification, désactivation (connexion refusée, session native
refusée, `jti` draco révoqué et publié) puis réactivation,
réinitialisation du mot de passe (ancien refusé, nouveau accepté,
changement forcé signalé puis effacé au changement), réinitialisation
MFA (connexion directe après), suppression, journalisation.
+21 -1
View File
@@ -20,7 +20,18 @@
le clair via data-theme="light" héritait quand même du sombre
système (piège constaté dans les copies vendangées de
Kanbante/Flowdeck ; les re-vendanges de la v0.2.0 le corrigent).
v0.3.0 (HUB-1) : aucun style nouveau — les liens du Hub du menu
compte (Télécharger, À Propos, Aides, Profil) réutilisent
.draco-menu-item (déjà ancre-compatible : text-decoration none)
et le séparateur .draco-menu-sep du menu des applications.
v0.4.0 (HUB-11) : .draco-menu-icone — les icônes monoline des
items du menu compte (SVG inline construit par le JS) : 18 px,
replacées à gauche du libellé par order: -1 (l'item est en
flex), couleur atténuée --ink-2, pleine --ink au survol/focus.
Jetons repris du prototype validé Console v7.2.
v0.4.1 (fusion #125) : menu compte resserré — min-width
248 → 232 px, le panneau épouse le contenu (demande de Bruno :
le menu était trop large une fois les icônes visibles).
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
(aucun attribut data-theme sur <html>). */
@@ -78,7 +89,7 @@
* droite ; ses items réutilisent .draco-apps-item. */
.draco-compte-menu {
position: absolute; top: calc(100% + 6px); right: 10px; z-index: 400;
min-width: 248px; padding: 6px;
min-width: 232px; padding: 6px;
background: var(--surface); border: 1px solid var(--line); border-radius: 14px;
box-shadow: 0 16px 44px rgb(0 0 0 / .28);
}
@@ -90,6 +101,15 @@
text-decoration: none; cursor: pointer;
}
.draco-menu-item:hover, .draco-menu-item:focus-visible { background: var(--surface-2); }
/* Icônes monoline du menu compte (v0.4.0) : appendues après le
libellé dans le DOM, replacées à gauche par order: -1. */
.draco-menu-icone {
order: -1; flex: none; display: inline-flex; align-items: center;
width: 18px; height: 18px; color: var(--ink-2);
}
.draco-menu-icone svg { display: block; width: 18px; height: 18px; }
.draco-menu-item:hover .draco-menu-icone,
.draco-menu-item:focus-visible .draco-menu-icone { color: var(--ink); }
.draco-menu-sep { height: 1px; margin: 6px 10px; background: var(--line); }
.draco-compte-nom {
padding: 8px 10px 10px; font-weight: 700; font-size: 14px;
+157 -11
View File
@@ -14,6 +14,28 @@
* contrat que les autres apps), entrée APP_LABELS ; l'onglet
* (data-app="shaarit", après Kanbante, avant agent) est repris
* automatiquement par la construction du menu de la marque.
* v0.3.0 (carte HUB-1, fusion #115) : le Hub, point central de la
* suite — nouvelle clé d'URL « hub » (jeton {{URL_HUB}} côté hôte,
* DRACO_BAR_CONFIG.urls.hub), défaut officiel
* https://hub.dracodev.net/, dérivation -dev hub-dev comme les
* sœurs. Le menu compte gagne trois vrais liens (patron
* buildAppsMenu : <a href>, classe draco-menu-item) — Télécharger
* → <hub>/telecharger, À Propos → <hub>/a-propos, Aides →
* <hub>/aide — et « Profil » devient lui aussi un lien, vers
* <hub>/profil (il n'est plus relayé aux hôtes : un seul Profil,
* celui du Hub). « Déconnexion » reste l'action relayée
* (data-compte-act="deconnexion" / onAction), inchangée.
* refreshMenuHrefs() resynchronise aussi les liens du Hub à
* l'ouverture des menus. Le Hub n'a pas d'onglet dans la barre :
* comme spark, sa clé est résolue et exposée via DRACO_BAR.urls,
* sans étape « href rendu » (voir « URL » ci-dessous).
* v0.4.0 (carte HUB-11, fusion #125) : icônes monoline dans le
* menu compte — chaque item (les 4 liens du Hub et « Déconnexion »)
* reçoit un glyphe SVG inline dans le style des icônes du rail de
* la console (contour, trait fin régulier, currentColor, aucun
* remplissage), aligné à gauche du libellé via .draco-menu-icone.
* L'en-tête d'identité ne reçoit pas d'icône. Aucun changement de
* comportement : liens résolus et Déconnexion relayée inchangés.
*
* 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
@@ -33,7 +55,8 @@
* kanbante: "https://kanbante.dracodev.net/",
* shaarit: "https://shaarit.dracodev.net/",
* agent: "https://agent.dracodev.net/",
* spark: "https://spark.dracodev.net/"
* spark: "https://spark.dracodev.net/",
* hub: "https://hub.dracodev.net/"
* },
* onAction: function (act, el) { … } // l'app relaie les actions
* } // qu'elle ne connaît pas
@@ -67,6 +90,12 @@
* la marque et DRACO_BAR.urls. Même résolution pour shaarit (v0.2.0) :
* explicite > dérivation -dev shaarit-dev > href rendu
* ({{URL_SHAARIT}}) > défaut officiel.
* Cas hub (v0.3.0) : PAS d'onglet, donc pas d'étape « href rendu » —
* explicite (DRACO_BAR_CONFIG.urls.hub, alimenté par le jeton
* {{URL_HUB}} remplacé côté serveur par l'hôte) > dérivation -dev
* hub-dev sur hôte -dev > défaut officiel
* https://hub.dracodev.net/. La base résolue sert les quatre liens
* du menu compte (telecharger, a-propos, aide, profil).
* Actions émises (data-act) : rail, palette, domaine (data-dom),
* alertes, spark, rappel-entete. L'app intégratrice décide de chacune
* (ouvrir la palette, le panneau Spark, changer de domaine…).
@@ -141,6 +170,14 @@
* mais le menu affiche l'identité même sans gestionnaire hôte.
* Accessibilité : vrais <button>, aria-haspopup / aria-expanded,
* role="menu" / "menuitem", fermeture par Échap et clic extérieur.
* Menu compte (v0.3.0) — Profil n'est PLUS relayé : c'est un lien
* vers <hub>/profil, comme Télécharger / À Propos / Aides (liens
* Hub, data-hub-link, href resynchronisés à chaque ouverture via
* refreshCompteHrefs / refreshMenuHrefs). Seule « Déconnexion »
* reste relayée à l'hôte (data-compte-act="deconnexion"), inchangée.
* Les hôtes qui traitaient onAction("profil") (Kanbante → /profile)
* voient ce comportement remplacé par le lien Hub — décision du
* plan Hub : un seul Profil, celui du Hub.
*/
(function () {
"use strict";
@@ -155,7 +192,8 @@
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
agent: "https://agent.dracodev.net/",
spark: "https://spark.dracodev.net/"
spark: "https://spark.dracodev.net/",
hub: "https://hub.dracodev.net/"
};
/* Dérivation -dev (v0.1.6) — appliquée UNIQUEMENT quand l'hôte courant
@@ -168,7 +206,8 @@
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
agent: "https://agent-dev.dracodev.net/"
agent: "https://agent-dev.dracodev.net/",
hub: "https://hub-dev.dracodev.net/"
};
var APP_LABELS = {
@@ -307,6 +346,28 @@
menu.querySelectorAll(".draco-apps-item").forEach(function (a) {
if (a.dataset.app && resolvedUrls[a.dataset.app]) a.setAttribute("href", resolvedUrls[a.dataset.app]);
});
/* v0.3.0 : les liens du Hub (menu compte) sont resynchronisés
* eux aussi à l'ouverture d'un menu, pour refléter setUrls et
* la dérivation -dev même s'ils ont été construits avant. */
refreshCompteHrefs();
}
/* Liens du Hub dans le menu compte (v0.3.0) : chemin → libellé.
* « Profil » est le quatrième lien (vers <hub>/profil) ; il
* remplace l'ancien item relayé « profil ». */
var HUB_LINKS = [
["telecharger", "Télécharger"],
["a-propos", "À Propos"],
["aide", "Aides"],
["profil", "Profil"]
];
function hubHref(path) {
return withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) + path;
}
function refreshCompteHrefs() {
if (!compteMenu) return;
compteMenu.querySelectorAll("[data-hub-link]").forEach(function (a) {
a.setAttribute("href", hubHref(a.getAttribute("data-hub-link")));
});
}
function togglePopup(menu, btn, force) {
if (!menu) return;
@@ -429,10 +490,80 @@
if (!appsMenu.hidden) lastOpener = brand;
}
/* Menu compte (v0.1.6) — en-tête d'identité + Profil / Déconnexion
* relayés à l'hôte. L'en-tête est rafraîchi à chaque ouverture, pour
* refléter un title posé après coup par l'hôte (ex. ObsiGate, qui
* remplit l'avatar depuis /api/auth/me après chargement). */
/* Icônes monoline du menu compte (v0.4.0) — glyphes contour
* dans le style des icônes du rail de la console : viewBox 24,
* stroke currentColor, remplissage nul, extrémités rondes.
* Chaque glyphe est une liste de primitives [balise, attributs]
* construites en DOM (jamais d'innerHTML), comme le reste du
* fichier. Le libellé reste le nœud texte de l'item ; l'icône,
* appendue après, est replacée à gauche par le CSS (order: -1
* sur .draco-menu-icone, l'item étant déjà en flex). */
var MENU_ICONES = {
/* Flèche vers le bas dans un plateau. */
"telecharger": [
["path", { d: "M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" }],
["polyline", { points: "7 10 12 15 17 10" }],
["line", { x1: "12", y1: "15", x2: "12", y2: "3" }]
],
/* Cercle d'information. */
"a-propos": [
["circle", { cx: "12", cy: "12", r: "9" }],
["line", { x1: "12", y1: "11", x2: "12", y2: "16" }],
["line", { x1: "12", y1: "8", x2: "12.01", y2: "8" }]
],
/* Cercle d'interrogation. */
"aide": [
["circle", { cx: "12", cy: "12", r: "9" }],
["path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }],
["line", { x1: "12", y1: "17", x2: "12.01", y2: "17" }]
],
/* Personne : tête + épaules. */
"profil": [
["path", { d: "M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" }],
["circle", { cx: "12", cy: "7", r: "4" }]
],
/* Flèche sortant d'un cadre (porte). */
"deconnexion": [
["path", { d: "M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" }],
["polyline", { points: "16 17 21 12 16 7" }],
["line", { x1: "21", y1: "12", x2: "9", y2: "12" }]
]
};
/* Espace de noms SVG — OBLIGATOIRE : un élément « svg » (ou
* « path », « circle »…) créé par createElement() appartient à
* l'espace de noms HTML et le navigateur ne le dessine JAMAIS
* (bug v0.4.0 : libellés décalés d'un cran d'icône, aucun glyphe
* visible ; corrigé en v0.4.1, carte fusion #125). */
var SVG_NS = "http://www.w3.org/2000/svg";
function menuIcone(nom) {
var span = document.createElement("span");
span.className = "draco-menu-icone";
span.setAttribute("aria-hidden", "true");
var svg = document.createElementNS(SVG_NS, "svg");
svg.setAttribute("viewBox", "0 0 24 24");
svg.setAttribute("fill", "none");
svg.setAttribute("stroke", "currentColor");
svg.setAttribute("stroke-width", "1.8");
svg.setAttribute("stroke-linecap", "round");
svg.setAttribute("stroke-linejoin", "round");
svg.setAttribute("focusable", "false");
(MENU_ICONES[nom] || []).forEach(function (spec) {
var el = document.createElementNS(SVG_NS, spec[0]);
for (var k in spec[1]) el.setAttribute(k, spec[1][k]);
svg.appendChild(el);
});
span.appendChild(svg);
return span;
}
/* Menu compte (v0.1.6 ; v0.3.0 pour les liens) — en-tête
* d'identité, puis les liens du Hub (Télécharger, À Propos,
* Aides, Profil — vrais <a href>, même patron que le menu des
* applications), un séparateur, puis « Déconnexion » relayée à
* l'hôte. L'en-tête et les href sont rafraîchis à chaque
* ouverture, pour refléter un title posé après coup par l'hôte
* (ex. ObsiGate, qui remplit l'avatar depuis /api/auth/me après
* chargement) et la résolution d'URL la plus récente. */
function buildCompteMenu() {
if (!bar || !avatarBtn || compteMenu) return;
compteMenu = document.createElement("div");
@@ -443,15 +574,28 @@
var head = document.createElement("div");
head.className = "draco-compte-nom";
compteMenu.appendChild(head);
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
HUB_LINKS.forEach(function (pair) {
var a = document.createElement("a");
a.className = "draco-menu-item";
a.href = hubHref(pair[0]);
a.setAttribute("data-hub-link", pair[0]);
a.setAttribute("role", "menuitem");
a.textContent = pair[1];
a.appendChild(menuIcone(pair[0]));
compteMenu.appendChild(a);
});
var sep = document.createElement("div");
sep.className = "draco-menu-sep";
sep.setAttribute("aria-hidden", "true");
compteMenu.appendChild(sep);
var b = document.createElement("button");
b.type = "button";
b.className = "draco-menu-item";
b.setAttribute("data-compte-act", pair[0]);
b.setAttribute("data-compte-act", "deconnexion");
b.setAttribute("role", "menuitem");
b.textContent = pair[1];
b.textContent = "Déconnexion";
b.appendChild(menuIcone("deconnexion"));
compteMenu.appendChild(b);
});
bar.appendChild(compteMenu);
}
function refreshCompteMenu() {
@@ -459,6 +603,7 @@
var head = compteMenu.querySelector(".draco-compte-nom");
var name = displayName();
if (head) head.textContent = name || "Compte";
refreshCompteHrefs();
}
function toggleCompteMenu(force) {
buildCompteMenu();
@@ -555,6 +700,7 @@
resolvedUrls = resolveUrls();
window.DRACO_BAR.urls = resolvedUrls;
applyUrls(resolvedUrls);
refreshCompteHrefs();
}
};
+14 -3
View File
@@ -268,10 +268,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.2.0
(ui/barre-apps/, commit 70c837a de la branche
shaarit-phase3-barre-contrats) : ne pas modifier la structure
Snippet FIGÉ vendangé depuis Projets/dracodev-commons v0.3.0
(ui/barre-apps/, branche hub-menu-compte-v0-3-0 de la PR
commons #9 — HUB-1, fusion #115) : ne pas modifier la structure
ici ; toute évolution passe par une nouvelle version de commons.
v0.3.0 : menu compte — liens Hub Télécharger / À Propos /
Aides et Profil devenu lien (vers le Hub résolu par
backend/draco_config, clé "hub", passé en urls.hub de la
configuration ci-dessous) ; « Déconnexion » reste relayée.
v0.2.0 : 6e onglet ShaarIt (data-app="shaarit", après Kanbante,
avant l'onglet agent) — son href porte le défaut officiel et
la réécriture DRACO_URLS ci-dessous le résout comme les
@@ -376,6 +380,13 @@
})();
window.DRACO_BAR_CONFIG = {
current: "obsigate",
/* Barre v0.3.0 (HUB-1) : la base du Hub vient de
window.DRACO_URLS (clé "hub" de backend/draco_config :
DRACO_HUB_URL explicite, dérivation -dev, défaut
officiel) — le Hub n'a pas d'onglet, c'est par
urls.hub que le menu compte construit ses liens
Télécharger / À Propos / Aides / Profil. */
urls: (function () { var u = window.DRACO_URLS && window.DRACO_URLS.hub; return u ? { hub: u.replace(/\/$/, "") + "/" } : {}; })(),
domain: (function () { try { return localStorage.getItem("draco_domain") || "pro"; } catch (e) { return "pro"; } })(),
counts: {},
onAction: function (act, el) {
+39 -9
View File
@@ -2,11 +2,13 @@
* chantier 2+3 : onglets, « Tout voir », focus).
*
* Vanilla, sans dépendance. Un seul fichier vendangé tel quel par chaque
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante) : la configuration vient
* des attributs data-* de #recherche, rendus côté serveur par l'hôte.
* hôte (ObsiGate, Flowdeck, TikTak, Kanbante, ShaarIt) : la configuration
* vient des attributs data-* de #recherche, rendus côté serveur par
* l'hôte. Source canonique : dracodev-commons, ui/recherche/
* (contrat : contrats/recherche-federee.md).
*
* Présentation en onglets : Tout, ObsiGate, Flowdeck, TikTak, Kanbante,
* Console Spark. « Tout » = les 4 sections groupées, chaque liste
* ShaarIt, Console Spark. « Tout » = les 5 sections groupées, chaque liste
* tronquée à PER_APP_LIMIT ; un onglet application = tous les résultats
* de cette application (aucune troncature côté client — la requête
* ObsiGate part avec limit=FULL_LIMIT, les autres API renvoient leur
@@ -46,6 +48,11 @@
* -> { groups: [{ full_name, rows: [{ issue, column }] }] }
* Lien profond : {base}board/<full_name> (la carte n'a pas d'URL propre
* adressable — même limite que la page /search native de Kanbante).
* - ShaarIt : GET {base}/api/search?q=
* -> { links: [{ id, title, url, description, tags, shorturl,
* private }] }
* Lien profond (contrat liens-profonds) : {base}shaare/<shorturl>.
* Recherche native adressable : {base}?q=<q> (page d'accueil).
* - Console Spark : GET {base-console}/api/v1/recherche?q=&limite=
* (agent-commun, authentifié par le cookie SSO draco_session) ->
* { q, domain, limite_par_type, resultats[], par_type,
@@ -87,8 +94,9 @@
* Authentification cross-origin : les appels sœurs partent avec
* credentials:"include". Le navigateur envoie alors les cookies de
* chaque origine, dont le cookie SSO partagé draco_session
* (Domain=.dracodev.net), accepté par les 4 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68)
* (Domain=.dracodev.net), accepté par les 5 API de recherche
* (ObsiGate et TikTak nativement, Flowdeck et Kanbante via #68,
* ShaarIt via la fédération v0.2.1 de commons)
* et par GET /api/v1/recherche de la console (identity() accepte
* draco_session). Chaque sœur n'autorise en CORS que les origines
* sœurs (GET sur l'endpoint de recherche uniquement, pas de "*") ;
@@ -109,12 +117,14 @@
flowdeck: "https://flowdeck.dracodev.net/",
tiktak: "https://tiktak.dracodev.net/",
kanbante: "https://kanbante.dracodev.net/",
shaarit: "https://shaarit.dracodev.net/",
};
var DEV = {
obsigate: "https://og-dev.dracodev.net/",
flowdeck: "https://flowdeck-dev.dracodev.net/",
tiktak: "https://tiktak-dev.dracodev.net/",
kanbante: "https://kanbante-dev.dracodev.net/",
shaarit: "https://shaarit-dev.dracodev.net/",
};
var PROD_AGENT = "https://agent.dracodev.net/";
var DEV_AGENT = "https://agent-dev.dracodev.net/";
@@ -125,6 +135,7 @@
{ key: "flowdeck", label: "Flowdeck" },
{ key: "tiktak", label: "TikTak" },
{ key: "kanbante", label: "Kanbante" },
{ key: "shaarit", label: "ShaarIt" },
];
var CONSOLE_APP = { key: "console", label: "Console Spark" };
var CONSOLE_TYPE_LABELS = {
@@ -145,7 +156,7 @@
planification: "/?section=planifie",
identite: "/?section=identites",
};
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "console"];
var TABS = ["all", "obsigate", "flowdeck", "tiktak", "kanbante", "shaarit", "console"];
var COLUMN_LABELS = {
backlog: "Backlog",
in_progress: "En cours",
@@ -227,7 +238,7 @@
return TABS.indexOf(v) !== -1 ? v : "all";
}
/* Sections visibles pour un onglet. « Tout » = les 4 sections
/* Sections visibles pour un onglet. « Tout » = les 5 sections
* groupées (vue #68) ; la console n'y figure pas (voir en-tête). */
function visibleKeys(tab) {
tab = parseTab(tab);
@@ -256,7 +267,7 @@
* Chargement -> "…", ok/vide -> total ("0" honnête),
* erreur/indisponible/inactif -> "" (pas de faux 0).
* Tout : "…" tant qu'une section charge, sinon somme des totaux
* connus, "" si les 4 sections sont en erreur/inactives. */
* connus, "" si les 5 sections sont en erreur/inactives. */
function tabCounts(states) {
states = states || {};
var out = {};
@@ -367,6 +378,22 @@
};
});
}
if (app === "shaarit") {
return cut(data.links || []).map(function (l) {
var tags = (l.tags || []).filter(function (t) { return !!t; });
var ctx = "Bookmark";
if (l.private) ctx += " · privé";
if (tags.length) ctx += " · " + tags.join(", ");
return {
title: l.title || l.url || "Sans titre",
snippet: snippetOf(l.description || ""),
context: ctx,
/* Lien profond canonique (contrats/liens-profonds.md) ;
* sans shorturl, repli sur l'URL du bookmark lui-même. */
url: l.shorturl ? base + "shaare/" + l.shorturl : l.url || null,
};
});
}
return [];
}
@@ -455,13 +482,16 @@
if (app === "tiktak" || app === "kanbante") {
return base + "search?q=" + encodeURIComponent(q || "");
}
if (app === "shaarit") {
return base + "?q=" + encodeURIComponent(q || "");
}
/* ObsiGate et Flowdeck n'ont pas de page de recherche adressable :
* leur recherche native vit dans la palette (Ctrl+K) de l'app. */
return base;
}
function nativeSearchLabel(app) {
if (app === "tiktak" || app === "kanbante") {
if (app === "tiktak" || app === "kanbante" || app === "shaarit") {
return "Ouvrir la recherche dans " + appLabel(app);
}
return "Ouvrir " + appLabel(app) + " (recherche via la palette Ctrl+K)";
+8 -2
View File
@@ -132,9 +132,10 @@
data-base-obsigate="{{URL_OBSIGATE}}"
data-base-flowdeck="{{URL_FLOWDECK}}"
data-base-tiktak="{{URL_TIKTAK}}"
data-base-kanbante="{{URL_KANBANTE}}">
data-base-kanbante="{{URL_KANBANTE}}"
data-base-shaarit="{{URL_SHAARIT}}">
<h1>Rechercher partout</h1>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<p class="rp-hint">Notes ObsiGate, pages Flowdeck, tâches TikTak, cartes Kanbante, bookmarks ShaarIt — dans l'environnement courant (dev reste dev). « / » ou Ctrl+K pour rechercher, ↑/↓ puis Entrée pour ouvrir un résultat. « Tout voir » bascule vers l'onglet de l'application, sans quitter la page.</p>
<div class="rp-form">
<input id="rp-q" class="rp-input" type="search" name="q" value=""
placeholder="Rechercher partout…" autocomplete="off" autofocus>
@@ -145,6 +146,7 @@
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="flowdeck">Flowdeck <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="tiktak">TikTak <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="kanbante">Kanbante <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="shaarit">ShaarIt <span class="rp-tab-count"></span></button>
<button type="button" class="rp-tab" role="tab" aria-selected="false" tabindex="-1" data-rp-tab="console">Console Spark <span class="rp-tab-count"></span></button>
</nav>
<section class="rp-section" data-rp-section="obsigate">
@@ -163,6 +165,10 @@
<div class="rp-head"><h2>Kanbante — cartes</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="shaarit">
<div class="rp-head"><h2>ShaarIt — bookmarks</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
</section>
<section class="rp-section" data-rp-section="console">
<div class="rp-head"><h2>Console Spark — discussions</h2><span class="rp-count"></span><a class="rp-all" href="#">Tout voir</a></div>
<div class="rp-body"><p class="rp-state">Tapez une recherche ci-dessus.</p></div>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "obsigate",
"version": "2.83.1",
"version": "2.84.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": {
+36 -5
View File
@@ -38,6 +38,7 @@ const b = rp.resolveBases({
ok(b.tiktak === "https://tiktak-perso.example/", "explicite prioritaire");
ok(b.obsigate === "https://og-dev.dracodev.net/", "dev pour l'hôte courant");
ok(b.kanbante === "https://kanbante-dev.dracodev.net/", "dev avant href");
ok(b.shaarit === "https://shaarit-dev.dracodev.net/", "base shaarit dev");
ok(
rp.searchPath("obsigate", "café") === "api/search?q=caf%C3%A9&vault=all&limit=8",
"path ObsiGate",
@@ -58,21 +59,47 @@ ok(
"https://tiktak-dev.dracodev.net/search?q=q",
"repli TikTak",
);
ok(
rp.nativeSearchUrl("shaarit", "https://shaarit-dev.dracodev.net/", "q") ===
"https://shaarit-dev.dracodev.net/?q=q",
"repli ShaarIt = accueil ?q=",
);
/* normalize ShaarIt (commons v0.2.1) : lien profond shaare/<shorturl>,
* contexte privé + tags, repli sur l'URL du bookmark sans shorturl */
const signets = rp.normalize(
"shaarit",
{ links: [{ title: "Yandex", url: "https://yandex.com", description: "moteur", tags: ["recherche", "web"], shorturl: "xYz9", private: true }] },
"https://shaarit-dev.dracodev.net/",
"recherche",
);
ok(signets[0].url === "https://shaarit-dev.dracodev.net/shaare/xYz9", "lien profond ShaarIt (shaare/<shorturl>)");
ok(signets[0].context === "Bookmark · privé · recherche, web", "contexte ShaarIt (privé + tags)");
ok(signets[0].snippet === "moteur", "extrait ShaarIt = description");
const sansShort = rp.normalize(
"shaarit",
{ links: [{ title: "", url: "https://exemple.fr", tags: [], shorturl: "" }] },
"https://shaarit.dracodev.net/",
"x",
);
ok(sansShort[0].url === "https://exemple.fr" && sansShort[0].title === "https://exemple.fr", "ShaarIt sans shorturl : repli URL + titre");
/* ---- Chantier 2+3 : onglets, compteurs, URL, console (logique pure) ---- */
/* parseTab */
ok(rp.parseTab("obsigate") === "obsigate", "onglet valide conservé");
ok(rp.parseTab("shaarit") === "shaarit", "onglet shaarit valide");
ok(rp.parseTab("console") === "console", "onglet console valide");
ok(rp.parseTab("bidon") === "all", "onglet inconnu -> Tout");
ok(rp.parseTab(undefined) === "all", "onglet absent -> Tout");
/* visibleKeys : Tout = les 4 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "Tout = 4 sections, pas la console");
/* visibleKeys : Tout = les 5 sections groupées, sans la console */
ok(JSON.stringify(rp.visibleKeys("all")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "Tout = 5 sections, pas la console");
ok(JSON.stringify(rp.visibleKeys("tiktak")) === JSON.stringify(["tiktak"]), "onglet app = sa seule section");
ok(JSON.stringify(rp.visibleKeys("shaarit")) === JSON.stringify(["shaarit"]), "onglet shaarit = sa seule section");
ok(JSON.stringify(rp.visibleKeys("console")) === JSON.stringify(["console"]), "onglet console = sa seule section");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante"]), "onglet inconnu -> Tout");
ok(JSON.stringify(rp.visibleKeys("bidon")) === JSON.stringify(["obsigate", "flowdeck", "tiktak", "kanbante", "shaarit"]), "onglet inconnu -> Tout");
/* pageItems : troncature Tout (8), complet dans l'onglet de l'app */
let douze = Array.from({ length: 12 }, (_, i) => ({ title: "T" + i }));
@@ -85,9 +112,11 @@ let c = rp.tabCounts({
flowdeck: { status: "ok", total: 3 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 2 },
console: { status: "idle", total: 0 },
});
ok(c.obsigate === "…", "compteur … pendant le chargement");
ok(c.shaarit === "2", "compteur shaarit");
ok(c.flowdeck === "3" && c.tiktak === "0", "compteur total, vide honnête à 0");
ok(c.kanbante === "" && c.console === "", "erreur/inactif : pas de faux 0");
ok(c.all === "…", "Tout = … tant qu'une section charge");
@@ -96,18 +125,20 @@ c = rp.tabCounts({
flowdeck: { status: "ok", total: 2 },
tiktak: { status: "empty", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "ok", total: 1 },
console: { status: "ok", total: 4 },
});
ok(c.all === "7", "Tout = somme des totaux connus");
ok(c.all === "8", "Tout = somme des totaux connus (5 sections)");
ok(c.console === "4", "compteur console");
c = rp.tabCounts({
obsigate: { status: "error", total: 0 },
flowdeck: { status: "error", total: 0 },
tiktak: { status: "error", total: 0 },
kanbante: { status: "error", total: 0 },
shaarit: { status: "error", total: 0 },
console: { status: "idle", total: 0 },
});
ok(c.all === "", "Tout vide si les 4 sections sont en erreur");
ok(c.all === "", "Tout vide si les 5 sections sont en erreur");
/* buildSearchUrl : état onglet partageable */
ok(rp.buildSearchUrl("café", "tiktak") === "?q=caf%C3%A9&tab=tiktak", "URL q+onglet");
+377
View File
@@ -0,0 +1,377 @@
# tests/test_comptes_machine.py — HUB-13 (fusion #127)
# API d'administration des comptes machine-à-machine
# (/api/admin/machine/comptes), protégée par la clé dédiée
# DRACO_ADMIN_MACHINE_KEY : lecture/écriture, désactivation avec
# révocation des sessions, réinitialisations du mot de passe (avec
# changement forcé) et du MFA, suppression, journalisation d'audit.
import json
import os
import shutil
import tempfile
from pathlib import Path
import pytest
MACHINE_KEY = "cle-admin-machine-test"
MACHINE_HEADERS = {"X-Draco-Machine-Key": MACHINE_KEY,
"X-Draco-Actor": "hub-test"}
BASE = "/api/admin/machine/comptes"
@pytest.fixture
def machine_client():
"""TestClient avec auth active, données isolées, clés machine posées."""
tmp = Path(tempfile.mkdtemp())
data_dir = tmp / "data"
data_dir.mkdir()
from backend.auth.mfa import generate_secret
from backend.auth.password import hash_password
users = {
"version": 1,
"users": {
"admin": {
"id": "admin-1",
"username": "admin",
"display_name": "admin",
"password_hash": hash_password("chab30"),
"role": "admin",
"vaults": ["*"],
"active": True,
"created_at": "2026-01-01T00:00:00",
},
"mfauser": {
"id": "user-mfa",
"username": "mfauser",
"display_name": "Utilisateur MFA",
"password_hash": hash_password("motdepasse-mfa"),
"role": "user",
"vaults": ["TestVault"],
"active": True,
"created_at": "2026-01-01T00:00:00",
"mfa_enabled": True,
"mfa_secret": generate_secret(),
"mfa_method": "totp",
"mfa_recovery_codes": ["hash-fictif"],
},
},
}
(data_dir / "users.json").write_text(json.dumps(users), encoding="utf-8")
src_secret = Path("data/secret.key")
if src_secret.exists():
shutil.copy2(str(src_secret), str(data_dir / "secret.key"))
orig_cwd = os.getcwd()
test_vault_path = os.path.abspath("test-vault")
os.chdir(str(tmp))
os.environ["VAULT_1_NAME"] = "TestVault"
os.environ["VAULT_1_PATH"] = test_vault_path
os.environ["OBSIGATE_AUTH_ENABLED"] = "true"
os.environ["OBSIGATE_WATCHER_ENABLED"] = "false"
os.environ["DRACO_ADMIN_MACHINE_KEY"] = MACHINE_KEY
os.environ["DRACO_SSO_MACHINE_KEY"] = "cle-sso-test"
os.environ["DRACO_SESSION_SECRET"] = "secret-draco-test"
from backend import draco_session
draco_session._revoked = None
draco_session._issued = None
import backend.main
backend.main._load_config = lambda: {"watcher_enabled": False}
from backend.main import app
from backend.indexer import build_index, index
import asyncio
for key in list(index.keys()):
del index[key]
loop = asyncio.new_event_loop()
asyncio.set_event_loop(loop)
loop.run_until_complete(build_index())
from backend.search import init_inverted_index
init_inverted_index()
from fastapi.testclient import TestClient
client = TestClient(app)
yield client
if hasattr(client, "close"):
client.close()
loop.run_until_complete(asyncio.sleep(0))
draco_session._revoked = None
draco_session._issued = None
os.chdir(orig_cwd)
shutil.rmtree(str(tmp), ignore_errors=True)
for k in ["VAULT_1_NAME", "VAULT_1_PATH", "OBSIGATE_AUTH_ENABLED",
"OBSIGATE_WATCHER_ENABLED", "DRACO_ADMIN_MACHINE_KEY",
"DRACO_SSO_MACHINE_KEY", "DRACO_SESSION_SECRET"]:
os.environ.pop(k, None)
def _login(client, username, password):
return client.post("/api/auth/login",
json={"username": username, "password": password})
def _creer(client, username="nouveau", password="motdepasse-initial", **kw):
corps = {"username": username, "password": password,
"display_name": kw.pop("display_name", "Nouveau Compte"),
"role": kw.pop("role", "user")}
corps.update(kw)
return client.post(BASE, headers=MACHINE_HEADERS, json=corps)
# ── Protection par clé machine ──────────────────────────────────────────
class TestProtection:
def test_sans_cle_refuse(self, machine_client):
assert machine_client.get(BASE).status_code == 401
assert machine_client.post(
BASE, json={"username": "x1", "password": "motdepasse"}
).status_code == 401
def test_mauvaise_cle_refusee(self, machine_client):
resp = machine_client.get(
BASE, headers={"X-Draco-Machine-Key": "mauvaise-cle"})
assert resp.status_code == 401
def test_cle_sso_ne_suffit_pas(self, machine_client):
"""La clé des révocations (lecture) n'ouvre pas l'administration."""
resp = machine_client.get(
BASE, headers={"X-Draco-Machine-Key": "cle-sso-test"})
assert resp.status_code == 401
def test_session_admin_ne_suffit_pas(self, machine_client):
"""Même une session administratrice ordinaire est refusée."""
token = _login(machine_client, "admin", "chab30").json()["access_token"]
resp = machine_client.get(
BASE, headers={"Authorization": f"Bearer {token}"})
assert resp.status_code == 401
def test_non_configuree_inerte(self, machine_client, monkeypatch):
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Lecture ─────────────────────────────────────────────────────────────
class TestLecture:
def test_liste(self, machine_client):
resp = machine_client.get(BASE, headers=MACHINE_HEADERS)
assert resp.status_code == 200
data = resp.json()
assert data["total"] >= 2
noms = [c["username"] for c in data["comptes"]]
assert "admin" in noms and "mfauser" in noms
def test_profil_etendu_sans_secret(self, machine_client):
resp = machine_client.get(f"{BASE}/mfauser", headers=MACHINE_HEADERS)
assert resp.status_code == 200
profil = resp.json()
assert profil["username"] == "mfauser"
assert profil["display_name"] == "Utilisateur MFA"
assert profil["language"] == "fr"
assert profil["avatar_present"] is False
assert profil["role"] == "user"
assert profil["active"] is True
assert profil["mfa_enabled"] is True
# Jamais de secret : ni le haché du mot de passe, ni le TOTP.
brut = resp.text
assert "password_hash" not in brut
assert "mfa_secret" not in brut
assert "mfa_recovery" not in brut
def test_compte_inconnu_404(self, machine_client):
resp = machine_client.get(f"{BASE}/personne", headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Création / modification ─────────────────────────────────────────────
class TestCreationModification:
def test_creation_puis_lecture(self, machine_client):
resp = _creer(machine_client)
assert resp.status_code == 201, resp.text
profil = resp.json()
assert profil["username"] == "nouveau"
assert profil["must_change_password"] is True
lu = machine_client.get(f"{BASE}/nouveau", headers=MACHINE_HEADERS)
assert lu.status_code == 200
assert lu.json()["display_name"] == "Nouveau Compte"
# Le compte créé se connecte, avec changement forcé signalé.
login = _login(machine_client, "nouveau", "motdepasse-initial")
assert login.status_code == 200
assert login.json()["user"]["must_change_password"] is True
def test_creation_doublon_409(self, machine_client):
assert _creer(machine_client).status_code == 201
assert _creer(machine_client).status_code == 409
assert _creer(machine_client, username="admin").status_code == 409
def test_modification(self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.patch(
f"{BASE}/nouveau", headers=MACHINE_HEADERS,
json={"display_name": "Nom Modifié", "role": "admin"})
assert resp.status_code == 200
assert resp.json()["display_name"] == "Nom Modifié"
assert resp.json()["role"] == "admin"
def test_modification_inconnue_404(self, machine_client):
resp = machine_client.patch(
f"{BASE}/personne", headers=MACHINE_HEADERS,
json={"display_name": "X"})
assert resp.status_code == 404
# ── Désactivation / réactivation ────────────────────────────────────────
class TestDesactivation:
def test_desactivation_revoque_puis_reactivation(self, machine_client):
from backend import draco_session
assert _creer(machine_client).status_code == 201
login = _login(machine_client, "nouveau", "motdepasse-initial")
assert login.status_code == 200
token_natif = login.json()["access_token"]
profil = machine_client.get(
f"{BASE}/nouveau", headers=MACHINE_HEADERS).json()
token_draco, jti, _exp = draco_session.issue_token(
{"id": profil["id"], "username": "nouveau",
"display_name": "Nouveau Compte"})
assert draco_session.verify_token(token_draco) is not None
resp = machine_client.post(f"{BASE}/nouveau/desactiver",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["active"] is False
# Connexion refusée, ancienne session native refusée,
# jti draco révoqué et publié dans la liste des révocations.
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
me = machine_client.get(
"/api/auth/me",
headers={"Authorization": f"Bearer {token_natif}"})
assert me.status_code == 401
assert draco_session.verify_token(token_draco) is None
revocations = machine_client.get(
"/api/sso/revocations",
headers={"X-Draco-Machine-Key": "cle-sso-test"})
assert revocations.status_code == 200
assert jti in [r["jti"] for r in revocations.json()["revoked"]]
resp = machine_client.post(f"{BASE}/nouveau/reactiver",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["active"] is True
# Réactivation : nouvelle connexion OK, ancien jti toujours révoqué.
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 200
assert draco_session.verify_token(token_draco) is None
# ── Réinitialisation du mot de passe ────────────────────────────────────
class TestReinitialisationMotDePasse:
def test_ancien_refuse_nouveau_accepte_changement_force(
self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.post(
f"{BASE}/nouveau/reinitialiser-mot-de-passe",
headers=MACHINE_HEADERS,
json={"password": "nouveau-mot-de-passe"})
assert resp.status_code == 200
assert resp.json()["must_change_password"] is True
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
login = _login(machine_client, "nouveau", "nouveau-mot-de-passe")
assert login.status_code == 200
assert login.json()["user"]["must_change_password"] is True
# Le changement effectif par l'utilisateur efface le marqueur.
token = login.json()["access_token"]
changement = machine_client.post(
"/api/auth/change-password",
headers={"Authorization": f"Bearer {token}"},
json={"current_password": "nouveau-mot-de-passe",
"new_password": "mot-de-passe-choisi"})
assert changement.status_code == 200, changement.text
login2 = _login(machine_client, "nouveau", "mot-de-passe-choisi")
assert login2.status_code == 200
assert login2.json()["user"]["must_change_password"] is False
# ── Réinitialisation du MFA ─────────────────────────────────────────────
class TestReinitialisationMfa:
def test_mfa_reinitialise(self, machine_client):
# Avant : la connexion exige le second facteur.
login = _login(machine_client, "mfauser", "motdepasse-mfa")
assert login.status_code == 200
assert login.json().get("mfa_required") is True
resp = machine_client.post(f"{BASE}/mfauser/reinitialiser-mfa",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert resp.json()["mfa_enabled"] is False
# Après : connexion directe, sans second facteur.
login = _login(machine_client, "mfauser", "motdepasse-mfa")
assert login.status_code == 200
assert "access_token" in login.json()
# ── Suppression ─────────────────────────────────────────────────────────
class TestSuppression:
def test_suppression(self, machine_client):
assert _creer(machine_client).status_code == 201
resp = machine_client.delete(f"{BASE}/nouveau",
headers=MACHINE_HEADERS)
assert resp.status_code == 200
assert machine_client.get(
f"{BASE}/nouveau", headers=MACHINE_HEADERS).status_code == 404
assert _login(machine_client, "nouveau",
"motdepasse-initial").status_code == 401
def test_suppression_inconnue_404(self, machine_client):
resp = machine_client.delete(f"{BASE}/personne",
headers=MACHINE_HEADERS)
assert resp.status_code == 404
# ── Journalisation ──────────────────────────────────────────────────────
class TestJournalisation:
def test_actions_consignees(self, machine_client):
assert _creer(machine_client).status_code == 201
machine_client.post(f"{BASE}/nouveau/desactiver",
headers=MACHINE_HEADERS)
audit_file = Path("data/audit.log")
assert audit_file.exists()
entrees = [json.loads(ligne)
for ligne in audit_file.read_text(
encoding="utf-8").splitlines() if ligne.strip()]
actions = {(e["action"], e["cible"]) for e in entrees
if e.get("cible") == "nouveau"}
assert ("compte_cree", "nouveau") in actions
assert ("compte_desactive", "nouveau") in actions
for e in entrees:
if e.get("cible") == "nouveau":
assert e["acteur"] == "hub-test"
assert e["timestamp"]
+56
View File
@@ -78,6 +78,29 @@ def test_barre_v020_onglet_shaarit_et_garde_data_theme():
assert 'shaarit: "https://shaarit-dev.dracodev.net/"' in js
def test_barre_v030_liens_hub_menu_compte():
# Barre v0.3.0 (dracodev-commons, HUB-1 / fusion #115) : clé
# urls.hub (défaut officiel + dérivation hub-dev), liens du menu
# compte (Télécharger, À Propos, Aides, Profil-lien) construits
# par le JS vendangé, et branchement hôte : index.html passe la
# base Hub de window.DRACO_URLS (backend/draco_config) en
# urls.hub de DRACO_BAR_CONFIG.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert 'hub: "https://hub.dracodev.net/"' in js
assert 'hub: "https://hub-dev.dracodev.net/"' in js
assert '"telecharger", "Télécharger"' in js
assert '"a-propos", "À Propos"' in js
assert '"aide", "Aides"' in js
assert '"profil", "Profil"' in js
assert 'data-hub-link' in js
# Profil n'est plus un item relayé ; Déconnexion le reste.
assert 'data-compte-act", "profil"' not in js
assert 'data-compte-act", "deconnexion"' in js
html = _index()
assert "window.DRACO_URLS.hub" in html
assert "v0.3.0" in html
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).
@@ -183,3 +206,36 @@ def test_panneau_version_service_affichee_225():
# La ligne naît masquée et n'est démasquée qu'avec une version lue.
assert 'class="draco-panneau-version"' in panneau
assert "versionEl.hidden = false" in panneau
def test_barre_v040_icones_monoline_menu_compte():
# v0.4.0 (HUB-11, fusion #125) : les items du menu compte
# portent des icônes SVG monoline construites en DOM, dans le
# style des icônes du rail de la console — aucun emoji, aucune
# icône pleine. La vendange reste verbatim commons.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert "MENU_ICONES" in js
for nom in ("telecharger", "a-propos", "aide", "profil", "deconnexion"):
assert nom in js
assert "draco-menu-icone" in js
assert "stroke: currentColor" not in js # attributs posés en DOM
assert 'setAttribute("stroke", "currentColor")' in js
assert 'setAttribute("fill", "none")' in js
css = _css()
assert ".draco-menu-icone" in css
bloc = css.split(".draco-menu-icone {", 1)[1].split("}", 1)[0]
assert "width: 18px" in bloc
assert "var(--ink-2)" in bloc
def test_barre_v041_correctif_icones_espace_de_noms_svg():
# v0.4.1 (correctif fusion #125) : les icônes du menu compte
# sont créées via createElementNS (espace de noms SVG). En
# v0.4.0, createElement() les créait dans l'espace de noms
# HTML : jamais dessinées par le navigateur, libellés
# décalés d'un cran d'icône sans aucun glyphe.
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
assert 'createElementNS(SVG_NS, "svg")' in js
assert "createElementNS(SVG_NS, spec[0])" in js
assert 'createElement("svg")' not in js
assert "createElement(spec[0])" not in js
+17
View File
@@ -20,6 +20,7 @@ def env_propre(monkeypatch):
for nom in (
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_SHAARIT_URL",
"DRACO_HUB_URL",
"DRACO_GITEA_URL",
"AGENT_EVENEMENTS_URL",
):
@@ -52,6 +53,22 @@ def test_shaarit_defaut_et_derivation(env_propre):
assert draco_config.app_url("shaarit") == "https://bm.example.net"
def test_hub_defaut_et_derivation(env_propre):
"""Barre v0.3.0 (HUB-1) : le Hub suit le même mécanisme que les
autres sœurs — défaut officiel, dérivation -dev depuis l'hôte
de l'agent, variable explicite prioritaire ; il est servi dans
urls_publiques() (window.DRACO_URLS) pour les liens du menu
compte, sans onglet dans la barre."""
assert draco_config.app_url("hub") == "https://hub.dracodev.net"
env_propre.setenv(
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
assert draco_config.app_url("hub") == "https://hub-dev.dracodev.net"
assert draco_config.urls_publiques()["hub"] == \
"https://hub-dev.dracodev.net"
env_propre.setenv("DRACO_HUB_URL", "https://hub.example.net/")
assert draco_config.app_url("hub") == "https://hub.example.net"
def test_agent_derive_des_evenements(env_propre):
"""Sans DRACO_AGENT_URL, l'origine suit AGENT_EVENEMENTS_URL —
la configuration réelle du poste (dev inclus)."""
+5 -2
View File
@@ -38,17 +38,20 @@ def test_origin_of():
def test_sister_origins_dev():
# Correctif 2026-10-28 : 10 origines officielles (8 sœurs + 2 agent), prod ET dev,
# quelle que soit la résolution.
# Correctif 2026-10-28 : origines officielles prod ET dev, quelle
# que soit la résolution ; ShaarIt ajoutée par la fédération
# v0.2.1 (12 origines : 10 sœurs + 2 agent).
assert sister_origins(RESOLVED_DEV) == {
"https://og.dracodev.net",
"https://flowdeck.dracodev.net",
"https://tiktak.dracodev.net",
"https://kanbante.dracodev.net",
"https://shaarit.dracodev.net",
"https://og-dev.dracodev.net",
"https://flowdeck-dev.dracodev.net",
"https://tiktak-dev.dracodev.net",
"https://kanbante-dev.dracodev.net",
"https://shaarit-dev.dracodev.net",
"https://agent.dracodev.net",
"https://agent-dev.dracodev.net",
}