Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5a5d7a5c59 | ||
|
|
b8949b2ddc | ||
|
|
15d015e59b | ||
|
|
dcf847e2fd | ||
|
|
a3f76e9b0c | ||
|
|
c157a22668 | ||
|
|
cc3707be7a | ||
|
|
75c6f61235 | ||
|
|
8d7692c6df | ||
|
|
318a20c652 | ||
|
|
006cbeed64 | ||
|
|
92ad47fc99 | ||
|
|
84648591ea | ||
|
|
308f99cf63 | ||
|
|
3bfda066ab | ||
|
|
0230f4fa72 | ||
|
|
9f6f366870 | ||
|
|
44a8154cae | ||
|
|
222cd0b562 | ||
|
|
5127510509 | ||
|
|
3876f0e9c3 | ||
|
|
c8d2eb19db | ||
|
|
2943e40e81 | ||
|
|
128deb248d | ||
|
|
151d9faeab | ||
|
|
c335f57ea2 | ||
|
|
c27fd977a8 | ||
|
|
65d01f91b8 | ||
|
|
0427c00015 | ||
|
|
b83de53447 | ||
|
|
5eba3e2558 | ||
|
|
10aa88e03c | ||
|
|
e0e0f9af0f | ||
|
|
35b828a4c5 | ||
|
|
6ebb3b3eae | ||
|
|
569d0e33a6 | ||
|
|
1d1ce5a848 | ||
|
|
f04369343d | ||
|
|
8290abd675 | ||
|
|
ea60d7e172 | ||
|
|
606d59f483 | ||
|
|
a046f286e6 | ||
|
|
0a92a55efa | ||
|
|
3c383559f4 | ||
|
|
6253c7558a | ||
|
|
6835607f66 | ||
|
|
478749566a | ||
|
|
d00c261ff1 | ||
|
|
646a4977f5 | ||
|
|
8683e1d724 | ||
|
|
5a7f1b9bcf | ||
|
|
14089ff59e | ||
|
|
0b0639ce61 | ||
|
|
603ba976e1 | ||
|
|
ad1cea23ab | ||
|
|
b5b5a0e820 | ||
|
|
60666bf79d | ||
|
|
099ffe7e8f | ||
|
|
bd5507d6ef | ||
|
|
3ba8b7b667 | ||
|
|
52dcbf3607 | ||
|
|
e28a537f79 | ||
|
|
b87ac72d78 | ||
|
|
ca367b6918 | ||
|
|
9037f2dd69 | ||
|
|
3a27fde176 | ||
|
|
d3acae2bd8 | ||
|
|
ad0a715a4c | ||
|
|
f7095f8057 | ||
|
|
67fbda60d3 | ||
|
|
5280b84d12 | ||
|
|
b42a81018c | ||
|
|
5c85763de1 | ||
|
|
2aacb60102 | ||
|
|
c4247b8bbc | ||
|
|
c29bafeccc | ||
|
|
fe4bed91d7 | ||
|
|
3abf908ca4 | ||
|
|
9c8e8f54dc | ||
|
|
1e59781d77 | ||
|
|
ead3393211 | ||
|
|
48beb692dc | ||
|
|
fe4b189697 | ||
|
|
c1e79d07e4 | ||
|
|
5de962b2c6 | ||
|
|
bc150bf225 | ||
|
|
31b8a86861 | ||
|
|
dda60313f0 | ||
|
|
fdf33c0f54 | ||
|
|
92bfd8f0ef | ||
|
|
64973a578b | ||
|
|
fb5414efbb | ||
|
|
174cd1c3e7 | ||
|
|
ee6748c261 | ||
|
|
dd0b12213f | ||
|
|
6195f252d9 | ||
|
|
397a60663c | ||
|
|
d792714f4f | ||
|
|
65edbc9e6c | ||
|
|
95df928035 | ||
|
|
78ac84e1cc | ||
|
|
550bcb5cc8 | ||
|
|
44baa0a273 | ||
|
|
78021d6549 | ||
|
|
72d8ce39d6 | ||
|
|
14dd77094d | ||
|
|
031879889c | ||
|
|
a5d2e0d085 | ||
|
|
8403b4fa08 | ||
|
|
757e32229d | ||
|
|
4c995f4c65 | ||
|
|
c950940e22 | ||
|
|
858dfd22e1 | ||
|
|
ab4a4dbd9d | ||
|
|
0bc1aca9bc | ||
|
|
515727ab9b | ||
|
|
3f375bca2a | ||
|
|
483af904af | ||
|
|
bfe0c75106 | ||
|
|
2a96ad3b1d | ||
|
|
12a29b29f6 | ||
|
|
8feab0188c | ||
|
|
fc44efabbb | ||
|
|
3ed09cbc0f | ||
|
|
a4f27bdbe7 | ||
|
|
05d1903045 | ||
|
|
b5b4a00b75 | ||
|
|
97f997840c | ||
|
|
474851b3bb | ||
|
|
4dfa8fd74c | ||
|
|
e709310e70 | ||
|
|
4d0c8ad934 | ||
|
|
b609e8913c |
@@ -47,6 +47,16 @@ OBSIGATE_ADMIN_PASSWORD=chab30
|
||||
# Ignored directories (séparés par des virgules)
|
||||
# OBSIGATE_IGNORED_DIRS=.obsidian,.trash,.git,__pycache__,node_modules,.obsigate-backup
|
||||
|
||||
# Vaults DIR (DIR_N_NAME / DIR_N_PATH dans le compose) — options par vault :
|
||||
# DIR_N_READONLY=true : lecture seule déclarée (mutations refusées
|
||||
# en 403, actions d'écriture masquées dans l'UI)
|
||||
# DIR_N_IGNORED_DIRS=.home,.venv : noms de dossiers cachés pour CE vault
|
||||
# (toute profondeur, en plus du global)
|
||||
# DIR_N_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env
|
||||
# : motifs fnmatch sur le chemin relatif dans
|
||||
# le vault (un dossier qui matche cache son
|
||||
# sous-arbre) — navigation, lecture, index
|
||||
|
||||
# Audit
|
||||
# OBSIGATE_AUDIT_MAX_SIZE=10485760
|
||||
|
||||
@@ -119,6 +129,8 @@ DEEPSEEK_MODEL=deepseek-chat
|
||||
# DRACO_FLOWDECK_URL=https://flowdeck.dracodev.net
|
||||
# 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) ──
|
||||
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
jobs:
|
||||
# ── Lint ──────────────────────────────────────────────────────────
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -100,7 +100,7 @@ jobs:
|
||||
# ── Tests ─────────────────────────────────────────────────────────
|
||||
test:
|
||||
needs: lint
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -127,7 +127,7 @@ jobs:
|
||||
|
||||
# ── Security scan ─────────────────────────────────────────────────
|
||||
security:
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -209,7 +209,7 @@ jobs:
|
||||
# ── Docker build ──────────────────────────────────────────────────
|
||||
build:
|
||||
needs: test
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -252,7 +252,7 @@ jobs:
|
||||
# ── E2E Tests (Playwright) ─────────────────────────────────────────
|
||||
e2e:
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
runs-on: ci-deepin
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
@@ -272,7 +272,13 @@ jobs:
|
||||
- name: Start ObsiGate
|
||||
run: |
|
||||
docker rm -f obsigate-e2e 2>/dev/null || true
|
||||
docker create --name obsigate-e2e -p 2029:8080 \
|
||||
# DinD robuste : PAS de port publié (les ports 2029 fuités par des
|
||||
# runs antérieurs font échouer « docker start », et l'IP passerelle
|
||||
# du job n'est pas toujours joignable → boucle de health curls sans
|
||||
# timeout qui pendaient 150 s chacun). À la place, l'app est
|
||||
# rattachée au réseau du job et jointable par nom DNS embarqué.
|
||||
JOBNET=$(docker inspect -f '{{range $k, $v := .NetworkSettings.Networks}}{{$k}} {{end}}' "$(hostname)" 2>/dev/null | awk '{print $1}')
|
||||
docker create --name obsigate-e2e \
|
||||
-v $(pwd)/test_vault:/vaults/TestVault \
|
||||
-v $(pwd)/test_dir:/vaults/TestDir \
|
||||
-e VAULT_1_NAME=TestVault \
|
||||
@@ -288,17 +294,22 @@ jobs:
|
||||
# fixtures avant le démarrage (l'indexeur scanne au démarrage).
|
||||
docker cp test_vault/. obsigate-e2e:/vaults/TestVault
|
||||
docker cp test_dir/. obsigate-e2e:/vaults/TestDir
|
||||
if [ -n "$JOBNET" ]; then docker network connect "$JOBNET" obsigate-e2e || true; fi
|
||||
docker start obsigate-e2e
|
||||
# Le port publié est joignable via l'IP de la passerelle (localhost
|
||||
# du job container ne voit pas le port publié sur l'hôte).
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Gateway IP: $GW"
|
||||
# Wait for health check
|
||||
for i in $(seq 1 30); do
|
||||
if curl -sf "http://$GW:2029/api/health"; then echo "Health OK"; break; fi
|
||||
if [ -n "$JOBNET" ]; then
|
||||
APP_URL="http://obsigate-e2e:8080"
|
||||
else
|
||||
echo "Réseau du job introuvable (JOBNET vide) — impossible de localiser l'app sans port publié"
|
||||
exit 1
|
||||
fi
|
||||
echo "APP_URL: $APP_URL"
|
||||
# --max-time : ne JAMAIS pendre sans timeout (bug d'origine : curls
|
||||
# sans timeout = 150 s × 31 tentatives, job « bloqué »).
|
||||
for i in $(seq 1 40); do
|
||||
if curl -sf --max-time 3 "$APP_URL/api/health"; then echo "Health OK"; break; fi
|
||||
sleep 1
|
||||
done
|
||||
curl -sf "http://$GW:2029/api/health" >/dev/null || { echo "App not reachable at $GW:2029"; exit 1; }
|
||||
curl -sf --max-time 5 "$APP_URL/api/health" >/dev/null || { echo "App not reachable at $APP_URL"; docker logs --tail 40 obsigate-e2e; exit 1; }
|
||||
# Les fixtures sont copiées via `docker cp` en root → rendre le vault
|
||||
# inscriptible par l'utilisateur non-root de l'app (UID 1000), sinon
|
||||
# toute création/édition de fichier renvoie 403 « Vault is read-only ».
|
||||
@@ -306,9 +317,10 @@ jobs:
|
||||
|
||||
- name: Run E2E tests
|
||||
run: |
|
||||
GW=$(ip route show default | awk '{print $3}' || echo 172.17.0.1)
|
||||
echo "Using BASE_URL=http://$GW:2029"
|
||||
BASE_URL="http://$GW:2029" npx playwright test --project=chromium-desktop --reporter=list
|
||||
BASE_URL="http://obsigate-e2e:8080"
|
||||
docker exec obsigate-e2e true >/dev/null 2>&1 || { echo "obsigate-e2e n'est pas en marche"; exit 1; }
|
||||
echo "Using BASE_URL=$BASE_URL"
|
||||
BASE_URL="$BASE_URL" npx playwright test --project=chromium-desktop --reporter=list
|
||||
|
||||
- name: Upload test results
|
||||
if: always()
|
||||
|
||||
@@ -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.82.1**.
|
||||
> [Unreleased](#unreleased). La dernière version livrée est **2.86.0**.
|
||||
|
||||
---
|
||||
|
||||
@@ -14,6 +14,105 @@ et [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
---
|
||||
|
||||
## [2.86.0] — 2026-11-06
|
||||
|
||||
- **BUG-122 — Déconnexion globale navigable : `GET /sso/logout`** (fusion #141) : le bouton « Déconnexion » du menu compte de la barre commune ne faisait rien dans les applications sœurs — la barre relaie l'action à l'hôte, et la déconnexion globale d'ObsiGate (ACC-3.1, #216) n'était appelable qu'en same-origin depuis l'interface d'ObsiGate (`POST /api/auth/logout`). Nouvelle route `GET /sso/logout?return_to=`, symétrique de `/sso/authorize` : elle exécute la déconnexion globale — logique extraite dans `apply_global_logout()` (`backend/auth/router.py`), désormais partagée par les deux entrées (jetons natifs refresh/access révoqués, `jti` du cookie `draco_session` révoqué, cookie effacé sur le domaine parent) — puis redirige vers le `return_to` validé par la liste blanche existante (`validate_return_to` : https + `*.dracodev.net`, repli « / »). Idempotente sans session. Les applications sœurs branchent leur action `deconnexion` sur cette route (leur session locale est détruite d'abord chez celles qui en ont une) — et la barre d'ObsiGate elle-même aussi (`frontend/index.html`, retour à sa racine). Tests : `tests/test_draco_session.py` (+3 : révocation et redirection, liste blanche, idempotence), `tests/test_draco_barre.py` (+1 : branchement de la barre d'ObsiGate). Fiche : `docs/features/deconnexion-globale-bug-122.md`.
|
||||
|
||||
---
|
||||
|
||||
## [2.85.1] — 2026-11-06
|
||||
|
||||
- **HUB-19 — Propagation des rôles du Hub à ObsiGate (suite du pilote ShaarIt, fusion Hub v2 §2.2, décision D3(a), #228)** : ObsiGate, à la fois émettrice et consommatrice du SSO, consomme à son tour le registre des rôles du Hub. Nouveau client `backend/hub_roles.py` : à l'ouverture de session draco (validation locale `draco_session.user_for_draco`), consultation de `GET {hub}/api/roles/obsigate` (en-tête Cookie complet relayé, `Accept: application/json`, délai 2 s), cache mémoire 60 s par `sub` sur le patron du pilote (horodatage posé avant l'appel : un échec conserve l'ancien cache, pas de rafale) ; URL du Hub résolue comme la barre commune (`draco_config.app_url("hub")` : `DRACO_HUB_URL` explicite, dérivation par l'hôte sœur, défaut officiel — jamais d'URL dev figée). **Effets, sessions établies par draco seulement** : niveau connu `aucun` → session draco refusée, `require_auth` répond 403 avec un message explicite en français (l'authentification native d'ObsiGate — l'émettrice, son accès propre est le secours — n'est jamais touchée) ; routes d'administration `/api/auth/admin/*` : nouvelle garde `require_admin_hub` — canal draco + rôle connu ⇒ niveau `administrateur` du registre exigé, sessions natives et rôle inconnu (Hub injoignable ou en erreur) ⇒ règles actuelles inchangées, aucun verrouillage nouveau, et le registre n'élève aucun privilège local. Validation locale du jeton, émission, révocations et renouvellement inchangés ; aucun rôle dans le jeton. Aucun impact frontend (pas d'i18n). Fiche : `docs/features/hub-roles-obsigate-228.md`. Tests : `tests/test_hub_roles.py` (client : parsing/cache/repli/résolution d'URL ; intégration : refus draco, garde admin). **Hypothèse de version** : branche depuis le main 2.84.1, cible 2.85.1 — rebasée sur la 2.85.0 (PR « API sessions HUB-22 » fusionnée d'abord) avant fusion.
|
||||
## [2.85.0] — 2026-11-06
|
||||
|
||||
- **HUB-22 — API machine des sessions draco (vague C du plan Hub v2.1, #227)** : trois nouvelles routes sous la garde machine existante (`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` / `X-Draco-Actor` ; 401 sans clé valide, 404 si l'API n'est pas configurée ou le compte inconnu) — `GET /api/admin/machine/comptes/{username}/sessions` liste les sessions actives du compte (`jti`, `exp`, `expire_le` en ISO 8601 UTC, `revoquee`), `POST …/sessions/{jti}/revoquer` révoque une session (404 si le `jti` n'est pas dans l'ensemble actif de ce compte — jamais de révocation cross-utilisateur) et `POST …/sessions/revoquer-toutes` révoque toutes les sessions (déconnexion globale, **sans désactiver le compte**). Backend : `draco_session.issued_sessions_for_user()` (sessions actives triées par expiration, purge des expirés au passage) et `revoke_one_for_user()` (appartenance au `sub` vérifiée avant révocation), sur le registre des émissions de HUB-13 et la même clé d'identité (`id`, à défaut `username`). Écritures journalisées dans `data/audit.log` (`session_revoquee`, `sessions_revoquees`). Fiche : `docs/features/hub-sessions-machine-227.md`. Tests : `tests/test_comptes_machine.py` (`TestSessions`), `tests/test_draco_session.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.84.1] — 2026-11-06
|
||||
|
||||
- **HUB-18 — Thème commun (fusion #132)** : barre commune re-vendangée **verbatim** en **v0.5.0** depuis dracodev-commons (branche `hub18-theme-commun`, PR commons #12) — `frontend/draco-barre.js` seul change côté barre (CSS et fragment inchangés). La barre apprend la préférence de thème du profil du Hub (`GET <hub>/api/profil/theme`, cookie draco ; `<hub>` = URL résolue de la barre, jamais figée) et l'applique à l'ouverture : cache `localStorage` `draco-theme-hub` (TTL 12 h) appliqué synchrone, puis fetch asynchrone non bloquant ; `sombre`/`clair` posent `data-theme`, `systeme` le retire (`prefers-color-scheme` décide). **Repli intégral** si le Hub ne répond pas, répond non-OK ou `theme: null` : le comportement actuel (choix local, puis système) reste seul maître. Initialisation propre à ObsiGate dans `frontend/app.js` : `initTheme()` écoute l'événement `draco:theme` de la barre et route le mode effectif par `applyTheme()` — icône et libellé du bouton de thème et feuilles highlight.js restent synchronisés, le mode appliqué devient le choix local mémorisé (`obsigate-theme`), et le bouton de bascule reste fonctionnel pour la session. Garde ajoutée à `tests/test_draco_barre.py`.
|
||||
|
||||
---
|
||||
|
||||
## [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.
|
||||
- **⚠️ Changement de comportement visible — garde `data-theme` (CSS v0.2.0)** : les blocs `prefers-color-scheme` du CSS vendangé sont désormais préfixés `html:not([data-theme])`. La copie précédente n'avait **pas** cette garde. ObsiGate pose toujours `data-theme` sur `<html>`, donc sa barre ne change pas en pratique ; la garde ne s'exercerait que sur une page hôte sans `data-theme` (elle ne forcerait plus le sombre système contre un choix clair). Signalé pour arbitrage à la fusion, comme chez Flowdeck où le piège était actif.
|
||||
|
||||
---
|
||||
|
||||
## [2.83.0] — 2026-11-03
|
||||
|
||||
- **Vaults DIR : exclusions par vault et lecture seule déclarable** (prépare le vault « Spark ») : trois nouvelles variables par vault DIR — `DIR_N_READONLY=true` (les mutations sont refusées en 403 « read_only » et l'arborescence n'affiche plus les actions d'écriture pour ce vault), `DIR_N_IGNORED_DIRS=.home,.venv,…` (noms de dossiers cachés à toute profondeur, en plus du global `IGNORED_DIRS`) et `DIR_N_IGNORED_PATHS=Executeurs/*/data,…` (motifs fnmatch sur le chemin relatif ; un dossier qui matche cache tout son sous-arbre). Les exclusions s'appliquent désormais **partout** — navigation (`/api/browse`), lecture (`/api/file`, `raw`, téléchargement : 404), liste des chemins et indexation — et plus seulement à l'indexeur. `/api/vaults` expose `readonly` pour chaque vault. Sans ces variables, comportement inchangé. Fiche : `docs/features/spark-vault-dir.md`. Tests : `tests/test_spark_vault_dir.py`.
|
||||
|
||||
---
|
||||
|
||||
## [2.82.8] — 2026-10-30
|
||||
|
||||
- **Panneau détail d'un secret : Afficher/Masquer + Copier la valeur** (PR #57) : la valeur reste masquée (••••••) par défaut ; Afficher la révèle en place, Masquer l'efface (effacée aussi dès que le détail change). « Copier la valeur » copie au presse-papiers sans afficher. Le bouton « Copier » existant ne copiait que la référence.
|
||||
- **Nouvel endpoint `POST /api/coffres/{slug}/secrets/{nom}/reveler`** : session + gestion du coffre exigées (propriétaire, ou admin sur coffre d'instance) ; un lecteur par octroi utilisateur reçoit 403. Chaque révélation est auditée (`revelation_manuelle`) et met à jour la dernière utilisation. Ce n'est pas une distribution : aucun octroi créé, aucun consommateur ne reçoit la valeur.
|
||||
- **Suppression de la « Copie manuelle à usage unique »** par consommateur temporaire : elle échouait systématiquement (aucun octroi pour le consommateur temporaire, donc 403 octroi_absent) ; « Copier la valeur » la remplace.
|
||||
- **Tests** : `tests/test_coffre_secrets.py` 44/44 (5 nouveaux TestRevelation).
|
||||
|
||||
## [2.82.7] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.11 vendangée)** : la marque de la barre n'est plus le logo rouge — c'est l'étoile Spark monochrome (`frontend/img/brand-spark.png`, servi en `/static/img/brand-spark.png`, 26 px), blanche sur barre foncée et noire sur barre claire : ObsiGate pose déjà `data-theme` sur `<html>` (sombre par défaut, mode persisté), donc la règle vendangée `[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }` s'applique directement et aucune surcharge propre n'est nécessaire. L'onglet Console Spark (et son entrée de menu, qui le clone) GARDE le logo rouge. Le bouton avatar Spark flottant bas-centre gagne un anneau rouge `#c00000` (bordure 3 px, `var(--agent)`), via le CSS vendangé.
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.11 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.11, `frontend/draco-barre.js` inchangé (identique à commons, lui-même inchangé en v0.1.11). Le reste de l'intégration est inchangé. Précision : sur `/recherche`, copie statique, le bouton flottant y est visuel seulement, comme au lot précédent — l'ouverture du panneau vit dans la SPA.
|
||||
|
||||
## [2.82.6] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.10 vendangée)** : le rose Spark `#c2437e` disparaît de la barre — `--agent` passe au rouge Spark `#c00000` (échantillonné dans le logo Spark), l'onglet Console Spark passe à `--app #c00000`, et la marque ✦ devient le logo Spark (26 px). Le bouton « Spark » rose de la barre est supprimé, remplacé par le bouton avatar Spark flottant bas-centre (cercle personnage, `frontend/img/avatar-spark.png`, servi en `/static/img/avatar-spark.png`, `data-act="spark"` → panneau Spark de l'hôte, comme l'ancien bouton). Le bouton Alertes n'affiche plus que la cloche (pastille conservée), donc une barre plus étroite.
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.10 ; `frontend/draco-barre.css` remplacé verbatim par la v0.1.10, `frontend/draco-barre.js` inchangé (identique à commons v0.1.9, lui-même inchangé en v0.1.10). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé. Précision : sur `/recherche`, copie statique, le panneau Spark n'est pas rejoué (comme avant) — le bouton flottant y est visuel, l'ouverture du panneau vit dans la SPA.
|
||||
|
||||
## [2.82.5] — 2026-10-29
|
||||
|
||||
- **Barre commune (dracodev-commons v0.1.9 vendangée)** : les 5 tuiles des onglets — et donc du menu de la marque, qui les clone — sont désormais les logos fournis, tels quels, jamais redessinés : ObsiGate (arbre), Flowdeck (cartes), TikTak (logo officiel SVG), Kanbante (cycle vert), Console Spark (étoile rouge validée). Fichiers dans `frontend/img/logos/` (copie de `ui/barre-apps/logos/` de commons, PNG 180 px), servis en `/static/img/logos/` ; l'entrée « Console Spark » du menu clone l'icône de l'onglet agent (`draco-barre.js`) ; onglet agent passé à `--app #d21f2c`. Les glyphes texte ▤/▦/✓/▥/✦ disparaissent des onglets (marque ✦, bouton Spark et rappel d'en-tête inchangés).
|
||||
- **Pages concernées** : la barre est rendue en ligne dans `frontend/index.html` et dans sa copie de `frontend/recherche.html` — les deux sont portées à la v0.1.9, `draco-barre.js` et `draco-barre.css` remplacés verbatim (les fichiers précédents étaient identiques à l'octet près à ceux de commons avant la v0.1.9, aucune adaptation locale perdue). Le reste de l'intégration (URL, `DRACO_URLS`, actions, compteurs) est inchangé.
|
||||
|
||||
## [2.82.4] — 2026-10-28
|
||||
|
||||
- **Recherche (PR #52)** : texte parasite du mécanisme de thème retiré du haut de la page `/recherche` (lignes 28-43 de `frontend/recherche.html`, hors commentaire).
|
||||
- **Démarrage (PR #53)** : écran de chargement plein écran `#boot-splash` retiré au démarrage d'ObsiGate (CSS, div et script de pilotage), sans écran de remplacement.
|
||||
|
||||
## [2.82.3] — 2026-10-28
|
||||
|
||||
- **Recherche (PR #49)** : la page `/recherche` applique désormais le thème ObsiGate.
|
||||
- **CORS recherche (PR #51)** : la recherche ObsiGate autorise désormais la console Spark (agent prod+dev) en inter-origines.
|
||||
|
||||
## [2.82.2] — 2026-10-28
|
||||
|
||||
- **Recherche (#68)** : l'onglet Console Spark de `/recherche` appelle désormais `GET /api/v1/recherche` (au lieu de `/api/search`) et reconstruit les liens profonds console (`?section=…&objet=…`, `disc=`, `activite=`) — les résultats console réels s'affichent enfin dans la page hôte ; barre de `/recherche` re-vendangée depuis `index.html` (5e onglet Console Spark) et `connect-src` CSP étendu à l'origine agent.
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.81.1).
|
||||
Consultez le [CHANGELOG.md](./CHANGELOG.md) pour l'historique complet de toutes les versions (v1.0.0 → v2.86.0).
|
||||
|
||||
---
|
||||
|
||||
*Projet : ObsiGate | Version : 2.81.1 | Dernière mise à jour : Septembre 2026*
|
||||
*Projet : ObsiGate | Version : 2.86.0 | Dernière mise à jour : Septembre 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
[]()
|
||||
[]()
|
||||
[](https://opensource.org/licenses/MIT)
|
||||
[](https://www.docker.com/)
|
||||
[](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.81.1).
|
||||
See [CHANGELOG.md](./CHANGELOG.md) for the complete version history (v1.0.0 → v2.86.0).
|
||||
|
||||
---
|
||||
|
||||
*Project: ObsiGate | Version: 2.81.1 | Last updated: September 2026*
|
||||
*Project: ObsiGate | Version: 2.86.0 | Last updated: September 2026*
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -141,9 +141,26 @@ def _draco_fallback(request: Request) -> dict | None:
|
||||
return user
|
||||
|
||||
|
||||
def require_auth(current_user=Depends(get_current_user)):
|
||||
def require_auth(request: Request, current_user=Depends(get_current_user)):
|
||||
"""Dependency: require a valid authenticated user."""
|
||||
if not current_user:
|
||||
# HUB-19 (#228) : la session draco a été refusée parce que le
|
||||
# registre des rôles du Hub attribue le niveau « aucun » à ce
|
||||
# compte pour ObsiGate — réponse explicite, distincte du 401
|
||||
# générique. L'authentification native n'est pas concernée :
|
||||
# ce refus ne naît que sur le chemin draco (user_for_draco).
|
||||
if getattr(request.state, "role_hub_refuse", None) == "aucun":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Accès refusé : le registre des rôles du Hub attribue "
|
||||
"le niveau « aucun » à votre compte pour ObsiGate, "
|
||||
"l'accès par la session draco est donc refusé. "
|
||||
"L'administrateur de la suite peut modifier ce niveau "
|
||||
"dans la matrice des rôles du Hub. L'accès natif "
|
||||
"d'ObsiGate (son propre identifiant) reste possible."
|
||||
),
|
||||
)
|
||||
raise HTTPException(
|
||||
status_code=401,
|
||||
detail="Authentification requise",
|
||||
@@ -159,6 +176,31 @@ def require_admin(current_user=Depends(require_auth)):
|
||||
return current_user
|
||||
|
||||
|
||||
def require_admin_hub(current_user=Depends(require_admin)):
|
||||
"""Dependency: admin + garde du registre des rôles du Hub (HUB-19, #228).
|
||||
|
||||
Pour les routes d'administration des comptes (``/api/auth/admin/*``) :
|
||||
quand la requête est authentifiée par le **canal draco** (``_draco_sub``
|
||||
présent) et que le rôle du Hub est **connu** (``_hub_role`` non None),
|
||||
le niveau ``administrateur`` du registre est exigé — le rôle local ne
|
||||
suffit plus. Session native, ou rôle inconnu (Hub injoignable) :
|
||||
règles actuelles inchangées.
|
||||
"""
|
||||
if current_user.get("_draco_sub"):
|
||||
role_hub = current_user.get("_hub_role")
|
||||
if role_hub is not None and role_hub != "administrateur":
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail=(
|
||||
"Accès réservé au niveau « administrateur » d'ObsiGate "
|
||||
"dans le registre des rôles du Hub (votre niveau actuel : "
|
||||
f"« {role_hub} »). L'administrateur de la suite peut "
|
||||
"l'accorder dans la matrice des rôles du Hub."
|
||||
),
|
||||
)
|
||||
return current_user
|
||||
|
||||
|
||||
def is_home_vault(vault_name: str) -> bool:
|
||||
"""Un dossier personnel (#194) : vault « home-<user> »."""
|
||||
return vault_name.startswith("home-")
|
||||
|
||||
@@ -40,7 +40,7 @@ from .mfa import (
|
||||
verify_recovery_code,
|
||||
verify_totp,
|
||||
)
|
||||
from .middleware import is_auth_enabled, require_admin, require_auth
|
||||
from .middleware import is_auth_enabled, require_admin_hub, require_auth
|
||||
from .password import hash_password, validate_password_strength, verify_password
|
||||
from .user_store import (
|
||||
create_user,
|
||||
@@ -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")),
|
||||
},
|
||||
}
|
||||
|
||||
@@ -373,12 +375,15 @@ async def refresh_token_endpoint(request: Request, response: Response):
|
||||
}
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||
def apply_global_logout(request: Request, response: Response) -> None:
|
||||
"""Déconnexion globale (Fusion ACC-3.1, #216) appliquée à ``response``.
|
||||
|
||||
Révoque les jetons natifs (refresh + access, BUG-027), révoque le
|
||||
``jti`` du cookie partagé ``draco_session`` et efface les cookies.
|
||||
Partagée entre ``POST /api/auth/logout`` (réponse JSON) et
|
||||
``GET /sso/logout`` (redirection, BUG-122) : les deux entrées
|
||||
terminent exactement la même session, celle de la suite entière.
|
||||
"""
|
||||
refresh_tok = request.cookies.get("refresh_token")
|
||||
if refresh_tok:
|
||||
payload = decode_token(refresh_tok)
|
||||
@@ -419,6 +424,15 @@ async def logout(
|
||||
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
|
||||
|
||||
|
||||
@router.post("/logout")
|
||||
async def logout(
|
||||
request: Request,
|
||||
response: Response,
|
||||
):
|
||||
"""Logout: revoke refresh and access tokens, then delete cookies."""
|
||||
apply_global_logout(request, response)
|
||||
return {"message": "Deconnecte avec succes"}
|
||||
|
||||
|
||||
@@ -433,6 +447,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 +503,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:
|
||||
@@ -949,9 +969,13 @@ async def mfa_recovery_login(body: MfaRecoveryRequest, response: Response, reque
|
||||
|
||||
|
||||
# ── Admin endpoints ───────────────────────────────────────────────────
|
||||
# Garde `require_admin_hub` (HUB-19, #228) : `require_admin` + — pour
|
||||
# une requête authentifiée par le canal draco dont le rôle du Hub est
|
||||
# connu — exigence du niveau « administrateur » du registre des rôles.
|
||||
# Sessions natives et rôle inconnu : règles actuelles inchangées.
|
||||
|
||||
@router.get("/admin/users")
|
||||
async def list_users(admin=Depends(require_admin)):
|
||||
async def list_users(admin=Depends(require_admin_hub)):
|
||||
"""List all users (admin only). Password hashes are never included."""
|
||||
return get_all_users()
|
||||
|
||||
@@ -959,7 +983,7 @@ async def list_users(admin=Depends(require_admin)):
|
||||
@router.post("/admin/users")
|
||||
async def create_user_endpoint(
|
||||
req: CreateUserRequest,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Create a new user (admin only)."""
|
||||
try:
|
||||
@@ -986,7 +1010,7 @@ async def create_user_endpoint(
|
||||
async def update_user_endpoint(
|
||||
username: str,
|
||||
req: UpdateUserRequest,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Update a user (admin only)."""
|
||||
updates = req.dict(exclude_none=True)
|
||||
@@ -999,7 +1023,7 @@ async def update_user_endpoint(
|
||||
@router.delete("/admin/users/{username}")
|
||||
async def delete_user_endpoint(
|
||||
username: str,
|
||||
admin=Depends(require_admin),
|
||||
admin=Depends(require_admin_hub),
|
||||
):
|
||||
"""Delete a user (admin only). Cannot delete own account."""
|
||||
if username == admin["username"]:
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -17,6 +17,10 @@ Variables (voir ``.env.example``) :
|
||||
- ``DRACO_FLOWDECK_URL`` — base publique de Flowdeck.
|
||||
- ``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
|
||||
@@ -50,6 +54,8 @@ DEFAUTS: dict[str, str] = {
|
||||
"flowdeck": "https://flowdeck.dracodev.net",
|
||||
"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",
|
||||
}
|
||||
|
||||
@@ -59,6 +65,8 @@ _VARIABLES: dict[str, str] = {
|
||||
"flowdeck": "DRACO_FLOWDECK_URL",
|
||||
"tiktak": "DRACO_TIKTAK_URL",
|
||||
"kanbante": "DRACO_KANBANTE_URL",
|
||||
"shaarit": "DRACO_SHAARIT_URL",
|
||||
"hub": "DRACO_HUB_URL",
|
||||
"gitea": "DRACO_GITEA_URL",
|
||||
}
|
||||
|
||||
@@ -71,6 +79,8 @@ _SLUGS_HOTE: dict[str, str] = {
|
||||
"flowdeck": "flowdeck",
|
||||
"tiktak": "tiktak",
|
||||
"kanbante": "kanbante",
|
||||
"shaarit": "shaarit",
|
||||
"hub": "hub",
|
||||
}
|
||||
|
||||
# Premier label de l'hôte de l'agent : ``agent`` suivi d'un suffixe
|
||||
|
||||
@@ -19,6 +19,27 @@
|
||||
# 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().
|
||||
# HUB-22 (#227) ajoute la lecture de ce registre
|
||||
# (issued_sessions_for_user) et la révocation unitaire contrôlée par
|
||||
# appartenance (revoke_one_for_user), exposées par l'API machine des
|
||||
# sessions (backend/routers/comptes_machine.py).
|
||||
#
|
||||
# Rôles du Hub (HUB-19, #228) : à l'ouverture de session draco,
|
||||
# `user_for_draco` consulte en plus le registre des rôles du Hub
|
||||
# (`backend/hub_roles.py`, contrat D3(a) posé par le pilote ShaarIt) :
|
||||
# niveau connu ``aucun`` → la session draco est refusée (l'authenti-
|
||||
# fication native d'ObsiGate, émettrice, n'est jamais touchée) ;
|
||||
# niveau connu → il est joint au dict utilisateur (``_hub_role``)
|
||||
# pour que les routes d'administration du canal draco exigent
|
||||
# ``administrateur`` ; rôle inconnu (Hub injoignable) → comportement
|
||||
# actuel, aucun verrouillage nouveau. Aucun rôle dans le jeton :
|
||||
# validation locale, émission et révocations sont inchangées.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -40,10 +61,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 +131,115 @@ 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)
|
||||
|
||||
|
||||
def issued_sessions_for_user(sub: str) -> list[dict]:
|
||||
"""Sessions draco actives émises pour ``sub`` (HUB-22, #227).
|
||||
|
||||
Retourne ``[{jti, exp, revoquee}]`` triée par ``exp`` croissant,
|
||||
où ``revoquee`` indique si le ``jti`` figure déjà dans la liste des
|
||||
révocations. Les entrées expirées de cet utilisateur sont purgées
|
||||
du registre au passage (même patron que ``_register_issued``).
|
||||
"""
|
||||
if not sub:
|
||||
return []
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
now = int(time.time())
|
||||
tokens = store.get(str(sub), {})
|
||||
actifs = {j: e for j, e in tokens.items() if e > now}
|
||||
if len(actifs) != len(tokens):
|
||||
if actifs:
|
||||
store[str(sub)] = actifs
|
||||
else:
|
||||
store.pop(str(sub), None)
|
||||
_save_issued()
|
||||
revoques = _load_revoked()
|
||||
entrees = sorted(actifs.items(), key=lambda kv: kv[1])
|
||||
return [{"jti": jti, "exp": exp, "revoquee": jti in revoques}
|
||||
for jti, exp in entrees]
|
||||
|
||||
|
||||
def revoke_one_for_user(sub: str, jti: str) -> bool:
|
||||
"""Révoque un seul ``jti`` draco de ``sub`` (HUB-22, #227).
|
||||
|
||||
Ne révoque (via ``revoke``) que si le ``jti`` appartient bien à
|
||||
l'ensemble émis de ce ``sub`` et n'est pas expiré — jamais de
|
||||
révocation d'une session d'un autre utilisateur. Retourne ``True``
|
||||
si la révocation a eu lieu, ``False`` sinon.
|
||||
"""
|
||||
if not sub or not jti:
|
||||
return False
|
||||
with _lock:
|
||||
store = _load_issued()
|
||||
exp = store.get(str(sub), {}).get(jti)
|
||||
if exp is None or exp <= int(time.time()):
|
||||
return False
|
||||
revoke(jti, exp)
|
||||
return True
|
||||
|
||||
|
||||
# ── Émission / vérification ─────────────────────────────────────────────
|
||||
|
||||
def issue_token(user: dict) -> tuple[str, str, int]:
|
||||
@@ -122,7 +254,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:
|
||||
@@ -211,6 +345,28 @@ def user_for_draco(request) -> dict | None:
|
||||
user["_token_vaults"] = candidate.get("vaults", [])
|
||||
user["_token_scope"] = "full"
|
||||
user["_draco_sub"] = sub
|
||||
# HUB-19 (#228) : rôle du registre du Hub pour ce porteur.
|
||||
# Niveau connu « aucun » → pas de session établie par draco :
|
||||
# le refus est consigné sur la requête pour que `require_auth`
|
||||
# réponde un message explicite en français (et non un simple
|
||||
# 401 générique). Rôle connu → joint au dict (`_hub_role`) ;
|
||||
# rôle inconnu → `_hub_role` vaut None et le comportement
|
||||
# actuel s'applique, sans verrouillage nouveau.
|
||||
from backend import hub_roles
|
||||
|
||||
cookie = ""
|
||||
try:
|
||||
cookie = request.headers.get("cookie", "") or ""
|
||||
except Exception: # un objet requête minimal (tests) ne casse rien
|
||||
cookie = ""
|
||||
role = hub_roles.role_pour(sub, cookie)
|
||||
if role == "aucun":
|
||||
try:
|
||||
request.state.role_hub_refuse = "aucun"
|
||||
except Exception: # pas d'état mutable : refus quand même
|
||||
pass
|
||||
return None
|
||||
user["_hub_role"] = role
|
||||
return user
|
||||
return None
|
||||
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
# backend/hub_roles.py
|
||||
# Hub HUB-19 (propagation, #228) — Rôles par application : consommation
|
||||
# du registre du Hub par ObsiGate, sur le contrat posé par l'hôte
|
||||
# pilote ShaarIt (0.7.0, décision D3(a) du plan Hub v2).
|
||||
#
|
||||
# À l'ouverture de session draco (validation locale du cookie partagé,
|
||||
# `draco_session.user_for_draco`), ObsiGate demande au Hub le niveau
|
||||
# du porteur pour l'application `obsigate` :
|
||||
# ``GET {hub}/api/roles/obsigate``, en relayant l'en-tête Cookie
|
||||
# complet de la requête, ``Accept: application/json``, délai 2 s.
|
||||
# Réponse 200 : ``{"app", "sub", "role", "explicite",
|
||||
# "ttl_secondes": 60}``, rôle ∈ {aucun, utilisateur, administrateur}.
|
||||
#
|
||||
# Cache mémoire par `sub`, TTL 60 s, sur le patron du pilote (et du
|
||||
# cache de révocations draco) : l'horodatage est posé **avant**
|
||||
# l'appel — un échec conserve l'ancien cache et ne déclenche pas de
|
||||
# rafale de requêtes vers un Hub en panne.
|
||||
#
|
||||
# **Repli obligatoire** : Hub injoignable, en erreur ou réponse
|
||||
# illisible → rôle **inconnu** (``None``) et l'appelant garde son
|
||||
# comportement actuel. Le registre ne crée jamais un verrouillage
|
||||
# nouveau.
|
||||
#
|
||||
# URL du Hub : `draco_config.app_url("hub")` — la même résolution que
|
||||
# la barre commune (#223) : variable explicite `DRACO_HUB_URL` d'abord,
|
||||
# dérivation par l'hôte sœur (suffixe d'environnement de l'hôte de
|
||||
# l'agent) ensuite, défaut officiel enfin. Jamais d'URL dev figée.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import urllib.request
|
||||
|
||||
logger = logging.getLogger("obsigate.hub_roles")
|
||||
|
||||
#: Identifiant d'ObsiGate dans le registre des applications du Hub.
|
||||
APP = "obsigate"
|
||||
|
||||
#: Niveaux reconnus du registre (contrat D3(a)).
|
||||
ROLES = ("aucun", "utilisateur", "administrateur")
|
||||
|
||||
#: TTL du cache, aligné sur ``ttl_secondes`` annoncé par le Hub (60 s).
|
||||
CACHE_TTL_SECONDS = 60
|
||||
|
||||
#: Délai maximal de la consultation du Hub (contrat : 2 s).
|
||||
DELAI_SECONDS = 2
|
||||
|
||||
_lock = threading.Lock()
|
||||
_cache: dict[str, str] = {} # sub -> rôle connu
|
||||
_cache_at: float | None = None # horodatage (monotone) du dernier appel
|
||||
|
||||
|
||||
def _hub_url() -> str:
|
||||
"""Base publique du Hub, résolue comme pour la barre commune."""
|
||||
from backend.draco_config import app_url
|
||||
|
||||
return app_url("hub")
|
||||
|
||||
|
||||
def _consulter_hub(url: str, cookie: str) -> str | None:
|
||||
"""Appelle le Hub et retourne le rôle validé, ou ``None``.
|
||||
|
||||
Toute erreur (réseau, délai dépassé, statut ≠ 200, JSON illisible,
|
||||
rôle hors contrat) retourne ``None`` : rôle inconnu, repli.
|
||||
``cookie`` est l'en-tête Cookie complet de la requête, relayé tel
|
||||
quel — c'est lui qui prouve l'identité du porteur au Hub.
|
||||
"""
|
||||
requete = urllib.request.Request(
|
||||
url,
|
||||
method="GET",
|
||||
headers={"Cookie": cookie, "Accept": "application/json"},
|
||||
)
|
||||
try:
|
||||
with urllib.request.urlopen(requete, timeout=DELAI_SECONDS) as reponse:
|
||||
if reponse.status != 200:
|
||||
return None
|
||||
donnees = json.loads(reponse.read().decode("utf-8"))
|
||||
except Exception: # le registre ne casse jamais une ouverture de session
|
||||
return None
|
||||
if not isinstance(donnees, dict):
|
||||
return None
|
||||
role = donnees.get("role")
|
||||
return role if role in ROLES else None
|
||||
|
||||
|
||||
def role_pour(sub: str, cookie: str) -> str | None:
|
||||
"""Niveau connu pour ce `sub` : un des ``ROLES`` si le Hub a
|
||||
répondu, ``None`` si le rôle est inconnu (Hub injoignable/erreur)
|
||||
— repli = comportement actuel de l'appelant.
|
||||
|
||||
`cookie` est l'en-tête Cookie complet de la requête d'origine,
|
||||
relayé tel quel au Hub.
|
||||
"""
|
||||
global _cache_at
|
||||
if not sub:
|
||||
return None
|
||||
with _lock:
|
||||
if _cache_at is not None \
|
||||
and time.monotonic() - _cache_at < CACHE_TTL_SECONDS \
|
||||
and sub in _cache:
|
||||
return _cache[sub]
|
||||
# Horodatage posé AVANT l'appel (patron du pilote) : l'ancien
|
||||
# cache reste en vigueur, un échec ne déclenche pas de rafale.
|
||||
_cache_at = time.monotonic()
|
||||
url = f"{_hub_url().rstrip('/')}/api/roles/{APP}"
|
||||
role = _consulter_hub(url, cookie)
|
||||
if role is None:
|
||||
logger.warning(
|
||||
"Registre des rôles du Hub indisponible (%s) — "
|
||||
"comportement actuel conservé", url)
|
||||
return None
|
||||
with _lock:
|
||||
_cache[sub] = role
|
||||
_cache_at = time.monotonic()
|
||||
return role
|
||||
|
||||
|
||||
def reinitialiser_cache() -> None:
|
||||
"""Vide le cache des rôles (tests, redémarrage à chaud)."""
|
||||
global _cache_at
|
||||
with _lock:
|
||||
_cache.clear()
|
||||
_cache_at = None
|
||||
@@ -97,6 +97,13 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
Also reads optional configuration:
|
||||
- VAULT_N_ATTACHMENTS_PATH: relative path to attachments folder
|
||||
- VAULT_N_SCAN_ATTACHMENTS: "true"/"false" to enable/disable scanning
|
||||
- DIR_N_READONLY: "true"/"false" — vault DIR déclaré en lecture seule
|
||||
(les mutations sont refusées en 403, l'UI masque les actions d'écriture)
|
||||
- DIR_N_IGNORED_DIRS: noms de dossiers ignorés pour ce vault, séparés
|
||||
par des virgules (toute profondeur, en plus du global IGNORED_DIRS)
|
||||
- DIR_N_IGNORED_PATHS: motifs fnmatch sur le chemin relatif POSIX dans
|
||||
le vault, séparés par des virgules (un dossier qui matche cache tout
|
||||
son sous-arbre) — navigation, lecture et indexation les respectent
|
||||
|
||||
Returns:
|
||||
Dict mapping vault names to configuration dicts with keys:
|
||||
@@ -104,6 +111,9 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
- attachmentsPath: relative attachments folder (optional)
|
||||
- scanAttachmentsOnStartup: boolean (default True)
|
||||
- type: "VAULT" or "DIR"
|
||||
- readonly: boolean (DIR only, default False)
|
||||
- ignoredDirs: set of directory names (DIR only, default empty)
|
||||
- ignoredPaths: list of fnmatch patterns (DIR only, default empty)
|
||||
"""
|
||||
vaults: dict[str, dict[str, Any]] = {}
|
||||
n = 1
|
||||
@@ -131,12 +141,23 @@ def load_vault_config() -> dict[str, dict[str, Any]]:
|
||||
path = os.environ.get(f"DIR_{n}_PATH")
|
||||
if not name or not path:
|
||||
break
|
||||
|
||||
|
||||
readonly = os.environ.get(f"DIR_{n}_READONLY", "false").lower() == "true"
|
||||
ignored_dirs = {
|
||||
d.strip() for d in os.environ.get(f"DIR_{n}_IGNORED_DIRS", "").split(",") if d.strip()
|
||||
}
|
||||
ignored_paths = [
|
||||
p.strip() for p in os.environ.get(f"DIR_{n}_IGNORED_PATHS", "").split(",") if p.strip()
|
||||
]
|
||||
|
||||
vaults[name] = {
|
||||
"path": path,
|
||||
"attachmentsPath": None,
|
||||
"scanAttachmentsOnStartup": False,
|
||||
"type": "DIR"
|
||||
"type": "DIR",
|
||||
"readonly": readonly,
|
||||
"ignoredDirs": ignored_dirs,
|
||||
"ignoredPaths": ignored_paths,
|
||||
}
|
||||
n += 1
|
||||
|
||||
@@ -509,7 +530,8 @@ def _scan_vault(
|
||||
Args:
|
||||
vault_name: Display name of the vault.
|
||||
vault_path: Absolute filesystem path to the vault root.
|
||||
vault_cfg: Optional vault configuration dict (unused for indexing, kept for compatibility).
|
||||
vault_cfg: Optional vault configuration dict — ses exclusions
|
||||
(``ignoredDirs`` / ``ignoredPaths``) sont appliquées au scan.
|
||||
previous_files: Optional ``{relative_path: file_info}`` snapshot from a
|
||||
previous scan used for differential reuse.
|
||||
|
||||
@@ -529,6 +551,16 @@ def _scan_vault(
|
||||
|
||||
root_resolved = vault_root.resolve(strict=False)
|
||||
|
||||
# Exclusions propres au vault (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) :
|
||||
# elles s'ajoutent au global IGNORED_DIRS, sans le modifier. Le cfg est
|
||||
# celui passé par l'appelant — l'index global ne contient pas encore ce
|
||||
# vault pendant son premier scan, chemin_exclu() (qui lit l'index) ne
|
||||
# peut donc pas servir ici.
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
cfg = vault_cfg or {}
|
||||
ignored_names = IGNORED_DIRS | set(cfg.get("ignoredDirs") or [])
|
||||
|
||||
# BUG-032: walk without following symlinks and refuse any symlink that
|
||||
# escapes the vault root, so external data can never be indexed/exposed.
|
||||
for dirpath, dirnames, filenames in os.walk(vault_root, followlinks=False):
|
||||
@@ -537,7 +569,11 @@ def _scan_vault(
|
||||
# Prune ignored and symlinked directories in place (no recursion).
|
||||
dirnames[:] = [
|
||||
d for d in dirnames
|
||||
if d not in IGNORED_DIRS and not (current_dir / d).is_symlink()
|
||||
if d not in ignored_names
|
||||
and not (current_dir / d).is_symlink()
|
||||
and not chemin_exclu_dans_config(
|
||||
cfg, str((current_dir / d).relative_to(vault_root)).replace("\\", "/")
|
||||
)
|
||||
]
|
||||
|
||||
for d in dirnames:
|
||||
@@ -565,6 +601,10 @@ def _scan_vault(
|
||||
|
||||
rel_path_str = str(fpath.relative_to(vault_root)).replace("\\", "/")
|
||||
|
||||
# Exclusion propre au vault (motif de chemin, ex. un .env ciblé).
|
||||
if chemin_exclu_dans_config(cfg, rel_path_str):
|
||||
continue
|
||||
|
||||
ext = fpath.suffix.lower()
|
||||
# Also match extensionless files named like Dockerfile, Makefile
|
||||
basename_lower = fpath.name.lower()
|
||||
|
||||
@@ -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
|
||||
@@ -966,7 +968,8 @@ if FRONTEND_DIR.exists():
|
||||
|
||||
frontend/recherche.html est servi avec les jetons {{URL_*}}
|
||||
remplacés par urls_publiques() (même contrat que le fragment
|
||||
de barre, jeton {{URL_AGENT}} inclus pour l'onglet Console
|
||||
de barre, jetons {{URL_SHAARIT}} et {{URL_AGENT}} inclus pour
|
||||
les onglets ShaarIt (barre v0.2.0) et Console
|
||||
Spark de la barre re-vendangée) et le nonce CSP frais ; la
|
||||
réponse pose sa propre CSP (préservée par
|
||||
SecurityHeadersMiddleware, qui ne la pose que si elle est
|
||||
@@ -992,6 +995,7 @@ if FRONTEND_DIR.exists():
|
||||
("{{URL_FLOWDECK}}", "flowdeck"),
|
||||
("{{URL_TIKTAK}}", "tiktak"),
|
||||
("{{URL_KANBANTE}}", "kanbante"),
|
||||
("{{URL_SHAARIT}}", "shaarit"),
|
||||
("{{URL_AGENT}}", "agent"),
|
||||
):
|
||||
html = html.replace(jeton, urls.get(cle, ""))
|
||||
@@ -1008,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'; "
|
||||
|
||||
@@ -9,7 +9,10 @@ Ce module n'autorise donc que :
|
||||
- le chemin ``/api/search`` (jamais les autres routes, jamais les
|
||||
écritures) ;
|
||||
- les origines sœurs : les origines officielles **prod ET dev,
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus**
|
||||
listées explicitement** (``PROD_ORIGINS | DEV_ORIGINS``), **plus
|
||||
les origines de la console Spark** (``AGENT_ORIGINS`` : agent prod
|
||||
ET dev — fédération de la recherche depuis la console,
|
||||
2026-10-28), **plus**
|
||||
celles de ``backend.draco_config`` (``urls_publiques()`` :
|
||||
explicite > dérivation par l'hôte de l'agent > défaut officiel)
|
||||
— jamais ``*``. Correctif 2026-10-28 (fusion #68/#69, matrice
|
||||
@@ -40,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({
|
||||
@@ -47,10 +51,17 @@ 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({
|
||||
"https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net",
|
||||
})
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -113,7 +124,7 @@ def sister_origins(resolved: dict | None = None, request_host: str | None = None
|
||||
# l'environnement de la requête, désormais inclus d'office).
|
||||
# ``resolved`` n'ajoute que des origines de configuration sans
|
||||
# jamais retirer une origine officielle.
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS
|
||||
origins |= PROD_ORIGINS | DEV_ORIGINS | AGENT_ORIGINS
|
||||
return frozenset(origins)
|
||||
|
||||
|
||||
|
||||
@@ -22,10 +22,12 @@ httpx>=0.27.0
|
||||
# l'utilisateur). #224 : la version préinstallée dans la toolcache du
|
||||
# runner (6.19.0) est frappée par 11 CVE supplémentaires (dont
|
||||
# CVE-2026-113786, CVE-2026-115572, CVE-2026-115585, CVE-2026-115580),
|
||||
# corrigées au plus tard en 6.21.2 — le plancher doit être >= 6.21.2 :
|
||||
# corrigées au plus tard en 6.21.2. La 6.21.2 est à son tour frappée
|
||||
# (CVE-2026-119203 et suivantes, relevées par le job security sur main),
|
||||
# corrigées en 6.21.4 — le plancher doit être >= 6.21.4 :
|
||||
# en dessous, pip répond « already satisfied » pour la toolcache et
|
||||
# n'aligne jamais (même piège que BUG-093).
|
||||
pypdf>=6.21.2
|
||||
pypdf>=6.21.4
|
||||
pyotp>=2.10.0
|
||||
segno>=1.5.0
|
||||
webauthn==2.6.0
|
||||
@@ -79,3 +81,9 @@ cryptography>=44.0
|
||||
# Plancher de securite : msgpack est transitif, version 1.2.1 relevee par le job
|
||||
# security (pip-audit, bloquant) — CVE-2026-114613, corrigee en 1.2.3.
|
||||
msgpack>=1.2.3
|
||||
# Plancher de sécurité : pyasn1 est transitive (python-jose/rsa), version
|
||||
# 0.6.4 relevée par le job security (pip-audit, bloquant) —
|
||||
# CVE-2026-119194 à CVE-2026-119197, corrigées en 0.6.5. Sans plancher
|
||||
# explicite, pip répond « already satisfied » pour la toolcache et
|
||||
# n'aligne jamais (même piège que BUG-093).
|
||||
pyasn1>=0.6.5
|
||||
|
||||
@@ -12,6 +12,14 @@ Règles d'or, appliquées côté serveur à chaque requête :
|
||||
- Seul ``POST /api/coffres/distribuer`` rend une valeur, et uniquement à
|
||||
un consommateur authentifié par jeton (hash + jti, jamais la session
|
||||
utilisateur), avec un octroi actif et un contexte obligatoire.
|
||||
- Seule exception, voulue : ``POST /api/coffres/{slug}/secrets/{nom}/reveler``
|
||||
rend la valeur au gestionnaire du coffre authentifié par sa session
|
||||
(propriétaire, ou admin sur coffre d'instance — jamais un simple
|
||||
lecteur par octroi utilisateur), pour l'affichage et la copie manuelle
|
||||
dans le panneau détail. Chaque révélation est auditée
|
||||
(``revelation_manuelle``) et met à jour la dernière utilisation ; la
|
||||
valeur n'est jamais journalisée et le frontend l'efface dès qu'elle
|
||||
est masquée ou que le détail change.
|
||||
- Autorisations (spec §6) : le propriétaire seul sur son coffre
|
||||
personnel ; l'admin gère les coffres d'instance, les consommateurs et
|
||||
la KEK, jamais le coffre personnel d'autrui ; l'ACL « * » des vaults
|
||||
@@ -57,7 +65,6 @@ from backend.coffre_store import (
|
||||
SecretExpireError,
|
||||
ValidationError,
|
||||
compter_octrois_actifs,
|
||||
est_expire,
|
||||
compter_secrets,
|
||||
create_coffre,
|
||||
create_consommateur,
|
||||
@@ -65,6 +72,7 @@ from backend.coffre_store import (
|
||||
create_secret,
|
||||
delete_secret,
|
||||
distribuer_valeur,
|
||||
est_expire,
|
||||
get_coffre,
|
||||
get_coffre_by_id,
|
||||
get_coffre_or_404,
|
||||
@@ -622,6 +630,50 @@ async def api_rotate_secret(
|
||||
return _metadata_secret(coffre, secret)
|
||||
|
||||
|
||||
@router.post("/api/coffres/{slug}/secrets/{nom}/reveler")
|
||||
async def api_reveler_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
):
|
||||
"""Révélation manuelle pour le panneau détail (afficher / copier).
|
||||
|
||||
Session utilisateur + gestion du coffre exigées : un lecteur par
|
||||
octroi utilisateur voit les métadonnées mais ne révèle jamais la
|
||||
valeur. Chaque appel est audité et touche la dernière utilisation.
|
||||
"""
|
||||
_exiger_actives()
|
||||
coffre = _coffre_or_404(slug)
|
||||
_exiger_gerer(current_user, coffre)
|
||||
secret = _secret_or_404(coffre, nom)
|
||||
try:
|
||||
valeur = distribuer_valeur(coffre, secret)
|
||||
except SecretExpireError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="secret_expire")
|
||||
raise HTTPException(status_code=410, detail="secret_expire") from None
|
||||
except KEKAbsenteError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="kek_absente")
|
||||
raise HTTPException(status_code=503, detail="kek_absente") from None
|
||||
except CryptoIndisponibleError:
|
||||
_audit(_username(current_user), "revelation_refusee",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="crypto_indisponible")
|
||||
raise HTTPException(status_code=503,
|
||||
detail="crypto_indisponible") from None
|
||||
toucher_utilisation(secret["id"])
|
||||
_audit(_username(current_user), "revelation_manuelle",
|
||||
coffre=coffre["slug"],
|
||||
secret_ref=f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
resultat="ok")
|
||||
return {"reference": f"coffre:{coffre['slug']}/{secret['nom']}",
|
||||
"valeur": valeur, "version": secret["version_courante"]}
|
||||
|
||||
|
||||
@router.delete("/api/coffres/{slug}/secrets/{nom}")
|
||||
async def api_delete_secret(
|
||||
slug: str, nom: str, current_user=Depends(require_auth)
|
||||
|
||||
@@ -0,0 +1,424 @@
|
||||
# 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.
|
||||
#
|
||||
# HUB-22 (#227, vague C du plan Hub v2.1) ajoute, sous la même garde et
|
||||
# avec la même journalisation, les routes « sessions » : lister les
|
||||
# sessions draco actives d'un compte, en révoquer une (par son `jti`,
|
||||
# jamais celle d'un autre compte) ou toutes (déconnexion globale, sans
|
||||
# désactiver le compte), via le registre des émissions de HUB-13.
|
||||
#
|
||||
# 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 datetime import UTC, datetime
|
||||
|
||||
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 _identifiant_sessions(user: dict) -> str:
|
||||
"""Clé du registre des émissions draco pour un compte.
|
||||
|
||||
C'est le ``sub`` des jetons draco : l'identifiant du compte
|
||||
(``id``), à défaut son nom d'utilisateur — exactement la clé
|
||||
qu'utilise ``_revoquer_sessions`` depuis HUB-13.
|
||||
"""
|
||||
return str(user.get("id") or user.get("username") or "")
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
return draco_session.revoke_all_for_user(_identifiant_sessions(user))
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
|
||||
|
||||
@router.get("/{username}/sessions")
|
||||
async def lister_sessions(username: str,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Liste les sessions draco actives d'un compte.
|
||||
|
||||
Une session par ``jti`` émis et non expiré (registre des émissions
|
||||
de HUB-13), triées par expiration croissante ; ``revoquee`` signale
|
||||
un ``jti`` déjà présent dans la liste des révocations et
|
||||
``expire_le`` est l'échéance ``exp`` en ISO 8601 UTC.
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
sessions = [{
|
||||
"jti": s["jti"],
|
||||
"exp": s["exp"],
|
||||
"expire_le": datetime.fromtimestamp(s["exp"], tz=UTC).isoformat(),
|
||||
"revoquee": s["revoquee"],
|
||||
} for s in draco_session.issued_sessions_for_user(
|
||||
_identifiant_sessions(user))]
|
||||
return {"username": username, "sessions": sessions}
|
||||
|
||||
|
||||
@router.post("/{username}/sessions/{jti}/revoquer")
|
||||
async def revoquer_session(username: str, jti: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Révoque une seule session draco d'un compte, par son ``jti``.
|
||||
|
||||
404 si le ``jti`` ne figure pas dans l'ensemble actif de ce
|
||||
compte : une session d'un autre utilisateur n'est jamais révoquée
|
||||
par cette route (aucune révocation cross-utilisateur).
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
if not draco_session.revoke_one_for_user(
|
||||
_identifiant_sessions(user), jti):
|
||||
raise HTTPException(404, "Session introuvable")
|
||||
_audit(request, acteur, "session_revoquee", username, {"jti": jti})
|
||||
return {"jti": jti, "revoquee": True}
|
||||
|
||||
|
||||
@router.post("/{username}/sessions/revoquer-toutes")
|
||||
async def revoquer_toutes_sessions(
|
||||
username: str, request: Request,
|
||||
acteur: str = Depends(require_machine_admin)):
|
||||
"""Révoque toutes les sessions draco actives d'un compte.
|
||||
|
||||
C'est la « déconnexion globale » : contrairement à la
|
||||
désactivation, le compte reste actif — seule l'émission d'une
|
||||
nouvelle session (nouvelle connexion) rouvre l'accès.
|
||||
"""
|
||||
user = _compte_ou_404(username)
|
||||
nb_jti = _revoquer_sessions(user)
|
||||
_audit(request, acteur, "sessions_revoquees", username,
|
||||
{"jti_revoques": nb_jti})
|
||||
return {"revoquees": nb_jti}
|
||||
@@ -44,7 +44,7 @@ from backend.schemas import (
|
||||
from backend.services.files import read_raw_file
|
||||
from backend.services.mutations import file_revision
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import browse_directory, get_vault_root
|
||||
from backend.services.vaults import browse_directory, chemin_exclu, get_vault_root
|
||||
from backend.share import list_shares
|
||||
|
||||
logger = logging.getLogger("obsigate")
|
||||
@@ -171,6 +171,9 @@ async def api_file_download(vault_name: str, path: str = Query(..., description=
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
@@ -243,6 +246,8 @@ def api_file_xlsx_dashboard(
|
||||
"""
|
||||
if not check_vault_access(vault_name, current_user):
|
||||
raise HTTPException(status_code=403, detail=f"Accès refusé à la vault '{vault_name}'")
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
_vault_root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(_vault_root, path)
|
||||
if not file_path.is_file():
|
||||
@@ -311,6 +316,9 @@ def api_file_xlsx_sheet(
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
file_path = resolve_safe_path(Path(vault_data["path"]), path)
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
@@ -366,6 +374,9 @@ async def api_file(vault_name: str, path: str = Query(..., description="Relative
|
||||
if not vault_data:
|
||||
raise HTTPException(status_code=404, detail=f"Vault '{vault_name}' not found")
|
||||
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise HTTPException(status_code=404, detail=f"File not found: {path}")
|
||||
|
||||
vault_root = Path(vault_data["path"])
|
||||
file_path = resolve_safe_path(vault_root, path)
|
||||
|
||||
|
||||
@@ -11,6 +11,14 @@
|
||||
# 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).
|
||||
#
|
||||
# GET /sso/logout?return_to=<url> : symétrique de /sso/authorize —
|
||||
# point d'entrée navigable de la déconnexion globale (BUG-122,
|
||||
# fusion #141). Les autres applications y naviguent depuis leur menu
|
||||
# compte ; la déconnexion exécutée est celle de POST /api/auth/logout
|
||||
# (logique partagée : jetons natifs révoqués, jti draco révoqué,
|
||||
# cookie effacé sur le domaine parent), puis redirection vers le
|
||||
# return_to validé (même liste blanche ; repli « / »).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -21,6 +29,7 @@ from fastapi import APIRouter, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
|
||||
from backend import draco_session
|
||||
from backend.auth.router import apply_global_logout
|
||||
|
||||
router = APIRouter(tags=["SSO"])
|
||||
|
||||
@@ -34,6 +43,14 @@ async def sso_authorize(return_to: str = ""):
|
||||
return RedirectResponse(url="/", status_code=302)
|
||||
|
||||
|
||||
@router.get("/sso/logout")
|
||||
async def sso_logout(request: Request, return_to: str = ""):
|
||||
target = draco_session.validate_return_to(return_to)
|
||||
response = RedirectResponse(url=target or "/", status_code=302)
|
||||
apply_global_logout(request, response)
|
||||
return response
|
||||
|
||||
|
||||
@router.get("/api/sso/revocations")
|
||||
async def sso_revocations(request: Request):
|
||||
machine_key = os.environ.get("DRACO_SSO_MACHINE_KEY", "")
|
||||
|
||||
@@ -609,6 +609,7 @@ class VaultInfo(BaseModel):
|
||||
file_count: int = Field(description="Number of indexed files")
|
||||
tag_count: int = Field(description="Number of unique tags")
|
||||
type: str = Field(default="VAULT", description="Type of the vault mapping (VAULT or DIR)")
|
||||
readonly: bool = Field(default=False, description="True when the vault is declared read-only (DIR_N_READONLY) — mutations are refused and the UI hides write actions")
|
||||
|
||||
|
||||
class BookmarkToggleRequest(BaseModel):
|
||||
|
||||
@@ -7,13 +7,30 @@ from typing import Any
|
||||
|
||||
from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
from backend.services.vaults import get_vault_root
|
||||
from backend.services.vaults import chemin_exclu, get_vault_root
|
||||
|
||||
logger = logging.getLogger("obsigate.services.files")
|
||||
|
||||
|
||||
def _ensure_not_excluded(vault_name: str, path: str) -> None:
|
||||
"""Raise ``not_found`` (404) when *path* is excluded in this vault.
|
||||
|
||||
Un chemin exclu (DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS) est
|
||||
indiscernable d'un chemin inexistant : même erreur, aucun détail
|
||||
sur la règle qui l'a caché.
|
||||
"""
|
||||
if chemin_exclu(vault_name, path):
|
||||
raise ServiceError(
|
||||
f"File not found: {path}",
|
||||
code="not_found",
|
||||
status=404,
|
||||
details={"vault": vault_name, "path": path},
|
||||
)
|
||||
|
||||
|
||||
def read_raw_file(vault_name: str, path: str) -> dict[str, Any]:
|
||||
"""Return the raw text content of a vault file (no redaction)."""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
@@ -56,6 +73,7 @@ def read_file_text(
|
||||
ServiceError: ``not_found`` (404), ``file_too_large`` (413) or a read
|
||||
error (500).
|
||||
"""
|
||||
_ensure_not_excluded(vault_name, path)
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path)
|
||||
|
||||
|
||||
@@ -69,8 +69,18 @@ def _rel(root: Path, path: Path) -> str:
|
||||
return str(path.relative_to(root)).replace("\\", "/")
|
||||
|
||||
|
||||
def _ensure_writable(root: Path) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault root is not writable."""
|
||||
def _ensure_writable(root: Path, vault_name: str | None = None) -> None:
|
||||
"""Raise ``read_only`` (403) when the vault must not be mutated.
|
||||
|
||||
Two sources, same error: the vault is **declared** read-only
|
||||
(``DIR_N_READONLY``, via :func:`vault_readonly`) or its root is
|
||||
not writable on the filesystem (``:ro`` mount).
|
||||
"""
|
||||
if vault_name is not None:
|
||||
from backend.services.vaults import vault_readonly
|
||||
|
||||
if vault_readonly(vault_name):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
if not os.access(root, os.W_OK):
|
||||
raise ServiceError("Vault is read-only", code="read_only", status=403)
|
||||
|
||||
@@ -139,7 +149,7 @@ def create_file(
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path)
|
||||
|
||||
@@ -190,7 +200,7 @@ def create_directory(vault_name: str, path: str, *, exist_ok: bool = False) -> d
|
||||
``already_exists`` (409) when *exist_ok* is False.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if dir_path.exists():
|
||||
@@ -234,7 +244,7 @@ def edit_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -470,7 +480,7 @@ def edit_xlsx_cells(
|
||||
reference or value.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -592,7 +602,7 @@ def mutate_xlsx_structure(
|
||||
— deletions are NOT recoverable from the viewer (only via the ``.bak``).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1000,7 +1010,7 @@ def mutate_xlsx_style(
|
||||
from openpyxl.utils.cell import range_boundaries
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1255,7 +1265,7 @@ def save_csv_cells(
|
||||
from backend.xlsx_reader import sniff_csv_delimiter
|
||||
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1341,7 +1351,7 @@ def append_to_file(
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1382,7 +1392,7 @@ def rename_file(vault_name: str, path: str, new_name: str) -> dict[str, Any]:
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_file():
|
||||
@@ -1424,7 +1434,7 @@ def rename_directory(vault_name: str, path: str, new_name: str) -> dict[str, Any
|
||||
``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
old_path = resolve_safe_path(root, path)
|
||||
|
||||
if not old_path.exists() or not old_path.is_dir():
|
||||
@@ -1467,7 +1477,7 @@ def move_path(vault_name: str, source_path: str, destination_dir: str = "") -> d
|
||||
``unsupported_extension`` (400) or ``already_exists`` (409).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1546,7 +1556,7 @@ def duplicate_path(vault_name: str, source_path: str, destination_dir: str = "")
|
||||
destination is inside the source directory.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
source = resolve_safe_path(root, source_path)
|
||||
if not source.exists():
|
||||
@@ -1764,7 +1774,7 @@ def delete_file(vault_name: str, path: str, *, backup: bool = True) -> dict[str,
|
||||
ServiceError: ``not_found`` (404) or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
if not file_path.exists() or not file_path.is_file():
|
||||
@@ -1797,7 +1807,7 @@ def delete_directory(vault_name: str, path: str, *, recursive: bool = True) -> d
|
||||
``invalid_arguments`` (400) when targeting the vault root.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
dir_path = resolve_safe_path(root, path)
|
||||
|
||||
if not dir_path.exists() or not dir_path.is_dir():
|
||||
@@ -1855,7 +1865,7 @@ def restore_backup(
|
||||
or ``read_only`` (403).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
|
||||
backup_dir = get_backup_dir(vault_name, path)
|
||||
@@ -1931,7 +1941,7 @@ def save_raw_file(
|
||||
Dict with ``success``, ``vault``, ``path``, and ``size``.
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
file_path = resolve_safe_path(root, path)
|
||||
_validate_extension(file_path, allow_images=True, allow_docs=allow_docs)
|
||||
|
||||
@@ -1980,7 +1990,7 @@ def batch_upload_files(
|
||||
and ``errors`` (list of error dicts).
|
||||
"""
|
||||
root = get_vault_root(vault_name)
|
||||
_ensure_writable(root)
|
||||
_ensure_writable(root, vault_name)
|
||||
|
||||
clean_target = (target_dir or "").strip().strip("/\\")
|
||||
uploaded: list[str] = []
|
||||
|
||||
@@ -215,8 +215,9 @@ def list_paths(vault: str, limit: int = 5000) -> dict[str, Any]:
|
||||
from datetime import datetime
|
||||
|
||||
from backend.indexer import get_vault_data, path_index
|
||||
from backend.services.vaults import chemin_exclu
|
||||
|
||||
entries = path_index.get(vault, [])
|
||||
entries = [e for e in path_index.get(vault, []) if not chemin_exclu(vault, e["path"])]
|
||||
meta: dict[str, dict[str, Any]] = {}
|
||||
data = get_vault_data(vault)
|
||||
if data:
|
||||
|
||||
@@ -7,6 +7,7 @@ Single source of truth consumed by both the REST routes (``/api/vaults``,
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import fnmatch
|
||||
from datetime import UTC
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -15,6 +16,68 @@ from backend.services.errors import ServiceError
|
||||
from backend.services.paths import resolve_safe_path
|
||||
|
||||
|
||||
def chemin_exclu_dans_config(config: dict[str, Any] | None, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu par la config d'exclusions d'un vault.
|
||||
|
||||
Deux mécanismes, lus dans le dict ``config`` du vault (alimenté par
|
||||
``DIR_N_IGNORED_DIRS`` / ``DIR_N_IGNORED_PATHS``, vaults DIR seulement) :
|
||||
|
||||
- ``ignoredDirs`` : le nom d'un segment du chemin (à toute profondeur)
|
||||
figure dans l'ensemble — ex. ``.home`` cache ``a/.home/x`` ;
|
||||
- ``ignoredPaths`` : motifs fnmatch testés contre le chemin relatif
|
||||
POSIX **et chacun de ses préfixes** — un dossier qui matche cache
|
||||
donc tout son sous-arbre, ex. ``Executeurs/*/data`` cache aussi
|
||||
``Executeurs/Hermes/data/config.md``.
|
||||
|
||||
Tolérant : config absente/vide ou chemin vide → False.
|
||||
"""
|
||||
if not config or not rel_path:
|
||||
return False
|
||||
rel = str(rel_path).replace("\\", "/").strip("/")
|
||||
if not rel:
|
||||
return False
|
||||
segments = rel.split("/")
|
||||
ignored_dirs = config.get("ignoredDirs") or []
|
||||
if any(seg in ignored_dirs for seg in segments):
|
||||
return True
|
||||
prefixes = ["/".join(segments[:i]) for i in range(1, len(segments) + 1)]
|
||||
for pattern in config.get("ignoredPaths") or []:
|
||||
pat = str(pattern).replace("\\", "/").strip("/")
|
||||
if pat and any(fnmatch.fnmatchcase(prefix, pat) for prefix in prefixes):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def chemin_exclu(vault_name: str, rel_path: str) -> bool:
|
||||
"""True si *rel_path* est exclu dans le vault *vault_name*.
|
||||
|
||||
Lit la config du vault via l'index (``get_vault_data``). Tolérant :
|
||||
vault inconnu ou sans config d'exclusions → False.
|
||||
"""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return chemin_exclu_dans_config(data.get("config") or {}, rel_path)
|
||||
|
||||
|
||||
def vault_readonly(vault_name: str) -> bool:
|
||||
"""True si le vault est déclaré en lecture seule (DIR_N_READONLY)."""
|
||||
try:
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(vault_name)
|
||||
except Exception:
|
||||
return False
|
||||
if not data:
|
||||
return False
|
||||
return bool((data.get("config") or {}).get("readonly", False))
|
||||
|
||||
|
||||
def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"""Return the vaults *user* may access, with summary metadata."""
|
||||
from backend.auth.middleware import check_vault_access
|
||||
@@ -29,6 +92,7 @@ def list_accessible_vaults(user: dict[str, Any]) -> list[dict[str, Any]]:
|
||||
"file_count": len(data.get("files", [])),
|
||||
"tag_count": len(data.get("tags", {})),
|
||||
"type": data.get("config", {}).get("type", "VAULT"),
|
||||
"readonly": bool(data.get("config", {}).get("readonly", False)),
|
||||
})
|
||||
return result
|
||||
|
||||
@@ -58,7 +122,7 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
root = get_vault_root(vault_name)
|
||||
target = resolve_safe_path(root, path) if path else root.resolve()
|
||||
|
||||
if not target.exists():
|
||||
if not target.exists() or (path and chemin_exclu(vault_name, path)):
|
||||
raise ServiceError(
|
||||
f"Path not found: {path}",
|
||||
code="not_found",
|
||||
@@ -74,12 +138,15 @@ def browse_directory(vault_name: str, path: str = "") -> dict[str, Any]:
|
||||
if hide_hidden and entry.name.startswith("."):
|
||||
continue
|
||||
rel = str(entry.relative_to(root)).replace("\\", "/")
|
||||
if chemin_exclu(vault_name, rel):
|
||||
continue
|
||||
if entry.is_dir():
|
||||
# Count only direct children (files and subdirs) for performance.
|
||||
try:
|
||||
file_count = sum(
|
||||
1 for child in entry.iterdir()
|
||||
if (not hide_hidden or not child.name.startswith("."))
|
||||
and not chemin_exclu(vault_name, f"{rel}/{child.name}")
|
||||
and (child.is_file() and (child.suffix.lower() in SUPPORTED_EXTENSIONS or child.name.lower() in ("dockerfile", "makefile"))
|
||||
or child.is_dir())
|
||||
)
|
||||
@@ -200,6 +267,9 @@ def list_all_files(
|
||||
stat = entry.stat()
|
||||
rel_path = str(entry.relative_to(root)).replace("\\", "/")
|
||||
|
||||
if chemin_exclu(vault_name, rel_path):
|
||||
continue
|
||||
|
||||
if recursive and dir_prefix:
|
||||
try:
|
||||
rel_to_dir = str(entry.parent.relative_to(dir_path)).replace("\\", "/")
|
||||
|
||||
@@ -2626,7 +2626,7 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.82.2"
|
||||
version = "2.86.0"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"env_logger",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "obsigate-desktop"
|
||||
version = "2.82.2"
|
||||
version = "2.86.0"
|
||||
description = "ObsiGate Desktop — Porte d'entrée native pour vos vaults Obsidian"
|
||||
authors = ["Bruno Charest"]
|
||||
edition = "2021"
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"$schema": "https://raw.githubusercontent.com/nicedoc/obsigate/main/desktop/tauri.conf.schema.json",
|
||||
"productName": "ObsiGate",
|
||||
"version": "2.82.2",
|
||||
"version": "2.86.0",
|
||||
"identifier": "com.obsigate.desktop",
|
||||
"build": {
|
||||
"frontendDist": "../frontend",
|
||||
|
||||
@@ -73,6 +73,16 @@ configurée (sinon 503 `kek_absente`).
|
||||
SSO : une session utilisateur, même admin, ne distribue jamais
|
||||
(401 `consommateur_invalide`). Il n'existe pas d'endpoint de
|
||||
distribution par session (voir §6).
|
||||
- Révélation manuelle (panneau détail, hors distribution) : `POST
|
||||
/api/coffres/{slug}/secrets/{nom}/reveler` rend la valeur au
|
||||
gestionnaire du coffre authentifié par sa session (propriétaire,
|
||||
ou admin sur coffre d'instance), pour l'afficher ou la copier.
|
||||
Ce n'est pas une distribution : aucun octroi n'est créé, aucun
|
||||
consommateur ne reçoit la valeur, chaque révélation est auditée
|
||||
(`revelation_manuelle`) et met à jour la dernière utilisation.
|
||||
Un lecteur par octroi utilisateur (métadonnées visibles) reçoit
|
||||
403. L'ancienne copie manuelle par consommateur temporaire a
|
||||
été supprimée (elle échouait : 403 octroi_absent).
|
||||
- Ajouter une app = enregistrer un consommateur + créer des octrois,
|
||||
**sans modification du coffre** et sans toucher à ses secrets.
|
||||
|
||||
|
||||
@@ -227,6 +227,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| *BUG-117* | Drag & drop des fichiers dans la sidebar : le déplacement d'un répertoire à un autre ne fait plus rien, sans message | 🟢 corrigé | P1 | 📱 frontend / sidebar | IA | `frontend/js/dragdrop.js` (`onDrop`) | Glisser un fichier sur un dossier : aucune requête `/api/move`, aucun toast | La détection `isExternalFiles` dans `onDrop` (`items.length > 0`, ajoutée par #89) comptait aussi les formats string du drag interne (`tree:vault/path/type` posés par `setData` au `dragstart`) : tout déplacement partait dans la branche upload où rien ne passe le filtre `kind === 'file'` → drop avalé silencieusement. Corrigé à la racine : `dragState` (interne) prioritaire + items réellement fichiers. Livré avec #201 : menu contextuel Déplacer/Dupliquer réutilisant `promptTargetFolder`, emplacement actuel pré-sélectionné | Vérifié Playwright instance E2E : glisser `note1.md` → `d1` = toast « Moved file to d1 » + chemin déplacé ; 4 tests `TestDuplicateApi` |
|
||||
| *BUG-119* | Panneau Spark : le « mode console » (surimpression iframe de la console du service d'agents) est bloqué par le navigateur (« This content is blocked ») | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/main.py` (`SecurityHeadersMiddleware`) | Panneau Spark → « Console ⤢ » : l'iframe reste sur la page d'erreur du navigateur | `frame-src 'self' blob: https://agent.dracodev.net` dans la CSP servie ; test `tests/test_security_headers.py::test_csp_frame_src_allows_agent_console` | Cause : `frame-src` n'autorisait pas l'origine du service d'agents, pourtant déjà présente dans `connect-src` (le panneau texte fonctionnait). Aucune autre directive modifiée. Redéploiement Docker requis |
|
||||
| *BUG-120* | Menu inter-apps : sur l'environnement dev, les URL sœurs servies (`window.DRACO_URLS` : ObsiGate, Flowdeck, TikTak, Kanbante) restent en noms **prod** alors que l'URL de l'agent est bien en `-dev` | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/draco_config.py` (`app_url`, `_suffixe_environnement`) | Sur og-dev : `window.DRACO_URLS` donne `agent: https://agent-dev.dracodev.net` mais `tiktak: https://tiktak.dracodev.net` (prod) — le `.env` dev ne déclare pas les quatre `DRACO_<APP>_URL` | `backend/draco_config.py` : résolution sœur en 3 temps — explicite, puis **dérivation d'environnement** depuis l'hôte de `agent_url()` (`agent<SUFFIXE>.dracodev.net` → `https://<slug><SUFFIXE>.dracodev.net`, slugs `og`/`flowdeck`/`tiktak`/`kanbante` ; hors motif → rien), puis défaut officiel ; Gitea exclue. Tests `tests/test_draco_config.py` (+9 : dérivation `-dev` via `AGENT_EVENEMENTS_URL` et via `DRACO_AGENT_URL`, priorité de l'explicite, 5 hôtes hors motif, suffixe vide, injection) | Suite de #223 : seule l'URL de l'agent avait une dérivation de repli (`AGENT_EVENEMENTS_URL`) ; les sœurs retombaient sur les défauts officiels. Correctif de code demandé par Bruno : l'environnement se déduit de l'agent déjà configuré, sans variable supplémentaire. Redéploiement Docker requis |
|
||||
| *BUG-122* | Déconnexion du menu compte inerte dans les applications sœurs : la barre commune relaie `deconnexion` à l'hôte, mais la déconnexion globale d'ObsiGate (ACC-3.1) n'est appelable qu'en same-origin (`POST /api/auth/logout`) — aucun point d'entrée navigable pour les autres hôtes | 🟢 corrigé | P1 | ⚙️ backend | IA | `backend/routers/sso.py` (`GET /sso/logout`), `backend/auth/router.py` (`apply_global_logout`) | Depuis la console Spark (ou Flowdeck/TikTak/ShaarIt/Hub) : menu compte → « Déconnexion » → rien ne se passe, la session draco reste vivante (vérifié en navigateur réel : cookie présent, `/api/v1/me` toujours 200) | Nouvelle route `GET /sso/logout?return_to=` : exécute la déconnexion globale (logique partagée `apply_global_logout` : jetons natifs refresh/access révoqués, `jti` draco révoqué, cookie effacé sur le domaine parent) puis redirige vers le `return_to` validé (liste blanche https + *.dracodev.net, repli `/`). Tests `tests/test_draco_session.py` (+3) | Signalé par Bruno le 2026-11-06 (capture console), carte fusion #141 ; les hôtes branchent leur action `deconnexion` sur cette route (PR sœurs). BUG-121 reste réservé au bug de couleur « Secrets » du vault Spark (proposé, non ordonné). Redéploiement Docker requis |
|
||||
|
||||
| # | Titre | Statut | Priorité | Scope | Assigné | Zone (fichier) | Cmd de repro | Correctif / Commit | Notes |
|
||||
|---|---|---|---|---|---|---|---|---|---|
|
||||
@@ -361,6 +362,7 @@ Avant de corriger quoi que ce soit, un agent IA doit :
|
||||
| 2026-10-09 | #194 | Correction (isolation) | `backend/auth/middleware.py`, `backend/services/recent.py`, `backend/services/search.py`, `backend/routers/search.py`, `backend/routers/config.py`, `backend/routers/conflicts.py`, `backend/routers/files_read.py`, `backend/routers/history.py`, `backend/push.py`, `backend/user_home.py`, `tests/test_user_home.py`, `tests/test_auth_api.py`, `docs/features/user-home-194.md`, `CHANGELOG.md` | **#194 — l'admin voyait le home de chaque utilisateur** (retour utilisateur) : `vaults: ["*"]` ouvrait **tous** les vaults, donc `home-<user>` compris. Correctif à la racine : `check_vault_access` exige un octroi **explicite** pour tout vault `home-*` (nouveau `is_home_vault()`), et les filtres « * » écrits en dur dans les routes ont été remplacés par cet appel unique — dashboard (`routers/config.py`), conflits, liens retour, favoris, abonnements push (`/api/search/replace` utilisait déjà `is_vault_allowed`). `/api/search` filtre désormais les bruts **avant** pagination via `search_vaults(is_allowed=…)` (sinon notes personnelles d'un autre compte dans les résultats). `_grant` n'écarte plus les comptes « * » → l'admin reçoit son propre `home-admin` (auto-réparé au démarrage). Vérifié : `test_user_home.py` 12→**14/14** (dont « * » sans homes + filtrage recherche), `test_auth_api.py` 2/2 (admin ne voit **plus** `home-alice`, alice voit le sien), suite **1705 passed / 2 skipped**, ruff backend 0, mypy 114 fichiers 0, **live Playwright instance 2020** : arborescence admin = `home-admin, TestDir, TestVault` (plus de `home-bruno`), `svg.lucide-home` rendu. Reste ouvert (fuite pré-existante, hors périmètre) : `/api/search/advanced`, `/api/tags`, `/api/tags/suggest`, `/api/tree-search`, `/api/suggest` ne filtrent pas par vault accessible | ✅ livré (en attente vérif utilisateur) |
|
||||
| 2026-10-23 | BUG-119 | Correction | `backend/main.py`, `tests/test_security_headers.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md` | **BUG-119 — le « mode console » du panneau Spark V3 (#222) était bloqué par le navigateur** (« This content is blocked ») : la console du service d'agents s'affiche dans une iframe `https://agent.dracodev.net`, mais la CSP servie par `SecurityHeadersMiddleware` limitait `frame-src` à `'self' blob:` — seul le panneau texte passait (`connect-src` autorisait déjà l'origine). Correctif strict : `frame-src 'self' blob: https://agent.dracodev.net`, toutes les autres directives inchangées à l'identique. Test de non-régression `test_csp_frame_src_allows_agent_console` (la CSP servie est analysée et la liste exacte des sources `frame-src` verrouillée). Vérifié : 100 passed (security_headers + csp_nonce + security_hardening + draco_barre + version), ruff propre sur les fichiers touchés (51 erreurs préexistantes ailleurs, inchangées), mypy 0 (120 fichiers) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-10-25 | BUG-120 (suite #223) | Correction | `backend/draco_config.py`, `tests/test_draco_config.py`, `.env.example`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/ROADMAP.md`, `docs/features/devprod-urls-config-223.md` | **BUG-120 — le menu inter-apps de la dev pointait la prod** : `window.DRACO_URLS` servait l'agent en `-dev` mais les quatre sœurs en noms officiels, le `.env` dev ne déclarant pas les `DRACO_<APP>_URL`. Correctif dans `draco_config.app_url()` : après l'explicite, **dérivation d'environnement** — hôte de `agent_url()` de la forme `agent<SUFFIXE>.dracodev.net` sans port → sœur en `<slug><SUFFIXE>.dracodev.net` ; hors motif (autre domaine, LAN, IP, port) aucune dérivation ; Gitea jamais dérivée. Vérifié : `test_draco_config.py` 16 passed, suite complète **1668 passed / 9 skipped**, ruff 0 et mypy 0 sur les fichiers touchés (51 erreurs ruff préexistantes ailleurs, inchangées) | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
| 2026-11-06 | BUG-122 (fusion #141) | Correction | `backend/routers/sso.py`, `backend/auth/router.py`, `tests/test_draco_session.py`, `CHANGELOG.md`, `docs/ISSUES_TODOLIST.md`, `docs/features/deconnexion-globale-bug-122.md`, `VERSION` (+ dérivés) | **BUG-122 — la déconnexion du menu compte ne terminait rien hors ObsiGate** : ajout de `GET /sso/logout`, point d'entrée navigable de la déconnexion globale (logique partagée avec `POST /api/auth/logout`). Vérifié : `test_draco_session.py` 34 passed, ruff 0 et mypy 0 sur les fichiers touchés | 🟢 corrigé (en attente vérif utilisateur) |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# ObsiGate — Roadmap
|
||||
|
||||
> **Version :** 2.82.2 | **Dernière mise à jour :** 2026-10-28
|
||||
> **Version :** 2.86.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,64 @@
|
||||
|
||||
## 🔵 En cours
|
||||
|
||||
### 227. Hub HUB-22 — API machine des sessions draco (liste et révocation)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.0 (PR #68 fusionnée et release publiée — voir CHANGELOG ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-sessions-machine-227.md](./features/hub-sessions-machine-227.md)
|
||||
- **Description :** vague C du plan Hub v2.1 (HUB-22) : le Hub doit lister
|
||||
les sessions actives d'un utilisateur et les révoquer une à une ou
|
||||
toutes, via le canal `jti` livré en HUB-13 (#226). Trois routes sous
|
||||
la même garde machine (`require_machine_admin`) et la même
|
||||
journalisation d'audit que les routes comptes existantes :
|
||||
`GET /api/admin/machine/comptes/{username}/sessions`,
|
||||
`POST …/sessions/{jti}/revoquer` (jamais de révocation
|
||||
cross-utilisateur) et `POST …/sessions/revoquer-toutes`
|
||||
(déconnexion globale, sans désactiver le compte).
|
||||
- **Sous-tâches :**
|
||||
- [x] `issued_sessions_for_user` + `revoke_one_for_user` (`backend/draco_session.py`)
|
||||
- [x] Trois routes sessions (`backend/routers/comptes_machine.py`)
|
||||
- [x] Tests étendus (`tests/test_comptes_machine.py`, `tests/test_draco_session.py`)
|
||||
### 228. Hub HUB-19 — propagation des rôles du Hub à ObsiGate (consommatrice)
|
||||
|
||||
- **Effort :** ~0,5 jour | **Impact :** 🟡 | **Ouvert :** 2026-11-06 | **Statut :** ✅ livré en 2.85.1 (voir CHANGELOG ; branche rebasée sur la 2.85.0 avant fusion ; redéploiement Docker requis)
|
||||
- **Fiche :** [features/hub-roles-obsigate-228.md](./features/hub-roles-obsigate-228.md)
|
||||
- **Description :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) : le
|
||||
pilote ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate —
|
||||
à la fois émettrice et consommatrice du SSO — le consomme à son tour.
|
||||
À l'ouverture de session draco, consultation de
|
||||
`GET {hub}/api/roles/obsigate` (Cookie relayé, délai 2 s), cache
|
||||
mémoire 60 s par `sub` (horodatage posé avant l'appel). Niveau connu
|
||||
`aucun` → session draco refusée (réponse explicite en français) ;
|
||||
routes `/api/auth/admin/*` authentifiées par le canal draco :
|
||||
niveau `administrateur` exigé quand le rôle est connu. Toute erreur
|
||||
du Hub → rôle inconnu → comportement actuel. L'authentification
|
||||
native d'ObsiGate (émettrice, accès de secours) n'est jamais touchée ;
|
||||
validation locale du jeton, émission et révocations inchangées,
|
||||
aucun rôle dans le jeton.
|
||||
- **Sous-tâches :**
|
||||
- [x] Client `backend/hub_roles.py` (contrat D3(a), cache/repli, URL du Hub résolue comme la barre — `draco_config.app_url("hub")`)
|
||||
- [x] Refus « aucun » dans `draco_session.user_for_draco` + réponse explicite de `require_auth`
|
||||
- [x] Garde `require_admin_hub` sur les routes `/api/auth/admin/*`
|
||||
- [x] Tests `tests/test_hub_roles.py` (client + intégration du chemin draco)
|
||||
|
||||
### 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)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
# BUG-122 — Déconnexion globale navigable : `GET /sso/logout`
|
||||
|
||||
**Carte :** fusion #141 (signalement de Bruno, 2026-11-06, capture
|
||||
depuis la console Spark). **Version :** 2.86.0. **Prérequis :** ACC-3.1
|
||||
(#216) — la déconnexion globale existe déjà dans
|
||||
`POST /api/auth/logout`.
|
||||
|
||||
## Constat
|
||||
|
||||
Le bouton « Déconnexion » du menu compte de la barre commune
|
||||
(dracodev-commons) est une **action relayée à l'hôte** (`cfg.onAction`,
|
||||
sinon événement `draco:bar`) : la barre ne déconnecte jamais
|
||||
elle-même. Or :
|
||||
|
||||
- console Spark, ObsiGate, Flowdeck, TikTak, ShaarIt et Hub ne
|
||||
branchaient **aucune** action `deconnexion` (clic sans effet,
|
||||
reproduit en navigateur réel sur la console : session draco intacte
|
||||
après le clic) ;
|
||||
- Kanbante, seul branché, ne détruisait que sa session locale — la
|
||||
session SSO survivait ;
|
||||
- et de toute façon, aucun hôte n'aurait pu terminer la session
|
||||
globale : `POST /api/auth/logout` n'est appelable qu'en same-origin
|
||||
depuis l'interface d'ObsiGate. Il manquait le point d'entrée
|
||||
navigable, symétrique de `GET /sso/authorize`.
|
||||
|
||||
## Livraison (cette PR, côté émettrice)
|
||||
|
||||
| Méthode | Chemin | Effet |
|
||||
|---|---|---|
|
||||
| GET | `/sso/logout?return_to=<url>` | Déconnexion globale, puis redirection 302 vers le `return_to` validé (repli « / ») |
|
||||
|
||||
- La logique de `POST /api/auth/logout` est extraite dans
|
||||
`apply_global_logout(request, response)` (`backend/auth/router.py`)
|
||||
et partagée par les deux entrées : révocation des jetons natifs
|
||||
refresh/access, révocation du `jti` du cookie `draco_session`,
|
||||
effacement des cookies (dont `draco_session` sur le domaine parent).
|
||||
- `return_to` passe par la liste blanche existante
|
||||
(`draco_session.validate_return_to` : https + `dracodev.net` ou
|
||||
sous-domaine) — aucun relais ouvert.
|
||||
- Idempotente : sans session, la route redirige quand même (les hôtes
|
||||
y naviguent même si leur session a déjà expiré).
|
||||
- La barre d'ObsiGate elle-même branche son action `deconnexion` sur
|
||||
cette route (`frontend/index.html`, retour à sa racine) — garde :
|
||||
`tests/test_draco_barre.py::test_barre_deconnexion_branchee`.
|
||||
|
||||
## Côté hôtes (PR sœurs, carte fusion #141)
|
||||
|
||||
Chaque hôte branche son action `deconnexion` : destruction de sa
|
||||
session locale d'abord (Flowdeck, TikTak, Kanbante, ShaarIt), puis
|
||||
navigation vers `<émettrice>/sso/logout?return_to=<sa base>`. La
|
||||
console et le Hub (session draco seule) naviguent directement.
|
||||
Après le parcours complet, « Déconnexion » termine **la session de la
|
||||
suite entière** et la session locale de l'hôte courant.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_draco_session.py` (+3) : révocation du `jti` + redirection
|
||||
vers le `return_to` + cookie effacé (`Max-Age=0`), liste blanche
|
||||
(relais refusé, repli « / »), idempotence sans session.
|
||||
@@ -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.
|
||||
@@ -0,0 +1,77 @@
|
||||
# #228 — HUB-19 : propagation des rôles du Hub à ObsiGate
|
||||
|
||||
**Carte :** suite de HUB-19 (plan Hub v2 §2.2, décision D3(a)) — le pilote
|
||||
ShaarIt (0.7.0, en production) a posé le contrat ; ObsiGate le consomme à
|
||||
son tour, comme hôte à la fois **émettrice** et **consommatrice** du SSO.
|
||||
**Version cible :** 2.85.1 (branche depuis le main 2.84.1 ; hypothèse : à
|
||||
rebaser après la PR « API sessions HUB-22 » — 2.85.0 — si elle fusionne
|
||||
d'abord).
|
||||
|
||||
## Contrat consommé (D3(a))
|
||||
|
||||
À l'ouverture de session draco — chez ObsiGate : la validation locale du
|
||||
cookie partagé, `draco_session.user_for_draco()` (ACC-3.2, #217), appelée
|
||||
par le repli draco de `get_current_user` — consultation du registre :
|
||||
|
||||
- `GET {hub}/api/roles/obsigate`, en-tête **Cookie complet** de la requête
|
||||
relayé tel quel, `Accept: application/json`, délai **2 s** ;
|
||||
- réponse 200 : `{"app", "sub", "role", "explicite", "ttl_secondes": 60}`,
|
||||
`role` ∈ {`aucun`, `utilisateur`, `administrateur`} ;
|
||||
- **URL du Hub** : `draco_config.app_url("hub")`, la résolution de la
|
||||
barre commune (#223) — `DRACO_HUB_URL` explicite d'abord, dérivation
|
||||
par l'hôte sœur (suffixe d'environnement de l'hôte de l'agent)
|
||||
ensuite, défaut officiel enfin. Jamais d'URL dev figée.
|
||||
|
||||
## Client — `backend/hub_roles.py`
|
||||
|
||||
Sur le patron du pilote (`src/hub_roles.rs` de ShaarIt) :
|
||||
|
||||
- cache mémoire par `sub`, TTL **60 s** ;
|
||||
- horodatage posé **avant** l'appel : un échec conserve l'ancien cache
|
||||
et ne déclenche pas de rafale vers un Hub en panne ;
|
||||
- toute erreur (réseau, délai, statut ≠ 200, JSON illisible, rôle hors
|
||||
contrat) → rôle **inconnu** (`None`) → l'appelant garde son
|
||||
comportement actuel. Le registre ne crée jamais un verrouillage
|
||||
nouveau.
|
||||
|
||||
## Effets (sessions établies par draco seulement)
|
||||
|
||||
1. **Niveau connu `aucun`** → `user_for_draco` refuse : pas de session
|
||||
établie par draco. `require_auth` répond alors **403** avec un
|
||||
message explicite en français (niveau « aucun », matrice des rôles
|
||||
du Hub, accès natif toujours possible), consigné via
|
||||
`request.state.role_hub_refuse`. L'**authentification native**
|
||||
d'ObsiGate (son propre login — c'est l'émettrice, son accès propre
|
||||
est le secours) n'est **jamais** touchée.
|
||||
2. **Routes d'administration `/api/auth/admin/*`** (CRUD des comptes,
|
||||
`backend/auth/router.py`) : nouvelle garde `require_admin_hub`
|
||||
(`backend/auth/middleware.py`) = `require_admin` + , quand la
|
||||
requête est authentifiée par le **canal draco** (`_draco_sub`
|
||||
présent, `_hub_role` joint par `user_for_draco`) et que le rôle est
|
||||
**connu**, exigence du niveau `administrateur` du registre.
|
||||
Sessions natives → règles actuelles inchangées ; rôle inconnu →
|
||||
règles actuelles inchangées. Le registre n'élève aucun privilège :
|
||||
un compte local non admin reste refusé par la règle locale, quel
|
||||
que soit son niveau au Hub.
|
||||
3. **Inchangés** : validation locale du jeton, émission du cookie,
|
||||
révocations (`jti`), renouvellement glissant. **Aucun rôle dans le
|
||||
jeton** (claims minimaux de #216 préservés).
|
||||
|
||||
Périmètre assumé : la garde du registre vise les routes nommées par la
|
||||
carte, `/api/auth/admin/*`. Les autres surfaces gardées par
|
||||
`require_admin` (configuration, vaults, coffres…) gardent leurs règles
|
||||
actuelles ; les y soumettre serait un changement d'une ligne par
|
||||
surface (`require_admin_hub` est réutilisable tel quel) si Bruno le
|
||||
décide.
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_hub_roles.py` : parsing du contrat (URL, Cookie relayé,
|
||||
`Accept`, délai 2 s), rôles reconnus / hors contrat, JSON illisible,
|
||||
erreurs HTTP et réseau → inconnu ; résolution de l'URL du Hub
|
||||
(explicite, dérivée `-dev`, défaut officiel) ; cache par `sub`, TTL,
|
||||
échec sans rafale avec ancien cache conservé ; intégration sur le
|
||||
chemin draco (refus « aucun » explicite, login natif intact sous
|
||||
« aucun », rôle inconnu = comportement actuel, `utilisateur` Hub
|
||||
refusé sur `/api/auth/admin/*`, `administrateur` Hub accepté, compte
|
||||
local non admin non élevé, session native jamais bloquée par le Hub).
|
||||
@@ -0,0 +1,85 @@
|
||||
# #227 — HUB-22 : API machine des sessions draco (liste et révocation)
|
||||
|
||||
**Carte :** vague C du plan Hub v2.1 (HUB-22) — le Hub consomme ces
|
||||
routes en 0.17.0 pour l'écran des sessions d'un utilisateur.
|
||||
**Version :** 2.85.0. **Prérequis :** HUB-13 (#226) — registre des
|
||||
émissions `jti` et garde machine.
|
||||
|
||||
## Objet
|
||||
|
||||
Le Hub doit lister les sessions actives d'un utilisateur et les
|
||||
révoquer une à une ou toutes, via le canal `jti` livré en HUB-13.
|
||||
Aucune route machine « sessions » n'existait : cette livraison la crée,
|
||||
sous la même garde (`require_machine_admin`, clé
|
||||
`DRACO_ADMIN_MACHINE_KEY`, en-têtes `X-Draco-Machine-Key` /
|
||||
`X-Draco-Actor`) et avec la même journalisation d'audit que les routes
|
||||
comptes existantes. Sans la clé configurée, l'API reste inerte (404) ;
|
||||
sans clé valide, 401.
|
||||
|
||||
## Routes — `/api/admin/machine/comptes/{username}/sessions`
|
||||
|
||||
| Méthode | Chemin | Effet |
|
||||
|---|---|---|
|
||||
| GET | `/api/admin/machine/comptes/{username}/sessions` | Sessions actives du compte |
|
||||
| POST | `/api/admin/machine/comptes/{username}/sessions/{jti}/revoquer` | Révocation d'une session |
|
||||
| POST | `/api/admin/machine/comptes/{username}/sessions/revoquer-toutes` | Révocation de toutes les sessions |
|
||||
|
||||
Codes : 401 sans clé machine valide, 404 compte inconnu (ou API non
|
||||
configurée), 404 `jti` absent de l'ensemble actif du compte.
|
||||
|
||||
### Liste — `GET …/sessions`
|
||||
|
||||
`200 {"username": …, "sessions": [{"jti", "exp", "expire_le",
|
||||
"revoquee"}]}` — une entrée par `jti` émis et non expiré, triée par
|
||||
`exp` croissant ; `expire_le` est l'échéance en ISO 8601 UTC et
|
||||
`revoquee` signale un `jti` déjà présent dans la liste des
|
||||
révocations.
|
||||
|
||||
### Révocation unitaire — `POST …/sessions/{jti}/revoquer`
|
||||
|
||||
`200 {"jti", "revoquee": true}`. Le `jti` n'est révoqué que s'il
|
||||
appartient à l'ensemble émis du compte visé et n'est pas expiré :
|
||||
**404 sinon, jamais de révocation cross-utilisateur**.
|
||||
|
||||
### Tout révoquer — `POST …/sessions/revoquer-toutes`
|
||||
|
||||
`200 {"revoquees": n}` — réutilise `revoke_all_for_user` de HUB-13.
|
||||
C'est la « déconnexion globale », **distincte de la désactivation** :
|
||||
le compte reste actif, une nouvelle connexion émet une nouvelle
|
||||
session.
|
||||
|
||||
## Backend — `backend/draco_session.py`
|
||||
|
||||
- `issued_sessions_for_user(sub)` : sessions actives
|
||||
(`exp > maintenant`) du `sub`, `[{jti, exp, revoquee}]` triées par
|
||||
`exp` croissant ; les entrées expirées du `sub` sont purgées du
|
||||
registre au passage.
|
||||
- `revoke_one_for_user(sub, jti)` : révoque via le `revoke()`
|
||||
existant uniquement si le `jti` appartient à l'ensemble émis de ce
|
||||
`sub` et n'est pas expiré ; retourne un booléen.
|
||||
|
||||
La clé du registre est le même `sub` que celui de HUB-13
|
||||
(`_identifiant_sessions` dans `comptes_machine.py` : `id` du compte,
|
||||
à défaut `username`) — factorisée depuis `_revoquer_sessions`, qui
|
||||
l'utilise aussi désormais.
|
||||
|
||||
## Journalisation
|
||||
|
||||
Les deux écritures sont consignées dans `data/audit.log` via
|
||||
`log_compte_admin` : actions `session_revoquee` (détail : le `jti`) et
|
||||
`sessions_revoquees` (détail : nombre de `jti` révoqués), acteur =
|
||||
identité de service `X-Draco-Actor`. La lecture suit le patron des
|
||||
lectures existantes (non journalisée).
|
||||
|
||||
## Tests
|
||||
|
||||
- `tests/test_draco_session.py` : liste triée et marquage `revoquee`,
|
||||
purge des expirés, révocation unitaire, refus cross-utilisateur,
|
||||
refus d'un `jti` expiré ; le registre des émissions est isolé en
|
||||
fichier temporaire dans la fixture.
|
||||
- `tests/test_comptes_machine.py` (`TestSessions`) : liste (contenu,
|
||||
`expire_le` ISO 8601 UTC), liste vide, compte inconnu 404,
|
||||
révocation unitaire (succès + effet sur `verify_token`),
|
||||
cross-utilisateur 404 sans effet, tout-révoquer (compte toujours
|
||||
actif, autres comptes intacts, seconde passe à 0), garde (401 sans
|
||||
clé, 404 API non configurée), journalisation.
|
||||
@@ -0,0 +1,79 @@
|
||||
# Vault DIR « Spark » — exclusions par vault et lecture seule déclarable
|
||||
|
||||
## Objectif
|
||||
|
||||
Permettre d'exposer dans ObsiGate des répertoires techniques (code du
|
||||
service d'agents, workspaces du compagnon, coquilles d'exécuteurs) sous un
|
||||
vault `DIR` monté en lecture seule — le vault « Spark » — sans jamais
|
||||
rendre visibles leurs zones sensibles (profils et clés des exécuteurs,
|
||||
`.home` des workspaces, `.venv`, etc.).
|
||||
|
||||
Avant ce chantier, deux manques le bloquaient proprement :
|
||||
|
||||
1. Les exclusions n'existaient qu'au niveau de l'indexeur (`IGNORED_DIRS`,
|
||||
global) : la navigation et la lecture montraient le disque tel quel.
|
||||
2. La lecture seule d'un vault n'était ni déclarable ni affichée : les
|
||||
menus contextuels proposaient renommer / déplacer / supprimer, qui
|
||||
échouaient ensuite côté serveur (montage `:ro`).
|
||||
|
||||
## Contrat de configuration (vaults DIR seulement)
|
||||
|
||||
Lues par `load_vault_config()` (`backend/indexer.py`, boucle `DIR_N`),
|
||||
rangées dans le dict `config` du vault ; les vaults `VAULT` ne sont pas
|
||||
touchés et les défauts (vide / faux) conservent le comportement actuel :
|
||||
|
||||
| Variable | Forme | Effet |
|
||||
|---|---|---|
|
||||
| `DIR_N_READONLY` | `true` / `false` | Mutations refusées en 403 `read_only` ; `readonly: true` exposé par `/api/vaults` ; l'UI masque les actions d'écriture |
|
||||
| `DIR_N_IGNORED_DIRS` | `.home,.venv` | Noms de dossiers cachés à toute profondeur, en plus du global |
|
||||
| `DIR_N_IGNORED_PATHS` | `Executeurs/*/data,Executeurs/*/.env` | Motifs fnmatch sur le chemin relatif POSIX ; un dossier qui matche cache tout son sous-arbre |
|
||||
|
||||
Exemple visé pour le vault Spark :
|
||||
|
||||
```
|
||||
DIR_1_NAME=Spark
|
||||
DIR_1_PATH=/vaults/Spark
|
||||
DIR_1_READONLY=true
|
||||
DIR_1_IGNORED_DIRS=.home,.venv,__pycache__,node_modules,.git
|
||||
DIR_1_IGNORED_PATHS=Executeurs/*/data,Executeurs/*/.env,*/compagnon.json
|
||||
```
|
||||
|
||||
(le montage lui-même — volume `:ro`, arborescence assemblée sur l'hôte —
|
||||
relève de la configuration de déploiement, pas d'ObsiGate.)
|
||||
|
||||
## Implémentation
|
||||
|
||||
- **Helper central** : `chemin_exclu(vault_name, rel_path)` et
|
||||
`chemin_exclu_dans_config(config, rel_path)` dans
|
||||
`backend/services/vaults.py` — segment présent dans `ignoredDirs`, ou
|
||||
fnmatch du chemin ou d'un de ses préfixes contre `ignoredPaths`.
|
||||
Tolérant si vault / config absents. `vault_readonly(vault_name)` au
|
||||
même endroit.
|
||||
- **Navigation** : `browse_directory` filtre les items exclus (et leurs
|
||||
compteurs d'enfants) ; naviguer directement dans un chemin exclu
|
||||
répond 404. `list_all_files` et `list_paths` filtrent aussi.
|
||||
- **Lecture** : un chemin exclu est indiscernable d'un chemin inexistant
|
||||
(404, sans détail sur la règle) — `read_raw_file` / `read_file_text`
|
||||
(`backend/services/files.py`), route `GET /api/file/{vault}` et route
|
||||
de téléchargement (`backend/routers/files_read.py`).
|
||||
- **Indexation** : le scan (`_scan_vault`, qui reçoit le `vault_cfg`)
|
||||
élague les dossiers exclus et saute les fichiers exclus, en plus du
|
||||
global `IGNORED_DIRS` inchangé.
|
||||
- **Lecture seule** : `_ensure_writable(root, vault_name)` dans
|
||||
`backend/services/mutations.py` refuse d'abord sur la déclaration
|
||||
(`DIR_N_READONLY`), avant le garde filesystem existant — toutes les
|
||||
mutations du service (créer, éditer, renommer, déplacer, supprimer,
|
||||
upload, xlsx/csv) passent par ce point.
|
||||
- **API / UI** : `VaultInfo.readonly` (`backend/schemas.py`) rempli par
|
||||
`list_accessible_vaults` ; côté frontend, `vaultIsReadonly(nom)` dans
|
||||
`frontend/js/sidebar.js` alimente `ContextMenuManager`,
|
||||
`attachTreeItemActionButton` et `attachTreeItemLongPress` pour le
|
||||
vault et ses dossiers / fichiers (aucun autre fichier JS touché).
|
||||
|
||||
## Tests
|
||||
|
||||
`tests/test_spark_vault_dir.py` : parsing des trois variables, helper
|
||||
`chemin_exclu` (noms et motifs), navigation filtrée, lectures exclues en
|
||||
404 (vue fichier, raw, téléchargement), fichier non exclu lisible,
|
||||
indexation filtrée, mutation refusée en 403 sur vault readonly et
|
||||
`/api/vaults` exposant `readonly=true`.
|
||||
@@ -1214,6 +1214,30 @@
|
||||
function initTheme() {
|
||||
const saved = localStorage.getItem("obsigate-theme") || "dark";
|
||||
applyTheme(saved);
|
||||
// Thème commun de la suite (HUB-18, fusion #132) : la barre
|
||||
// commune (barre v0.5.0) applique la préférence du Hub
|
||||
// (sombre/clair/système) à l'ouverture — directement sur
|
||||
// data-theme, et via l'événement « draco:theme » pour que le
|
||||
// mécanisme propre d'ObsiGate suive (icône et libellé du bouton,
|
||||
// feuilles highlight.js). applyTheme mémorise le mode appliqué
|
||||
// dans « obsigate-theme » : la préférence du Hub devient le
|
||||
// choix local courant, et le bouton de bascule reste le maître
|
||||
// pour la suite de la session (aucune ré-application de la barre
|
||||
// après l'ouverture). « systeme » arrive déjà résolu en mode
|
||||
// effectif (detail.mode) par la barre. Repli : si le Hub ne
|
||||
// répond pas, aucun événement n'arrive et le choix local
|
||||
// ci-dessus reste seul maître, comme avant.
|
||||
document.addEventListener("draco:theme", (e) => {
|
||||
const mode = e && e.detail && e.detail.mode;
|
||||
if (mode === "dark" || mode === "light") applyTheme(mode);
|
||||
});
|
||||
// Si la barre s'est initialisée AVANT ce module (ordre des
|
||||
// scripts defer/module selon la page), sa préférence déjà
|
||||
// résolue depuis son cache est reprise ici.
|
||||
const barre = window.DRACO_BAR;
|
||||
if (barre && (barre.themeMode === "dark" || barre.themeMode === "light")) {
|
||||
applyTheme(barre.themeMode);
|
||||
}
|
||||
}
|
||||
|
||||
function applyTheme(theme) {
|
||||
|
||||
@@ -7,14 +7,39 @@
|
||||
menu des applications) ; l'avatar est un vrai <button>.
|
||||
Correctif fusion #70 (SANS bump) : bouton trois points et menu par
|
||||
défaut supprimés ; le menu de la marque gagne « Console Spark ».
|
||||
v0.1.10 : rose --agent → rouge Spark #c00000 ; marque = logo Spark ;
|
||||
bouton Spark supprimé (remplacé par l'avatar flottant bas-centre) ;
|
||||
Alertes icône seule.
|
||||
v0.1.11 : marque = étoile Spark monochrome (blanche barre foncée,
|
||||
noire barre claire) ; anneau rouge #c00000 autour de l'avatar flottant.
|
||||
v0.2.0 : nouvel onglet « ShaarIt » (data-app="shaarit", --app émeraude
|
||||
#059669, logo logos/shaarit.svg via {{LOGO_SHAARIT}}) avant l'onglet
|
||||
agent ; GARDE data-theme : les blocs prefers-color-scheme ne
|
||||
s'appliquent que si <html> n'a AUCUN attribut data-theme
|
||||
(html:not([data-theme])) — avant ce correctif, un hôte ayant choisi
|
||||
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. */
|
||||
ou prefers-color-scheme si l'app intégratrice ne force rien
|
||||
(aucun attribut data-theme sur <html>). */
|
||||
|
||||
.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;
|
||||
--agent: #c00000; --danger: #cf4444;
|
||||
position: relative;
|
||||
height: 60px; box-sizing: border-box;
|
||||
display: grid; grid-template-columns: auto minmax(200px, 520px) auto;
|
||||
@@ -22,8 +47,12 @@
|
||||
background: var(--surface); border-bottom: 1px solid var(--line);
|
||||
font: 500 14px/1.3 system-ui, "Segoe UI", sans-serif; color: var(--ink);
|
||||
}
|
||||
/* Garde data-theme (v0.2.0) : le sombre système ne s'applique QUE si
|
||||
* l'hôte n'a posé aucun data-theme sur <html>. Sans cette garde, un
|
||||
* hôte en data-theme="light" sur un système sombre affichait la barre
|
||||
* sombre malgré son choix explicite. */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
.draco-bar { --bg:#101211; --surface:#1a1c1a; --surface-2:#242624; --surface-3:#20221f;
|
||||
html:not([data-theme]) .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;
|
||||
@@ -37,7 +66,15 @@
|
||||
.draco-brand { display: flex; align-items: center; gap: 7px; color: var(--ink); text-decoration: none; font-weight: 800; cursor: pointer; border-radius: 10px; padding: 4px 6px; margin: -4px -6px; }
|
||||
.draco-brand:hover { background: var(--surface-2); }
|
||||
.draco-brand:focus-visible { outline: 2px solid var(--agent); outline-offset: 2px; }
|
||||
.draco-brand-mark { color: var(--agent); font-size: 24px; line-height: 1; }
|
||||
.draco-brand-mark { line-height: 0; }
|
||||
/* Marque monochrome v0.1.11 : étoile blanche extraite du logo rouge,
|
||||
fond transparent — noire sur barre claire (filtre), blanche sur
|
||||
barre foncée (filtre annulé par les règles thème ci-dessous). */
|
||||
.draco-brand-mark img { width: 26px; height: 26px; object-fit: cover; display: block; filter: brightness(0); }
|
||||
@media (prefers-color-scheme: dark) {
|
||||
html:not([data-theme]) .draco-bar .draco-brand-mark img { filter: none; }
|
||||
}
|
||||
[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }
|
||||
|
||||
/* Menu des applications — construit par le JS depuis les onglets */
|
||||
.draco-apps-menu {
|
||||
@@ -52,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);
|
||||
}
|
||||
@@ -64,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;
|
||||
@@ -94,6 +140,13 @@
|
||||
width: 30px; height: 30px; flex: none; display: grid; place-items: center;
|
||||
border-radius: 9px; background: var(--app); color: #fff; font-size: 15px;
|
||||
}
|
||||
/* Icônes logos (2026-10-29, corrigé le même jour) : l'image du logo
|
||||
* fourni, telle quelle, remplit la tuile 30 px — jamais de glyphe
|
||||
* texte, jamais de redessin. Le fond de la tuile est transparent :
|
||||
* les logos PNG portent déjà leur fond (dégradé), le SVG TikTak
|
||||
* reste sur le fond de la barre. */
|
||||
.draco-apptab-ico { background: transparent; }
|
||||
.draco-apptab-ico img { width: 30px; height: 30px; display: block; border-radius: 9px; object-fit: cover; }
|
||||
.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); }
|
||||
@@ -131,18 +184,23 @@
|
||||
|
||||
/* 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);
|
||||
position: relative; display: flex; align-items: center; height: 38px;
|
||||
padding: 0 9px; 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;
|
||||
/* Bouton avatar Spark flottant — bas-centre, ouvre le panneau Spark
|
||||
de l'hôte via data-act="spark" (délégation globale existante).
|
||||
La console Spark ne l'inclut pas : son fragment est propre. */
|
||||
.draco-spark-flottant {
|
||||
position: fixed; left: 50%; bottom: 20px; transform: translateX(-50%);
|
||||
z-index: 65; width: 64px; height: 64px; padding: 0; border: 0;
|
||||
border-radius: 50%; background: transparent; cursor: pointer;
|
||||
box-shadow: 0 8px 28px rgb(0 0 0 / .45);
|
||||
}
|
||||
.draco-spark .draco-badge { border-color: var(--agent); }
|
||||
.draco-spark-flottant img { width: 64px; height: 64px; border-radius: 50%; object-fit: cover; display: block; box-sizing: border-box; border: 3px solid var(--agent); }
|
||||
.draco-spark-flottant:hover { transform: translateX(-50%) scale(1.05); }
|
||||
.draco-spark-flottant:focus-visible { outline: 2px solid var(--agent); outline-offset: 3px; }
|
||||
|
||||
.draco-icon-btn {
|
||||
width: 38px; height: 38px; border-radius: 10px; border: 1px solid transparent;
|
||||
@@ -169,7 +227,7 @@
|
||||
.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-txt, .draco-search kbd { 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; }
|
||||
@@ -193,6 +251,9 @@
|
||||
.draco-bar[data-current="flowdeck"] .draco-apptab[data-app="flowdeck"],
|
||||
.draco-bar[data-current="tiktak"] .draco-apptab[data-app="tiktak"],
|
||||
.draco-bar[data-current="kanbante"] .draco-apptab[data-app="kanbante"],
|
||||
/* v0.2.0 : sur ShaarIt elle-même (data-current="shaarit"), c'est
|
||||
l'onglet ShaarIt qui reste visible en mobile. */
|
||||
.draco-bar[data-current="shaarit"] .draco-apptab[data-app="shaarit"],
|
||||
/* Chantier 5 : sur la console elle-même (data-current="agent",
|
||||
programme console #71), c'est l'onglet Console Spark qui reste
|
||||
visible en mobile — les 4 hôtes gardent leur propre courant. */
|
||||
|
||||
@@ -8,7 +8,40 @@
|
||||
* correctif fusion #70 (SANS bump de version, publication séparée) :
|
||||
* bouton trois points et son menu par défaut SUPPRIMÉS, « Console Spark »
|
||||
* ajoutée au menu de la marque, fermeture/focus corrigés (voir
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux »).
|
||||
* « Menu des applications », « Focus » et « Écouteurs globaux ») ;
|
||||
* v0.2.0 : 5e application ShaarIt — clé urls.shaarit, défaut officiel
|
||||
* https://shaarit.dracodev.net/, dérivation -dev shaarit-dev (même
|
||||
* 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.
|
||||
* v0.5.0 (carte HUB-18, fusion #132) : thème commun de la suite —
|
||||
* la barre apprend la préférence de thème du profil du Hub
|
||||
* (GET <hub>/api/profil/theme, cookie draco) et l'applique à
|
||||
* l'ouverture par le mécanisme data-theme des hôtes, avec cache
|
||||
* local court et repli intégral sur le comportement actuel de
|
||||
* l'hôte si le Hub ne répond pas. Voir « Thème commun » plus bas.
|
||||
*
|
||||
* 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
|
||||
@@ -26,8 +59,10 @@
|
||||
* flowdeck: "https://flowdeck.dracodev.net/",
|
||||
* tiktak: "https://tiktak.dracodev.net/",
|
||||
* 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
|
||||
@@ -54,10 +89,19 @@
|
||||
* La clé spark ne correspond à aucun onglet de la barre : elle est
|
||||
* résolue et exposée via DRACO_BAR.urls pour le panneau Spark
|
||||
* (panneau-agent), qui suit le même contrat d'injection. La clé agent,
|
||||
* elle, a un onglet depuis le chantier 5 (ordre : ObsiGate, Flowdeck,
|
||||
* TikTak, Kanbante, Console Spark) : elle suit donc l'étape « href
|
||||
* elle, a un onglet depuis le chantier 5 (ordre, v0.2.0 : ObsiGate,
|
||||
* Flowdeck, TikTak, Kanbante, ShaarIt, Console Spark) : elle suit
|
||||
* donc l'étape « href
|
||||
* rendu » comme les autres onglets, en plus d'alimenter le menu de
|
||||
* la marque et DRACO_BAR.urls.
|
||||
* 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…).
|
||||
@@ -132,6 +176,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";
|
||||
@@ -144,8 +196,10 @@
|
||||
flowdeck: "https://flowdeck.dracodev.net/",
|
||||
tiktak: "https://tiktak.dracodev.net/",
|
||||
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
|
||||
@@ -157,12 +211,14 @@
|
||||
flowdeck: "https://flowdeck-dev.dracodev.net/",
|
||||
tiktak: "https://tiktak-dev.dracodev.net/",
|
||||
kanbante: "https://kanbante-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/"
|
||||
shaarit: "https://shaarit-dev.dracodev.net/",
|
||||
agent: "https://agent-dev.dracodev.net/",
|
||||
hub: "https://hub-dev.dracodev.net/"
|
||||
};
|
||||
|
||||
var APP_LABELS = {
|
||||
obsigate: "ObsiGate", flowdeck: "Flowdeck",
|
||||
tiktak: "TikTak", kanbante: "Kanbante"
|
||||
tiktak: "TikTak", kanbante: "Kanbante", shaarit: "ShaarIt"
|
||||
};
|
||||
|
||||
/* Vrai si hostname est un hôte -dev dracodev.net : première étiquette
|
||||
@@ -218,6 +274,131 @@
|
||||
}
|
||||
var resolvedUrls = resolveUrls();
|
||||
|
||||
/* ── Thème commun (v0.5.0, carte HUB-18, fusion #132) ────────────
|
||||
* La préférence de thème de la suite vit dans le profil du Hub :
|
||||
* GET <hub>/api/profil/theme → {"theme": "sombre"|"clair"|"systeme"|null}
|
||||
* authentifié par le cookie draco (credentials: "include").
|
||||
* <hub> est l'URL RÉSOLUE ci-dessus (explicite > dérivation -dev >
|
||||
* défaut officiel) — jamais un nom figé de plus.
|
||||
* Application par le mécanisme data-theme existant des hôtes :
|
||||
* "sombre" → data-theme="dark", "clair" → data-theme="light",
|
||||
* "systeme" → attribut RETIRÉ (prefers-color-scheme décide, garde
|
||||
* html:not([data-theme]) du CSS de la barre comme des hôtes).
|
||||
* L'événement draco:theme ({theme, mode} — mode = "dark"/"light"
|
||||
* EFFECTIF) est ensuite dispatché pour les hôtes dont le mécanisme
|
||||
* propre doit suivre (icônes et libellés, feuilles de style,
|
||||
* palettes ShaarIt, attribut dédié de la console…) ; un hôte qui
|
||||
* s'initialise après la barre lit la même information dans
|
||||
* DRACO_BAR.themePref / DRACO_BAR.themeMode.
|
||||
* Ordre d'application : 1) le cache local FRAIS est appliqué de
|
||||
* façon SYNCHRONE à l'initialisation de la barre (script en defer
|
||||
* chez tous les hôtes : avant DOMContentLoaded, donc avant le
|
||||
* premier rendu dans le cas général) ; 2) un fetch ASYNCHRONE et
|
||||
* NON BLOQUANT rafraîchit le cache et ré-applique si la préférence
|
||||
* a changé. À la toute première visite (aucun cache), un bref
|
||||
* flash du thème local de l'hôte reste possible le temps du fetch :
|
||||
* c'est le prix du repli — l'état affiché transitoire est le thème
|
||||
* que l'hôte aurait de toute façon choisi seul, jamais un état
|
||||
* cassé, et les visites suivantes sont servies par le cache.
|
||||
* Cache : localStorage "draco-theme-hub" ({theme, ts}), TTL 12 h
|
||||
* (ordre de la journée) ; il ne contient QUE la préférence du Hub,
|
||||
* jamais le choix local de l'hôte.
|
||||
* REPLI INTÉGRAL : Hub injoignable, réponse non-OK (401…), JSON
|
||||
* invalide, theme null ou valeur inconnue → RIEN n'est appliqué et
|
||||
* RIEN n'est mis en cache ; un échec de fetch n'efface pas un cache
|
||||
* existant. Le comportement actuel de l'hôte (son localStorage,
|
||||
* puis le système) reste alors exactement le maître, comme avant
|
||||
* la v0.5.0. Le sélecteur de thème LOCAL de chaque hôte reste
|
||||
* fonctionnel : la barre n'applique qu'à l'ouverture (cache, puis
|
||||
* fetch) ; elle ne ré-applique ensuite que sur changement du
|
||||
* schéma système, et seulement tant que la préférence résolue est
|
||||
* "systeme". */
|
||||
var THEME_CACHE_CLE = "draco-theme-hub";
|
||||
var THEME_CACHE_TTL_MS = 12 * 60 * 60 * 1000;
|
||||
var themePref = null; /* "sombre"|"clair"|"systeme"|null (non résolue) */
|
||||
var themeMode = null; /* "dark"|"light"|null : mode effectif appliqué */
|
||||
|
||||
function themeSystemeEstSombre() {
|
||||
try {
|
||||
return !!(window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)").matches);
|
||||
} catch (e) { return false; }
|
||||
}
|
||||
function lireCacheTheme() {
|
||||
try {
|
||||
var brut = window.localStorage &&
|
||||
window.localStorage.getItem(THEME_CACHE_CLE);
|
||||
if (!brut) return null;
|
||||
var c = JSON.parse(brut);
|
||||
if (!c || typeof c.ts !== "number") return null;
|
||||
if (Date.now() - c.ts > THEME_CACHE_TTL_MS) return null;
|
||||
if (c.theme === "sombre" || c.theme === "clair" || c.theme === "systeme")
|
||||
return c.theme;
|
||||
} catch (e) { /* stockage indisponible ou corrompu : pas de cache */ }
|
||||
return null;
|
||||
}
|
||||
function ecrireCacheTheme(theme) {
|
||||
try {
|
||||
if (window.localStorage) {
|
||||
window.localStorage.setItem(THEME_CACHE_CLE,
|
||||
JSON.stringify({ theme: theme, ts: Date.now() }));
|
||||
}
|
||||
} catch (e) { /* stockage indisponible : le prochain fetch refera le travail */ }
|
||||
}
|
||||
function appliquerThemeCommun(theme) {
|
||||
var root = document.documentElement;
|
||||
if (!root) return;
|
||||
if (theme === "sombre" || theme === "clair") {
|
||||
themeMode = theme === "sombre" ? "dark" : "light";
|
||||
root.setAttribute("data-theme", themeMode);
|
||||
} else if (theme === "systeme") {
|
||||
themeMode = themeSystemeEstSombre() ? "dark" : "light";
|
||||
root.removeAttribute("data-theme");
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
themePref = theme;
|
||||
if (window.DRACO_BAR) {
|
||||
window.DRACO_BAR.themePref = themePref;
|
||||
window.DRACO_BAR.themeMode = themeMode;
|
||||
}
|
||||
try {
|
||||
document.dispatchEvent(new CustomEvent("draco:theme",
|
||||
{ detail: { theme: themePref, mode: themeMode } }));
|
||||
} catch (e) { /* aucun écouteur hôte : data-theme suffit */ }
|
||||
}
|
||||
function initThemeCommun() {
|
||||
var enCache = lireCacheTheme();
|
||||
if (enCache) appliquerThemeCommun(enCache);
|
||||
/* Suivi du schéma système tant que la préférence est "systeme" :
|
||||
* sans data-theme, le CSS suit tout seul ; l'événement est
|
||||
* re-dispatché pour les hôtes qui matérialisent le mode. */
|
||||
try {
|
||||
var mq = window.matchMedia &&
|
||||
window.matchMedia("(prefers-color-scheme: dark)");
|
||||
if (mq && mq.addEventListener) {
|
||||
mq.addEventListener("change", function () {
|
||||
if (themePref === "systeme") appliquerThemeCommun("systeme");
|
||||
});
|
||||
}
|
||||
} catch (e) { /* pas de matchMedia : pas de suivi */ }
|
||||
if (typeof window.fetch !== "function") return;
|
||||
var url = withSlash(resolvedUrls.hub || DEFAULT_URLS.hub) +
|
||||
"api/profil/theme";
|
||||
window.fetch(url, { credentials: "include" }).then(function (resp) {
|
||||
if (!resp || !resp.ok) return null;
|
||||
return resp.json();
|
||||
}).then(function (data) {
|
||||
if (!data) return;
|
||||
var t = data.theme;
|
||||
if (t === "sombre" || t === "clair" || t === "systeme") {
|
||||
ecrireCacheTheme(t);
|
||||
appliquerThemeCommun(t);
|
||||
}
|
||||
/* theme null ou inconnu : repli intégral, rien n'est touché. */
|
||||
}).catch(function () { /* Hub injoignable : repli intégral */ });
|
||||
}
|
||||
|
||||
function setCounts(counts) {
|
||||
Object.keys(counts || {}).forEach(function (k) {
|
||||
/* Toutes les occurrences : l'onglet ET sa ligne dans le menu. */
|
||||
@@ -296,6 +477,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;
|
||||
@@ -365,11 +568,15 @@
|
||||
if (badge) a.appendChild(badge.cloneNode(true));
|
||||
appsMenu.appendChild(a);
|
||||
});
|
||||
/* Entrée « Console Spark » (#70) : après Kanbante, vers la console
|
||||
/* Entrée « Console Spark » (#70) : après les onglets d'apps
|
||||
* (ObsiGate…ShaarIt, v0.2.0), vers la console
|
||||
* de l'agent commun (URL agent résolue — explicite > -dev >
|
||||
* défaut prod ; aucun hôte spark-dev inventé). data-app="agent"
|
||||
* l'intègre à refreshMenuHrefs/applyUrls/setCurrent comme les
|
||||
* autres items ; l'icône ✦ reprend la couleur agent du CSS. */
|
||||
* autres items ; son icône CLONE celle de l'onglet agent
|
||||
* (2026-10-29, logos barre-apps, corrigé le même jour) : le logo
|
||||
* fourni, tel quel, jamais redessiné — le CSS --agent (rose) des
|
||||
* autres éléments de la barre n'est pas changé ici. */
|
||||
var sep = document.createElement("div");
|
||||
sep.className = "draco-menu-sep";
|
||||
sep.setAttribute("aria-hidden", "true");
|
||||
@@ -379,10 +586,18 @@
|
||||
sparkItem.href = resolvedUrls.agent || "#";
|
||||
sparkItem.dataset.app = "agent";
|
||||
sparkItem.setAttribute("role", "menuitem");
|
||||
var sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#c2437e");
|
||||
sparkIco.textContent = "✦";
|
||||
var sparkIco;
|
||||
var agentTab = document.querySelector('.draco-apptab[data-app="agent"]');
|
||||
var agentIco = agentTab ? agentTab.querySelector(".draco-apptab-ico") : null;
|
||||
if (agentIco) {
|
||||
sparkIco = agentIco.cloneNode(true);
|
||||
} else {
|
||||
sparkIco = document.createElement("span");
|
||||
sparkIco.className = "draco-apptab-ico";
|
||||
sparkIco.setAttribute("style", "--app:#d21f2c");
|
||||
sparkIco.setAttribute("data-icon", "agent");
|
||||
sparkIco.textContent = "✦";
|
||||
}
|
||||
sparkItem.appendChild(sparkIco);
|
||||
var sparkTxt = document.createElement("span");
|
||||
sparkTxt.className = "draco-apps-item-txt";
|
||||
@@ -406,10 +621,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");
|
||||
@@ -420,15 +705,28 @@
|
||||
var head = document.createElement("div");
|
||||
head.className = "draco-compte-nom";
|
||||
compteMenu.appendChild(head);
|
||||
[["profil", "Profil"], ["deconnexion", "Déconnexion"]].forEach(function (pair) {
|
||||
var b = document.createElement("button");
|
||||
b.type = "button";
|
||||
b.className = "draco-menu-item";
|
||||
b.setAttribute("data-compte-act", pair[0]);
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.textContent = pair[1];
|
||||
compteMenu.appendChild(b);
|
||||
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", "deconnexion");
|
||||
b.setAttribute("role", "menuitem");
|
||||
b.textContent = "Déconnexion";
|
||||
b.appendChild(menuIcone("deconnexion"));
|
||||
compteMenu.appendChild(b);
|
||||
bar.appendChild(compteMenu);
|
||||
}
|
||||
function refreshCompteMenu() {
|
||||
@@ -436,6 +734,7 @@
|
||||
var head = compteMenu.querySelector(".draco-compte-nom");
|
||||
var name = displayName();
|
||||
if (head) head.textContent = name || "Compte";
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
function toggleCompteMenu(force) {
|
||||
buildCompteMenu();
|
||||
@@ -527,11 +826,15 @@
|
||||
window.DRACO_BAR = {
|
||||
setCounts: setCounts, setCurrent: setCurrent, setDomain: setDomain,
|
||||
urls: resolvedUrls,
|
||||
/* Thème commun (v0.5.0) : préférence du Hub résolue et mode
|
||||
* effectif appliqué ; null tant que rien n'a été résolu (repli). */
|
||||
themePref: null, themeMode: null,
|
||||
setUrls: function (urls) {
|
||||
cfg.urls = Object.assign({}, cfg.urls, urls || {});
|
||||
resolvedUrls = resolveUrls();
|
||||
window.DRACO_BAR.urls = resolvedUrls;
|
||||
applyUrls(resolvedUrls);
|
||||
refreshCompteHrefs();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -540,6 +843,7 @@
|
||||
buildCompteMenu();
|
||||
syncAvatar();
|
||||
refreshCompteMenu();
|
||||
initThemeCommun();
|
||||
if (cfg.current) setCurrent(cfg.current);
|
||||
if (cfg.domain) setDomain(cfg.domain);
|
||||
if (cfg.counts) setCounts(cfg.counts);
|
||||
|
||||
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 13 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 27 KiB |
|
After Width: | Height: | Size: 40 KiB |
@@ -0,0 +1,9 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<!-- ShaarIt — signet (bookmark) blanc sur fond émeraude #059669,
|
||||
couleur signature de l'app dans la barre (#059669, distincte du
|
||||
teal Kanbante #0d9488). Fond arrondi comme les logos PNG 180 px
|
||||
des autres apps ; géométrie simple, aucun redessin d'un logo
|
||||
existant : le signet EST le symbole de l'app (bookmarks). -->
|
||||
<rect x="8" y="8" width="224" height="224" rx="48" fill="#059669"/>
|
||||
<path d="M 82 52 L 158 52 L 158 190 L 120 152 L 82 190 Z" fill="#ffffff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 569 B |
|
After Width: | Height: | Size: 38 KiB |
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 240 240">
|
||||
<!-- TikTak — deux arcs (les deux temps), coche-flèche. Bleu #0076fb, orange #fc750c.
|
||||
Géométrie mesurée sur le visuel validé par Bruno (variante B). -->
|
||||
<path d="M 38.2 101.1 A 84 84 0 0 1 201.8 101.1" fill="none" stroke="#0076fb" stroke-width="28" stroke-linecap="round"/>
|
||||
<path d="M 201.5 140.3 A 84 84 0 0 1 38.5 140.3" fill="none" stroke="#fc750c" stroke-width="28" stroke-linecap="round"/>
|
||||
<polyline points="80,129 106,146 163,84" fill="none" stroke="#0076fb" stroke-width="24" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M 128 80 L 163 84 L 167 122" fill="none" stroke="#0076fb" stroke-width="22" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 760 B |
@@ -47,33 +47,6 @@
|
||||
href="/static/icons/icon-192x192.svg"
|
||||
/>
|
||||
|
||||
<!-- Boot splash CSS — inline so it applies BEFORE /static/style.css is
|
||||
available (desktop: embedded page served from tauri:// has no /static) -->
|
||||
<style id="boot-splash-css">
|
||||
body:not(.app-booted) > *:not(#boot-splash) { display: none !important; }
|
||||
#boot-splash {
|
||||
position: fixed; inset: 0; z-index: 2147483647;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
background: radial-gradient(1200px 600px at 50% -10%, #26272b 0%, #17181a 60%);
|
||||
color: #eceeeb; font-family: "Segoe UI", system-ui, -apple-system, sans-serif;
|
||||
}
|
||||
body.app-booted #boot-splash { display: none; }
|
||||
.bs-card { display: flex; flex-direction: column; align-items: center; gap: 14px; }
|
||||
.bs-logo {
|
||||
width: 72px; height: 72px; display: flex; align-items: center; justify-content: center;
|
||||
font-size: 38px; background: #1f2023; border: 1px solid #33353a; border-radius: 18px;
|
||||
box-shadow: 0 8px 30px rgba(0, 0, 0, 0.45);
|
||||
}
|
||||
.bs-title { font-size: 22px; font-weight: 600; letter-spacing: 0.3px; }
|
||||
.bs-spinner {
|
||||
width: 34px; height: 34px; border-radius: 50%;
|
||||
border: 3px solid rgba(124, 58, 237, 0.25); border-top-color: #7c3aed;
|
||||
animation: bs-spin 0.9s linear infinite;
|
||||
}
|
||||
@keyframes bs-spin { to { transform: rotate(360deg); } }
|
||||
.bs-status { font-size: 13px; color: #9aa3b2; min-height: 18px; }
|
||||
@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="/static/draco-alertes.css" />
|
||||
@@ -89,7 +62,7 @@
|
||||
id="hljs-theme-light"
|
||||
disabled
|
||||
/>
|
||||
<!-- defer: ne bloque pas le premier paint (le splash inline s'affiche immédiatement) -->
|
||||
<!-- defer: ne bloque pas le premier paint -->
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/highlight.min.js"></script>
|
||||
<!-- Langues highlight.js hors bundle "common" (enregistrement auto apres le core) -->
|
||||
<script defer src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.9.0/languages/dockerfile.min.js"></script>
|
||||
@@ -226,44 +199,6 @@
|
||||
</script>
|
||||
</head>
|
||||
<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
|
||||
démarrage du backend (FOUC 3-10 s). Styles 100% inline. -->
|
||||
<div id="boot-splash" role="status" aria-live="polite">
|
||||
<div class="bs-card">
|
||||
<div class="bs-logo">📖</div>
|
||||
<div class="bs-title">ObsiGate</div>
|
||||
<div class="bs-spinner" aria-hidden="true"></div>
|
||||
<div class="bs-status" id="boot-splash-status">Démarrage…</div>
|
||||
</div>
|
||||
</div>
|
||||
<script>
|
||||
(function () {
|
||||
var done = false;
|
||||
function bootReady() {
|
||||
if (done) return;
|
||||
done = true;
|
||||
document.body.classList.add("app-booted");
|
||||
var s = document.getElementById("boot-splash");
|
||||
if (s && s.parentNode) s.parentNode.removeChild(s);
|
||||
}
|
||||
window.__obsigateBootReady = bootReady;
|
||||
window.__setBootStatus = function (txt) {
|
||||
var el = document.getElementById("boot-splash-status");
|
||||
if (el) el.textContent = txt;
|
||||
};
|
||||
var poll = setInterval(function () {
|
||||
if (window.__OBSIGATE_BOOTED) { clearInterval(poll); bootReady(); }
|
||||
}, 80);
|
||||
// Filet de sécurité : si le CSS principal a chargé (origine backend réelle)
|
||||
// mais que le JS a échoué, révéler l'app après 10 s au lieu d'un splash infini.
|
||||
setTimeout(function () {
|
||||
var link = document.querySelector('link[href*="style.css"]');
|
||||
if (link && link.sheet) { clearInterval(poll); bootReady(); }
|
||||
}, 10000);
|
||||
})();
|
||||
</script>
|
||||
<div class="search-progress-bar" id="search-progress-bar">
|
||||
<div class="search-progress-bar__fill"></div>
|
||||
</div>
|
||||
@@ -333,10 +268,37 @@
|
||||
|
||||
<!-- 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.6
|
||||
(ui/barre-apps/) : ne pas modifier la structure ici ; toute
|
||||
évolution passe par une nouvelle version de commons.
|
||||
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
|
||||
autres (config backend/draco_config, dérivation -dev incluse) ;
|
||||
le CSS vendangé gagne la garde data-theme sur
|
||||
prefers-color-scheme (html:not([data-theme])).
|
||||
CSS/JS : /static/draco-barre.css, /static/draco-barre.js.
|
||||
Icônes logos v0.1.9 (2026-10-29) : les 5 tuiles sont les logos
|
||||
fournis, tels quels, servis depuis /static/img/logos/ (copie de
|
||||
ui/barre-apps/logos/ de dracodev-commons) ; l'entrée « Console Spark »
|
||||
du menu clone l'icône de l'onglet agent (draco-barre.js).
|
||||
v0.1.10 (2026-10-29) : --agent rose → rouge Spark #c00000, marque ✦
|
||||
→ logo Spark 26 px, bouton Spark rose supprimé (remplacé par
|
||||
l'avatar flottant bas-centre /static/img/avatar-spark.png,
|
||||
data-act="spark" → panneau Spark), Alertes = cloche seule.
|
||||
v0.1.11 (2026-10-29) : marque → étoile Spark monochrome
|
||||
/static/img/brand-spark.png 26 px (blanche sur barre foncée
|
||||
via [data-theme="dark"] d'ObsiGate, noire sur barre claire
|
||||
via le filtre brightness(0) du CSS vendangé — aucune
|
||||
surcharge propre nécessaire, la barre suit déjà
|
||||
data-theme) ; onglet agent GARDANT le logo rouge ;
|
||||
anneau rouge #c00000 (bordure 3 px) autour de l'avatar
|
||||
flottant, via le CSS vendangé.
|
||||
Chantier 5 (fusion #69/#71, sans bump) : 5e onglet Console Spark
|
||||
(data-app="agent", défaut prod ; la réécriture DRACO_URLS
|
||||
couvre la clé "agent" comme les autres onglets), après Kanbante ;
|
||||
@@ -345,29 +307,33 @@
|
||||
<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="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></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-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></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-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></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-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></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-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="shaarit" href="https://shaarit.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#059669" data-icon="shaarit"><img src="/static/img/logos/shaarit.svg" alt="ShaarIt" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ShaarIt</strong><small>Bookmarks</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="agent" href="https://agent.dracodev.net/">
|
||||
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -388,17 +354,13 @@
|
||||
</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-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="draco-bar-rappel" data-act="rappel-entete" hidden>✦ dracodev — afficher l'en-tête</div>
|
||||
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
|
||||
<script>
|
||||
/* Configuration ObsiGate de la barre commune (Fusion #215).
|
||||
Phase 1 : navigation entre apps et domaine mémorisé
|
||||
@@ -418,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) {
|
||||
@@ -441,6 +410,7 @@
|
||||
return;
|
||||
}
|
||||
if (act === "palette") { location.href = "/recherche"; return; }
|
||||
if (act === "deconnexion") { location.href = "/sso/logout?return_to=" + encodeURIComponent(location.origin + "/"); return; }
|
||||
/* Correctif fusion #70 : relais "menu" supprimé avec le bouton trois points de la barre (la console agent est dans le menu de la marque, entrée « Console Spark »). Le bouton natif #header-menu-btn d'ObsiGate lui-même est inchangé et reste utilisable depuis l'en-tête ObsiGate. */
|
||||
if (act === "alertes") { if (window.DRACO_ALERTES) { window.DRACO_ALERTES.open(); } else { dire("Alertes unifiées : le module n'a pas pu se charger."); } return; }
|
||||
if (act === "spark") {
|
||||
|
||||
@@ -120,16 +120,8 @@ async function init() {
|
||||
}
|
||||
|
||||
document.addEventListener("DOMContentLoaded", async () => {
|
||||
try {
|
||||
await init();
|
||||
Sync.init();
|
||||
} finally {
|
||||
// Signale au splash de démarrage (index.html #boot-splash) que l'app est
|
||||
// initialisée : le splash est retiré et le squelette stylé révélé.
|
||||
// Set AFTER init() resolves so every handler (search, tabs, …) is bound.
|
||||
window.__OBSIGATE_BOOTED = true;
|
||||
if (typeof window.__obsigateBootReady === "function") window.__obsigateBootReady();
|
||||
}
|
||||
await init();
|
||||
Sync.init();
|
||||
// Show version in header — always visible, fallback before the async fetch
|
||||
populateVersions();
|
||||
fetch('/api/health')
|
||||
|
||||
@@ -4,9 +4,15 @@
|
||||
*
|
||||
* Règles de sécurité appliquées ici (jamais assouplies) :
|
||||
* - Le backend ne renvoie que des métadonnées (has_value, jamais la valeur,
|
||||
* jamais un texte chiffré, jamais un nonce). Ce module n'affiche donc
|
||||
* jamais de valeur de secret après enregistrement : colonne valeur =
|
||||
* « •••••• » seulement, aucun bouton « afficher ».
|
||||
* jamais un texte chiffré, jamais un nonce). La valeur d'un secret
|
||||
* enregistré reste donc masquée (« •••••• ») par défaut ; seul le
|
||||
* gestionnaire du coffre (propriétaire, ou admin sur coffre
|
||||
* d'instance) peut la révéler, via POST .../secrets/{nom}/reveler
|
||||
* (session + gestion, chaque révélation auditée) : bouton
|
||||
* Afficher/Masquer dans le détail, ou « Copier la valeur » sans
|
||||
* l'afficher. La valeur est effacée dès qu'elle est masquée ou que
|
||||
* le détail change ; un simple lecteur par octroi utilisateur ne
|
||||
* révèle jamais.
|
||||
* - Aucune valeur ne transite par un titre d'onglet, une URL, un hash, un
|
||||
* journal console ou un log. Les variables contenant une valeur sont
|
||||
* effacées dès que l'opération est terminée (champ vidé, variable = null).
|
||||
@@ -14,10 +20,11 @@
|
||||
* consommateur (csk_…) et une KEK générée sont rendus UNE fois, dans une
|
||||
* modale, avec un bouton Copier et un avertissement ; la modale fermée,
|
||||
* ils sont perdus. Ce ne sont pas des valeurs de secret du coffre.
|
||||
* - La copie manuelle à usage unique (admin seulement, voir
|
||||
* _copieManuelle) passe par un consommateur temporaire créé puis révoqué
|
||||
* immédiatement, avec distribution auditée ; il n'existe pas encore
|
||||
* d'endpoint de distribution par session utilisateur côté backend.
|
||||
* - L'ancienne copie manuelle à usage unique par consommateur
|
||||
* temporaire a été supprimée : elle échouait systématiquement
|
||||
* (aucun octroi pour le consommateur temporaire, donc 403
|
||||
* octroi_absent à la distribution). Elle est remplacée par
|
||||
* « Copier la valeur », qui passe par l'endpoint de révélation.
|
||||
* - Phase 2 (issue #20) : « Importer l'existant » migre les clés de
|
||||
* data/api_keys.json vers le coffre d'instance « ObsiGate interne »,
|
||||
* en deux temps (import, puis purge confirmée). Aucune valeur ne
|
||||
@@ -52,6 +59,8 @@ let _coffresCache = null;
|
||||
let _coffresCacheAt = 0;
|
||||
const CACHE_MS = 30000;
|
||||
|
||||
let _valeurAffichee = null; // valeur révélée du détail courant, sinon null
|
||||
|
||||
const viewState = {
|
||||
coffres: [],
|
||||
currentSlug: null,
|
||||
@@ -354,6 +363,7 @@ function renderAlerts(host) {
|
||||
|
||||
function renderDetail(host) {
|
||||
const s = viewState.detail;
|
||||
_valeurAffichee = null;
|
||||
host.innerHTML = '';
|
||||
if (!s) {
|
||||
host.appendChild(text('p', t('coffres.select_secret'), 'coffres-empty'));
|
||||
@@ -377,7 +387,9 @@ function renderDetail(host) {
|
||||
const dl = el('dl', { class: 'coffres-dl' }, []);
|
||||
rows.forEach(([k, v]) => {
|
||||
dl.appendChild(el('dt', {}, [document.createTextNode(k)]));
|
||||
dl.appendChild(el('dd', {}, [document.createTextNode(v)]));
|
||||
const dd = el('dd', {}, [document.createTextNode(v)]);
|
||||
if (k === t('coffres.value')) dd.id = 'coffres-valeur';
|
||||
dl.appendChild(dd);
|
||||
});
|
||||
host.appendChild(dl);
|
||||
const actions = el('div', { class: 'coffres-actions' }, []);
|
||||
@@ -386,11 +398,36 @@ function renderDetail(host) {
|
||||
actions.appendChild(modalBtn(t('coffres.acces'), '', () => {
|
||||
document.getElementById('coffres-acces')?.scrollIntoView({ behavior: 'smooth' });
|
||||
}));
|
||||
actions.appendChild(modalBtn(t('coffres.manual_copy'), '', () => copieManuelle(s)));
|
||||
if (!isAdmin()) {
|
||||
actions.lastChild.disabled = true;
|
||||
actions.lastChild.title = t('coffres.manual_copy_admin_only');
|
||||
}
|
||||
const btnAfficher = modalBtn(t('coffres.show_value'), '', async () => {
|
||||
if (_valeurAffichee !== null) {
|
||||
_valeurAffichee = null;
|
||||
document.getElementById('coffres-valeur').textContent = '••••••';
|
||||
btnAfficher.textContent = t('coffres.show_value');
|
||||
return;
|
||||
}
|
||||
let valeur;
|
||||
try {
|
||||
valeur = await revelerValeur(s);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
return;
|
||||
}
|
||||
_valeurAffichee = valeur;
|
||||
document.getElementById('coffres-valeur').textContent = valeur;
|
||||
btnAfficher.textContent = t('coffres.hide_value');
|
||||
});
|
||||
actions.appendChild(btnAfficher);
|
||||
actions.appendChild(modalBtn(t('coffres.copy_value'), '', async () => {
|
||||
let valeur;
|
||||
try {
|
||||
valeur = await revelerValeur(s);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
return;
|
||||
}
|
||||
await copyText(valeur);
|
||||
valeur = null;
|
||||
}));
|
||||
actions.appendChild(modalBtn(t('coffres.delete'), 'danger', () => supprimerSecret(s)));
|
||||
host.appendChild(actions);
|
||||
|
||||
@@ -819,55 +856,19 @@ function openCreateCoffreModal() {
|
||||
]);
|
||||
}
|
||||
|
||||
/** Copie manuelle à usage unique, admin seulement.
|
||||
* Le backend n'expose pas (encore) de distribution par session : on passe
|
||||
* par un consommateur temporaire créé, utilisé une fois, puis révoqué
|
||||
* immédiatement. La valeur est copiée au presse-papiers sans jamais être
|
||||
* affichée, puis la variable est effacée, même en cas d'échec. */
|
||||
async function copieManuelle(s) {
|
||||
if (!isAdmin()) return;
|
||||
const ok = await showConfirm({
|
||||
title: t('coffres.manual_copy'),
|
||||
message: t('coffres.manual_copy_confirm'),
|
||||
danger: true,
|
||||
});
|
||||
if (!ok) return;
|
||||
let consommateurId = null;
|
||||
let valeur = null;
|
||||
try {
|
||||
const created = await api('/api/coffres/consommateurs', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ nom: `copie-manuelle-${me()}-${Date.now()}` }),
|
||||
});
|
||||
consommateurId = created.id;
|
||||
const jeton = created.jeton;
|
||||
const res = await fetch('/api/coffres/distribuer', {
|
||||
method: 'POST',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${jeton}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
reference: s.reference,
|
||||
contexte_type: 'copie_manuelle',
|
||||
contexte_id: `copie-manuelle-${Date.now()}`,
|
||||
}),
|
||||
});
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||
valeur = data.valeur;
|
||||
await copyText(valeur);
|
||||
} catch (e) {
|
||||
showToast(e.message || t('common.error'), 'error');
|
||||
} finally {
|
||||
valeur = null;
|
||||
if (consommateurId) {
|
||||
try {
|
||||
await api(`/api/coffres/consommateurs/${encodeURIComponent(consommateurId)}`, { method: 'DELETE' });
|
||||
} catch { /* révocation best-effort, l'octroi reste révocable depuis Accès */ }
|
||||
}
|
||||
}
|
||||
/** Révélation manuelle (gestionnaire du coffre seulement, auditée
|
||||
* côté backend). Ne conserve rien : l'appelant affiche ou copie
|
||||
* puis efface. Un lecteur par octroi utilisateur reçoit 403. */
|
||||
async function revelerValeur(s) {
|
||||
const ref = (s.reference || '').replace(/^coffre:/, '');
|
||||
const [slug, nom] = ref.split('/');
|
||||
const res = await fetch(
|
||||
`/api/coffres/${encodeURIComponent(slug)}/secrets/${encodeURIComponent(nom)}/reveler`,
|
||||
{ method: 'POST', credentials: 'include' },
|
||||
);
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data.detail || `Erreur ${res.status}`);
|
||||
return data.valeur;
|
||||
}
|
||||
|
||||
/** Phase 2 — « Importer l'existant » (admin seulement, deux temps).
|
||||
|
||||
@@ -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)";
|
||||
|
||||
@@ -209,6 +209,17 @@ function _openSecretsMenu(x, y) {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
/**
|
||||
* Vault déclaré en lecture seule (DIR_N_READONLY, champ « readonly » de
|
||||
* /api/vaults) : ses menus et boutons d'action ne doivent proposer aucune
|
||||
* mutation (renommer, déplacer, supprimer…) — le backend les refuse en
|
||||
* 403 de toute façon, l'UI le dit avant le clic.
|
||||
*/
|
||||
function vaultIsReadonly(vaultName) {
|
||||
const v = (state.allVaults || []).find((x) => x.name === vaultName);
|
||||
return !!(v && v.readonly);
|
||||
}
|
||||
|
||||
/** Fichier partagé (créé ou reçu) → icône share-2 bleue, sinon icône usuelle. */
|
||||
function sharedFileIconSync(sharedPaths, itemName, vaultName, itemPath) {
|
||||
if (sharedPaths && sharedPaths.has(`${vaultName}::${itemPath}`)) return _sharedIcon();
|
||||
@@ -356,11 +367,11 @@ async function refreshSidebarForContext() {
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(v.name);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||
});
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||
|
||||
container.appendChild(vaultItem);
|
||||
|
||||
@@ -460,11 +471,11 @@ async function loadVaults() {
|
||||
|
||||
vaultItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(v.name);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, v.name, '', 'vault', isReadonly);
|
||||
});
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", false);
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: false }));
|
||||
attachTreeItemActionButton(vaultItem, v.name, "", "vault", vaultIsReadonly(v.name));
|
||||
attachTreeItemLongPress(vaultItem, () => ({ vault: v.name, path: "", type: "vault", isReadonly: vaultIsReadonly(v.name) }));
|
||||
|
||||
container.appendChild(vaultItem);
|
||||
|
||||
@@ -596,8 +607,8 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
// New item — create it
|
||||
if (item.type === "directory") {
|
||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fragment.appendChild(dirItem);
|
||||
|
||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||||
@@ -624,14 +635,14 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
dirItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", false);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
});
|
||||
} else {
|
||||
const fileIconName = getFileIcon(item.name);
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [sharedFileIconSync(sharedPaths, item.name, vaultName, item.path), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -649,7 +660,7 @@ async function incrementalLoadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
fileItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", false);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
});
|
||||
|
||||
fragment.appendChild(fileItem);
|
||||
@@ -944,8 +955,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
if (item.type === "directory") {
|
||||
const dirItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "directory" }, [icon("chevron-right", 14), icon("folder", 16), el("span", { class: "tree-item-text" }, [document.createTextNode(item.name)]), smallBadge(item.children_count)]);
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", false);
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: false }));
|
||||
attachTreeItemActionButton(dirItem, vaultName, item.path, "directory", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(dirItem, () => ({ vault: vaultName, path: item.path, type: "directory", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fragment.appendChild(dirItem);
|
||||
|
||||
const subContainer = el("div", { class: "tree-children collapsed", id: `dir-${vaultName}-${item.path}` });
|
||||
@@ -972,7 +983,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
dirItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(vaultName);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'directory', isReadonly);
|
||||
});
|
||||
} else {
|
||||
@@ -980,8 +991,8 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
const displayName = item.name.match(/\.md$/i) ? item.name.replace(/\.md$/i, "") : item.name;
|
||||
const isShared = sharedPaths.has(`${vaultName}::${item.path}`);
|
||||
const fileItem = el("div", { class: "tree-item", "data-vault": vaultName, "data-path": item.path, "data-type": "file" }, [isShared ? _sharedIcon() : icon(fileIconName, 16), el("span", { class: "tree-item-text" }, [document.createTextNode(displayName)])]);
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", false);
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: false }));
|
||||
attachTreeItemActionButton(fileItem, vaultName, item.path, "file", vaultIsReadonly(vaultName));
|
||||
attachTreeItemLongPress(fileItem, () => ({ vault: vaultName, path: item.path, type: "file", isReadonly: vaultIsReadonly(vaultName) }));
|
||||
fileItem.addEventListener("click", () => {
|
||||
scrollTreeItemIntoView(fileItem, false);
|
||||
if (window.innerWidth <= 768) {
|
||||
@@ -999,7 +1010,7 @@ async function loadDirectory(vaultName, dirPath, container) {
|
||||
|
||||
fileItem.addEventListener("contextmenu", (e) => {
|
||||
e.preventDefault();
|
||||
const isReadonly = false;
|
||||
const isReadonly = vaultIsReadonly(vaultName);
|
||||
ContextMenuManager.show(e.clientX, e.clientY, vaultName, item.path, 'file', isReadonly);
|
||||
});
|
||||
|
||||
|
||||
@@ -2680,6 +2680,9 @@
|
||||
"coffres.copy_failed": "Copy failed.",
|
||||
"coffres.copy_generated": "Copy the generated value",
|
||||
"coffres.copy_reference": "Copy",
|
||||
"coffres.show_value": "Show",
|
||||
"coffres.hide_value": "Hide",
|
||||
"coffres.copy_value": "Copy value",
|
||||
"coffres.create_coffre_title": "New vault",
|
||||
"coffres.create_octroi": "Create a grant",
|
||||
"coffres.create_secret_title": "New secret",
|
||||
|
||||
@@ -2680,6 +2680,9 @@
|
||||
"coffres.copy_failed": "Copie impossible.",
|
||||
"coffres.copy_generated": "Copier la valeur générée",
|
||||
"coffres.copy_reference": "Copier",
|
||||
"coffres.show_value": "Afficher",
|
||||
"coffres.hide_value": "Masquer",
|
||||
"coffres.copy_value": "Copier la valeur",
|
||||
"coffres.create_coffre_title": "Nouveau coffre",
|
||||
"coffres.create_octroi": "Créer un octroi",
|
||||
"coffres.create_secret_title": "Nouveau secret",
|
||||
|
||||
@@ -14,7 +14,7 @@
|
||||
retomberait sur les défauts prod (les jetons sont ignorés par
|
||||
le JS) — jamais d'URL inventée.
|
||||
La barre ci-dessous est une copie statique du snippet figé de
|
||||
index.html (commons v0.1.6 + chantier 5 fusion #69/#71, sans
|
||||
index.html (commons v0.2.0 + chantier 5 fusion #69/#71, sans
|
||||
bump) : les onglets sont des liens réels, le bouton central
|
||||
replace le curseur dans la boîte (recherche.js,
|
||||
clic data-act="palette"). Re-vendoring alignement recherche :
|
||||
@@ -25,22 +25,6 @@
|
||||
comportements de la SPA et ne sont pas rejoués ici.
|
||||
Sidebar statique (navigation de l'app) : les vaults, tags et
|
||||
récents dynamiques vivent dans la SPA, pas sur cette page. -->
|
||||
Thème (défaut 4) : le thème ObsiGate n'est PAS une préférence
|
||||
serveur — il vit dans localStorage, lu par la SPA au démarrage
|
||||
(frontend/js/ui.js initTheme : mode ; frontend/js/sync.js
|
||||
init() -> themes.js initThemes : clé + mode). Étape 1 ci-dessous
|
||||
(script synchrone en tête, avant les feuilles) : pose
|
||||
data-theme = mode persisté (obsigate-theme-mode, repli
|
||||
obsigate-theme historique quand il valait déjà un mode, sinon
|
||||
'dark', le défaut SPA) pour que la première peinture soit déjà
|
||||
dans le bon mode clair/sombre. Étape 2 en fin de page (module
|
||||
différé) : initThemes() de themes.js applique la palette
|
||||
complète — 16 thèmes intégrés, modes high-contrast/sepia
|
||||
générés, thèmes personnalisés — via variables inline sur :root,
|
||||
exactement comme la SPA. data-theme="dark" sur <html> n'est
|
||||
plus que le repli sans JS. Aucun endpoint serveur, aucune
|
||||
police externe ajoutée (la CSP stricte de /recherche et le
|
||||
repli typographique système sont inchangés).
|
||||
<script>
|
||||
/* Thème ObsiGate, étape 1 (synchrone, avant les feuilles) : poser
|
||||
data-theme = mode persisté AVANT la première peinture, sinon la
|
||||
@@ -74,29 +58,33 @@
|
||||
<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="/" data-act="apps" aria-haspopup="true" aria-expanded="false" aria-label="dracodev — changer d'application" title="Changer d'application">
|
||||
<span class="draco-brand-mark">✦</span><span class="draco-brand-name">dracodev</span>
|
||||
<span class="draco-brand-mark"><img src="/static/img/brand-spark.png" alt="" width="26" height="26"></span><span class="draco-brand-name">dracodev</span>
|
||||
</a>
|
||||
<nav class="draco-apptabs" aria-label="Applications">
|
||||
<a class="draco-apptab" data-app="obsigate" href="{{URL_OBSIGATE}}">
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5">▤</span>
|
||||
<span class="draco-apptab-ico" style="--app:#7a5af5" data-icon="obsigate"><img src="/static/img/logos/obsigate.png" alt="ObsiGate" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ObsiGate</strong><small>Notes</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="flowdeck" href="{{URL_FLOWDECK}}">
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed">▦</span>
|
||||
<span class="draco-apptab-ico" style="--app:#2f6fed" data-icon="flowdeck"><img src="/static/img/logos/flowdeck.png" alt="Flowdeck" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Flowdeck</strong><small>Projets</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="tiktak" href="{{URL_TIKTAK}}">
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c">✓</span>
|
||||
<span class="draco-apptab-ico" style="--app:#e8762c" data-icon="tiktak"><img src="/static/img/logos/tiktak.svg" alt="TikTak" width="30" height="30"></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="{{URL_KANBANTE}}">
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488">▥</span>
|
||||
<span class="draco-apptab-ico" style="--app:#0d9488" data-icon="kanbante"><img src="/static/img/logos/kanbante.png" alt="Kanbante" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Kanbante</strong><small>Kanban</small></span>
|
||||
<span class="draco-badge" data-badge="kanbante" hidden>0</span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="shaarit" href="{{URL_SHAARIT}}">
|
||||
<span class="draco-apptab-ico" style="--app:#059669" data-icon="shaarit"><img src="/static/img/logos/shaarit.svg" alt="ShaarIt" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>ShaarIt</strong><small>Bookmarks</small></span>
|
||||
</a>
|
||||
<a class="draco-apptab" data-app="agent" href="{{URL_AGENT}}">
|
||||
<span class="draco-apptab-ico" style="--app:#c2437e">✦</span>
|
||||
<span class="draco-apptab-ico" style="--app:#c00000" data-icon="agent"><img src="/static/img/logos/spark.png" alt="Console Spark" width="30" height="30"></span>
|
||||
<span class="draco-apptab-txt"><strong>Console Spark</strong><small>Agent commun</small></span>
|
||||
</a>
|
||||
</nav>
|
||||
@@ -117,16 +105,12 @@
|
||||
</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-avatar" id="draco-avatar" data-act="compte" aria-label="Compte" aria-haspopup="menu" aria-expanded="false" title="">✦</button>
|
||||
</div>
|
||||
</header>
|
||||
<button type="button" class="draco-spark-flottant" data-act="spark" aria-label="Ouvrir Spark, l'agent commun"><img src="/static/img/avatar-spark.png" alt="" width="64" height="64"></button>
|
||||
<div id="app" class="app-container">
|
||||
<header class="header">
|
||||
<div class="header-left">
|
||||
@@ -148,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>
|
||||
@@ -161,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">
|
||||
@@ -179,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>
|
||||
|
||||
@@ -8493,7 +8493,7 @@ body.popup-mode .content-area {
|
||||
box-shadow: 0 2px 16px rgba(0,0,0,0.35);
|
||||
width: fit-content;
|
||||
max-width: 95%;
|
||||
z-index: 50;
|
||||
z-index: 66; /* au-dessus de .draco-spark-flottant (65) sinon la barre flottante DRACO intercepte les clics des flèches de recherche */
|
||||
backdrop-filter: blur(8px);
|
||||
}
|
||||
.results-bar-count {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "obsigate",
|
||||
"version": "2.82.2",
|
||||
"version": "2.86.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": {
|
||||
|
||||
@@ -25,7 +25,7 @@ async function login(page) {
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
// Wait until app init() resolved (all handlers bound).
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -28,7 +28,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function authEnabled(page) {
|
||||
|
||||
@@ -26,7 +26,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -25,7 +25,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -12,7 +12,7 @@ import { test, expect } from '@playwright/test';
|
||||
|
||||
async function boot(page) {
|
||||
await page.goto('/');
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ const BASE = process.env.BASE_URL || 'http://localhost:2029';
|
||||
async function goHome(page) {
|
||||
await page.goto(BASE);
|
||||
// Wait until app init() resolved (all handlers bound, incl. search).
|
||||
await page.waitForFunction(() => (window as any).__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await page.waitForSelector('#dashboard-home, #search-input', { timeout: 15000 });
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', CREDS.password);
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFile(page, vault, filePath) {
|
||||
|
||||
@@ -57,7 +57,7 @@ async function login(page) {
|
||||
await page.fill('#login-password', process.env.OBSIGATE_PASS || 'test123');
|
||||
await page.click('#login-btn');
|
||||
}
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
}
|
||||
|
||||
async function openFixture(page) {
|
||||
|
||||
@@ -117,7 +117,7 @@ test.describe('XSS — lecteur markdown (BUG-021)', () => {
|
||||
await api(request, 'POST', `/api/file/${VAULT}`, { path: XSS_FILE, content: `${XSS_BODY}\n` });
|
||||
|
||||
await page.goto(BASE);
|
||||
await page.waitForFunction(() => window.__OBSIGATE_BOOTED === true, { timeout: 20000 });
|
||||
await page.waitForFunction(() => document.readyState === 'complete' && !!document.querySelector('.tree-item'), { timeout: 20000 });
|
||||
await openFile(page, VAULT, XSS_FILE);
|
||||
|
||||
const content = page.locator('#content-area');
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -112,8 +112,10 @@ function testModulesHaveImports() {
|
||||
{ file: 'navfacets.js', reason: 'pure helpers — no deps, imported by vaulthome.js' },
|
||||
{ file: 'sidebar_raw.js', reason: 'legacy file — excluded' },
|
||||
{ file: 'desktop.js', reason: 'standalone — reads window.__TAURI__ global, no imports needed' },
|
||||
{ file: 'recherche.js', reason: 'standalone — vanilla sans dépendance, vendangé tel quel par chaque hôte (#68)', noExport: true },
|
||||
];
|
||||
const expectNoImport = new Set(expected.map(e => e.file));
|
||||
const expectNoExport = new Set(expected.filter(e => e.noExport).map(e => e.file));
|
||||
|
||||
for (const file of files) {
|
||||
const content = readFileSync(join(JS_DIR, file), 'utf-8');
|
||||
@@ -123,7 +125,7 @@ function testModulesHaveImports() {
|
||||
if (!expectNoImport.has(file)) {
|
||||
assert.ok(hasImport, `${file} should have at least one import`);
|
||||
}
|
||||
if (file !== 'state.js' && file !== 'app.js') {
|
||||
if (file !== 'state.js' && file !== 'app.js' && !expectNoExport.has(file)) {
|
||||
assert.ok(hasExport, `${file} should have at least one export`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -778,3 +778,56 @@ class TestDistribuables:
|
||||
# Flag désactivé : 503 comme les autres routes.
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
assert self._lister(client, jeton).status_code == 503
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Révélation manuelle (panneau détail : Afficher / Copier la valeur)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRevelation:
|
||||
def test_proprietaire_revele_et_audit_sans_valeur(self, client, env_coffre):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 200, resp.text
|
||||
corps = resp.json()
|
||||
assert corps["valeur"] == VALEUR_TEST
|
||||
assert corps["reference"] == "coffre:personnel/gitea-token"
|
||||
assert corps["version"] == 1
|
||||
audit = (env_coffre / "audit.log").read_text(encoding="utf-8")
|
||||
assert "revelation_manuelle" in audit
|
||||
assert VALEUR_TEST not in audit
|
||||
|
||||
def test_lecteur_octroi_utilisateur_ne_revele_pas(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/octrois", json={
|
||||
"cible_type": "utilisateur", "cible_id": "alice",
|
||||
"secret_nom": "gitea-token"})
|
||||
assert resp.status_code == 200, resp.text
|
||||
_connecter(client, "alice", role="user")
|
||||
# Voit les métadonnées…
|
||||
assert client.get("/api/coffres/personnel/secrets").status_code == 200
|
||||
# …mais ne révèle jamais la valeur.
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_secret_inexistant_404(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
resp = client.post("/api/coffres/personnel/secrets/absent/reveler")
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_secret_expire_410(self, client):
|
||||
_creer_coffre_secret(client)
|
||||
hier = (datetime.now(timezone.utc) - timedelta(days=1)).isoformat()
|
||||
resp = client.post("/api/coffres/personnel/secrets", json={
|
||||
"nom": "vieux-jeton", "type": "token", "valeur": VALEUR_TEST,
|
||||
"expiration": hier})
|
||||
assert resp.status_code == 200, resp.text
|
||||
resp = client.post("/api/coffres/personnel/secrets/vieux-jeton/reveler")
|
||||
assert resp.status_code == 410
|
||||
assert resp.json()["detail"] == "secret_expire"
|
||||
|
||||
def test_flag_desactive_503(self, client, monkeypatch):
|
||||
_creer_coffre_secret(client)
|
||||
monkeypatch.setenv("OBSIGATE_SECRETS_ENABLED", "0")
|
||||
resp = client.post("/api/coffres/personnel/secrets/gitea-token/reveler")
|
||||
assert resp.status_code == 503
|
||||
|
||||
@@ -0,0 +1,534 @@
|
||||
# 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
|
||||
|
||||
|
||||
# ── Sessions draco (HUB-22) ─────────────────────────────────────────────
|
||||
|
||||
def _emettre(client, username, nombre=1):
|
||||
"""Émet ``nombre`` jetons draco pour un compte ; retourne
|
||||
[(jeton, jti, exp), …]."""
|
||||
from backend import draco_session
|
||||
|
||||
profil = client.get(f"{BASE}/{username}",
|
||||
headers=MACHINE_HEADERS).json()
|
||||
return [draco_session.issue_token(
|
||||
{"id": profil["id"], "username": username,
|
||||
"display_name": profil["display_name"]})
|
||||
for _ in range(nombre)]
|
||||
|
||||
|
||||
class TestSessions:
|
||||
def test_liste_sessions(self, machine_client):
|
||||
from datetime import datetime
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
emis = _emettre(machine_client, "nouveau", 2)
|
||||
|
||||
resp = machine_client.get(f"{BASE}/nouveau/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
assert data["username"] == "nouveau"
|
||||
assert len(data["sessions"]) == 2
|
||||
jtis = [s["jti"] for s in data["sessions"]]
|
||||
assert jtis == [j for _t, j, _e in emis] # exp croissant
|
||||
for session, (_t, _j, exp) in zip(data["sessions"], emis):
|
||||
assert session["exp"] == exp
|
||||
assert session["revoquee"] is False
|
||||
echeance = datetime.fromisoformat(session["expire_le"])
|
||||
assert echeance.tzinfo is not None
|
||||
assert int(echeance.timestamp()) == exp
|
||||
|
||||
def test_liste_sessions_vide_et_compte_inconnu(self, machine_client):
|
||||
resp = machine_client.get(f"{BASE}/mfauser/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"username": "mfauser", "sessions": []}
|
||||
resp = machine_client.get(f"{BASE}/personne/sessions",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revocation_unitaire(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(t1, jti1, _e1), (t2, _jti2, _e2) = _emettre(
|
||||
machine_client, "nouveau", 2)
|
||||
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/{jti1}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"jti": jti1, "revoquee": True}
|
||||
|
||||
# La session révoquée est morte, l'autre vit ; la liste la
|
||||
# signale comme révoquée.
|
||||
assert draco_session.verify_token(t1) is None
|
||||
assert draco_session.verify_token(t2) is not None
|
||||
sessions = machine_client.get(
|
||||
f"{BASE}/nouveau/sessions", headers=MACHINE_HEADERS).json()
|
||||
etats = {s["jti"]: s["revoquee"] for s in sessions["sessions"]}
|
||||
assert etats[jti1] is True
|
||||
|
||||
def test_revocation_cross_utilisateur_404(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(token, jti, _e), = _emettre(machine_client, "nouveau", 1)
|
||||
|
||||
# Le jti de « nouveau » demandé sous « mfauser » : 404, et la
|
||||
# session de « nouveau » reste valide (jamais de révocation
|
||||
# cross-utilisateur).
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/mfauser/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
assert draco_session.verify_token(token) is not None
|
||||
|
||||
# Jti inconnu et compte inconnu : 404 également.
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/jti-inconnu/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/personne/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_revoquer_toutes_sans_desactiver(self, machine_client):
|
||||
from backend import draco_session
|
||||
|
||||
assert _creer(machine_client).status_code == 201
|
||||
emis = _emettre(machine_client, "nouveau", 2)
|
||||
(autre_token, _j, _e), = _emettre(machine_client, "mfauser", 1)
|
||||
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.status_code == 200
|
||||
assert resp.json() == {"revoquees": 2}
|
||||
|
||||
for token, _jti, _exp in emis:
|
||||
assert draco_session.verify_token(token) is None
|
||||
# Les sessions des autres comptes ne sont pas touchées.
|
||||
assert draco_session.verify_token(autre_token) is not None
|
||||
|
||||
# Plus rien à révoquer : le registre a été vidé pour ce compte
|
||||
# (avant toute nouvelle connexion, qui émettrait une session).
|
||||
resp = machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
assert resp.json() == {"revoquees": 0}
|
||||
|
||||
# Déconnexion globale ≠ désactivation : le compte reste actif
|
||||
# et une nouvelle connexion fonctionne.
|
||||
profil = machine_client.get(f"{BASE}/nouveau",
|
||||
headers=MACHINE_HEADERS).json()
|
||||
assert profil["active"] is True
|
||||
assert _login(machine_client, "nouveau",
|
||||
"motdepasse-initial").status_code == 200
|
||||
|
||||
def test_sessions_protegees_par_cle_machine(self, machine_client,
|
||||
monkeypatch):
|
||||
assert machine_client.get(
|
||||
f"{BASE}/nouveau/sessions").status_code == 401
|
||||
assert machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/un-jti/revoquer").status_code == 401
|
||||
assert machine_client.post(
|
||||
f"{BASE}/nouveau/sessions/revoquer-toutes").status_code == 401
|
||||
monkeypatch.delenv("DRACO_ADMIN_MACHINE_KEY")
|
||||
assert machine_client.get(
|
||||
f"{BASE}/nouveau/sessions",
|
||||
headers=MACHINE_HEADERS).status_code == 404
|
||||
|
||||
def test_sessions_journalisees(self, machine_client):
|
||||
assert _creer(machine_client).status_code == 201
|
||||
(_t, jti, _e), = _emettre(machine_client, "nouveau", 1)
|
||||
machine_client.post(f"{BASE}/nouveau/sessions/{jti}/revoquer",
|
||||
headers=MACHINE_HEADERS)
|
||||
machine_client.post(f"{BASE}/nouveau/sessions/revoquer-toutes",
|
||||
headers=MACHINE_HEADERS)
|
||||
|
||||
audit_file = Path("data/audit.log")
|
||||
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 ("session_revoquee", "nouveau") in actions
|
||||
assert ("sessions_revoquees", "nouveau") in actions
|
||||
|
||||
|
||||
# ── 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"]
|
||||
@@ -34,13 +34,71 @@ def test_palier_mobile_resserre_v012():
|
||||
|
||||
|
||||
def test_menu_des_applications_par_la_marque():
|
||||
# v0.1.3 : la marque ✦ ouvre le menu des 4 applications
|
||||
# (changement d'app en mobile), logo à 24 px.
|
||||
# v0.1.3 : la marque ouvre le menu des applications
|
||||
# (changement d'app en mobile). v0.1.10 : la marque n'est plus
|
||||
# le glyphe ✦ à 24 px mais le logo Spark en img 26 px.
|
||||
# v0.1.11 : la marque n'est plus le logo rouge mais l'étoile
|
||||
# Spark monochrome (brand-spark.png) ; l'onglet agent, lui,
|
||||
# garde le logo rouge.
|
||||
html = _index()
|
||||
assert 'data-act="apps"' in html
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "draco-apps-menu" in js
|
||||
assert "font-size: 24px" in _css()
|
||||
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in html
|
||||
assert ".draco-brand-mark img" in _css()
|
||||
assert "width: 26px" in _css()
|
||||
assert "filter: brightness(0)" in _css()
|
||||
assert '[data-theme="dark"] .draco-bar .draco-brand-mark img { filter: none; }' in _css()
|
||||
assert 'data-icon="agent"><img src="/static/img/logos/spark.png"' in html
|
||||
assert "border: 3px solid var(--agent)" in _css()
|
||||
recherche = (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
||||
assert '<span class="draco-brand-mark"><img src="/static/img/brand-spark.png"' in recherche
|
||||
|
||||
|
||||
def test_barre_v020_onglet_shaarit_et_garde_data_theme():
|
||||
# Barre v0.2.0 (dracodev-commons @70c837a) : 6e onglet ShaarIt
|
||||
# après Kanbante et avant l'onglet agent, logo fourni tel quel,
|
||||
# et garde data-theme sur les blocs prefers-color-scheme du CSS
|
||||
# vendangé (html:not([data-theme])) — sans elle, un hôte en
|
||||
# data-theme="light" sur système sombre héritait du sombre.
|
||||
css = _css()
|
||||
assert "html:not([data-theme]) .draco-bar {" in css
|
||||
assert "html:not([data-theme]) .draco-bar .draco-brand-mark img" in css
|
||||
assert (FRONTEND / "img" / "logos" / "shaarit.svg").is_file()
|
||||
for nom, jeton in (("index.html", "https://shaarit.dracodev.net/"),
|
||||
("recherche.html", "{{URL_SHAARIT}}")):
|
||||
html = (FRONTEND / nom).read_text(encoding="utf-8")
|
||||
nav = html.split('<nav class="draco-apptabs"')[1].split("</nav>")[0]
|
||||
assert f'data-app="shaarit" href="{jeton}"' in nav
|
||||
assert 'data-icon="shaarit"><img src="/static/img/logos/shaarit.svg"' in nav
|
||||
assert nav.index('data-app="kanbante"') < nav.index('data-app="shaarit"')
|
||||
assert nav.index('data-app="shaarit"') < nav.index('data-app="agent"')
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert 'shaarit: "https://shaarit.dracodev.net/"' in js
|
||||
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():
|
||||
@@ -87,7 +145,8 @@ def test_panneau_console_surimpression_222():
|
||||
assert "draco-console-sur" in panneau
|
||||
assert "assurerSurimpression" in panneau
|
||||
assert "draco-console-styles" in panneau
|
||||
assert 'surCadre.src = urlConsole()' in panneau
|
||||
assert 'var url = urlConsole()' in panneau
|
||||
assert 'surCadre.src = url' in panneau
|
||||
assert 'surEl.style.width = "100%"' in panneau
|
||||
assert 'data-dc="sidebar"' in panneau
|
||||
assert "Mode sidebar" in panneau
|
||||
@@ -104,7 +163,8 @@ def test_csp_ouvre_le_service_d_agents_218():
|
||||
# dérivée de la configuration (backend.draco_config.agent_origine,
|
||||
# testée via la config dans tests/test_security_headers.py).
|
||||
main = (Path(__file__).resolve().parent.parent / "backend" / "main.py").read_text(encoding="utf-8")
|
||||
ligne = next(l for l in main.splitlines() if "connect-src" in l)
|
||||
ligne = next(l for l in main.splitlines()
|
||||
if "connect-src" in l and not l.strip().startswith("#"))
|
||||
assert "agent_origine" in main
|
||||
assert "{agent}" in ligne
|
||||
|
||||
@@ -124,6 +184,16 @@ def test_urls_ecosysteme_via_injection_hote_223():
|
||||
assert "DRACO_URLS[a.dataset.app]" in html
|
||||
|
||||
|
||||
def test_barre_deconnexion_branchee():
|
||||
# BUG-122 (fusion #141) : la barre d'ObsiGate elle-même branche
|
||||
# l'action « deconnexion » sur son point d'entrée de déconnexion
|
||||
# globale (GET /sso/logout, retour à la racine) — avant, le clic
|
||||
# ne faisait rien ici non plus.
|
||||
html = _index()
|
||||
assert 'act === "deconnexion"' in html
|
||||
assert '"/sso/logout?return_to="' in html
|
||||
|
||||
|
||||
def test_rail_mobile_vers_le_vrai_hamburger():
|
||||
html = _index()
|
||||
assert 'matchMedia("(max-width: 768px)")' in html
|
||||
@@ -146,3 +216,56 @@ 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
|
||||
|
||||
|
||||
def test_barre_v050_theme_commun_hub18():
|
||||
# v0.5.0 (HUB-18, fusion #132) : la barre apprend la préférence
|
||||
# de thème du Hub et l'applique à l'ouverture. La vendange reste
|
||||
# verbatim commons ; l'initialisation propre à ObsiGate vit dans
|
||||
# app.js (écouteur draco:theme → applyTheme).
|
||||
js = (FRONTEND / "draco-barre.js").read_text(encoding="utf-8")
|
||||
assert "api/profil/theme" in js
|
||||
assert 'credentials: "include"' in js
|
||||
assert "draco-theme-hub" in js
|
||||
assert "THEME_CACHE_TTL_MS" in js
|
||||
assert 'CustomEvent("draco:theme"' in js
|
||||
assert "themePref" in js and "themeMode" in js
|
||||
# Aucun nom de Hub figé de plus que les défauts/dérivations
|
||||
# documentés : l'URL du Hub reste la résolue de la barre.
|
||||
assert "resolvedUrls.hub" in js
|
||||
app = (FRONTEND / "app.js").read_text(encoding="utf-8")
|
||||
assert 'addEventListener("draco:theme"' in app
|
||||
assert "barre.themeMode" in app
|
||||
|
||||
@@ -19,7 +19,9 @@ def env_propre(monkeypatch):
|
||||
"""Aucune variable d'URL d'écosystème posée (défauts officiels)."""
|
||||
for nom in (
|
||||
"DRACO_AGENT_URL", "DRACO_OBSIGATE_URL", "DRACO_FLOWDECK_URL",
|
||||
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_GITEA_URL",
|
||||
"DRACO_TIKTAK_URL", "DRACO_KANBANTE_URL", "DRACO_SHAARIT_URL",
|
||||
"DRACO_HUB_URL",
|
||||
"DRACO_GITEA_URL",
|
||||
"AGENT_EVENEMENTS_URL",
|
||||
):
|
||||
monkeypatch.delenv(nom, raising=False)
|
||||
@@ -37,6 +39,36 @@ def test_defauts_officiels_sans_dev(env_propre):
|
||||
assert "-dev" not in url
|
||||
|
||||
|
||||
def test_shaarit_defaut_et_derivation(env_propre):
|
||||
"""Barre v0.2.0 : ShaarIt 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."""
|
||||
assert draco_config.app_url("shaarit") == "https://shaarit.dracodev.net"
|
||||
env_propre.setenv(
|
||||
"AGENT_EVENEMENTS_URL", "https://agent-dev.dracodev.net/api/v1/evenements")
|
||||
assert draco_config.app_url("shaarit") == "https://shaarit-dev.dracodev.net"
|
||||
assert draco_config.urls_publiques()["shaarit"] == \
|
||||
"https://shaarit-dev.dracodev.net"
|
||||
env_propre.setenv("DRACO_SHAARIT_URL", "https://bm.example.net/")
|
||||
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)."""
|
||||
|
||||
@@ -18,9 +18,13 @@ 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")
|
||||
monkeypatch.setattr(draco_session, "ISSUED_FILE",
|
||||
tmp_path / "draco_issued.json")
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
yield
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
|
||||
|
||||
# ── Jeton : émission / vérification ─────────────────────────────────────
|
||||
@@ -185,6 +189,44 @@ def test_sso_authorize_liste_blanche(client):
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
|
||||
|
||||
# ── Déconnexion globale navigable (BUG-122, fusion #141) ────────────────
|
||||
|
||||
def test_sso_logout_revoque_et_redirige(admin_client):
|
||||
token = _token_depuis_login(admin_client)
|
||||
assert draco_session.verify_token(token)
|
||||
r = admin_client.get(
|
||||
"/sso/logout",
|
||||
params={"return_to": "https://tiktak.dracodev.net/"},
|
||||
headers={"Cookie": f"draco_session={token}"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "https://tiktak.dracodev.net/"
|
||||
assert draco_session.verify_token(token) is None # jti révoqué
|
||||
cookie = _draco_cookie(r) # cookie effacé
|
||||
assert cookie is not None and "Max-Age=0" in cookie
|
||||
|
||||
|
||||
def test_sso_logout_liste_blanche(admin_client):
|
||||
r = admin_client.get("/sso/logout",
|
||||
params={"return_to": "https://evil.com/phish"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/" # refusé, pas de relais
|
||||
r = admin_client.get("/sso/logout", follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "/" # sans return_to
|
||||
|
||||
|
||||
def test_sso_logout_sans_session_idempotent(client):
|
||||
# Aucun cookie : la route reste une redirection propre (les autres
|
||||
# applications y naviguent même si la session a déjà expiré).
|
||||
r = client.get("/sso/logout",
|
||||
params={"return_to": "https://hub.dracodev.net/"},
|
||||
follow_redirects=False)
|
||||
assert r.status_code == 302
|
||||
assert r.headers["location"] == "https://hub.dracodev.net/"
|
||||
|
||||
|
||||
# ── Validation locale (ACC-3.2, #9) ──────────────────────────────────────
|
||||
|
||||
def test_me_avec_cookie_draco_seul(admin_client):
|
||||
@@ -232,3 +274,58 @@ def test_revocations_protegees_par_cle_machine(admin_client, monkeypatch):
|
||||
headers={"X-Draco-Machine-Key": "cle-machine"})
|
||||
assert r.status_code == 200
|
||||
assert {"jti": jti, "exp": exp} in r.json()["revoked"]
|
||||
|
||||
|
||||
# ── Registre des sessions (HUB-22, #227) ────────────────────────────────
|
||||
|
||||
AUTRE = {"id": "user-2", "username": "autre", "display_name": "Autre"}
|
||||
|
||||
|
||||
def test_sessions_emises_listees_triees_et_marquees():
|
||||
_t1, jti1, exp1 = draco_session.issue_token(USER)
|
||||
t2, jti2, exp2 = draco_session.issue_token(USER)
|
||||
draco_session.issue_token(AUTRE) # autre sub : exclu
|
||||
draco_session.revoke(jti2, exp2)
|
||||
|
||||
sessions = draco_session.issued_sessions_for_user("admin-1")
|
||||
assert [s["jti"] for s in sessions] == [jti1, jti2] # exp croissant
|
||||
assert sessions[0] == {"jti": jti1, "exp": exp1, "revoquee": False}
|
||||
assert sessions[1] == {"jti": jti2, "exp": exp2, "revoquee": True}
|
||||
assert draco_session.verify_token(t2) is None # cohérent
|
||||
|
||||
|
||||
def test_sessions_emises_expirees_purgees():
|
||||
now = int(time.time())
|
||||
with draco_session._lock:
|
||||
store = draco_session._load_issued()
|
||||
store["admin-1"] = {"ancien": now - 10, "frais": now + 600}
|
||||
assert [s["jti"] for s in
|
||||
draco_session.issued_sessions_for_user("admin-1")] == ["frais"]
|
||||
with draco_session._lock:
|
||||
assert "ancien" not in draco_session._load_issued()["admin-1"]
|
||||
assert draco_session.issued_sessions_for_user("personne") == []
|
||||
assert draco_session.issued_sessions_for_user("") == []
|
||||
|
||||
|
||||
def test_revoquer_une_session_appartenante():
|
||||
token, jti, _exp = draco_session.issue_token(USER)
|
||||
assert draco_session.revoke_one_for_user("admin-1", jti) is True
|
||||
assert draco_session.verify_token(token) is None
|
||||
assert draco_session.is_revoked(jti)
|
||||
|
||||
|
||||
def test_revoquer_une_session_cross_utilisateur_refusee():
|
||||
token, jti, _exp = draco_session.issue_token(USER)
|
||||
# Le jti existe, mais pas dans l'ensemble émis de cet autre sub.
|
||||
assert draco_session.revoke_one_for_user("user-2", jti) is False
|
||||
assert draco_session.verify_token(token) is not None
|
||||
assert draco_session.revoke_one_for_user("admin-1", "inconnu") is False
|
||||
assert draco_session.revoke_one_for_user("", jti) is False
|
||||
|
||||
|
||||
def test_revoquer_une_session_expiree_refusee():
|
||||
now = int(time.time())
|
||||
with draco_session._lock:
|
||||
draco_session._load_issued()["admin-1"] = {"perime": now - 10}
|
||||
assert draco_session.revoke_one_for_user("admin-1", "perime") is False
|
||||
assert not draco_session.is_revoked("perime")
|
||||
|
||||
@@ -0,0 +1,291 @@
|
||||
# tests/test_hub_roles.py
|
||||
# Hub HUB-19 (propagation, #228) — Rôles par application : client du
|
||||
# registre du Hub (parsing, cache 60 s par sub, repli systématique) et
|
||||
# effets sur le chemin draco d'ObsiGate (refus « aucun », garde
|
||||
# administrateur des routes /api/auth/admin/* pour le canal draco).
|
||||
# Contrat posé par l'hôte pilote ShaarIt 0.7.0 (décision D3(a)).
|
||||
|
||||
import io
|
||||
import json
|
||||
import time
|
||||
import urllib.error
|
||||
|
||||
import pytest
|
||||
|
||||
from backend import draco_session, hub_roles
|
||||
|
||||
USER = {"id": "admin-1", "username": "admin", "display_name": "Admin"}
|
||||
USER_SIMPLE = {"id": "user-1", "username": "normaluser", "display_name": "normal"}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _hub_env(monkeypatch):
|
||||
"""Cache vierge + Hub de test explicite pour chaque test."""
|
||||
hub_roles.reinitialiser_cache()
|
||||
monkeypatch.setenv("DRACO_HUB_URL", "https://hub-test.dracodev.net")
|
||||
yield
|
||||
hub_roles.reinitialiser_cache()
|
||||
|
||||
|
||||
def _reponse(role, statut=200, corps=None):
|
||||
"""Fausse réponse urlopen portant un payload du contrat D3(a)."""
|
||||
if corps is None:
|
||||
corps = {"app": "obsigate", "sub": "admin-1", "role": role,
|
||||
"explicite": True, "ttl_secondes": 60}
|
||||
charge = json.dumps(corps).encode("utf-8")
|
||||
|
||||
class _Resp:
|
||||
status = statut
|
||||
|
||||
def read(self):
|
||||
return charge
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
return _Resp()
|
||||
|
||||
|
||||
def _patch_hub(monkeypatch, role, appels):
|
||||
"""Remplace la consultation HTTP par un rôle fixe (compte les appels)."""
|
||||
def _faux(url, cookie):
|
||||
appels.append((url, cookie))
|
||||
return role
|
||||
monkeypatch.setattr(hub_roles, "_consulter_hub", _faux)
|
||||
|
||||
|
||||
# ── Client : parsing du contrat ─────────────────────────────────────────
|
||||
|
||||
def test_consultation_parse_le_contrat(monkeypatch):
|
||||
vu = {}
|
||||
|
||||
def _faux_urlopen(requete, timeout=None):
|
||||
vu["url"] = requete.full_url
|
||||
vu["cookie"] = requete.get_header("Cookie")
|
||||
vu["accept"] = requete.get_header("Accept")
|
||||
vu["timeout"] = timeout
|
||||
return _reponse("administrateur")
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _faux_urlopen)
|
||||
role = hub_roles.role_pour("admin-1", "draco_session=jeton; autre=1")
|
||||
assert role == "administrateur"
|
||||
assert vu["url"] == "https://hub-test.dracodev.net/api/roles/obsigate"
|
||||
assert vu["cookie"] == "draco_session=jeton; autre=1" # Cookie complet relayé
|
||||
assert vu["accept"] == "application/json"
|
||||
assert vu["timeout"] == 2 # délai du contrat
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["aucun", "utilisateur", "administrateur"])
|
||||
def test_consultation_roles_reconnus(monkeypatch, role):
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse(role))
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == role
|
||||
|
||||
|
||||
def test_consultation_role_hors_contrat_inconnu(monkeypatch):
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _reponse("super-admin"))
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_json_illisible_inconnu(monkeypatch):
|
||||
class _Resp:
|
||||
status = 200
|
||||
|
||||
def read(self):
|
||||
return b"pas du json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen",
|
||||
lambda requete, timeout=None: _Resp())
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_erreur_http_inconnu(monkeypatch):
|
||||
def _erreur(requete, timeout=None):
|
||||
raise urllib.error.HTTPError(requete.full_url, 500, "boom", {}, io.BytesIO(b""))
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _erreur)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_consultation_hub_injoignable_inconnu(monkeypatch):
|
||||
def _panne(requete, timeout=None):
|
||||
raise urllib.error.URLError("injoignable")
|
||||
|
||||
monkeypatch.setattr(hub_roles.urllib.request, "urlopen", _panne)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") is None
|
||||
|
||||
|
||||
def test_url_hub_derivee_par_hote_soeur(monkeypatch):
|
||||
# Sans variable explicite : dérivation depuis l'hôte de l'agent
|
||||
# (agent-dev → hub-dev), comme la barre commune (#223).
|
||||
monkeypatch.delenv("DRACO_HUB_URL")
|
||||
monkeypatch.setenv("DRACO_AGENT_URL", "https://agent-dev.dracodev.net")
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert appels[0][0] == "https://hub-dev.dracodev.net/api/roles/obsigate"
|
||||
|
||||
|
||||
def test_url_hub_defaut_officiel(monkeypatch):
|
||||
monkeypatch.delenv("DRACO_HUB_URL")
|
||||
monkeypatch.delenv("DRACO_AGENT_URL", raising=False)
|
||||
monkeypatch.delenv("AGENT_EVENEMENTS_URL", raising=False)
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
hub_roles.role_pour("admin-1", "c=1")
|
||||
assert appels[0][0] == "https://hub.dracodev.net/api/roles/obsigate"
|
||||
|
||||
|
||||
# ── Client : cache 60 s par sub, horodatage avant l'appel ──────────────
|
||||
|
||||
def test_cache_par_sub_et_ttl(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert len(appels) == 1 # 2e lecture : cache
|
||||
assert hub_roles.role_pour("user-1", "c=2") == "utilisateur"
|
||||
assert len(appels) == 2 # autre sub : nouvel appel
|
||||
# TTL expiré → nouvel appel.
|
||||
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "utilisateur"
|
||||
assert len(appels) == 3
|
||||
|
||||
|
||||
def test_echec_conserve_ancien_cache_sans_rafale(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "administrateur", appels)
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
|
||||
assert len(appels) == 1
|
||||
# Le Hub tombe ; le TTL expire : un appel est tenté, échoue, et
|
||||
# l'horodatage posé avant l'appel empêche la rafale — l'ancien
|
||||
# rôle connu n'est ni effacé ni réinterrogé aussitôt.
|
||||
hub_roles._cache_at = time.monotonic() - hub_roles.CACHE_TTL_SECONDS - 1
|
||||
_patch_hub(monkeypatch, None, appels)
|
||||
assert hub_roles.role_pour("user-1", "c=2") is None
|
||||
assert len(appels) == 2
|
||||
# Dans la fenêtre qui suit l'échec, l'ancien cache sert toujours.
|
||||
assert hub_roles.role_pour("admin-1", "c=1") == "administrateur"
|
||||
assert len(appels) == 2
|
||||
|
||||
|
||||
def test_sub_vide_aucun_appel(monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "utilisateur", appels)
|
||||
assert hub_roles.role_pour("", "c=1") is None
|
||||
assert appels == []
|
||||
|
||||
|
||||
# ── Intégration : chemin draco (admin_client, secret draco posé) ──────
|
||||
|
||||
@pytest.fixture()
|
||||
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")
|
||||
monkeypatch.setattr(draco_session, "ISSUED_FILE",
|
||||
tmp_path / "draco_issued.json")
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
yield
|
||||
draco_session._revoked = None
|
||||
draco_session._issued = None
|
||||
|
||||
|
||||
def _pose_cookie_draco(client, user):
|
||||
token, _jti, _exp = draco_session.issue_token(user)
|
||||
client.cookies.set("draco_session", token)
|
||||
return token
|
||||
|
||||
|
||||
def test_draco_aucun_refuse_session_explicite(admin_client, _draco_env, monkeypatch):
|
||||
appels = []
|
||||
_patch_hub(monkeypatch, "aucun", appels)
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 403
|
||||
assert "niveau « aucun »" in r.json()["detail"]
|
||||
assert "registre des rôles du Hub" in r.json()["detail"]
|
||||
assert appels and appels[0][1].find("draco_session=") >= 0 # Cookie relayé
|
||||
|
||||
|
||||
def test_draco_aucun_mais_connexion_native_intacte(admin_client, _draco_env, monkeypatch):
|
||||
# L'authentification native (émettrice, accès de secours) n'est
|
||||
# jamais touchée par le registre : le login par mot de passe du
|
||||
# même compte passe, même avec un niveau « aucun » au Hub.
|
||||
_patch_hub(monkeypatch, "aucun", [])
|
||||
r = admin_client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert r.status_code == 200, r.text
|
||||
# Et la session native ainsi établie ouvre /me (canal natif, le
|
||||
# rôle du Hub ne s'applique pas).
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
|
||||
|
||||
def test_draco_role_inconnu_comportement_actuel(admin_client, _draco_env, monkeypatch):
|
||||
_patch_hub(monkeypatch, None, []) # Hub injoignable
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text
|
||||
assert r.json()["username"] == "admin"
|
||||
# Canal draco + rôle inconnu : les routes admin gardent les
|
||||
# règles actuelles (le rôle local admin suffit).
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
|
||||
|
||||
def test_draco_utilisateur_admin_local_refuse_admin(admin_client, _draco_env, monkeypatch):
|
||||
# Compte local admin authentifié par draco, mais niveau Hub
|
||||
# « utilisateur » : /api/auth/admin/* exige « administrateur ».
|
||||
_patch_hub(monkeypatch, "utilisateur", [])
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/me")
|
||||
assert r.status_code == 200, r.text # session draco OK
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert "administrateur" in r.json()["detail"]
|
||||
|
||||
|
||||
def test_draco_administrateur_hub_admin_ok(admin_client, _draco_env, monkeypatch):
|
||||
_patch_hub(monkeypatch, "administrateur", [])
|
||||
_pose_cookie_draco(admin_client, USER)
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
assert any(u["username"] == "admin" for u in r.json())
|
||||
|
||||
|
||||
def test_draco_utilisateur_simple_admin_refuse_comme_avant(admin_client, _draco_env, monkeypatch):
|
||||
# Compte local non admin, niveau Hub « administrateur » : le
|
||||
# registre n'élève aucun privilège local — refus par la règle
|
||||
# actuelle (rôle local), comme avant HUB-19.
|
||||
_patch_hub(monkeypatch, "administrateur", [])
|
||||
_pose_cookie_draco(admin_client, USER_SIMPLE)
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 403
|
||||
assert r.json()["detail"] == "Accès admin requis"
|
||||
|
||||
|
||||
def test_natif_admin_sans_draco_jamais_bloque_par_hub(admin_client, _draco_env, monkeypatch):
|
||||
# Session native (login) + niveau Hub « utilisateur » : le canal
|
||||
# n'est pas draco, la garde du registre ne s'applique pas.
|
||||
_patch_hub(monkeypatch, "utilisateur", [])
|
||||
r = admin_client.post("/api/auth/login",
|
||||
json={"username": "admin", "password": "chab30"})
|
||||
assert r.status_code == 200, r.text
|
||||
# Le jeton natif valide prime : get_current_user ne passe pas par
|
||||
# le repli draco, le dict utilisateur n'a ni _draco_sub ni
|
||||
# _hub_role — la garde du registre ne peut pas s'appliquer.
|
||||
r = admin_client.get("/api/auth/admin/users")
|
||||
assert r.status_code == 200, r.text
|
||||
@@ -38,20 +38,38 @@ def test_origin_of():
|
||||
|
||||
|
||||
def test_sister_origins_dev():
|
||||
# Correctif 2026-10-28 : 8 origines officielles, 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",
|
||||
}
|
||||
|
||||
|
||||
|
||||
def test_cors_headers_agent_console_autorise():
|
||||
# Fédération depuis la console (2026-10-28) : les origines agent
|
||||
# prod ET dev passent, avec credentials ; un tiers reste refusé.
|
||||
for origine in ("https://agent.dracodev.net",
|
||||
"https://agent-dev.dracodev.net"):
|
||||
headers = cors_headers_for(origine, RESOLVED_DEV)
|
||||
assert headers is not None
|
||||
assert headers["Access-Control-Allow-Origin"] == origine
|
||||
assert headers["Access-Control-Allow-Credentials"] == "true"
|
||||
assert cors_headers_for("https://agent.dracodev.net.evil.example",
|
||||
RESOLVED_DEV) is None
|
||||
|
||||
def test_sister_origins_ajoute_env_de_la_requete():
|
||||
# Correctif 2026-10-28 : configuration muette, quel que soit
|
||||
# l'hôte de la requête, prod ET dev passent — l'hôte ne
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests — page /recherche sans texte parasite dans <head>.
|
||||
|
||||
Régression du texte de chantier resté hors commentaire dans
|
||||
``frontend/recherche.html`` : le commentaire de tête se fermait, puis
|
||||
un paragraphe de description du mécanisme de thème suivait en texte
|
||||
brut dans ``<head>`` — le navigateur le rendait tout en haut de la
|
||||
page, au-dessus de la barre header. Le correctif supprime ce texte
|
||||
du rendu (il n'est pas remis en commentaire : un grep doit donner
|
||||
0 occurrence dans les gabarits) sans toucher au mécanisme lui-même,
|
||||
déjà documenté par les commentaires des scripts étape 1 / étape 2.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
|
||||
def _html() -> str:
|
||||
return (FRONTEND / "recherche.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_pas_de_texte_brut_dans_head():
|
||||
"""Entre les balises et les scripts, <head> ne contient aucun
|
||||
texte rendu : tout descriptif vit dans un commentaire ou dans un
|
||||
commentaire de script, jamais en texte brut."""
|
||||
head = _html().split("</head>")[0]
|
||||
sans_commentaires = re.sub(r"<!--.*?-->", "", head, flags=re.DOTALL)
|
||||
sans_scripts = re.sub(
|
||||
r"<script.*?</script>", "", sans_commentaires, flags=re.DOTALL
|
||||
)
|
||||
sans_titre = re.sub(
|
||||
r"<title>.*?</title>", "", sans_scripts, flags=re.DOTALL
|
||||
)
|
||||
texte = re.sub(r"<[^>]+>", "", sans_titre).strip()
|
||||
assert texte == "", f"texte brut rendu dans <head> : {texte[:200]!r}"
|
||||
|
||||
|
||||
def test_mecanisme_theme_intact():
|
||||
"""Le retrait du texte ne touche ni l'étape 1 (script synchrone
|
||||
avant les feuilles) ni l'étape 2 (initThemes différé)."""
|
||||
html = _html()
|
||||
assert html.index("<script>") < html.index('<link rel="stylesheet"')
|
||||
assert "obsigate-theme-mode" in html
|
||||
assert "import('/static/js/themes.js')" in html
|
||||
assert "initThemes" in html
|
||||
assert '<html lang="fr" data-theme="dark">' in html
|
||||
assert '<script src="/static/js/recherche.js"></script>' in html
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests — démarrage ObsiGate sans écran de chargement plein écran.
|
||||
|
||||
Le splash (``#boot-splash`` dans ``frontend/index.html`` : icône
|
||||
livre, titre, spinner, « Démarrage… ») masquait tout le reste de la
|
||||
page via ``body:not(.app-booted) > * { display: none !important }``
|
||||
jusqu'au signal de ``frontend/js/app.js`` (ou un repli à 10 s).
|
||||
Les autres applications (TikTak, Kanbante, Flowdeck, console Spark)
|
||||
n'affichent aucun splash de ce type au démarrage : le contenu y
|
||||
apparaît dès qu'il est prêt. Le retrait supprime donc l'écran, son
|
||||
CSS de masquage, son script de pilotage et le signal côté ``app.js``,
|
||||
sans toucher aux indicateurs internes aux vues ni à l'écran de
|
||||
connexion.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
ROOT = Path(__file__).resolve().parent.parent
|
||||
FRONTEND = ROOT / "frontend"
|
||||
|
||||
MARQUEURS_SPLASH = (
|
||||
"boot-splash",
|
||||
"app-booted",
|
||||
"__OBSIGATE_BOOTED",
|
||||
"__obsigateBootReady",
|
||||
"__setBootStatus",
|
||||
"bs-card",
|
||||
"bs-logo",
|
||||
"bs-title",
|
||||
"bs-spinner",
|
||||
"bs-status",
|
||||
"bs-spin",
|
||||
"Démarrage…",
|
||||
)
|
||||
|
||||
|
||||
def _index() -> str:
|
||||
return (FRONTEND / "index.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def _app_js() -> str:
|
||||
return (FRONTEND / "js" / "app.js").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_aucun_marqueur_splash():
|
||||
index = _index()
|
||||
app_js = _app_js()
|
||||
for marqueur in MARQUEURS_SPLASH:
|
||||
assert marqueur not in index, f"{marqueur} encore dans index.html"
|
||||
assert marqueur not in app_js, f"{marqueur} encore dans js/app.js"
|
||||
|
||||
|
||||
def test_pas_de_masquage_global_au_demarrage():
|
||||
"""Aucune règle ne cache tout le <body> en attendant un signal :
|
||||
sans elle, un chargement lent ne peut plus donner un écran blanc
|
||||
bloqué."""
|
||||
index = _index()
|
||||
assert "body:not(.app-booted)" not in index
|
||||
assert "display: none !important" not in index
|
||||
|
||||
|
||||
def test_boot_js_conserve_et_indicateurs_internes_intacts():
|
||||
"""Le démarrage SPA reste branché ; seuls le splash et son signal
|
||||
disparaissent. L'écran de connexion et la barre de progression
|
||||
de recherche (indicateurs internes aux vues) ne sont PAS visés."""
|
||||
app_js = _app_js()
|
||||
assert 'document.addEventListener("DOMContentLoaded"' in app_js
|
||||
assert "await init();" in app_js
|
||||
assert "Sync.init();" in app_js
|
||||
assert "populateVersions" in app_js
|
||||
index = _index()
|
||||
assert 'id="login-screen"' in index
|
||||
assert 'id="search-progress-bar"' in index
|
||||
assert 'id="app"' in index
|
||||
@@ -33,7 +33,7 @@ class TestListAccessibleVaults:
|
||||
|
||||
def test_metadata_fields(self, client):
|
||||
vault = next(v for v in list_accessible_vaults(ADMIN) if v["name"] == "TestVault")
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type"}
|
||||
assert set(vault) == {"name", "file_count", "tag_count", "type", "readonly"}
|
||||
assert vault["file_count"] >= 1
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Tests des vaults DIR « Spark » : exclusions par vault et lecture seule.
|
||||
|
||||
Couvre le contrat DIR_N_READONLY / DIR_N_IGNORED_DIRS / DIR_N_IGNORED_PATHS :
|
||||
- parsing des trois variables dans load_vault_config() ;
|
||||
- helper chemin_exclu (noms de dossiers et motifs fnmatch à préfixes) ;
|
||||
- navigation filtrée, lectures exclues en 404 (vue, raw, téléchargement),
|
||||
fichier non exclu lisible, indexation et list_paths filtrés ;
|
||||
- mutation refusée en 403 sur un vault déclaré readonly ;
|
||||
- /api/vaults expose readonly=true pour un DIR readonly.
|
||||
|
||||
Le vault de test est un répertoire temporaire monté en DIR (jamais un
|
||||
vault de notes), avec une arborescence qui imite la vue Spark visée :
|
||||
Console/, Workspaces/ (avec un .home), Executeurs/Hermes/{missions,data,.env}.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import ClassVar
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
VAULT_NAME = "SparkTest"
|
||||
|
||||
|
||||
def _build_spark_tree(root: Path) -> None:
|
||||
"""Crée l'arborescence technique de test sous *root*."""
|
||||
(root / "Console").mkdir(parents=True)
|
||||
(root / "Console" / "code.py").write_text('print("visible")\n', encoding="utf-8")
|
||||
(root / "Console" / "notes.md").write_text("# Notes\n", encoding="utf-8")
|
||||
|
||||
atelier = root / "Workspaces" / "atelier"
|
||||
(atelier / ".home").mkdir(parents=True)
|
||||
(atelier / "note.md").write_text("# Atelier\n", encoding="utf-8")
|
||||
(atelier / ".home" / "cache.md").write_text("# Cache du sandbox\n", encoding="utf-8")
|
||||
|
||||
venv = root / ".venv"
|
||||
venv.mkdir()
|
||||
(venv / "lib.py").write_text("# lib du venv\n", encoding="utf-8")
|
||||
|
||||
hermes = root / "Executeurs" / "Hermes"
|
||||
(hermes / "missions").mkdir(parents=True)
|
||||
(hermes / "data").mkdir(parents=True)
|
||||
(hermes / "missions" / "mission.md").write_text("# Mission\n", encoding="utf-8")
|
||||
(hermes / "data" / "config.md").write_text("# Profil de l'exécuteur\n", encoding="utf-8")
|
||||
(hermes / ".env").write_text("CLE_FICTIVE=ne-pas-exposer\n", encoding="utf-8")
|
||||
|
||||
(root / "racine.md").write_text("# Racine\n", encoding="utf-8")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_dir(tmp_path: Path) -> Path:
|
||||
root = tmp_path / "SparkDir"
|
||||
root.mkdir()
|
||||
_build_spark_tree(root)
|
||||
return root
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def spark_client(spark_dir: Path):
|
||||
"""TestClient avec le répertoire de test monté en vault DIR readonly.
|
||||
|
||||
Même patron que le fixture ``app_with_vault`` de conftest.py, mais en
|
||||
DIR_N_* avec les trois nouvelles variables posées.
|
||||
"""
|
||||
os.environ["DIR_1_NAME"] = VAULT_NAME
|
||||
os.environ["DIR_1_PATH"] = str(spark_dir)
|
||||
os.environ["DIR_1_READONLY"] = "true"
|
||||
os.environ["DIR_1_IGNORED_DIRS"] = ".home,.venv"
|
||||
os.environ["DIR_1_IGNORED_PATHS"] = "Executeurs/*/data,Executeurs/*/.env"
|
||||
os.environ["OBSIGATE_AUTH_ENABLED"] = "false"
|
||||
|
||||
import backend.main
|
||||
|
||||
backend.main._load_config = lambda: {"watcher_enabled": False}
|
||||
|
||||
from backend.indexer import build_index
|
||||
from backend.main import app
|
||||
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
loop.run_until_complete(build_index())
|
||||
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
# ── Parsing de la configuration ─────────────────────────────────────
|
||||
|
||||
|
||||
class TestParsingConfig:
|
||||
def test_dir_variables_parsees(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "SparkParse")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
monkeypatch.setenv("DIR_1_READONLY", "true")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_DIRS", ".home, .venv ,__pycache__")
|
||||
monkeypatch.setenv("DIR_1_IGNORED_PATHS", "Executeurs/*/data, Executeurs/*/.env")
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["SparkParse"]
|
||||
assert cfg["type"] == "DIR"
|
||||
assert cfg["readonly"] is True
|
||||
assert cfg["ignoredDirs"] == {".home", ".venv", "__pycache__"}
|
||||
assert cfg["ignoredPaths"] == ["Executeurs/*/data", "Executeurs/*/.env"]
|
||||
|
||||
def test_dir_sans_variables_defauts(self, monkeypatch, tmp_path):
|
||||
monkeypatch.setenv("DIR_1_NAME", "DirNu")
|
||||
monkeypatch.setenv("DIR_1_PATH", str(tmp_path))
|
||||
|
||||
from backend.indexer import load_vault_config
|
||||
|
||||
cfg = load_vault_config()["DirNu"]
|
||||
assert cfg["readonly"] is False
|
||||
assert cfg["ignoredDirs"] == set()
|
||||
assert cfg["ignoredPaths"] == []
|
||||
|
||||
|
||||
# ── Helper chemin_exclu ─────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCheminExclu:
|
||||
CFG: ClassVar[dict] = {
|
||||
"ignoredDirs": {".home", ".venv"},
|
||||
"ignoredPaths": ["Executeurs/*/data", "Executeurs/*/.env"],
|
||||
}
|
||||
|
||||
def test_nom_de_dossier_a_toute_profondeur(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Workspaces/atelier/.home/cache.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".venv/lib.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, ".home")
|
||||
|
||||
def test_motif_chemin_et_sous_arbre(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/data/config.md")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Pi/data/x/y.py")
|
||||
assert chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/.env")
|
||||
|
||||
def test_non_exclus_et_tolerance(self):
|
||||
from backend.services.vaults import chemin_exclu_dans_config
|
||||
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "Executeurs/Hermes/missions/mission.md")
|
||||
assert not chemin_exclu_dans_config({}, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(None, "Console/code.py")
|
||||
assert not chemin_exclu_dans_config(self.CFG, "")
|
||||
|
||||
|
||||
# ── Navigation et lectures via l'API ────────────────────────────────
|
||||
|
||||
|
||||
class TestNavigationEtLecture:
|
||||
def test_browse_racine_cache_les_dossiers_ignores(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}")
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert {"Console", "Workspaces", "Executeurs", "racine.md"} <= names
|
||||
assert ".venv" not in names
|
||||
|
||||
def test_browse_sous_dossier_cache_data(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes"})
|
||||
assert resp.status_code == 200
|
||||
names = {item["name"] for item in resp.json()["items"]}
|
||||
assert "missions" in names
|
||||
assert "data" not in names
|
||||
|
||||
def test_browse_chemin_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/browse/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_vue_fichier_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/data/config.md"})
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_raw_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_telechargement_env_exclu_404(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/download", params={"path": "Executeurs/Hermes/.env"})
|
||||
assert resp.status_code == 404
|
||||
assert "CLE_FICTIVE" not in resp.text
|
||||
|
||||
def test_fichier_non_exclu_lisible(self, spark_client):
|
||||
resp = spark_client.get(f"/api/file/{VAULT_NAME}/raw", params={"path": "Console/code.py"})
|
||||
assert resp.status_code == 200
|
||||
assert "visible" in resp.json()["raw"]
|
||||
|
||||
|
||||
# ── Indexation et liste des chemins ─────────────────────────────────
|
||||
|
||||
|
||||
class TestIndexation:
|
||||
def test_index_ne_contient_aucun_exclu(self, spark_client):
|
||||
from backend.indexer import get_vault_data
|
||||
|
||||
data = get_vault_data(VAULT_NAME)
|
||||
assert data is not None
|
||||
file_paths = {f["path"] for f in data.get("files", [])}
|
||||
assert "Console/code.py" in file_paths
|
||||
for p in file_paths:
|
||||
segments = p.split("/")
|
||||
assert ".home" not in segments and ".venv" not in segments
|
||||
assert "data" not in segments[:-1] or not p.startswith("Executeurs/")
|
||||
path_entries = {e["path"] for e in data.get("paths", [])}
|
||||
assert "Executeurs/Hermes/data" not in path_entries
|
||||
assert "Workspaces/atelier/.home" not in path_entries
|
||||
|
||||
def test_list_paths_filtre_les_exclus(self, spark_client):
|
||||
from backend.services.search import list_paths
|
||||
|
||||
result = list_paths(VAULT_NAME)
|
||||
paths = {r["path"] for r in result["results"]}
|
||||
assert "Console/code.py" in paths
|
||||
assert not any("/data/" in f"/{p}/" and p.startswith("Executeurs/") for p in paths)
|
||||
assert not any(".home" in p.split("/") or ".venv" in p.split("/") for p in paths)
|
||||
|
||||
|
||||
# ── Lecture seule ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLectureSeule:
|
||||
def test_save_refuse_403(self, spark_client):
|
||||
resp = spark_client.put(
|
||||
f"/api/file/{VAULT_NAME}/save",
|
||||
params={"path": "Console/code.py"},
|
||||
json={"content": "print('modifié')\n"},
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_creation_dossier_refusee_403(self, spark_client):
|
||||
resp = spark_client.post(f"/api/directory/{VAULT_NAME}", json={"path": "NouveauDossier"})
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_vaults_expose_readonly(self, spark_client):
|
||||
resp = spark_client.get("/api/vaults")
|
||||
assert resp.status_code == 200
|
||||
entry = next(v for v in resp.json() if v["name"] == VAULT_NAME)
|
||||
assert entry["type"] == "DIR"
|
||||
assert entry["readonly"] is True
|
||||